Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- Added the `--no-color`/`--color` option to disable/enable colored output.
- Exclude wrapped properties from assign-only analysis, as Periphery cannot observe the behavior of the property wrapper.
- Improved the readability of result messages.
- 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.

##### Bug Fixes

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ Any class that inherits `XCTestCase` is automatically retained along with its te

### Interface Builder

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.
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.

## Comment Commands

Expand Down
161 changes: 153 additions & 8 deletions Sources/Indexer/XibParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,169 @@ final class XibParser {
func parse() throws -> [AssetReference] {
guard let data = FileManager.default.contents(atPath: path.string) else { return [] }
let structure = try AEXMLDocument(xml: data)
return references(from: structure.root).map {
AssetReference(absoluteName: $0, source: .interfaceBuilder)

// Build a map of element id -> customClass for resolving action destinations
var idToCustomClass: [String: String] = [:]
buildIdToCustomClassMap(from: structure.root, into: &idToCustomClass)

// Collect all references with their outlets, actions, and runtime attributes
var referencesByClass: [String: (outlets: Set<String>, actions: Set<String>, runtimeAttributes: Set<String>)] = [:]
collectReferences(from: structure.root, idToCustomClass: idToCustomClass, into: &referencesByClass)

return referencesByClass.map { className, members in
AssetReference(
absoluteName: className,
source: .interfaceBuilder,
outlets: Array(members.outlets),
actions: Array(members.actions),
runtimeAttributes: Array(members.runtimeAttributes)
)
}
}

// MARK: - Private

private func references(from element: AEXMLElement) -> [String] {
var names: [String] = []
/// Builds a map of element id to customClass for resolving action destinations.
private func buildIdToCustomClassMap(from element: AEXMLElement, into map: inout [String: String]) {
if let id = element.attributes["id"], let customClass = element.attributes["customClass"] {
map[id] = customClass
}
for child in element.children {
buildIdToCustomClassMap(from: child, into: &map)
}
}

/// Recursively collects class references, outlets, actions, and runtime attributes.
private func collectReferences(
from element: AEXMLElement,
idToCustomClass: [String: String],
into referencesByClass: inout [String: (outlets: Set<String>, actions: Set<String>, runtimeAttributes: Set<String>)]
) {
// Check if this element has a customClass
if let customClass = element.attributes["customClass"] {
// Initialize entry for this class if needed
if referencesByClass[customClass] == nil {
referencesByClass[customClass] = (outlets: [], actions: [], runtimeAttributes: [])
}

// Collect outlets from this element's connections
collectOutlets(from: element, forClass: customClass, into: &referencesByClass)

// Collect runtime attributes from this element
collectRuntimeAttributes(from: element, forClass: customClass, into: &referencesByClass)
}

// Collect actions - these reference a destination class
collectActions(from: element, idToCustomClass: idToCustomClass, into: &referencesByClass)

// Collect Cocoa Bindings (macOS) - these reference properties on destination objects
collectBindings(from: element, idToCustomClass: idToCustomClass, into: &referencesByClass)

// Recurse into children
for child in element.children {
if let name = child.attributes["customClass"] {
names.append(name)
collectReferences(from: child, idToCustomClass: idToCustomClass, into: &referencesByClass)
}
}

/// Collects outlet property names from an element's connections.
/// Handles both `<outlet>` and `<outletCollection>` elements.
private func collectOutlets(
from element: AEXMLElement,
forClass customClass: String,
into referencesByClass: inout [String: (outlets: Set<String>, actions: Set<String>, runtimeAttributes: Set<String>)]
) {
for child in element.children where child.name == "connections" {
for connection in child.children {
// Handle both regular outlets and outlet collections (for @IBOutlet arrays like [UIButton])
guard connection.name == "outlet" || connection.name == "outletCollection" else { continue }
if let property = connection.attributes["property"] {
referencesByClass[customClass]?.outlets.insert(property)
}
}
}
}

/// Collects action selectors and associates them with the destination class.
private func collectActions(
from element: AEXMLElement,
idToCustomClass: [String: String],
into referencesByClass: inout [String: (outlets: Set<String>, actions: Set<String>, runtimeAttributes: Set<String>)]
) {
for child in element.children where child.name == "connections" {
for connection in child.children where connection.name == "action" {
guard let selector = connection.attributes["selector"] else { continue }

// iOS uses "destination", macOS uses "target"
guard let targetId = connection.attributes["destination"] ?? connection.attributes["target"]
else { continue }

// Resolve the target to a customClass
if let customClass = idToCustomClass[targetId] {
if referencesByClass[customClass] == nil {
referencesByClass[customClass] = (outlets: [], actions: [], runtimeAttributes: [])
}
referencesByClass[customClass]?.actions.insert(selector)
}
}
}
}

/// Collects user-defined runtime attribute key paths (IBInspectable).
private func collectRuntimeAttributes(
from element: AEXMLElement,
forClass customClass: String,
into referencesByClass: inout [String: (outlets: Set<String>, actions: Set<String>, runtimeAttributes: Set<String>)]
) {
for child in element.children where child.name == "userDefinedRuntimeAttributes" {
for attr in child.children where attr.name == "userDefinedRuntimeAttribute" {
if let keyPath = attr.attributes["keyPath"] {
referencesByClass[customClass]?.runtimeAttributes.insert(keyPath)
}
}
}
}

/// Collects Cocoa Bindings (macOS) which reference properties via keyPath.
/// Bindings connect UI elements to controller properties, e.g., `<binding destination="..." keyPath="self.propertyName" ...>`.
private func collectBindings(
from element: AEXMLElement,
idToCustomClass: [String: String],
into referencesByClass: inout [String: (outlets: Set<String>, actions: Set<String>, runtimeAttributes: Set<String>)]
) {
for child in element.children where child.name == "connections" {
for connection in child.children where connection.name == "binding" {
guard let keyPath = connection.attributes["keyPath"],
let destination = connection.attributes["destination"]
else { continue }

// Resolve the destination to a customClass
if let customClass = idToCustomClass[destination] {
if referencesByClass[customClass] == nil {
referencesByClass[customClass] = (outlets: [], actions: [], runtimeAttributes: [])
}
// Extract the first component of the keyPath (e.g., "self.propertyName" -> "propertyName")
let propertyName = extractPropertyName(from: keyPath)
referencesByClass[customClass]?.outlets.insert(propertyName)
}
}
}
}

/// Extracts the property name from a binding keyPath.
/// Handles formats like "self.propertyName", "propertyName", "self.object.nestedProperty".
private func extractPropertyName(from keyPath: String) -> String {
var path = keyPath

// Remove "self." prefix if present
if path.hasPrefix("self.") {
path = String(path.dropFirst(5))
}

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

return names
return path
}
}
31 changes: 31 additions & 0 deletions Sources/SourceGraph/Elements/AssetReference.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,39 @@ public struct AssetReference: Hashable {
name = absoluteName
}
self.source = source
outlets = []
actions = []
runtimeAttributes = []
}

/// Initializer for Interface Builder references with outlet/action/attribute details.
public init(
absoluteName: String,
source: ProjectFileKind,
outlets: [String],
actions: [String],
runtimeAttributes: [String]
) {
if let name = absoluteName.split(separator: ".").last {
self.name = String(name)
} else {
name = absoluteName
}
self.source = source
self.outlets = outlets
self.actions = actions
self.runtimeAttributes = runtimeAttributes
}

public let name: String
public let source: ProjectFileKind

/// Outlet property names referenced in Interface Builder (e.g., "button", "label").
public let outlets: [String]

/// Action selector names referenced in Interface Builder (e.g., "click:", "handleTap:").
public let actions: [String]

/// User-defined runtime attribute key paths (IBInspectable, e.g., "cornerRadius", "borderColor").
public let runtimeAttributes: [String]
}
36 changes: 34 additions & 2 deletions Sources/SourceGraph/Mutators/AssetReferenceRetainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,15 @@ final class AssetReferenceRetainer: SourceGraphMutator {
}

func mutate() {
interfaceBuilderPropertyRetainer.retainPropertiesDeclaredInExtensions()
// Aggregate all IB references by class name to collect all outlets/actions/attributes
// across multiple XIB/storyboard files that might reference the same class.
let ibReferencesByClass = aggregateInterfaceBuilderReferences()

// Collect all runtime attributes for extension-based IBInspectable properties
let allRuntimeAttributes = ibReferencesByClass.values.reduce(into: Set<String>()) { result, refs in
result.formUnion(refs.runtimeAttributes)
}
interfaceBuilderPropertyRetainer.retainPropertiesDeclaredInExtensions(referencedAttributes: allRuntimeAttributes)

graph
.declarations(ofKind: .class)
Expand All @@ -34,7 +42,14 @@ final class AssetReferenceRetainer: SourceGraphMutator {
graph.unmarkRedundantPublicAccessibility(declaration)
declaration.descendentDeclarations.forEach { graph.unmarkRedundantPublicAccessibility($0) }
case .interfaceBuilder:
interfaceBuilderPropertyRetainer.retainPropertiesDeclared(in: declaration)
// Get aggregated references for this class
let aggregated = ibReferencesByClass[declaration.name ?? ""]
interfaceBuilderPropertyRetainer.retainPropertiesDeclared(
in: declaration,
referencedOutlets: aggregated?.outlets ?? [],
referencedActions: aggregated?.actions ?? [],
referencedAttributes: aggregated?.runtimeAttributes ?? []
)
default:
break
}
Expand All @@ -46,4 +61,21 @@ final class AssetReferenceRetainer: SourceGraphMutator {
private func reference(for declaration: Declaration) -> AssetReference? {
graph.assetReferences.first { $0.name == declaration.name }
}

/// Aggregates all Interface Builder references by class name, combining outlets, actions,
/// and runtime attributes from all XIB/storyboard files that reference each class.
private func aggregateInterfaceBuilderReferences() -> [String: (outlets: Set<String>, actions: Set<String>, runtimeAttributes: Set<String>)] {
var result: [String: (outlets: Set<String>, actions: Set<String>, runtimeAttributes: Set<String>)] = [:]

for ref in graph.assetReferences where ref.source == .interfaceBuilder {
if result[ref.name] == nil {
result[ref.name] = (outlets: [], actions: [], runtimeAttributes: [])
}
result[ref.name]?.outlets.formUnion(ref.outlets)
result[ref.name]?.actions.formUnion(ref.actions)
result[ref.name]?.runtimeAttributes.formUnion(ref.runtimeAttributes)
}

return result
}
}
Loading