Describe the bug
When Periphery detects code that is only referenced from #Preview macro blocks, it correctly identifies that code as unused. However, the current detection has two related problems:
-
False positives on preview-support code. If a declaration is referenced from multiple #Preview blocks, and at least one of those previews is for a view that is used in the app, that declaration should be kept. Currently, all #Preview macro expansions are un-retained equally, so code that exists to support a legitimate preview gets flagged as unused.
For example, a helper like previewPadding() or a view like ViewOnlyUsedInPreview might appear in both a "dead" preview (one that only previews unused views) and a "live" preview (one that previews a view used in the app). The helper should be kept because the live preview needs it, but Periphery flags it as unused.
-
The #Preview block itself is never reported. When a #Preview block exclusively references unused code (views not used anywhere in the app), Periphery flags the referenced views but does not flag the #Preview block itself. If the warning is heeded and the view is removed, this would leave an orphaned preview in the codebase that the developer has no reason to keep. Ideally, Periphery would report the dead #Preview block as unused (similar to behavior of a PreviewProvider preview), giving the developer a clear signal to remove it along with the dead code it references.
The root cause is that the algorithm treats all #Preview blocks identically without distinguishing between previews that exercise live app code and previews that only reference otherwise-dead code.
Reproduction
The attached PreviewPeriphery Xcode project demonstrates the scenarios. The key file is ContentView.swift, which contains:
ContentView and UsedView — views used by the app (via @main entry point). Should be kept.
ViewOnlyUsedInPreview and previewPadding() — only referenced from previews, but one of those previews is for UsedView (live app code). Should be kept, but currently flagged.
UnusedView, CompletelyUnusedView, DeadChainedView, DeadChainedHelper — views not used anywhere in the app, only referenced from dead previews. Should be flagged as unused.
- Three dead
#Preview blocks — each exclusively references unused views. These should themselves be reported as unused, but currently are not.
- Three live
#Preview blocks — each references at least one view that is used in the app (UsedView or ContentView). These should not be reported.
- Old-style
PreviewProvider structs for both a used and an unused view, to verify consistent behavior across preview styles.
Environment
periphery version: 3.4.0
Apple Swift version 6.2.3 (swiftlang-6.2.3.3.21 clang-1700.6.3.2)
Target: arm64-apple-macosx26.0
Xcode 26.2
Build version 17C52
PreviewPeriphery.zip
Describe the bug
When Periphery detects code that is only referenced from
#Previewmacro blocks, it correctly identifies that code as unused. However, the current detection has two related problems:False positives on preview-support code. If a declaration is referenced from multiple
#Previewblocks, and at least one of those previews is for a view that is used in the app, that declaration should be kept. Currently, all#Previewmacro expansions are un-retained equally, so code that exists to support a legitimate preview gets flagged as unused.For example, a helper like
previewPadding()or a view likeViewOnlyUsedInPreviewmight appear in both a "dead" preview (one that only previews unused views) and a "live" preview (one that previews a view used in the app). The helper should be kept because the live preview needs it, but Periphery flags it as unused.The
#Previewblock itself is never reported. When a#Previewblock exclusively references unused code (views not used anywhere in the app), Periphery flags the referenced views but does not flag the#Previewblock itself. If the warning is heeded and the view is removed, this would leave an orphaned preview in the codebase that the developer has no reason to keep. Ideally, Periphery would report the dead#Previewblock as unused (similar to behavior of aPreviewProviderpreview), giving the developer a clear signal to remove it along with the dead code it references.The root cause is that the algorithm treats all
#Previewblocks identically without distinguishing between previews that exercise live app code and previews that only reference otherwise-dead code.Reproduction
The attached
PreviewPeripheryXcode project demonstrates the scenarios. The key file isContentView.swift, which contains:ContentViewandUsedView— views used by the app (via@mainentry point). Should be kept.ViewOnlyUsedInPreviewandpreviewPadding()— only referenced from previews, but one of those previews is forUsedView(live app code). Should be kept, but currently flagged.UnusedView,CompletelyUnusedView,DeadChainedView,DeadChainedHelper— views not used anywhere in the app, only referenced from dead previews. Should be flagged as unused.#Previewblocks — each exclusively references unused views. These should themselves be reported as unused, but currently are not.#Previewblocks — each references at least one view that is used in the app (UsedVieworContentView). These should not be reported.PreviewProviderstructs for both a used and an unused view, to verify consistent behavior across preview styles.Environment
PreviewPeriphery.zip