Skip to content

[Macros] Infer nonisolated conformances in macro-expanded code. #3069

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ import SwiftSyntaxMacros
@_spi(PluginMessage)
public enum PluginFeature: String {
case loadPluginLibrary = "load-plugin-library"

/// Whether the plugin knows how to infer nonisolated conformances.
case inferNonisolatedConformances = "infer-nonisolated-conformances"
Copy link
Member

Choose a reason for hiding this comment

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

This doesn't seem to be used.

Copy link
Member Author

Choose a reason for hiding this comment

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

It ends up getting passed back to the compiler, which checks for its presence. See the compiler PR I just put up.

Copy link
Member

@rintaro rintaro May 2, 2025

Choose a reason for hiding this comment

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

Okay, but I consider PluginFeature is more like variation of plugin executables. E.g. single-module-plugin vs. plugin-server.

For something like this, I'd use PluginMessage.PROTOCOL_VERSION_NUMBER. Like, in PluginHost.swift in the swift repo:

extension CompilerPlugin.Capability {
  hasIinferNonisolatedConformances: Bool { protcolVersion >= 8 }
}

Also, you only added this feature to LibraryPluginProvider, which doesn't cover single-module executable plugins.

}

/// A type that provides the actual plugin functions.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ public class LibraryPluginProvider: PluginProvider {
public static let shared: LibraryPluginProvider = LibraryPluginProvider()

public var features: [PluginFeature] {
[.loadPluginLibrary]
[.loadPluginLibrary, .inferNonisolatedConformances]
}

public func loadPluginLibrary(libraryPath: String, moduleName: String) throws {
Expand Down
1 change: 1 addition & 0 deletions Sources/SwiftSyntaxMacroExpansion/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ add_swift_syntax_library(SwiftSyntaxMacroExpansion
MacroReplacement.swift
MacroSpec.swift
MacroSystem.swift
SyntaxProtocol+NonisolatedConformances.swift
)

target_link_swift_syntax_libraries(SwiftSyntaxMacroExpansion PUBLIC
Expand Down
42 changes: 28 additions & 14 deletions Sources/SwiftSyntaxMacroExpansion/MacroExpansion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ public func expandFreestandingMacro(
(.codeItem, _), (.preamble, _), (.body, _):
throw MacroExpansionError.unmatchedMacroRole(definition, macroRole)
}
return expandedSyntax.formattedExpansion(definition.formatMode, indentationWidth: indentationWidth)
return expandedSyntax.adjustedMacroExpansion(for: definition, indentationWidth: indentationWidth)
} catch {
context.addDiagnostics(from: error, node: node)
return nil
Expand Down Expand Up @@ -273,7 +273,7 @@ public func expandAttachedMacroWithoutCollapsing<Context: MacroExpansionContext>
in: context
)
return accessors.map {
$0.formattedExpansion(definition.formatMode, indentationWidth: indentationWidth)
$0.adjustedMacroExpansion(for: definition, indentationWidth: indentationWidth)
}

case (let attachedMacro as MemberAttributeMacro.Type, .memberAttribute):
Expand All @@ -294,7 +294,7 @@ public func expandAttachedMacroWithoutCollapsing<Context: MacroExpansionContext>

// Form a buffer containing an attribute list to return to the caller.
return attributes.map {
$0.formattedExpansion(definition.formatMode, indentationWidth: indentationWidth)
$0.adjustedMacroExpansion(for: definition, indentationWidth: indentationWidth)
}

case (let attachedMacro as MemberMacro.Type, .member):
Expand All @@ -313,7 +313,7 @@ public func expandAttachedMacroWithoutCollapsing<Context: MacroExpansionContext>

// Form a buffer of member declarations to return to the caller.
return members.map {
$0.formattedExpansion(definition.formatMode, indentationWidth: indentationWidth)
$0.adjustedMacroExpansion(for: definition, indentationWidth: indentationWidth)
}

case (let attachedMacro as PeerMacro.Type, .peer):
Expand All @@ -326,7 +326,7 @@ public func expandAttachedMacroWithoutCollapsing<Context: MacroExpansionContext>

// Form a buffer of peer declarations to return to the caller.
return peers.map {
$0.formattedExpansion(definition.formatMode, indentationWidth: indentationWidth)
$0.adjustedMacroExpansion(for: definition, indentationWidth: indentationWidth)
}

case (let attachedMacro as ExtensionMacro.Type, .extension):
Expand Down Expand Up @@ -357,7 +357,7 @@ public func expandAttachedMacroWithoutCollapsing<Context: MacroExpansionContext>

// Form a buffer of peer declarations to return to the caller.
return extensions.map {
$0.formattedExpansion(definition.formatMode, indentationWidth: indentationWidth)
$0.adjustedMacroExpansion(for: definition, indentationWidth: indentationWidth)
}

case (let attachedMacro as PreambleMacro.Type, .preamble):
Expand All @@ -375,7 +375,7 @@ public func expandAttachedMacroWithoutCollapsing<Context: MacroExpansionContext>
in: context
)
return preamble.map {
$0.formattedExpansion(definition.formatMode, indentationWidth: indentationWidth)
$0.adjustedMacroExpansion(for: definition, indentationWidth: indentationWidth)
}

case (let attachedMacro as BodyMacro.Type, .body):
Expand All @@ -400,7 +400,7 @@ public func expandAttachedMacroWithoutCollapsing<Context: MacroExpansionContext>
}

return body.map {
$0.formattedExpansion(definition.formatMode, indentationWidth: indentationWidth)
$0.adjustedMacroExpansion(for: definition, indentationWidth: indentationWidth)
}

default:
Expand Down Expand Up @@ -511,15 +511,29 @@ public func expandAttachedMacro<Context: MacroExpansionContext>(
}

fileprivate extension SyntaxProtocol {
/// Perform a format if required and then trim any leading/trailing
/// whitespace.
func formattedExpansion(_ mode: FormatMode, indentationWidth: Trivia?) -> String {
switch mode {
/// Perform post-expansion adjustments to the result of a macro expansion.
///
/// This applies adjustments to the result of a macro expansion to normalize
/// it for use in later tools. Each of the adjustments here should have a
/// corresponding configuration option in the `Macro` protocol.
func adjustedMacroExpansion(
for macro: Macro.Type,
indentationWidth: Trivia?
) -> String {
var syntax = Syntax(self)

// Infer nonisolated conformances.
if macro.inferNonisolatedConformances {
syntax = syntax.inferNonisolatedConformances()
}

// Formatting.
switch macro.formatMode {
case .auto:
return self.formatted(using: BasicFormat(indentationWidth: indentationWidth))
return syntax.formatted(using: BasicFormat(indentationWidth: indentationWidth))
.trimmedDescription(matching: \.isWhitespace)
case .disabled:
return Syntax(self).description
return syntax.description
#if RESILIENT_LIBRARIES
@unknown default:
fatalError()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//
//
// This file implements inference of "nonisolated" on the conformances that
// occur within macro-expanded code. It's meant to provide source compatibility
Copy link
Member

Choose a reason for hiding this comment

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

Incomplete comment?

//

import SwiftSyntax

extension SyntaxProtocol {
/// Given some Swift syntax that may contain type definitions and extensions,
/// add "nonisolated" to protocol conformances when there are nonisolated
/// members. For example, given:
///
/// extension X: P {
/// nonisolated func f() { }
/// }
///
/// this operation will produce:
///
/// extension X: nonisolated P {
/// nonisolated func f() { }
/// }
@_spi(Testing) @_spi(Compiler)
public func inferNonisolatedConformances() -> Syntax {
Copy link
Member

Choose a reason for hiding this comment

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

This could be a computed property. Also, because it is non-mutating, it should be called inferringNonisolatedConformances

let rewriter = NonisolatedConformanceRewriter()
return rewriter.rewrite(self)
}
}

fileprivate class NonisolatedConformanceRewriter: SyntaxRewriter {
override func visitAny(_ node: Syntax) -> Syntax? {
// We only care about decl groups (non-protocol nominal types + extensions)
// that have nonisolated members and an inheritance clause.
guard let declGroup = node.asProtocol(DeclGroupSyntax.self),
!declGroup.is(ProtocolDeclSyntax.self),
declGroup.containsNonisolatedMembers,
let inheritanceClause = declGroup.inheritanceClause
else {
return nil
}

var skipFirst =
declGroup.is(ClassDeclSyntax.self)
|| (declGroup.is(EnumDeclSyntax.self) && inheritanceClause.inheritedTypes.first?.looksLikeEnumRawType ?? false)
let inheritedTypes = inheritanceClause.inheritedTypes.map { inheritedType in
// If there's already a 'nonisolated' or some kind of custom attribute
if inheritedType.type.hasNonisolatedOrCustomAttribute {
return inheritedType
}

if skipFirst {
skipFirst = false
return inheritedType
}
Comment on lines +61 to +64
Copy link
Member

Choose a reason for hiding this comment

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

Should this check be before the hasNonisolatedOrCustomAttribute check? Not that it makes much sense but we skip the second type using the skipFirst check if you have something like enum Foo: nonisolated Int, MyProtocol.


return inheritedType.with(\.type, "nonisolated \(inheritedType.type)")
}
Copy link
Member

Choose a reason for hiding this comment

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

If this doesn't change anything, can we return nil here? To save the rewrite cost.

Copy link
Member

Choose a reason for hiding this comment

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

Returning a non-nil result here means that we’ll traverse the children as well. Returning the node itself stops traversal. We do check the ID of the returned node and if it’s the same as the original, we don’t perform any rewriting.


return Syntax(
fromProtocol: declGroup.with(
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
fromProtocol: declGroup.with(
fromProtocol: declGroup.detached.with(

so that it doesn't rewrite the whole tree. Same for other .with(..)

\.inheritanceClause,
inheritanceClause.with(
\.inheritedTypes,
InheritedTypeListSyntax(inheritedTypes)
)
)
)
Copy link
Member

Choose a reason for hiding this comment

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

Returning something from SyntaxRewriter.visitAny(_:) prevents it from waling into children. If we what to rewrite things like

extension MyStruct: P {
  nonisolated func f() { }
  struct Inner: Q {
    nonisolated func b() {}
  }
}

to

extension MyStruct: nonisolated P {
  nonisolated func f() { }
  struct Inner: nonisolated Q {
    nonisolated func b() {}
  }
}

You'd need

.with(
  \.memberBlock
  self.rewrite(declGroup.memberBlock, detach: true)
)

}
}

extension TypeSyntax {
/// Determine whether the given type has a 'nonisolated' specifier or a
/// custom attribute (that could be a global actor).
fileprivate var hasNonisolatedOrCustomAttribute: Bool {
var type = self
while let attributedType = type.as(AttributedTypeSyntax.self) {
// nonisolated
let hasNonisolated = attributedType.specifiers.contains { specifier in
if case .nonisolatedTypeSpecifier = specifier {
return true
}

return false
}
if hasNonisolated {
return true
}

// Any attribute will do.
if !attributedType.attributes.isEmpty {
return true
}

type = attributedType.baseType
}

return false
}
}

extension InheritedTypeSyntax {
/// Determine whether this inherited type "looks like" a raw type, e.g.,
/// if it's one of the integer types or String. This can only be an heuristic,
/// because it does not
Copy link
Member

Choose a reason for hiding this comment

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

Incomplete comment?

fileprivate var looksLikeEnumRawType: Bool {
// TODO: We could probably use a utility to syntactically recognize types
// from the
Comment on lines +116 to +117
Copy link
Member

Choose a reason for hiding this comment

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

Incomplete comment?

var text = type.trimmed.description[...]
if text.starts(with: "Swift.") {
text = text.dropFirst(6)
}
Comment on lines +119 to +121
Copy link
Member

Choose a reason for hiding this comment

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

It would be nice to instead look for a MemberTypeSyntax. The current implementation wouldn’t detect Swift . Int, which is valid.


switch text {
case "Int", "Int8", "Int16", "Int32", "Int64",
"UInt", "UInt8", "UInt16", "UInt32", "UInt64",
Copy link
Member

Choose a reason for hiding this comment

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

Do we also need to include Int128 and UInt128?

"String":
return true

default: return false
}
}
}
extension DeclModifierListSyntax {
/// Whether the modifier list contains "nonisolated".
fileprivate var hasNonisolated: Bool {
contains { $0.name.tokenKind == .keyword(.nonisolated) }
}
}

extension DeclGroupSyntax {
/// Determine whether any of members is marked "nonisolated.
fileprivate var containsNonisolatedMembers: Bool {
memberBlock.members.lazy.map(\.decl).contains {
$0.asProtocol(WithModifiersSyntax.self)?.modifiers.hasNonisolated ?? false
}
}
}
25 changes: 25 additions & 0 deletions Sources/SwiftSyntaxMacros/MacroProtocols/Macro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,29 @@ public protocol Macro {
/// How the resulting expansion should be formatted, `.auto` by default.
/// Use `.disabled` for the expansion to be used as is.
static var formatMode: FormatMode { get }

/// Whether to infer "nonisolated" on protocol conformances introduced in
/// the macro expansion when there are some nonisolated members in the
/// corresponding declaration group. When true, macro expansion will adjust
/// expanded code such as
///
/// extension C: P {
/// nonisolated func f() { }
/// }
///
/// to
///
/// extension C: nonisolated P {
/// nonisolated func f() { }
/// }
///
/// This operation defaults to `true`. Macros can implement it to return
/// `false` to prevent this adjustment to the macro-expanded code.
static var inferNonisolatedConformances: Bool { get }
Copy link
Member

Choose a reason for hiding this comment

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

Could you create an RFC for the API addition?

}

extension Macro {
/// Default implementation of the Macro protocol's
/// `inferNonisolatedConformances` that returns `true`.
public static var inferNonisolatedConformances: Bool { true }
}
35 changes: 35 additions & 0 deletions Tests/SwiftSyntaxMacroExpansionTest/ExtensionMacroTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,41 @@ final class ExtensionMacroTests: XCTestCase {
indentationWidth: indentationWidth
)
}

func testNonisolatedConformances() {
struct NonisolatedConformanceMacro: ExtensionMacro {
static func expansion(
of node: AttributeSyntax,
attachedTo declaration: some DeclGroupSyntax,
providingExtensionsOf type: some TypeSyntaxProtocol,
conformingTo protocols: [TypeSyntax],
in context: some MacroExpansionContext
) throws -> [ExtensionDeclSyntax] {
return [
("""
extension \(type): P {
nonisolated func f() { }
}
""" as DeclSyntax).cast(ExtensionDeclSyntax.self)
]
}
}

assertMacroExpansion(
"@NonisolatedConformance struct Foo {}",
expandedSource: """
struct Foo {}

extension Foo: nonisolated P {
nonisolated func f() {
}
}
""",
macros: [
"NonisolatedConformance": NonisolatedConformanceMacro.self
]
)
}
}

fileprivate struct SendableExtensionMacro: ExtensionMacro {
Expand Down
Loading