Skip to content

Commit 644c9c3

Browse files
authored
Parse connections from xib/storyboards (#1024)
1 parent 222b8bd commit 644c9c3

18 files changed

+545
-67
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+
- Improved Interface Builder file parsing 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: 153 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,169 @@ 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)
2563

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)
70+
71+
// Collect Cocoa Bindings (macOS) - these reference properties on destination objects
72+
collectBindings(from: element, idToCustomClass: idToCustomClass, into: &referencesByClass)
73+
74+
// Recurse into children
2675
for child in element.children {
27-
if let name = child.attributes["customClass"] {
28-
names.append(name)
76+
collectReferences(from: child, idToCustomClass: idToCustomClass, into: &referencesByClass)
77+
}
78+
}
79+
80+
/// Collects outlet property names from an element's connections.
81+
/// Handles both `<outlet>` and `<outletCollection>` elements.
82+
private func collectOutlets(
83+
from element: AEXMLElement,
84+
forClass customClass: String,
85+
into referencesByClass: inout [String: (outlets: Set<String>, actions: Set<String>, runtimeAttributes: Set<String>)]
86+
) {
87+
for child in element.children where child.name == "connections" {
88+
for connection in child.children {
89+
// Handle both regular outlets and outlet collections (for @IBOutlet arrays like [UIButton])
90+
guard connection.name == "outlet" || connection.name == "outletCollection" else { continue }
91+
if let property = connection.attributes["property"] {
92+
referencesByClass[customClass]?.outlets.insert(property)
93+
}
2994
}
95+
}
96+
}
97+
98+
/// Collects action selectors and associates them with the destination class.
99+
private func collectActions(
100+
from element: AEXMLElement,
101+
idToCustomClass: [String: String],
102+
into referencesByClass: inout [String: (outlets: Set<String>, actions: Set<String>, runtimeAttributes: Set<String>)]
103+
) {
104+
for child in element.children where child.name == "connections" {
105+
for connection in child.children where connection.name == "action" {
106+
guard let selector = connection.attributes["selector"] else { continue }
107+
108+
// iOS uses "destination", macOS uses "target"
109+
guard let targetId = connection.attributes["destination"] ?? connection.attributes["target"]
110+
else { continue }
111+
112+
// Resolve the target to a customClass
113+
if let customClass = idToCustomClass[targetId] {
114+
if referencesByClass[customClass] == nil {
115+
referencesByClass[customClass] = (outlets: [], actions: [], runtimeAttributes: [])
116+
}
117+
referencesByClass[customClass]?.actions.insert(selector)
118+
}
119+
}
120+
}
121+
}
122+
123+
/// Collects user-defined runtime attribute key paths (IBInspectable).
124+
private func collectRuntimeAttributes(
125+
from element: AEXMLElement,
126+
forClass customClass: String,
127+
into referencesByClass: inout [String: (outlets: Set<String>, actions: Set<String>, runtimeAttributes: Set<String>)]
128+
) {
129+
for child in element.children where child.name == "userDefinedRuntimeAttributes" {
130+
for attr in child.children where attr.name == "userDefinedRuntimeAttribute" {
131+
if let keyPath = attr.attributes["keyPath"] {
132+
referencesByClass[customClass]?.runtimeAttributes.insert(keyPath)
133+
}
134+
}
135+
}
136+
}
137+
138+
/// Collects Cocoa Bindings (macOS) which reference properties via keyPath.
139+
/// Bindings connect UI elements to controller properties, e.g., `<binding destination="..." keyPath="self.propertyName" ...>`.
140+
private func collectBindings(
141+
from element: AEXMLElement,
142+
idToCustomClass: [String: String],
143+
into referencesByClass: inout [String: (outlets: Set<String>, actions: Set<String>, runtimeAttributes: Set<String>)]
144+
) {
145+
for child in element.children where child.name == "connections" {
146+
for connection in child.children where connection.name == "binding" {
147+
guard let keyPath = connection.attributes["keyPath"],
148+
let destination = connection.attributes["destination"]
149+
else { continue }
150+
151+
// Resolve the destination to a customClass
152+
if let customClass = idToCustomClass[destination] {
153+
if referencesByClass[customClass] == nil {
154+
referencesByClass[customClass] = (outlets: [], actions: [], runtimeAttributes: [])
155+
}
156+
// Extract the first component of the keyPath (e.g., "self.propertyName" -> "propertyName")
157+
let propertyName = extractPropertyName(from: keyPath)
158+
referencesByClass[customClass]?.outlets.insert(propertyName)
159+
}
160+
}
161+
}
162+
}
163+
164+
/// Extracts the property name from a binding keyPath.
165+
/// Handles formats like "self.propertyName", "propertyName", "self.object.nestedProperty".
166+
private func extractPropertyName(from keyPath: String) -> String {
167+
var path = keyPath
168+
169+
// Remove "self." prefix if present
170+
if path.hasPrefix("self.") {
171+
path = String(path.dropFirst(5))
172+
}
30173

31-
names += references(from: child)
174+
// Return the first component (property name)
175+
if let dotIndex = path.firstIndex(of: ".") {
176+
return String(path[..<dotIndex])
32177
}
33178

34-
return names
179+
return path
35180
}
36181
}

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
}

0 commit comments

Comments
 (0)