Skip to content

Commit 52d27b0

Browse files
authored
Fix unused parameter false-positive for closure capture lists (#1013)
Parameters used in closure capture lists (e.g., [captured = param]) were incorrectly reported as unused. The parser now correctly analyzes capture list expressions to detect parameter usage. Fixes #994
1 parent 6c3aace commit 52d27b0

File tree

4 files changed

+94
-2
lines changed

4 files changed

+94
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
- Fix indexing of xib/storyboard files in SPM projects.
1515
- Fix types conforming to App Intents protocols being reported as unused.
1616
- Fix superclass initializer reported as unused when called on subclass.
17+
- Fix unused parameter false-positive for parameters used in closure capture lists.
1718

1819
## 3.3.0 (2025-12-13)
1920

Sources/SyntaxAnalysis/UnusedParameterParser.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,8 +253,12 @@ struct UnusedParameterParser {
253253
let signature = syntax.children(viewMode: .sourceAccurate).mapFirst { $0.as(ClosureSignatureSyntax.self) }
254254
let rawParams = signature?.parameterClause?.children(viewMode: .sourceAccurate).compactMap { $0.as(ClosureShorthandParameterSyntax.self) }
255255
let params = rawParams?.map(\.name.text) ?? []
256-
let items = syntax.statements.compactMap { parse(node: $0.item, collector) }
257-
return Closure(params: params, items: items)
256+
257+
// Parse capture list expressions (e.g., [captured = state.someProperty])
258+
let captureItems = signature?.capture?.items.compactMap { parse(node: $0.initializer?.value, collector) } ?? []
259+
260+
let bodyItems = syntax.statements.compactMap { parse(node: $0.item, collector) }
261+
return Closure(params: params, items: captureItems + bodyItems)
258262
}
259263

260264
private func parse(variableDecl syntax: VariableDeclSyntax, _ collector: Collector<some Any>?) -> Variable {
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import Foundation
2+
3+
struct CaptureListFixtureState {
4+
var someProperty: String
5+
}
6+
7+
class CaptureListFixture {
8+
// Parameter used in capture list should be considered used
9+
func functionWithCaptureList(_ state: CaptureListFixtureState) -> () -> String {
10+
return { [someProperty = state.someProperty] in
11+
return someProperty
12+
}
13+
}
14+
15+
// Multiple parameters used in capture list
16+
func multipleParamsInCaptureList(param1: String, param2: Int, unusedParam: Bool) -> () -> String {
17+
return { [captured1 = param1, captured2 = param2] in
18+
return "\(captured1) \(captured2)"
19+
}
20+
}
21+
22+
// Parameter used both in capture list and directly - should be used
23+
func paramUsedInCaptureListAndDirectly(param: String) -> () -> String {
24+
print(param)
25+
return { [captured = param] in
26+
return captured
27+
}
28+
}
29+
30+
// Weak capture (common pattern for avoiding retain cycles)
31+
func weakCapture(object: AnyObject) -> () -> Void {
32+
return { [weak object] in
33+
_ = object
34+
}
35+
}
36+
37+
// Nested closure with capture list
38+
func nestedClosureWithCaptureList(outerParam: String) -> () -> () -> String {
39+
return {
40+
return { [captured = outerParam] in
41+
return captured
42+
}
43+
}
44+
}
45+
46+
// Shorthand capture (just [param] without assignment)
47+
func shorthandCapture(param: String) -> () -> String {
48+
return { [param] in
49+
return param
50+
}
51+
}
52+
53+
// Capture list only - parameter not used in body, only in capture
54+
func captureListOnly(param: String) -> () -> String {
55+
return { [captured = param] in
56+
return "constant"
57+
}
58+
}
59+
}
60+

Tests/PeripheryTests/Syntax/UnusedParameterTest.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,33 @@ final class UnusedParameterTest: XCTestCase {
206206
assertUnused(label: "otherUnused", name: "otherUnused", in: "myFunc(class:func:otherUsed:otherUnused:)")
207207
}
208208

209+
// https://github.com/peripheryapp/periphery/issues/994
210+
func testCaptureListUsage() {
211+
analyze()
212+
// Parameter used in capture list should be considered used
213+
assertUsed(label: "_", name: "state", in: "functionWithCaptureList(_:)")
214+
215+
// Multiple parameters - some used in capture list, one unused
216+
assertUsed(label: "param1", name: "param1", in: "multipleParamsInCaptureList(param1:param2:unusedParam:)")
217+
assertUsed(label: "param2", name: "param2", in: "multipleParamsInCaptureList(param1:param2:unusedParam:)")
218+
assertUnused(label: "unusedParam", name: "unusedParam", in: "multipleParamsInCaptureList(param1:param2:unusedParam:)")
219+
220+
// Parameter used both in capture list and directly
221+
assertUsed(label: "param", name: "param", in: "paramUsedInCaptureListAndDirectly(param:)")
222+
223+
// Weak capture (common pattern for avoiding retain cycles)
224+
assertUsed(label: "object", name: "object", in: "weakCapture(object:)")
225+
226+
// Nested closure with capture list
227+
assertUsed(label: "outerParam", name: "outerParam", in: "nestedClosureWithCaptureList(outerParam:)")
228+
229+
// Shorthand capture (just [param] without assignment)
230+
assertUsed(label: "param", name: "param", in: "shorthandCapture(param:)")
231+
232+
// Capture list only - parameter not used in body, only in capture
233+
assertUsed(label: "param", name: "param", in: "captureListOnly(param:)")
234+
}
235+
209236
// MARK: - Private
210237

211238
private var unusedParamsByFunction: [(Function, Set<Parameter>)] = []

0 commit comments

Comments
 (0)