From 064b1564ee12f2dfca19fec980dbc6542effd242 Mon Sep 17 00:00:00 2001 From: Ian Leitch Date: Sat, 17 Jan 2026 10:39:50 +0100 Subject: [PATCH] Fix IBAction methods with named parameters or no parameters marked as unused The swiftNameToSelector function was incorrectly converting Swift method names to Objective-C selectors for IBAction methods: - Named first parameter: `colorTapped(sender:)` should produce `colorTappedWithSender:` but was producing `colorTapped:` - No parameters: `confirmTapped()` should produce `confirmTapped` but was producing `confirmTapped:` - Preposition parameters: `click(for:)` should produce `clickFor:` (no "With" prefix) The fix implements the full Swift-to-ObjC selector conversion rules, including handling of prepositions from Swift's PartsOfSpeech.def. Resolves #1049 --- CHANGELOG.md | 2 +- .../InterfaceBuilderPropertyRetainer.swift | 63 ++++++++++++-- ...InterfaceBuilderPropertyRetainerTest.swift | 83 +++++++++++++++++++ .../UIKitProject/XibViewController.swift | 25 ++++++ .../UIKitProject/XibViewController.xib | 24 ++++++ Tests/XcodeTests/UIKitProjectTest.swift | 8 ++ 6 files changed, 196 insertions(+), 9 deletions(-) create mode 100644 Tests/PeripheryTests/InterfaceBuilderPropertyRetainerTest.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b0c59954..8c311747a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ##### Bug Fixes -- None. +- Fix IBAction methods with named parameters or no parameters incorrectly marked as unused. ## 3.4.0 (2026-01-06) diff --git a/Sources/SourceGraph/Mutators/InterfaceBuilderPropertyRetainer.swift b/Sources/SourceGraph/Mutators/InterfaceBuilderPropertyRetainer.swift index adfadad07..805fc291d 100644 --- a/Sources/SourceGraph/Mutators/InterfaceBuilderPropertyRetainer.swift +++ b/Sources/SourceGraph/Mutators/InterfaceBuilderPropertyRetainer.swift @@ -53,7 +53,7 @@ final class InterfaceBuilderPropertyRetainer { // Check IBAction/IBSegueAction methods if decl.attributes.contains(where: { ibActionAttributes.contains($0.name) }) { - let selectorName = swiftNameToSelector(declName) + let selectorName = Self.swiftNameToSelector(declName) if referencedActions.contains(selectorName) { graph.markRetained(decl) } @@ -70,13 +70,38 @@ final class InterfaceBuilderPropertyRetainer { } } - // MARK: - Private + // MARK: - Helpers + + /// Prepositions that Swift recognizes for Objective-C selector conversion. + /// When a first parameter label is one of these, it's just capitalized without adding "With". + /// Source: https://github.com/apple/swift/blob/main/lib/Basic/PartsOfSpeech.def + private static let knownPrepositions: Set = [ + "above", "after", "along", "alongside", "as", "at", + "before", "below", "by", + "following", "for", "from", + "given", + "in", "including", "inside", "into", + "matching", + "of", "on", + "passing", "preceding", + "since", + "to", + "until", "using", + "via", + "when", "with", "within", + ] /// 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:" + /// + /// Swift to Objective-C selector conversion rules: + /// - `func myMethod()` → `myMethod` (no parameters, no colon) + /// - `func myMethod(_ sender: Any)` → `myMethod:` (unnamed first param) + /// - `func myMethod(sender: Any)` → `myMethodWithSender:` (named first param gets "With" prefix) + /// - `func myMethod(for value: Any)` → `myMethodFor:` (preposition labels are just capitalized) + /// - `func myMethod(_:secondParam:)` → `myMethod:secondParam:` + /// - `func myMethod(firstParam:secondParam:)` → `myMethodWithFirstParam:secondParam:` + static func swiftNameToSelector(_ swiftName: String) -> String { guard let parenStart = swiftName.firstIndex(of: "("), let parenEnd = swiftName.lastIndex(of: ")") else { @@ -86,15 +111,37 @@ final class InterfaceBuilderPropertyRetainer { let methodName = String(swiftName[.. "For:", "with" -> "With:", "withSender" -> "WithSender:" + selector += label.prefix(1).uppercased() + label.dropFirst() + ":" + } else { + // Other labels get "With" prefix + // e.g., "sender" -> "WithSender:" + selector += "With" + label.prefix(1).uppercased() + label.dropFirst() + ":" + } + } } else if !param.isEmpty { // Subsequent parameters: add label + ":" selector += String(param) + ":" diff --git a/Tests/PeripheryTests/InterfaceBuilderPropertyRetainerTest.swift b/Tests/PeripheryTests/InterfaceBuilderPropertyRetainerTest.swift new file mode 100644 index 000000000..bb69a5095 --- /dev/null +++ b/Tests/PeripheryTests/InterfaceBuilderPropertyRetainerTest.swift @@ -0,0 +1,83 @@ +import Foundation +@testable import SourceGraph +import XCTest + +/// Tests for InterfaceBuilderPropertyRetainer's Swift-to-Objective-C selector conversion. +final class InterfaceBuilderPropertyRetainerTest: XCTestCase { + // MARK: - No Parameters + + func testNoParameters() { + // func confirmTapped() → confirmTapped (no colon) + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("confirmTapped()"), "confirmTapped") + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("doSomething()"), "doSomething") + } + + // MARK: - Unnamed First Parameter (using _) + + func testUnnamedFirstParameter() { + // func click(_ sender: Any) → click: + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("click(_:)"), "click:") + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("handleTap(_:)"), "handleTap:") + } + + // MARK: - Named First Parameter + + func testNamedFirstParameter() { + // func colorTapped(sender: Any) → colorTappedWithSender: + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("colorTapped(sender:)"), "colorTappedWithSender:") + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("configure(model:)"), "configureWithModel:") + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("update(value:)"), "updateWithValue:") + } + + // MARK: - Multiple Parameters with Unnamed First + + func testMultipleParametersUnnamedFirst() { + // func handleTap(_:forEvent:) → handleTap:forEvent: + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("handleTap(_:forEvent:)"), "handleTap:forEvent:") + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("doSomething(_:withValue:andExtra:)"), "doSomething:withValue:andExtra:") + } + + // MARK: - Multiple Parameters with Named First + + func testMultipleParametersNamedFirst() { + // func configure(model:animated:) → configureWithModel:animated: + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("configure(model:animated:)"), "configureWithModel:animated:") + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("update(sender:completion:)"), "updateWithSender:completion:") + } + + // MARK: - Preposition First Parameters (no "With" prefix) + + func testPrepositionFirstParameter() { + // Prepositions are just capitalized, not prefixed with "With" + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("action(for:)"), "actionFor:") + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("action(with:)"), "actionWith:") + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("action(using:)"), "actionUsing:") + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("action(by:)"), "actionBy:") + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("action(to:)"), "actionTo:") + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("action(at:)"), "actionAt:") + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("action(in:)"), "actionIn:") + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("action(on:)"), "actionOn:") + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("action(from:)"), "actionFrom:") + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("action(into:)"), "actionInto:") + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("action(after:)"), "actionAfter:") + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("action(before:)"), "actionBefore:") + } + + func testWithPrefixedFirstParameter() { + // Labels starting with "with" are just capitalized (no double "With") + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("action(withSender:)"), "actionWithSender:") + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("action(withValue:)"), "actionWithValue:") + } + + // MARK: - Edge Cases + + func testSingleLetterParameter() { + // func tap(x:) → tapWithX: + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("tap(x:)"), "tapWithX:") + } + + func testMethodWithNoParentheses() { + // Should return as-is (shouldn't happen in practice, but handles edge case) + XCTAssertEqual(InterfaceBuilderPropertyRetainer.swiftNameToSelector("someProperty"), "someProperty") + } +} diff --git a/Tests/XcodeTests/UIKitProject/UIKitProject/XibViewController.swift b/Tests/XcodeTests/UIKitProject/UIKitProject/XibViewController.swift index 6f168ba16..b08c46f81 100644 --- a/Tests/XcodeTests/UIKitProject/UIKitProject/XibViewController.swift +++ b/Tests/XcodeTests/UIKitProject/UIKitProject/XibViewController.swift @@ -14,11 +14,36 @@ class XibViewController: UIViewController { showAlert(title: "IBAction", message: "clickFromSubclass(_:) - Connected via Interface Builder") } + // IBAction with named first parameter (selector: clickWithNamedParamWithSender:) + @IBAction func clickWithNamedParam(sender: Any) { + showAlert(title: "IBAction", message: "clickWithNamedParam(sender:) - Connected via Interface Builder") + } + + // IBAction with no parameters (selector: clickNoParams) + @IBAction func clickNoParams() { + showAlert(title: "IBAction", message: "clickNoParams() - Connected via Interface Builder") + } + + // IBAction with preposition first parameter (selector: clickFor:) + @IBAction func click(for sender: Any) { + showAlert(title: "IBAction", message: "click(for:) - 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!") } + // Unreferenced - IBAction with named param but not connected + @IBAction func unusedActionWithNamedParam(sender: Any) { + showAlert(title: "Unused", message: "unusedActionWithNamedParam(sender:) - This should be reported as unused!") + } + + // Unreferenced - IBAction with no params but not connected + @IBAction func unusedActionNoParams() { + showAlert(title: "Unused", message: "unusedActionNoParams() - This should be reported as unused!") + } + // MARK: - IBInspectable @IBInspectable var controllerProperty: UIColor? @IBInspectable var unusedInspectable: CGFloat = 0 diff --git a/Tests/XcodeTests/UIKitProject/UIKitProject/XibViewController.xib b/Tests/XcodeTests/UIKitProject/UIKitProject/XibViewController.xib index e72de26ab..6a311e299 100644 --- a/Tests/XcodeTests/UIKitProject/UIKitProject/XibViewController.xib +++ b/Tests/XcodeTests/UIKitProject/UIKitProject/XibViewController.xib @@ -40,6 +40,30 @@ + + +