Skip to content

Add shared cursorInfo infrastructure and forEach-to-forIn code action#2588

Open
MoonMao42 wants to merge 8 commits intoswiftlang:mainfrom
MoonMao42:shared-cursorinfo-pr
Open

Add shared cursorInfo infrastructure and forEach-to-forIn code action#2588
MoonMao42 wants to merge 8 commits intoswiftlang:mainfrom
MoonMao42:shared-cursorinfo-pr

Conversation

@MoonMao42
Copy link
Copy Markdown

Closes #2584
Closes #2509

Adds SharedCursorInfo so code action providers can share a single cursorInfo request. SyntaxCodeActionProvider.codeActions(in:) is now async, and the scope object (renamed to CodeActionScope) is created once in codeAction() and passed to all retrieve methods.

Also adds a code action to convert .forEach to for-in loops, as the first provider that uses this.

Before:

names.forEach { name in
  print(name)
}

After:

for name in names {
  print(name)
}

Only handles closures with explicit named parameters ($0 shorthand is not supported).

Copy link
Copy Markdown
Member

@rintaro rintaro left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks pretty good already!

/// Lazy, shared cache for a single `cursorInfo` request across concurrent code action providers.
///
/// The first call to `get()` triggers the sourcekitd request; subsequent calls await the same `Task`.
actor SharedCursorInfo {
Copy link
Copy Markdown
Member

@rintaro rintaro Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this whole actor can be generalized as something like:

actor LazyTask<Success: Sendable> {
  private let operation: @Sendable () async throws -> Success

  init(_ operation: @escaping @Sendable () async throws -> Success) {
    self.operation = operation
  }

  private lazy var task: Task<Success, any Error> = Task {
    try await operation()
  }

  var value: Success {
    get async throws {
      try await task.value
    }
  }
}

Maybe we already have something like it?

//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors
// Copyright (c) 2014 - 2026 Apple Inc. and the Swift project authors

return nil
}

let visitor = ForEachCallVisitor(rangeToMatch: scope.range)
Copy link
Copy Markdown
Member

@rintaro rintaro Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is SyntaxVisitor really relevant? For example, for:

1️⃣func test() {
  2️⃣foo.bar.3️⃣forEach4️⃣ { item in
    print(item)
  }5️⃣
}6️⃣

I think we should only offer the code action when the curosr/selection is 2️⃣..<4️⃣, 2️⃣..<5️⃣, 3️⃣..<3️⃣, 3️⃣..<4️⃣, and 3️⃣..<5️⃣.
I think using a visitor and returning .visitChildren for every failure case makes it available for 1️⃣..<6️⃣ or even for the entire source file.
Please add test cases as needed.

return []
}

guard !hasTryOrAwait(match.closure) else {
Copy link
Copy Markdown
Member

@rintaro rintaro Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you explain what is a case we can't convert it to for-in?

I understand having try? outside the forEach call expression is not easy:

try? foo.forEach { ...; try ...; }

to:

do {
  for item in foo { ...; try ...; }
} catch { /*ignore*/ }

But I'm not sure having try in the closure body prevents anything.

}

// Reject if parameter is shorthand $0 syntax
if param.firstName.text == "$0" {
Copy link
Copy Markdown
Member

@rintaro rintaro Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this handles anonymous closure parameters. $0 does not appear in the closure parameter syntax tree.

It would be great this also rewrites $0. I.e. rewrite

foo.forEach {
  print($0)
}

with

for element in foo {
  print(element)
}

It's not super obvious whether we can use element though, we should check if it's not already used in the body.

}

override func visit(_ node: FunctionDeclSyntax) -> DeclSyntax {
DeclSyntax(node)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should dig into any non-StmtSyntax.
Are there any cases where return in an expression or decl escapes the surrounding function scope?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see you addressed this? Also applies to other visitors/rewriters. Please avoid dig into unnecessary nodes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed — all three visitor/rewriter classes (ClosureEligibilityVisitor, ReturnToContinueRewriter, DollarZeroRewriter) now consistently skip the same five scope-introducing constructs: FunctionDeclSyntax, InitializerDeclSyntax, DeinitializerDeclSyntax, ClosureExprSyntax, AccessorBlockSyntax.

Copy link
Copy Markdown
Member

@rintaro rintaro Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors
// Copyright (c) 2014 - 2026 Apple Inc. and the Swift project authors

return nil
}

guard let parameters = signature.parameterClause?.as(ClosureParameterClauseSyntax.self) else {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about ClosureShortParameterSyntax? Ideally the conversion should be:
foo.forEach { item in ... } -> for item in foo { ... }
foo.forEach { (item) in ... } -> for item in foo { ... }
foo.forEach { (item: Ty) in ... } -> for item: Ty in foo { ... }

}

let codeActionCapabilities = capabilityRegistry.clientCapabilities.textDocument?.codeAction
let codeActions = try await retrieveCodeActions(req, providers: providers)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

func retrieveCodeActions(_:providers:) below is now unused.


if wantedActionKinds == nil {
allCodeActions += await retrieveSyntaxCodeActions(scope)
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think retrieveSyntaxCodeActions was added even if wantedActionKinds exist. Am I missing something?

/// A lazily-evaluated, shared async value that is computed at most once.
///
/// The first access triggers the operation; subsequent accesses await the same `Task`.
actor LazyValue<Success: Sendable> {
Copy link
Copy Markdown
Member

@rintaro rintaro Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you use a different name?
We already have LazyValue with different semantics here https://github.com/swiftlang/sourcekit-lsp/blob/main/Sources/SwiftExtensions/LazyValue.swift

@MoonMao42
Copy link
Copy Markdown
Author

Done, thanks for the review.

}

/// Returns cursorInfo at the position of the given syntax node.
func cursorInfo(at absolutePosition: AbsolutePosition) async throws -> CursorInfo? {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah sorry I didn't think about what information is in the cursor-info result when I was suggesting the ranges to offer actions.

We'd like to avoid requesting non-shared cursorInfo in textDocument/codeAction request. Could you remove this? And I think it's okay for now to suggest this action only when the cursor is on forEach.

I.e. for

      1️⃣func test() {
        let array = [1, 2, 3]
        2️⃣array.reversed().3️⃣forEach4️⃣ { item in
          print(item)
        }5️⃣
      }6️⃣

The action is active only for 3️⃣..<3️⃣ and 3️⃣..<4️⃣.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense — the action now activates only when the cursor/selection is exactly on the forEach token, so shared cursorInfo at the request position works.


// Walk up from the innermost node to find a forEach call expression,
// stopping at function/closure boundaries to avoid matching distant calls.
guard let callExpr = node.findParentOfSelf(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's only active at forEach, the check can be something like:

guard
  let token = node.as(TokenSyntax.self),
  token.text = "forEach"
  let memberName = token.parent?.as(DeclReferenceExprSyntax.self),
  let memberAccessExpr = member.parent?.as(MemberAccessExprSyntax.self),
  memberAccessExpr.declName.id == memberName.id,
  let callExpr = memberAccessExpr.parent?.as(FunctionCallExprSyntax.self),
  callExpr.calledExpression.id == memberAccessExpr,
else {
  return nil
}

let closure: ClosureExprSyntax?
...

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — replaced with selectedForEachToken(in:) that validates directly against the request range positions.

return Match(
callExpr: callExpr,
memberAccess: memberAccess,
collection: collection,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

forEach(_:) is available on Sequence, so could you name this sequence instead of collection?

}

let forEachPosition = match.memberAccess.declName.baseName.positionAfterSkippingLeadingTrivia
guard let info = try? await scope.cursorInfo(at: forEachPosition),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And this can be scope.cursorInfo()

Comment on lines +36 to +42
guard !hasReturnWithValue(match.closure) else {
return []
}

guard !hasAwait(match.closure) else {
return []
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you make a single function/visitor checking both? So we can check the eligibility in single pass.

private func extractParameter(from closure: ClosureExprSyntax) -> ClosureParam? {
guard let signature = closure.signature else {
// No signature → anonymous $0 usage.
let name = generateUniqueName(avoiding: collectIdentifiers(in: closure.statements))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to separate function for this.
Also, could you avoid inserting every identifier in the Set, we're only interested in identifiers starting with element

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — containsIdentifier(named:in:) now checks on demand instead of collecting all names.

}

override func visit(_ node: FunctionDeclSyntax) -> DeclSyntax {
DeclSyntax(node)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see you addressed this? Also applies to other visitors/rewriters. Please avoid dig into unnecessary nodes.

Copy link
Copy Markdown
Member

@rintaro rintaro left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's getting close!
Just a few nit-picky edge case handling suggestions.

guard node.expression == nil else {
return StmtSyntax(node)
}
return StmtSyntax(ContinueStmtSyntax())
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I realized this is not enough for cases like

items.forEach { item in
  for elem in item.elements {
    if condition(elem) {
      return
    }
  }
  // ...
}

In this case, we need a label on the rewritten for-in:

ITEM: for item in items {
  for elem in item.elements {
    if condition(elem) {
      continue ITEM
    }
  }
  // ...
}

That said, I'm fine with ignoring such cases for this PR. But in that case, could you add FIXME and file an issue for that?

override func visit(_ node: InitializerDeclSyntax) -> SyntaxVisitorContinueKind { .skipChildren }
override func visit(_ node: DeinitializerDeclSyntax) -> SyntaxVisitorContinueKind { .skipChildren }
override func visit(_ node: ClosureExprSyntax) -> SyntaxVisitorContinueKind { .skipChildren }
override func visit(_ node: AccessorBlockSyntax) -> SyntaxVisitorContinueKind { .skipChildren }
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any type declarations too. i.e. struct, class, enum, etc.

}

private func extractParameter(from closure: ClosureExprSyntax) -> ClosureParam? {
guard let signature = closure.signature else {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rare edge case:

arr.forEach { [] in
  print($0)
}

In this case signature is non-nil.
Even more edge case:

arr.forEach { [foo = bar] in
  print($0, foo)
}

I guess we should rewrite this like

do {
  let foo = bar 
  for element in arr {
    print(element, foo)
  }
}

But it's too much effort.
I'm fine with just rejecting (not offering the action) for closures with capture list.

Comment on lines +91 to +92
guard let token = selectedForEachToken(in: scope),
token.text == "forEach",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this is what swift-format suggests. Could you run swift format -i -r . at the package top-level directory? Otherwise the test will fail.


/// Matches only when the cursor/selection is on the `forEach` token.
private func findForEachCall(in scope: CodeActionScope) -> Match? {
guard let token = selectedForEachToken(in: scope),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does scope.innermostNodeContainingRange not work?

Copy link
Copy Markdown
Author

@MoonMao42 MoonMao42 Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tokenForRefactoring shifts the cursor to the previous token at exact token boundaries, so innermostNodeContainingRange resolves to . instead of forEach when the cursor is at the start of forEach. selectedForEachToken bypasses this by matching against the raw request position.

@rintaro
Copy link
Copy Markdown
Member

rintaro commented Apr 11, 2026

@swift-ci Please test

@rintaro
Copy link
Copy Markdown
Member

rintaro commented Apr 13, 2026

@MoonMao42 Could you rebase on the current main and fix the build issue?
Also macOS failure indicates the formatting is still off. Try swift format -i -r . again right before pushing.

async let refactorActions = wantActionKind(.refactor) ? try await retrieveRefactorCodeActions(scope) : []
async let quickFixActions = wantActionKind(.quickFix) ? try await retrieveQuickFixCodeActions(scope) : []
async let unusedImportActions = wantActionKind(.sourceOrganizeImports)
? try await retrieveRemoveUnusedImportsCodeAction(scope) : []
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I think try await shouldn't be here.

- Generalize SharedCursorInfo into a reusable LazyValue<Success> actor
- Extract result tuple into CursorInfoResponse struct
- Make sharedCursorInfo non-optional in CodeActionScope
- Remove unnecessary `package` access level and error swallowing
- Bake additionalParameters into the init closure instead of get()
- Remove infrastructure tests that don't verify meaningful behavior
- Replace downward SyntaxVisitor with upward findParentOfSelf walk
- Handle all closure parameter forms (simpleInput, parameterClause)
- Support $0 shorthand with DollarZeroRewriter (skips nested closures)
- Allow try in closure body (forEach is rethrows)
- Request cursorInfo at forEach position, not cursor position
- Add cursorInfoProvider to CodeActionScope for positional lookups
- Narrow return/continue rewriting to skip accessor boundaries
- Rename LazyValue to AsyncLazy, delete unused retrieveCodeActions
- Add CMakeLists.txt entries, fix copyright years
- Rewrite tests using SwiftPMTestProject for proper stdlib resolution
…e and await

walk(closure) triggers visit(ClosureExprSyntax) on the root node itself,
which returns .skipChildren — making the visitor a no-op. Walk the closure's
statements instead so the skip-closure logic only applies to nested closures.
- Use BasicFormat to produce correctly indented for-in output
- Trim collection expression trivia for clean output
- Tests now verify exact replacement text instead of just action presence
- Add selection range boundary tests
- Add test for closure body cursor position (should not offer action)
- Add test for nested $0 not being rewritten
- Add test verifying bare return → continue in output
- Unify scope boundaries across all visitor/rewriter classes to skip
  FunctionDeclSyntax, InitializerDeclSyntax, DeinitializerDeclSyntax,
  ClosureExprSyntax, and AccessorBlockSyntax
- Merge ReturnWithValueVisitor and AwaitVisitor into single-pass
  ClosureEligibilityVisitor
- Replace findParentOfSelf with token-level matching via
  selectedForEachToken for precise cursor/selection validation
- Rename collection to sequence
- Simplify generateUniqueName to avoid collecting all identifiers
- Remove cursorInfoProvider from CodeActionScope, use shared cursorInfo
- Parallelize code action retrieval with async let
- Handle bare return in nested loops with labeled continue
- Skip type declarations (struct, class, enum, etc.) in all visitors
- Reject closures with capture lists
- Add tests for labeled continue, capture list rejection
- Apply swift-format
@MoonMao42 MoonMao42 force-pushed the shared-cursorinfo-pr branch from 5a20f1f to e31adb4 Compare April 14, 2026 14:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Shared cursorInfo infrastructure in SwiftLanguageService.codeAction(_:) Add Convert forEach to for-in loop code action

2 participants