diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fd4cc43b..565ab016c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 2a24787dd..10474ff32 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/Indexer/XibParser.swift b/Sources/Indexer/XibParser.swift index 54e003b87..1d00eabd7 100644 --- a/Sources/Indexer/XibParser.swift +++ b/Sources/Indexer/XibParser.swift @@ -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, actions: Set, runtimeAttributes: Set)] = [:] + 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, actions: Set, runtimeAttributes: Set)] + ) { + // 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 `` and `` elements. + private func collectOutlets( + from element: AEXMLElement, + forClass customClass: String, + into referencesByClass: inout [String: (outlets: Set, actions: Set, runtimeAttributes: Set)] + ) { + 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, actions: Set, runtimeAttributes: Set)] + ) { + 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, actions: Set, runtimeAttributes: Set)] + ) { + 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., ``. + private func collectBindings( + from element: AEXMLElement, + idToCustomClass: [String: String], + into referencesByClass: inout [String: (outlets: Set, actions: Set, runtimeAttributes: Set)] + ) { + 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[..()) { result, refs in + result.formUnion(refs.runtimeAttributes) + } + interfaceBuilderPropertyRetainer.retainPropertiesDeclaredInExtensions(referencedAttributes: allRuntimeAttributes) graph .declarations(ofKind: .class) @@ -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 } @@ -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, actions: Set, runtimeAttributes: Set)] { + var result: [String: (outlets: Set, actions: Set, runtimeAttributes: Set)] = [:] + + 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 + } } diff --git a/Sources/SourceGraph/Mutators/InterfaceBuilderPropertyRetainer.swift b/Sources/SourceGraph/Mutators/InterfaceBuilderPropertyRetainer.swift index a3873d046..e72caa590 100644 --- a/Sources/SourceGraph/Mutators/InterfaceBuilderPropertyRetainer.swift +++ b/Sources/SourceGraph/Mutators/InterfaceBuilderPropertyRetainer.swift @@ -2,30 +2,105 @@ import Foundation class InterfaceBuilderPropertyRetainer { private let graph: SourceGraph - private let ibAttributes = ["IBOutlet", "IBAction", "IBInspectable", "IBSegueAction"] + private let ibOutletAttributes: Set = ["IBOutlet"] + private let ibActionAttributes: Set = ["IBAction", "IBSegueAction"] + private let ibInspectableAttributes: Set = ["IBInspectable"] required init(graph: SourceGraph) { self.graph = graph } - /// Some properties may be declared in extensions on external types, e.g IBInspectable. - func retainPropertiesDeclaredInExtensions() { + /// Retains IBInspectable properties declared in extensions on external types. + /// These cannot be reliably matched to XIB runtime attributes since the extended + /// type may not have a customClass in the XIB. + func retainPropertiesDeclaredInExtensions(referencedAttributes: Set) { let extensions = graph.declarations(ofKind: .extensionClass) for extDecl in extensions { - for decl in extDecl.declarations where decl.attributes.contains(where: { ibAttributes.contains($0) }) { - graph.markRetained(decl) + for decl in extDecl.declarations { + // IBInspectable properties in extensions: check if referenced + if decl.attributes.contains(where: { ibInspectableAttributes.contains($0) }) { + if let name = decl.name, referencedAttributes.contains(name) { + graph.markRetained(decl) + } + } } } } - func retainPropertiesDeclared(in declaration: Declaration) { + /// Retains only the outlets, actions, and inspectable properties that are actually + /// referenced in the Interface Builder file. + func retainPropertiesDeclared( + in declaration: Declaration, + referencedOutlets: Set, + referencedActions: Set, + referencedAttributes: Set + ) { let inheritedDeclarations = graph.inheritedDeclarations(of: declaration) let descendentInheritedDeclarations = inheritedDeclarations.map(\.declarations).joined() let allDeclarations = declaration.declarations.union(descendentInheritedDeclarations) - for declaration in allDeclarations where declaration.attributes.contains(where: { ibAttributes.contains($0) }) { - graph.markRetained(declaration) + for decl in allDeclarations { + guard let declName = decl.name else { continue } + + // Check IBOutlet properties + if decl.attributes.contains(where: { ibOutletAttributes.contains($0) }) { + if referencedOutlets.contains(declName) { + graph.markRetained(decl) + } + continue + } + + // Check IBAction/IBSegueAction methods + if decl.attributes.contains(where: { ibActionAttributes.contains($0) }) { + let selectorName = swiftNameToSelector(declName) + if referencedActions.contains(selectorName) { + graph.markRetained(decl) + } + continue + } + + // Check IBInspectable properties + if decl.attributes.contains(where: { ibInspectableAttributes.contains($0) }) { + if referencedAttributes.contains(declName) { + graph.markRetained(decl) + } + continue + } + } + } + + // MARK: - Private + + /// Converts a Swift function name like `click(_:)` or `doSomething(_:withValue:)` + /// to an Objective-C selector like `click:` or `doSomething:withValue:`. + private func swiftNameToSelector(_ swiftName: String) -> String { + // Remove the trailing parenthesis content to get just the method name with params + // e.g., "click(_:)" -> "click:" or "handleTap(_:forEvent:)" -> "handleTap:forEvent:" + guard let parenStart = swiftName.firstIndex(of: "("), + let parenEnd = swiftName.lastIndex(of: ")") + else { + return swiftName + } + + let methodName = String(swiftName[.. + + + + + diff --git a/Tests/SPMTests/SPMProjectMacOS/Sources/SPMProjectMacOSKit/SPMXibViewController.swift b/Tests/SPMTests/SPMProjectMacOS/Sources/SPMProjectMacOSKit/SPMXibViewController.swift index 6c318232b..5141ba9e6 100644 --- a/Tests/SPMTests/SPMProjectMacOS/Sources/SPMProjectMacOSKit/SPMXibViewController.swift +++ b/Tests/SPMTests/SPMProjectMacOS/Sources/SPMProjectMacOSKit/SPMXibViewController.swift @@ -1,6 +1,38 @@ import AppKit public class SPMXibViewController: NSViewController { + // MARK: - Referenced via XIB (connected) + @IBOutlet var button: NSButton! - @IBAction func buttonTapped(_: Any) {} + + @IBAction func buttonTapped(_: Any) { + showAlert(title: "SPMXibViewController", message: "buttonTapped(_:) action triggered!") + } + + @IBInspectable var borderWidth: CGFloat = 0 + + // MARK: - Unreferenced (not connected in XIB) + + @IBOutlet var unusedMacOutlet: NSTextField! + + @IBAction func unusedMacAction(_: Any) { + showAlert(title: "SPMXibViewController", message: "unusedMacAction(_:) - this should be reported as unused!") + } + + @IBInspectable var unusedMacInspectable: CGFloat = 0 + + override public func viewDidLoad() { + super.viewDidLoad() + // Verify button outlet is connected + button.title = "Tap Me!" + } + + private func showAlert(title: String, message: String) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .informational + alert.addButton(withTitle: "OK") + alert.runModal() + } } diff --git a/Tests/SPMTests/SPMProjectMacOSTest.swift b/Tests/SPMTests/SPMProjectMacOSTest.swift index 160107aa4..29b2d44db 100644 --- a/Tests/SPMTests/SPMProjectMacOSTest.swift +++ b/Tests/SPMTests/SPMProjectMacOSTest.swift @@ -13,8 +13,14 @@ func testRetainsInterfaceBuilderDeclarations() { assertReferenced(.class("SPMXibViewController")) { + // Referenced via XIB (connected) self.assertReferenced(.functionMethodInstance("buttonTapped(_:)")) self.assertReferenced(.varInstance("button")) + self.assertReferenced(.varInstance("borderWidth")) + // Unreferenced - not connected in XIB + self.assertNotReferenced(.varInstance("unusedMacOutlet")) + self.assertNotReferenced(.functionMethodInstance("unusedMacAction(_:)")) + self.assertNotReferenced(.varInstance("unusedMacInspectable")) } } } diff --git a/Tests/XcodeTests/UIKitProject/UIKitProject/Info.plist b/Tests/XcodeTests/UIKitProject/UIKitProject/Info.plist index b638cb383..4570c3203 100644 --- a/Tests/XcodeTests/UIKitProject/UIKitProject/Info.plist +++ b/Tests/XcodeTests/UIKitProject/UIKitProject/Info.plist @@ -26,17 +26,15 @@ UISceneConfigurations - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Launch Screen - - + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + UIApplicationSupportsIndirectInputEvents diff --git a/Tests/XcodeTests/UIKitProject/UIKitProject/SceneDelegate.swift b/Tests/XcodeTests/UIKitProject/UIKitProject/SceneDelegate.swift index b4ebfa21d..06d675d45 100644 --- a/Tests/XcodeTests/UIKitProject/UIKitProject/SceneDelegate.swift +++ b/Tests/XcodeTests/UIKitProject/UIKitProject/SceneDelegate.swift @@ -8,37 +8,36 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { print(MultiTargetStruct.usedInApp) print(MultiTargetStruct.usedInBoth) - // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. - // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. - // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). - guard scene is UIWindowScene else { return } - } + guard let windowScene = scene as? UIWindowScene else { return } - func sceneDidDisconnect(_ scene: UIScene) { - // Called as the scene is being released by the system. - // This occurs shortly after the scene enters the background, or when its session is discarded. - // Release any resources associated with this scene that can be re-created the next time the scene connects. - // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). - } + let window = UIWindow(windowScene: windowScene) - func sceneDidBecomeActive(_ scene: UIScene) { - // Called when the scene has moved from an inactive state to an active state. - // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. - } + // Create a tab bar controller to navigate between different XIB/storyboard view controllers + let tabBarController = UITabBarController() - func sceneWillResignActive(_ scene: UIScene) { - // Called when the scene will move from an active state to an inactive state. - // This may occur due to temporary interruptions (ex. an incoming phone call). - } + // Tab 1: XibViewController (from XIB) + let xibVC = XibViewController(nibName: "XibViewController", bundle: nil) + xibVC.tabBarItem = UITabBarItem(title: "XIB", image: UIImage(systemName: "1.circle"), tag: 0) - func sceneWillEnterForeground(_ scene: UIScene) { - // Called as the scene transitions from the background to the foreground. - // Use this method to undo the changes made on entering the background. - } + // Tab 2: StoryboardViewController (from storyboard) + let storyboard = UIStoryboard(name: "StoryboardViewController", bundle: nil) + let storyboardVC = storyboard.instantiateInitialViewController() ?? StoryboardViewController() + storyboardVC.tabBarItem = UITabBarItem(title: "Storyboard", image: UIImage(systemName: "2.circle"), tag: 1) + + // Tab 3: XibViewController2Subclass (tests inherited IBAction) + let xibVC2 = XibViewController2Subclass(nibName: "XibViewController2Subclass", bundle: nil) + xibVC2.tabBarItem = UITabBarItem(title: "Subclass", image: UIImage(systemName: "3.circle"), tag: 2) - func sceneDidEnterBackground(_ scene: UIScene) { - // Called as the scene transitions from the foreground to the background. - // Use this method to save data, release shared resources, and store enough scene-specific state information - // to restore the scene back to its current state. + tabBarController.viewControllers = [xibVC, storyboardVC, xibVC2] + + window.rootViewController = tabBarController + window.makeKeyAndVisible() + self.window = window } + + func sceneDidDisconnect(_ scene: UIScene) {} + func sceneDidBecomeActive(_ scene: UIScene) {} + func sceneWillResignActive(_ scene: UIScene) {} + func sceneWillEnterForeground(_ scene: UIScene) {} + func sceneDidEnterBackground(_ scene: UIScene) {} } diff --git a/Tests/XcodeTests/UIKitProject/UIKitProject/StoryboardViewController.storyboard b/Tests/XcodeTests/UIKitProject/UIKitProject/StoryboardViewController.storyboard index d3adec2f7..59a0e1db8 100644 --- a/Tests/XcodeTests/UIKitProject/UIKitProject/StoryboardViewController.storyboard +++ b/Tests/XcodeTests/UIKitProject/UIKitProject/StoryboardViewController.storyboard @@ -12,6 +12,11 @@ + + + + + diff --git a/Tests/XcodeTests/UIKitProject/UIKitProject/StoryboardViewController.swift b/Tests/XcodeTests/UIKitProject/UIKitProject/StoryboardViewController.swift index 5ef1b0cc0..44adb7e3c 100644 --- a/Tests/XcodeTests/UIKitProject/UIKitProject/StoryboardViewController.swift +++ b/Tests/XcodeTests/UIKitProject/UIKitProject/StoryboardViewController.swift @@ -1,6 +1,32 @@ import UIKit class StoryboardViewController: UIViewController { + // MARK: - Referenced via storyboard (connected) @IBOutlet weak var button: UIButton! - @IBAction func click(_ sender: Any) {} + + @IBAction func click(_ sender: Any) { + showAlert(title: "StoryboardViewController", message: "click(_:) action triggered!") + } + + @IBInspectable var cornerRadius: CGFloat = 0 + + // MARK: - Unreferenced (not connected in storyboard) + @IBOutlet weak var unusedStoryboardOutlet: UILabel! + + @IBAction func unusedStoryboardAction(_ sender: Any) { + showAlert(title: "StoryboardViewController", message: "unusedStoryboardAction(_:) - this should be reported as unused!") + } + + @IBInspectable var unusedInspectable: CGFloat = 0 + + override func viewDidLoad() { + super.viewDidLoad() + // Button outlet is connected - title comes from storyboard + } + + private func showAlert(title: String, message: String) { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + } } diff --git a/Tests/XcodeTests/UIKitProject/UIKitProject/XibViewController.swift b/Tests/XcodeTests/UIKitProject/UIKitProject/XibViewController.swift index dc7f18c32..6f168ba16 100644 --- a/Tests/XcodeTests/UIKitProject/UIKitProject/XibViewController.swift +++ b/Tests/XcodeTests/UIKitProject/UIKitProject/XibViewController.swift @@ -1,19 +1,72 @@ import UIKit class XibViewController: UIViewController { + // MARK: - IBOutlets (connected in XIB) @IBOutlet weak var button: UIButton! - @IBAction func click(_ sender: Any) {} - @IBAction func clickFromSubclass(_ sender: Any) {} + @IBOutlet weak var unusedOutlet: UILabel! + + // MARK: - IBActions (connected in XIB) + @IBAction func click(_ sender: Any) { + showAlert(title: "IBAction", message: "click(_:) - Connected via Interface Builder") + } + + @IBAction func clickFromSubclass(_ sender: Any) { + showAlert(title: "IBAction", message: "clickFromSubclass(_:) - Connected via Interface Builder") + } + + // Unreferenced - not connected in XIB + @IBAction func unusedAction(_ sender: Any) { + showAlert(title: "Unused", message: "unusedAction(_:) - This should be reported as unused!") + } + + // MARK: - IBInspectable @IBInspectable var controllerProperty: UIColor? + @IBInspectable var unusedInspectable: CGFloat = 0 + + // MARK: - Programmatic button for #selector test + private var selectorButton: UIButton! override func viewDidLoad() { super.viewDidLoad() + + // This reference retains selectorMethod even though it's never called _ = #selector(selectorMethod) - button.addTarget(self, action: #selector(addTargetMethod), for: .touchUpInside) + + // Create a separate button for testing addTarget/selector retention + setupSelectorButton() + + // Verify IBInspectable was applied + if controllerProperty != nil { + view.backgroundColor = controllerProperty + } } + private func setupSelectorButton() { + selectorButton = UIButton(type: .system) + selectorButton.setTitle("Selector Button", for: .normal) + selectorButton.translatesAutoresizingMaskIntoConstraints = false + selectorButton.addTarget(self, action: #selector(addTargetMethod), for: .touchUpInside) + view.addSubview(selectorButton) + + NSLayoutConstraint.activate([ + selectorButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + selectorButton.topAnchor.constraint(equalTo: button.bottomAnchor, constant: 20) + ]) + } + + // MARK: - Selector-referenced methods (retained via #selector) @objc private func selectorMethod() {} - @objc private func addTargetMethod() {} + + @objc private func addTargetMethod() { + showAlert(title: "Selector", message: "addTargetMethod() - Connected via addTarget/#selector") + } + + // MARK: - Alert helpers + private func showAlert(title: String, message: String) { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + } } class XibView: UIView { @@ -21,6 +74,7 @@ class XibView: UIView { } extension UIView { + // Referenced via XIB (used in userDefinedRuntimeAttributes) @IBInspectable var customBorderColor: UIColor? { get { if let borderColor = layer.borderColor { @@ -33,4 +87,10 @@ extension UIView { layer.borderColor = newValue?.cgColor } } + + // Unreferenced - not used in any XIB + @IBInspectable var unusedExtensionInspectable: CGFloat { + get { 0 } + set { _ = newValue } + } } diff --git a/Tests/XcodeTests/UIKitProject/UIKitProject/XibViewController.xib b/Tests/XcodeTests/UIKitProject/UIKitProject/XibViewController.xib index 58b1d3afa..e72de26ab 100644 --- a/Tests/XcodeTests/UIKitProject/UIKitProject/XibViewController.xib +++ b/Tests/XcodeTests/UIKitProject/UIKitProject/XibViewController.xib @@ -13,11 +13,24 @@ + + + + + + + + + + + + +