Skip to content

Commit 22cfe65

Browse files
committed
Parse connections from xib/storyboards
1 parent 222b8bd commit 22cfe65

File tree

13 files changed

+314
-20
lines changed

13 files changed

+314
-20
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- Added the `--no-color`/`--color` option to disable/enable colored output.
1010
- Exclude wrapped properties from assign-only analysis, as Periphery cannot observe the behavior of the property wrapper.
1111
- Improved the readability of result messages.
12+
- Periphery now parses Interface Builder files to detect unused `@IBOutlet`, `@IBAction`, `@IBInspectable`, and `@IBSegueAction` members. Previously, all `@IB*` members were blindly retained if their containing class was referenced in a XIB or storyboard.
1213

1314
##### Bug Fixes
1415

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ Any class that inherits `XCTestCase` is automatically retained along with its te
317317

318318
### Interface Builder
319319

320-
If your project contains Interface Builder files (such as storyboards and XIBs), Periphery will take these into account when identifying unused declarations. However, Periphery currently only identifies unused classes. This limitation exists because Periphery does not yet fully parse Interface Builder files (see [issue #212](https://github.com/peripheryapp/periphery/issues/212)). Due to Periphery's design principle of avoiding false positives, it is assumed that if a class is referenced in an Interface Builder file, all of its `IBOutlets` and `IBActions` are used, even if they might not be in reality. This approach will be revised to accurately identify unused `IBActions` and `IBOutlets` once Periphery gains the capability to parse Interface Builder files.
320+
If your project contains Interface Builder files (such as storyboards and XIBs), Periphery will take these into account when identifying unused declarations. Periphery parses these files to identify which classes, `@IBOutlet` properties, `@IBAction` methods, and `@IBInspectable` properties are actually referenced. Only those members that are connected in the Interface Builder file will be retained. Any `@IB*` members that are declared but not connected will be reported as unused.
321321

322322
## Comment Commands
323323

Sources/Indexer/XibParser.swift

Lines changed: 103 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,119 @@ final class XibParser {
1313
func parse() throws -> [AssetReference] {
1414
guard let data = FileManager.default.contents(atPath: path.string) else { return [] }
1515
let structure = try AEXMLDocument(xml: data)
16-
return references(from: structure.root).map {
17-
AssetReference(absoluteName: $0, source: .interfaceBuilder)
16+
17+
// Build a map of element id -> customClass for resolving action destinations
18+
var idToCustomClass: [String: String] = [:]
19+
buildIdToCustomClassMap(from: structure.root, into: &idToCustomClass)
20+
21+
// Collect all references with their outlets, actions, and runtime attributes
22+
var referencesByClass: [String: (outlets: Set<String>, actions: Set<String>, runtimeAttributes: Set<String>)] = [:]
23+
collectReferences(from: structure.root, idToCustomClass: idToCustomClass, into: &referencesByClass)
24+
25+
return referencesByClass.map { className, members in
26+
AssetReference(
27+
absoluteName: className,
28+
source: .interfaceBuilder,
29+
outlets: Array(members.outlets),
30+
actions: Array(members.actions),
31+
runtimeAttributes: Array(members.runtimeAttributes)
32+
)
1833
}
1934
}
2035

2136
// MARK: - Private
2237

23-
private func references(from element: AEXMLElement) -> [String] {
24-
var names: [String] = []
38+
/// Builds a map of element id to customClass for resolving action destinations.
39+
private func buildIdToCustomClassMap(from element: AEXMLElement, into map: inout [String: String]) {
40+
if let id = element.attributes["id"], let customClass = element.attributes["customClass"] {
41+
map[id] = customClass
42+
}
43+
for child in element.children {
44+
buildIdToCustomClassMap(from: child, into: &map)
45+
}
46+
}
47+
48+
/// Recursively collects class references, outlets, actions, and runtime attributes.
49+
private func collectReferences(
50+
from element: AEXMLElement,
51+
idToCustomClass: [String: String],
52+
into referencesByClass: inout [String: (outlets: Set<String>, actions: Set<String>, runtimeAttributes: Set<String>)]
53+
) {
54+
// Check if this element has a customClass
55+
if let customClass = element.attributes["customClass"] {
56+
// Initialize entry for this class if needed
57+
if referencesByClass[customClass] == nil {
58+
referencesByClass[customClass] = (outlets: [], actions: [], runtimeAttributes: [])
59+
}
60+
61+
// Collect outlets from this element's connections
62+
collectOutlets(from: element, forClass: customClass, into: &referencesByClass)
63+
64+
// Collect runtime attributes from this element
65+
collectRuntimeAttributes(from: element, forClass: customClass, into: &referencesByClass)
66+
}
67+
68+
// Collect actions - these reference a destination class
69+
collectActions(from: element, idToCustomClass: idToCustomClass, into: &referencesByClass)
2570

71+
// Recurse into children
2672
for child in element.children {
27-
if let name = child.attributes["customClass"] {
28-
names.append(name)
73+
collectReferences(from: child, idToCustomClass: idToCustomClass, into: &referencesByClass)
74+
}
75+
}
76+
77+
/// Collects outlet property names from an element's connections.
78+
private func collectOutlets(
79+
from element: AEXMLElement,
80+
forClass customClass: String,
81+
into referencesByClass: inout [String: (outlets: Set<String>, actions: Set<String>, runtimeAttributes: Set<String>)]
82+
) {
83+
for child in element.children where child.name == "connections" {
84+
for connection in child.children where connection.name == "outlet" {
85+
if let property = connection.attributes["property"] {
86+
referencesByClass[customClass]?.outlets.insert(property)
87+
}
2988
}
89+
}
90+
}
3091

31-
names += references(from: child)
92+
/// Collects action selectors and associates them with the destination class.
93+
private func collectActions(
94+
from element: AEXMLElement,
95+
idToCustomClass: [String: String],
96+
into referencesByClass: inout [String: (outlets: Set<String>, actions: Set<String>, runtimeAttributes: Set<String>)]
97+
) {
98+
for child in element.children where child.name == "connections" {
99+
for connection in child.children where connection.name == "action" {
100+
guard let selector = connection.attributes["selector"] else { continue }
101+
102+
// iOS uses "destination", macOS uses "target"
103+
guard let targetId = connection.attributes["destination"] ?? connection.attributes["target"]
104+
else { continue }
105+
106+
// Resolve the target to a customClass
107+
if let customClass = idToCustomClass[targetId] {
108+
if referencesByClass[customClass] == nil {
109+
referencesByClass[customClass] = (outlets: [], actions: [], runtimeAttributes: [])
110+
}
111+
referencesByClass[customClass]?.actions.insert(selector)
112+
}
113+
}
32114
}
115+
}
33116

34-
return names
117+
/// Collects user-defined runtime attribute key paths (IBInspectable).
118+
private func collectRuntimeAttributes(
119+
from element: AEXMLElement,
120+
forClass customClass: String,
121+
into referencesByClass: inout [String: (outlets: Set<String>, actions: Set<String>, runtimeAttributes: Set<String>)]
122+
) {
123+
for child in element.children where child.name == "userDefinedRuntimeAttributes" {
124+
for attr in child.children where attr.name == "userDefinedRuntimeAttribute" {
125+
if let keyPath = attr.attributes["keyPath"] {
126+
referencesByClass[customClass]?.runtimeAttributes.insert(keyPath)
127+
}
128+
}
129+
}
35130
}
36131
}

Sources/SourceGraph/Elements/AssetReference.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,39 @@ public struct AssetReference: Hashable {
99
name = absoluteName
1010
}
1111
self.source = source
12+
outlets = []
13+
actions = []
14+
runtimeAttributes = []
15+
}
16+
17+
/// Initializer for Interface Builder references with outlet/action/attribute details.
18+
public init(
19+
absoluteName: String,
20+
source: ProjectFileKind,
21+
outlets: [String],
22+
actions: [String],
23+
runtimeAttributes: [String]
24+
) {
25+
if let name = absoluteName.split(separator: ".").last {
26+
self.name = String(name)
27+
} else {
28+
name = absoluteName
29+
}
30+
self.source = source
31+
self.outlets = outlets
32+
self.actions = actions
33+
self.runtimeAttributes = runtimeAttributes
1234
}
1335

1436
public let name: String
1537
public let source: ProjectFileKind
38+
39+
/// Outlet property names referenced in Interface Builder (e.g., "button", "label").
40+
public let outlets: [String]
41+
42+
/// Action selector names referenced in Interface Builder (e.g., "click:", "handleTap:").
43+
public let actions: [String]
44+
45+
/// User-defined runtime attribute key paths (IBInspectable, e.g., "cornerRadius", "borderColor").
46+
public let runtimeAttributes: [String]
1647
}

Sources/SourceGraph/Mutators/AssetReferenceRetainer.swift

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,15 @@ final class AssetReferenceRetainer: SourceGraphMutator {
1313
}
1414

1515
func mutate() {
16-
interfaceBuilderPropertyRetainer.retainPropertiesDeclaredInExtensions()
16+
// Aggregate all IB references by class name to collect all outlets/actions/attributes
17+
// across multiple XIB/storyboard files that might reference the same class.
18+
let ibReferencesByClass = aggregateInterfaceBuilderReferences()
19+
20+
// Collect all runtime attributes for extension-based IBInspectable properties
21+
let allRuntimeAttributes = ibReferencesByClass.values.reduce(into: Set<String>()) { result, refs in
22+
result.formUnion(refs.runtimeAttributes)
23+
}
24+
interfaceBuilderPropertyRetainer.retainPropertiesDeclaredInExtensions(referencedAttributes: allRuntimeAttributes)
1725

1826
graph
1927
.declarations(ofKind: .class)
@@ -34,7 +42,14 @@ final class AssetReferenceRetainer: SourceGraphMutator {
3442
graph.unmarkRedundantPublicAccessibility(declaration)
3543
declaration.descendentDeclarations.forEach { graph.unmarkRedundantPublicAccessibility($0) }
3644
case .interfaceBuilder:
37-
interfaceBuilderPropertyRetainer.retainPropertiesDeclared(in: declaration)
45+
// Get aggregated references for this class
46+
let aggregated = ibReferencesByClass[declaration.name ?? ""]
47+
interfaceBuilderPropertyRetainer.retainPropertiesDeclared(
48+
in: declaration,
49+
referencedOutlets: aggregated?.outlets ?? [],
50+
referencedActions: aggregated?.actions ?? [],
51+
referencedAttributes: aggregated?.runtimeAttributes ?? []
52+
)
3853
default:
3954
break
4055
}
@@ -46,4 +61,21 @@ final class AssetReferenceRetainer: SourceGraphMutator {
4661
private func reference(for declaration: Declaration) -> AssetReference? {
4762
graph.assetReferences.first { $0.name == declaration.name }
4863
}
64+
65+
/// Aggregates all Interface Builder references by class name, combining outlets, actions,
66+
/// and runtime attributes from all XIB/storyboard files that reference each class.
67+
private func aggregateInterfaceBuilderReferences() -> [String: (outlets: Set<String>, actions: Set<String>, runtimeAttributes: Set<String>)] {
68+
var result: [String: (outlets: Set<String>, actions: Set<String>, runtimeAttributes: Set<String>)] = [:]
69+
70+
for ref in graph.assetReferences where ref.source == .interfaceBuilder {
71+
if result[ref.name] == nil {
72+
result[ref.name] = (outlets: [], actions: [], runtimeAttributes: [])
73+
}
74+
result[ref.name]?.outlets.formUnion(ref.outlets)
75+
result[ref.name]?.actions.formUnion(ref.actions)
76+
result[ref.name]?.runtimeAttributes.formUnion(ref.runtimeAttributes)
77+
}
78+
79+
return result
80+
}
4981
}

Sources/SourceGraph/Mutators/InterfaceBuilderPropertyRetainer.swift

Lines changed: 83 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,105 @@ import Foundation
22

33
class InterfaceBuilderPropertyRetainer {
44
private let graph: SourceGraph
5-
private let ibAttributes = ["IBOutlet", "IBAction", "IBInspectable", "IBSegueAction"]
5+
private let ibOutletAttributes: Set<String> = ["IBOutlet"]
6+
private let ibActionAttributes: Set<String> = ["IBAction", "IBSegueAction"]
7+
private let ibInspectableAttributes: Set<String> = ["IBInspectable"]
68

79
required init(graph: SourceGraph) {
810
self.graph = graph
911
}
1012

11-
/// Some properties may be declared in extensions on external types, e.g IBInspectable.
12-
func retainPropertiesDeclaredInExtensions() {
13+
/// Retains IBInspectable properties declared in extensions on external types.
14+
/// These cannot be reliably matched to XIB runtime attributes since the extended
15+
/// type may not have a customClass in the XIB.
16+
func retainPropertiesDeclaredInExtensions(referencedAttributes: Set<String>) {
1317
let extensions = graph.declarations(ofKind: .extensionClass)
1418

1519
for extDecl in extensions {
16-
for decl in extDecl.declarations where decl.attributes.contains(where: { ibAttributes.contains($0) }) {
17-
graph.markRetained(decl)
20+
for decl in extDecl.declarations {
21+
// IBInspectable properties in extensions: check if referenced
22+
if decl.attributes.contains(where: { ibInspectableAttributes.contains($0) }) {
23+
if let name = decl.name, referencedAttributes.contains(name) {
24+
graph.markRetained(decl)
25+
}
26+
}
1827
}
1928
}
2029
}
2130

22-
func retainPropertiesDeclared(in declaration: Declaration) {
31+
/// Retains only the outlets, actions, and inspectable properties that are actually
32+
/// referenced in the Interface Builder file.
33+
func retainPropertiesDeclared(
34+
in declaration: Declaration,
35+
referencedOutlets: Set<String>,
36+
referencedActions: Set<String>,
37+
referencedAttributes: Set<String>
38+
) {
2339
let inheritedDeclarations = graph.inheritedDeclarations(of: declaration)
2440
let descendentInheritedDeclarations = inheritedDeclarations.map(\.declarations).joined()
2541
let allDeclarations = declaration.declarations.union(descendentInheritedDeclarations)
2642

27-
for declaration in allDeclarations where declaration.attributes.contains(where: { ibAttributes.contains($0) }) {
28-
graph.markRetained(declaration)
43+
for decl in allDeclarations {
44+
guard let declName = decl.name else { continue }
45+
46+
// Check IBOutlet properties
47+
if decl.attributes.contains(where: { ibOutletAttributes.contains($0) }) {
48+
if referencedOutlets.contains(declName) {
49+
graph.markRetained(decl)
50+
}
51+
continue
52+
}
53+
54+
// Check IBAction/IBSegueAction methods
55+
if decl.attributes.contains(where: { ibActionAttributes.contains($0) }) {
56+
let selectorName = swiftNameToSelector(declName)
57+
if referencedActions.contains(selectorName) {
58+
graph.markRetained(decl)
59+
}
60+
continue
61+
}
62+
63+
// Check IBInspectable properties
64+
if decl.attributes.contains(where: { ibInspectableAttributes.contains($0) }) {
65+
if referencedAttributes.contains(declName) {
66+
graph.markRetained(decl)
67+
}
68+
continue
69+
}
70+
}
71+
}
72+
73+
// MARK: - Private
74+
75+
/// Converts a Swift function name like `click(_:)` or `doSomething(_:withValue:)`
76+
/// to an Objective-C selector like `click:` or `doSomething:withValue:`.
77+
private func swiftNameToSelector(_ swiftName: String) -> String {
78+
// Remove the trailing parenthesis content to get just the method name with params
79+
// e.g., "click(_:)" -> "click:" or "handleTap(_:forEvent:)" -> "handleTap:forEvent:"
80+
guard let parenStart = swiftName.firstIndex(of: "("),
81+
let parenEnd = swiftName.lastIndex(of: ")")
82+
else {
83+
return swiftName
84+
}
85+
86+
let methodName = String(swiftName[..<parenStart])
87+
let paramsSection = String(swiftName[swiftName.index(after: parenStart) ..< parenEnd])
88+
89+
// Split by ":" to get parameter labels
90+
let params = paramsSection.split(separator: ":", omittingEmptySubsequences: false)
91+
92+
// Build the selector: methodName + ":" for each parameter
93+
var selector = methodName
94+
for (index, param) in params.enumerated() {
95+
if index == 0 {
96+
// First parameter: just add ":"
97+
selector += ":"
98+
} else if !param.isEmpty {
99+
// Subsequent parameters: add label + ":"
100+
selector += String(param) + ":"
101+
}
29102
}
103+
104+
return selector
30105
}
31106
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import AppKit
22

33
public class SPMXibViewController: NSViewController {
4+
// Referenced via XIB (connected)
45
@IBOutlet var button: NSButton!
56
@IBAction func buttonTapped(_: Any) {}
7+
8+
// Unreferenced - not connected in XIB
9+
@IBOutlet var unusedMacOutlet: NSTextField!
10+
@IBAction func unusedMacAction(_: Any) {}
611
}

Tests/SPMTests/SPMProjectMacOSTest.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,12 @@
1313

1414
func testRetainsInterfaceBuilderDeclarations() {
1515
assertReferenced(.class("SPMXibViewController")) {
16+
// Referenced via XIB (connected)
1617
self.assertReferenced(.functionMethodInstance("buttonTapped(_:)"))
1718
self.assertReferenced(.varInstance("button"))
19+
// Unreferenced - not connected in XIB
20+
self.assertNotReferenced(.varInstance("unusedMacOutlet"))
21+
self.assertNotReferenced(.functionMethodInstance("unusedMacAction(_:)"))
1822
}
1923
}
2024
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import UIKit
22

33
class StoryboardViewController: UIViewController {
4+
// Referenced via storyboard (connected)
45
@IBOutlet weak var button: UIButton!
56
@IBAction func click(_ sender: Any) {}
7+
8+
// Unreferenced - not connected in storyboard
9+
@IBOutlet weak var unusedStoryboardOutlet: UILabel!
10+
@IBAction func unusedStoryboardAction(_ sender: Any) {}
11+
@IBInspectable var unusedStoryboardInspectable: CGFloat = 0
612
}

0 commit comments

Comments
 (0)