From f03797039e532d7c271217fdf9057b82efde6264 Mon Sep 17 00:00:00 2001 From: wuyangfan <1102042793@qq.com> Date: Sun, 31 May 2026 20:27:00 +0800 Subject: [PATCH] fix: unused_enumerated skip split usage across chained closures When offset and element are each used in different trailing closures chained after enumerated() (e.g. first then flatMap), do not report either member as unused. Fixes #5600 --- CHANGELOG.md | 5 + .../Idiomatic/UnusedEnumeratedRule.swift | 92 ++++++++++++++++++- 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a172d6e6..51b0436a92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,11 @@ ### Bug Fixes +* Fix `unused_enumerated` false positives when `offset` and `element` are used in + separate trailing closures chained after `.enumerated()`. + [leno23](https://github.com/leno23) + [#5600](https://github.com/realm/SwiftLint/issues/5600) + * Avoid false positives in `prefer_self_in_static_references` for generic constraints and generic parameter bounds such as `where A: P` and `` in classes and extensions. diff --git a/Source/SwiftLintBuiltInRules/Rules/Idiomatic/UnusedEnumeratedRule.swift b/Source/SwiftLintBuiltInRules/Rules/Idiomatic/UnusedEnumeratedRule.swift index 31789636cd..70bd15a096 100644 --- a/Source/SwiftLintBuiltInRules/Rules/Idiomatic/UnusedEnumeratedRule.swift +++ b/Source/SwiftLintBuiltInRules/Rules/Idiomatic/UnusedEnumeratedRule.swift @@ -54,6 +54,11 @@ struct UnusedEnumeratedRule: Rule { print(i) } """, excludeFromDocumentation: true), + Example(""" + rules.enumerated() + .first { $0.element.isValid() == false } + .flatMap { messages[$0.offset] } + """), ], triggeringExamples: [ Example("for (↓_, foo) in bar.enumerated() { }"), @@ -110,6 +115,7 @@ private extension UnusedEnumeratedRule { private struct Closure { let enumeratedPosition: AbsolutePosition let usedEnumeratedResultMembers: (zero: Bool, one: Bool) + let enclosingCall: FunctionCallExprSyntax var zeroPosition: AbsolutePosition? var onePosition: AbsolutePosition? } @@ -118,6 +124,7 @@ private extension UnusedEnumeratedRule { let id: SyntaxIdentifier let enumeratedPosition: AbsolutePosition let usedEnumeratedResultMembers: (zero: Bool, one: Bool) + let enclosingCall: FunctionCallExprSyntax } final class Visitor: ViolationsSyntaxVisitor { @@ -180,7 +187,8 @@ private extension UnusedEnumeratedRule { pendingClosure = PendingClosure( id: trailingClosure.id, enumeratedPosition: enumeratedPosition, - usedEnumeratedResultMembers: ExprSyntax(parentCall).usedEnumeratedResultMembers + usedEnumeratedResultMembers: ExprSyntax(parentCall).usedEnumeratedResultMembers, + enclosingCall: parentCall ) } @@ -191,7 +199,8 @@ private extension UnusedEnumeratedRule { if let pendingClosure, pendingClosure.id == node.id { closures.push(Closure( enumeratedPosition: pendingClosure.enumeratedPosition, - usedEnumeratedResultMembers: pendingClosure.usedEnumeratedResultMembers + usedEnumeratedResultMembers: pendingClosure.usedEnumeratedResultMembers, + enclosingCall: pendingClosure.enclosingCall )) self.pendingClosure = nil } else { @@ -200,13 +209,18 @@ private extension UnusedEnumeratedRule { return .visitChildren } - override func visitPost(_: ClosureExprSyntax) { + override func visitPost(_ node: ClosureExprSyntax) { guard let closure = closures.pop().flatMap(\.self) else { return } + let chainedUsage = closure.enclosingCall.enumeratedMembersUsedInChainedClosures( + excluding: node.id + ) let zeroPosition = closure.zeroPosition - ?? (closure.usedEnumeratedResultMembers.zero ? closure.enumeratedPosition : nil) + ?? (closure.usedEnumeratedResultMembers.zero || chainedUsage.zero + ? closure.enumeratedPosition : nil) let onePosition = closure.onePosition - ?? (closure.usedEnumeratedResultMembers.one ? closure.enumeratedPosition : nil) + ?? (closure.usedEnumeratedResultMembers.one || chainedUsage.one + ? closure.enumeratedPosition : nil) guard (zeroPosition != nil) != (onePosition != nil) else { return } addViolation( @@ -341,6 +355,74 @@ private extension ClosureShorthandParameterSyntax { } } +private extension FunctionCallExprSyntax { + func enumeratedMembersUsedInChainedClosures( + excluding closureId: SyntaxIdentifier + ) -> (zero: Bool, one: Bool) { + var usesOffset = false + var usesElement = false + var current: FunctionCallExprSyntax? = self + + while let call = current, + let outerCall = call.outerFunctionCallWithTrailingClosure, + let trailingClosure = outerCall.trailingClosure, + trailingClosure.id != closureId { + let usage = trailingClosure.enumeratedTupleMembersUsed() + usesOffset = usesOffset || usage.zero + usesElement = usesElement || usage.one + current = outerCall + } + + return (usesOffset, usesElement) + } + + var outerFunctionCallWithTrailingClosure: FunctionCallExprSyntax? { + guard let memberAccess = parent?.as(MemberAccessExprSyntax.self), + let outerCall = memberAccess.parent?.as(FunctionCallExprSyntax.self), + outerCall.trailingClosure != nil else { + return nil + } + return outerCall + } +} + +private extension ClosureExprSyntax { + func enumeratedTupleMembersUsed() -> (zero: Bool, one: Bool) { + EnumeratedTupleUsageScanner() + .walk(tree: Syntax(self), handler: \.usage) + } +} + +private final class EnumeratedTupleUsageScanner: SyntaxVisitor { + private(set) var usage = (zero: false, one: false) + + init() { + super.init(viewMode: .sourceAccurate) + } + + override func visitPost(_ node: DeclReferenceExprSyntax) { + guard node.baseName.text == "$0" || node.baseName.text == "$1" else { + return + } + + if node.baseName.text == "$0" { + let member = node.parent?.as(MemberAccessExprSyntax.self)?.declName.baseName.text + if member == "element" || member == "1" { + usage.one = true + } else if member == "offset" || member == "0" { + usage.zero = true + } else if node.isUnpacked { + usage.zero = true + usage.one = true + } else { + usage.zero = true + } + } else { + usage.one = true + } + } +} + private extension DeclReferenceExprSyntax { var isUnpacked: Bool { if let initializer = parent?.as(InitializerClauseSyntax.self),