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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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<String> = [
"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 {
Expand All @@ -86,15 +111,37 @@ final class InterfaceBuilderPropertyRetainer {
let methodName = String(swiftName[..<parenStart])
let paramsSection = String(swiftName[swiftName.index(after: parenStart) ..< parenEnd])

// No parameters: return just the method name (no colon)
if paramsSection.isEmpty {
return methodName
}

// Split by ":" to get parameter labels
let params = paramsSection.split(separator: ":", omittingEmptySubsequences: false)

// Build the selector: methodName + ":" for each parameter
// Build the selector
var selector = methodName
for (index, param) in params.enumerated() {
if index == 0 {
// First parameter: just add ":"
selector += ":"
// First parameter handling
if param == "_" || param.isEmpty {
// Unnamed first param: just add ":"
selector += ":"
} else {
let label = String(param)
let lowercasedLabel = label.lowercased()

// Check if label is a known preposition or starts with "with"
if knownPrepositions.contains(lowercasedLabel) || lowercasedLabel.hasPrefix("with") {
// Prepositions and "with*" labels are just capitalized
// e.g., "for" -> "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) + ":"
Expand Down
83 changes: 83 additions & 0 deletions Tests/PeripheryTests/InterfaceBuilderPropertyRetainerTest.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
25 changes: 25 additions & 0 deletions Tests/XcodeTests/UIKitProject/UIKitProject/XibViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions Tests/XcodeTests/UIKitProject/UIKitProject/XibViewController.xib
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,30 @@
<action selector="click:" destination="-1" eventType="touchUpInside" id="jLb-bl-k6b"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" fixedFrame="YES" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="btn-named-param">
<rect key="frame" x="140" y="479" width="134" height="30"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<state key="normal" title="Named Param"/>
<connections>
<action selector="clickWithNamedParamWithSender:" destination="-1" eventType="touchUpInside" id="act-named-param"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" fixedFrame="YES" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="btn-no-params">
<rect key="frame" x="160" y="519" width="94" height="30"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<state key="normal" title="No Params"/>
<connections>
<action selector="clickNoParams" destination="-1" eventType="touchUpInside" id="act-no-params"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" fixedFrame="YES" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="btn-preposition">
<rect key="frame" x="160" y="559" width="94" height="30"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<state key="normal" title="Preposition"/>
<connections>
<action selector="clickFor:" destination="-1" eventType="touchUpInside" id="act-preposition"/>
</connections>
</button>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" fixedFrame="YES" text="XibViewController" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ega-I7-Zu8">
<rect key="frame" x="139" y="410" width="136" height="21"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
Expand Down
8 changes: 8 additions & 0 deletions Tests/XcodeTests/UIKitProjectTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,19 @@ final class UIKitProjectTest: XcodeSourceGraphTestCase {
self.assertReferenced(.varInstance("button"))
self.assertReferenced(.varInstance("controllerProperty"))
self.assertReferenced(.functionMethodInstance("click(_:)"))
// IBAction with named first parameter
self.assertReferenced(.functionMethodInstance("clickWithNamedParam(sender:)"))
// IBAction with no parameters
self.assertReferenced(.functionMethodInstance("clickNoParams()"))
// IBAction with preposition first parameter
self.assertReferenced(.functionMethodInstance("click(for:)"))
// Unreferenced - not connected in XIB
self.assertNotReferenced(.varInstance("unusedOutlet"))
self.assertNotReferenced(.functionMethodInstance("unusedAction(_:)"))
self.assertNotReferenced(.functionMethodInstance("clickFromSubclass(_:)"))
self.assertNotReferenced(.varInstance("unusedInspectable"))
self.assertNotReferenced(.functionMethodInstance("unusedActionWithNamedParam(sender:)"))
self.assertNotReferenced(.functionMethodInstance("unusedActionNoParams()"))
}
assertReferenced(.class("XibView")) {
self.assertReferenced(.varInstance("viewProperty"))
Expand Down