Skip to content
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

[WIP][Macro] Emit error if AccessorMacro is applied to node that is not a variable #2624

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
5 changes: 4 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,10 @@ let package = Package(

.target(
name: "SwiftSyntaxMacroExpansion",
dependencies: ["SwiftSyntax", "SwiftSyntaxBuilder", "SwiftSyntaxMacros", "SwiftDiagnostics", "SwiftOperators"],
dependencies: [
"SwiftSyntax", "SwiftSyntaxBuilder", "SwiftSyntaxMacros", "SwiftDiagnostics", "SwiftOperators",
"SwiftParserDiagnostics",
],
exclude: ["CMakeLists.txt"]
),

Expand Down
2 changes: 1 addition & 1 deletion Sources/SwiftParserDiagnostics/SyntaxExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ extension Syntax {
extension SyntaxProtocol {
/// A name that can be used to describe this node's type in diagnostics or `nil` if there is no good name for this node.
/// If `allowBlockNames` is `false`, ``CodeBlockSyntax`` and ``MemberDeclBlockSyntax`` are not considered to have a good name and will return `nil`.
func nodeTypeNameForDiagnostics(allowBlockNames: Bool) -> String? {
public func nodeTypeNameForDiagnostics(allowBlockNames: Bool) -> String? {
Copy link
Collaborator

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 make this method public. The entire logic with allowBlockNames is very specific to SwiftParserDiagnsotic. Using SyntaxKind.nameForDiagnostics would probably be the better choice.

Copy link
Author

Choose a reason for hiding this comment

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

Using SyntaxKind.nameForDiagnostics would probably be the better choice.

How to make nameForDiagnostics public? I found the file is generated.

By the way, if the files are generated, should we add generated files to .gitignore?

Copy link
Collaborator

Choose a reason for hiding this comment

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

The files are being generated as described here:

https://github.com/apple/swift-syntax/blob/main/CONTRIBUTING.md#generating-source-code

We need to commit the generated files to resolve a circular dependency: The sources are being generated using swift-syntax itself, which means that you need a version of swift-syntax to generate the sources of swift-syntax. If the generated sources weren’t committed anywhere, you couldn’t build swift-syntax.

let syntax = Syntax(self)
if !allowBlockNames && (syntax.is(CodeBlockSyntax.self) || syntax.is(MemberBlockSyntax.self)) {
return nil
Expand Down
19 changes: 18 additions & 1 deletion Sources/SwiftSyntaxMacroExpansion/MacroExpansion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import SwiftSyntax
@_spi(MacroExpansion) @_spi(ExperimentalLanguageFeature) import SwiftSyntaxMacros
#endif

public enum MacroRole: Sendable {
public enum MacroRole: String, Sendable {
RayZhao1998 marked this conversation as resolved.
Show resolved Hide resolved
case expression
case declaration
case accessor
Expand Down Expand Up @@ -63,6 +63,7 @@ enum MacroExpansionError: Error, CustomStringConvertible {
case noFreestandingMacroRoles(Macro.Type)
case moreThanOneBodyMacro
case preambleWithoutBody
case noAttachedMacroRoles(Macro.Type)

var description: String {
switch self {
Expand Down Expand Up @@ -92,6 +93,9 @@ enum MacroExpansionError: Error, CustomStringConvertible {

case .preambleWithoutBody:
return "preamble macro cannot be applied to a function with no body"

case .noAttachedMacroRoles(let type):
return "macro implementation type '\(type)' does not conform to any attached macro protocol"
}
}
}
Expand Down Expand Up @@ -173,6 +177,19 @@ public func inferFreestandingMacroRole(definition: Macro.Type) throws -> MacroRo
}
}

public func inferAttachedMacroRole(definition: Macro.Type) throws -> MacroRole {
switch definition {
case is AccessorMacro.Type: return .accessor
case is MemberAttributeMacro.Type: return .memberAttribute
case is MemberMacro.Type: return .member
case is PeerMacro.Type: return .peer
case is ExtensionMacro.Type: return .extension

default:
throw MacroExpansionError.noAttachedMacroRoles(definition)
}
}

@available(*, deprecated, message: "pass a macro role, please!")
public func expandFreestandingMacro(
definition: Macro.Type,
Expand Down
58 changes: 55 additions & 3 deletions Sources/SwiftSyntaxMacroExpansion/MacroSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ internal import SwiftOperators
@_spi(MacroExpansion) internal import SwiftParser
public import SwiftSyntax
internal import SwiftSyntaxBuilder
internal import SwiftParserDiagnostics
Copy link
Collaborator

Choose a reason for hiding this comment

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

Are these imports still necessary if you use nameKindForDiagnostics? Same for the dependency declaration in the package manifest.

Copy link
Author

Choose a reason for hiding this comment

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

Yes, it doesn't need any more.

@_spi(MacroExpansion) @_spi(ExperimentalLanguageFeature) public import SwiftSyntaxMacros
#else
import SwiftDiagnostics
Expand All @@ -24,6 +25,7 @@ import SwiftOperators
import SwiftSyntax
import SwiftSyntaxBuilder
@_spi(MacroExpansion) @_spi(ExperimentalLanguageFeature) import SwiftSyntaxMacros
import SwiftParserDiagnostics
#endif

// MARK: - Public entry function
Expand Down Expand Up @@ -633,6 +635,7 @@ private enum MacroApplicationError: DiagnosticMessage, Error {
case accessorMacroOnVariableWithMultipleBindings
case peerMacroOnVariableWithMultipleBindings
case malformedAccessor
case macroAttachedToInvalidDecl(String, String)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could you add labels for these associated values so it’s easier to tell what the two strings do?


var diagnosticID: MessageID {
return MessageID(domain: diagnosticDomain, id: "\(self)")
Expand All @@ -650,6 +653,8 @@ private enum MacroApplicationError: DiagnosticMessage, Error {
return """
macro returned a malformed accessor. Accessors should start with an introducer like 'get' or 'set'.
"""
case let .macroAttachedToInvalidDecl(macroType, declType):
return "'\(macroType)' macro cannot be attached to \(declType)"
}
}
}
Expand All @@ -666,6 +671,7 @@ private class MacroApplication<Context: MacroExpansionContext>: SyntaxRewriter {
/// Store expanded extension while visiting member decls. This should be
/// added to top-level 'CodeBlockItemList'.
var extensions: [CodeBlockItemSyntax] = []
var expandedAttributes: [AttributeSyntax] = []
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this can be private.


Also, I just tried to suggest a doc comment for it and couldn’t really come up with a great explanation, which is usually a sign that the design isn’t quite right (maybe you can find a good explanation). Thinking about it made me wonder if it would it be easier to

  • Change this variable to
/// Attributes that look like macro attributes and should be expanded. 
///
/// Attributes are added to this list from `visitAny`. Any attributes that weren’t actually expanded (eg. because they are accessor macros but not attached to a variable or subscript), will be diagnosed from `visitAny`.
var attributesToExpand: [AttributeSyntax] = []
  • In visitAny set this variable to self.macroAttributes(attachedTo: declSyntax)
  • Whenever we expand a macro, remove it from the list (I think we might even do this based on the ID now and don’t need to resort to the position).
  • If there are macros left over in attributesToExpand, diagnose them.
  • After visitAny is done, restore attributesToExpand to the value it had before we assigned it to self.macroAttributes(attachedTo: declSyntax).

Do you think that would work?

Copy link
Author

Choose a reason for hiding this comment

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

I think we might even do this based on the ID now and don’t need to resort to the position

I found that the id is still different. The macroAttributes method depends on the attachedTo node. In visitAny the declSyntax's root is SourceFileSyntax, while in visit(for example visit(_ node: VariableDeclSyntax)) the node's root is VariableDeclSyntax(itself).

It seems that in visitChildren, the node will have a new layout and generate a new syntax without origin root info. Is this by design?

Copy link
Author

Choose a reason for hiding this comment

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

I also delved into the implementation of AttributeSyntax.position. It's the position of the start of this node's leading trivia(not the root). For Attribute, it always represents its index in the AttributeList. So I don't think it's a proper way to compare two attributes by position

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think I’ll need to play around with the code myself a little to figure out how we want to continue, but am too busy with other things at the moment. I’ll definitely come back to you but it might take a few weeks.


init(
macroSystem: MacroSystem,
Expand Down Expand Up @@ -715,14 +721,56 @@ private class MacroApplication<Context: MacroExpansionContext>: SyntaxRewriter {
let visitedNode = self.visit(declSyntax)
skipVisitAnyHandling.remove(Syntax(declSyntax))

let attributesToRemove = self.macroAttributes(attachedTo: visitedNode).map(\.attributeNode)

return AttributeRemover(removingWhere: { attributesToRemove.contains($0) }).rewrite(visitedNode)
let attributesToRemove = self.macroAttributes(attachedTo: visitedNode)
attributesToRemove.forEach { (attribute, spec) in
RayZhao1998 marked this conversation as resolved.
Show resolved Hide resolved
if let index = self.expandedAttributes.firstIndex(where: { expandedAttribute in
expandedAttribute.position == attribute.position
}) {
self.expandedAttributes.remove(at: index)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is removing necessary for correctness or do we just do it to keep the number of elements in self.expandedAttributes low (which is a good reason on its own). Would be worth a comment though, I think.

Copy link
Author

Choose a reason for hiding this comment

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

It will be incorrect if it's possible that two attributes with same position are actually different during the process. For example, the list still contains an attribute expanded in the last visit, and in the same position there is attribute not expanded in this visit, I think it'll go wrong.

I think we should remove it from the list if it has been checked to make sure it's correct

Copy link
Collaborator

Choose a reason for hiding this comment

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

Ah, I thought we were fine because macros only ever introduce code after the the macro attribute itself, so I thought that the position would still uniquely identify a macro within the source file but thinking about it again, I realize that isn’t true because the macro itself gets removed, which shifts positions forward and thus allows another macro to have the same position.

For example, you could have the following

@NoOpMacroAB
@MacroThatExpandsToMemberAttributesMacroAndWhichIsAlsoAnAccessorMacro
sturct Foo {
func foo() {}
}

which expands to

sturct Foo {
@MacroThatExpandsToMemberAttributesMacroAndWhichIsAlsoAnAccessorMacro
func foo() {}
}

And the two @MacroThatExpandsToMemberAttributesMacroAndWhichIsAlsoAnAccessorMacro have the same location despite being two different macro expansions.


And yes, I think with the removal of attributes from self.expandedAttributes, this should be fine because we always clear that list whenever removing attributes.

I hope that I’m making sense here. I think it would be good to have a test case for what I described above and make sure that we do actually diagnose that MacroThatExpandsToMemberAttributesMacroAndWhichIsAlsoAnAccessorMacro can’t be attached to func foo(). Also, feel free to pick a shorter name for MacroThatExpandsToMemberAttributesMacroAndWhichIsAlsoAnAccessorMacro. I only picked it to convey the meaning of the attribute without writing an implementation.

} else {
if let macroRole = try? inferAttachedMacroRole(definition: spec.type),
isInvalidAttachedMacro(macroRole: macroRole, attachedTo: declSyntax)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we even need to check isInvalidAttachedMacro here? I would think that we should diagnose all macro attributes that didn’t actually expand. Or am I missing something?

Copy link
Author

Choose a reason for hiding this comment

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

I found out how it diagnose when AccessorMacro is applied to struct in the compiler and imitated it. Also the diagnostics I'v implemented below is just for attached macros(actually only AccessorMacro).

If we should diagnose all macro attributes that didn't expand, what error messages should we emit, a common error message or the same as what compiler emit? Also I don't know all the cases that attribute won't expand.

Copy link
Author

Choose a reason for hiding this comment

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

I made some changes. I've abstracted a method called diagnosticForUnexpandedMacro. Currently, it only implements diagnostics for AccessorMacro. The rest can be left for others to implement.

{
contextGenerator(node).addDiagnostics(
from: MacroApplicationError.macroAttachedToInvalidDecl(
macroRole.rawValue,
declSyntax.nodeTypeNameForDiagnostics(allowBlockNames: true) ?? ""
),
node: declSyntax,
fixIts: [
FixIt(
message: SwiftSyntaxMacros.MacroExpansionFixItMessage(
"Remove '\(attribute.trimmedDescription)'"
),
changes: [.replace(oldNode: Syntax(attribute), newNode: Syntax(AttributeListSyntax()))]
)
]
)
}
}
}
return AttributeRemover(removingWhere: { attributesToRemove.map(\.attributeNode).contains($0) }).rewrite(
visitedNode
)
}

return nil
}

private func isInvalidAttachedMacro(macroRole: MacroRole, attachedTo: DeclSyntax) -> Bool {
switch macroRole {
case .accessor:
// Only var decls and subscripts have accessors
if (!attachedTo.is(VariableDeclSyntax.self) && !attachedTo.is(SubscriptDeclSyntax.self)) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
if (!attachedTo.is(VariableDeclSyntax.self) && !attachedTo.is(SubscriptDeclSyntax.self)) {
if !attachedTo.is(VariableDeclSyntax.self) && !attachedTo.is(SubscriptDeclSyntax.self) {

return true
}
break
default:
break
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think your error message '\(macroType)' macro cannot be attached to \(declType) is sufficiently generic that we could apply it for all cases, not just the accessor macro attached to a non-variable and non-subscript case.

Copy link
Author

Choose a reason for hiding this comment

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

If that's the case, I believe there's no need to diagnose based on the type of macroType or the type of DeclSyntax. A unified diagnosis with message '\(macroType)' macro cannot be attached to \(declType) will suffice.

}
return false
}

/// Visit for both the body and preamble macros.
func visitBodyAndPreambleMacros<Node: DeclSyntaxProtocol & WithOptionalCodeBlockSyntax>(
_ node: Node
Expand Down Expand Up @@ -1023,6 +1071,7 @@ extension MacroApplication {
macroAttribute.conformanceList
) {
result += expanded
self.expandedAttributes.append(macroAttribute.attributeNode)
}
} catch {
contextGenerator(Syntax(decl)).addDiagnostics(from: error, node: macroAttribute.attributeNode)
Expand Down Expand Up @@ -1170,6 +1219,7 @@ extension MacroApplication {
from: newAccessors,
indentationWidth: self.indentationWidth
)
self.expandedAttributes.append(macro.attributeNode)
}
} else if let newAccessors = try expandAccessorMacroWithoutExistingAccessors(
definition: macro.definition,
Expand All @@ -1192,11 +1242,13 @@ extension MacroApplication {
} else {
newAccessorsBlock = newAccessors
}
self.expandedAttributes.append(macro.attributeNode)
}
} catch {
contextGenerator(Syntax(storage)).addDiagnostics(from: error, node: macro.attributeNode)
}
}

return (newAccessorsBlock, expandsGetSet)
}
}
Expand Down
15 changes: 11 additions & 4 deletions Sources/SwiftSyntaxMacros/MacroExpansionContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,21 +108,28 @@ private struct ThrownErrorDiagnostic: DiagnosticMessage {

extension MacroExpansionContext {
/// Adds diagnostics from the error thrown during a macro expansion.
public func addDiagnostics(from error: Error, node: some SyntaxProtocol) {
public func addDiagnostics(from error: Error, node: some SyntaxProtocol, fixIts: [FixIt] = []) {
// Inspect the error to form an appropriate set of diagnostics.
var diagnostics: [Diagnostic]
if let diagnosticsError = error as? DiagnosticsError {
diagnostics = diagnosticsError.diagnostics
} else if let message = error as? DiagnosticMessage {
diagnostics = [Diagnostic(node: Syntax(node), message: message)]
diagnostics = [Diagnostic(node: Syntax(node), message: message, fixIts: fixIts)]
} else if let error = error as? SyntaxStringInterpolationInvalidNodeTypeError {
let diagnostic = Diagnostic(
node: Syntax(node),
message: ThrownErrorDiagnostic(message: "Internal macro error: \(error.description)")
message: ThrownErrorDiagnostic(message: "Internal macro error: \(error.description)"),
fixIts: fixIts
)
diagnostics = [diagnostic]
} else {
diagnostics = [Diagnostic(node: Syntax(node), message: ThrownErrorDiagnostic(message: String(describing: error)))]
diagnostics = [
Diagnostic(
node: Syntax(node),
message: ThrownErrorDiagnostic(message: String(describing: error)),
fixIts: fixIts
)
]
}

// Emit the diagnostics.
Expand Down
66 changes: 66 additions & 0 deletions Tests/SwiftSyntaxMacroExpansionTest/AccessorMacroTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -411,4 +411,70 @@ final class AccessorMacroTests: XCTestCase {
indentationWidth: indentationWidth
)
}

func testAccessorNotOnVariableOrSubscript() {
struct TestMacro: AccessorMacro {
static func expansion(
of node: AttributeSyntax,
providingAccessorsOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [AccessorDeclSyntax] {
return []
}
}

assertMacroExpansion(
"@Test struct Foo {}",
expandedSource: "struct Foo {}",
diagnostics: [
DiagnosticSpec(
message: "'accessor' macro cannot be attached to struct",
line: 1,
column: 1,
fixIts: [FixItSpec(message: "Remove '@Test'")]
)
],
macros: ["Test": TestMacro.self]
)

assertMacroExpansion(
"@Test func Foo() {}",
expandedSource: "func Foo() {}",
diagnostics: [
// The compiler will reject this with "'accessor' macro cannot be attached to global function"
DiagnosticSpec(
message: "'accessor' macro cannot be attached to function",
line: 1,
column: 1,
fixIts: [FixItSpec(message: "Remove '@Test'")]
)
],
macros: ["Test": TestMacro.self]
)

assertMacroExpansion(
"""
struct Foo {
@Test
func Bar() {}
}
""",
expandedSource: """
struct Foo {
func Bar() {}
}
""",
diagnostics: [
// The compiler will reject this with "'accessor' macro cannot be attached to instance method"
DiagnosticSpec(
message: "'accessor' macro cannot be attached to function",
line: 2,
column: 3,
fixIts: [FixItSpec(message: "Remove '@Test'")]
)
],
macros: ["Test": TestMacro.self],
indentationWidth: indentationWidth
)
}
}