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
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ let syntaxKindNameForDiagnosticFile = SourceFileSyntax(leadingTrivia: copyrightH
)

try! ExtensionDeclSyntax("extension SyntaxKind") {
try VariableDeclSyntax("var nameForDiagnostics: String?") {
try VariableDeclSyntax("public var nameForDiagnostics: String?") {
try SwitchExprSyntax("switch self") {
SwitchCaseSyntax("case .token:") {
StmtSyntax(#"return "token""#)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
#endif

extension SyntaxKind {
var nameForDiagnostics: String? {
public var nameForDiagnostics: String? {
switch self {
case .token:
return "token"
Expand Down
20 changes: 20 additions & 0 deletions Sources/SwiftSyntaxMacroExpansion/MacroExpansion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,22 @@ extension MacroRole {
case .body: return "BodyMacro"
}
}

var description: String {
switch self {
case .expression: return "expression"
case .declaration: return "declaration"
case .accessor: return "accessor"
case .memberAttribute: return "memberAttribute"
case .member: return "member"
case .peer: return "peer"
case .conformance: return "conformance"
case .codeItem: return "codeItem"
case .extension: return "extension"
case .preamble: return "preamble"
case .body: return "body"
}
}
}

/// Simple diagnostic message
Expand All @@ -63,6 +79,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 +109,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
54 changes: 51 additions & 3 deletions Sources/SwiftSyntaxMacroExpansion/MacroSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,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 +651,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 +669,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 +719,55 @@ 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)
for (attribute, spec) in attributesToRemove {
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 {
self.diagnosticForUnexpandedMacro(attribute: attribute, macroType: spec.type, attachedTo: declSyntax)
}
}
return AttributeRemover(removingWhere: { attributesToRemove.map(\.attributeNode).contains($0) }).rewrite(
visitedNode
)
}

return nil
}

private func diagnosticForUnexpandedMacro(attribute: AttributeSyntax, macroType: Macro.Type, attachedTo: DeclSyntax) {
var diagnosticError: Error? = nil
var fixitMessage: FixItMessage? = nil
switch macroType {
case is AccessorMacro.Type:
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) {

diagnosticError = MacroApplicationError.macroAttachedToInvalidDecl(
MacroRole.accessor.description,
attachedTo.kind.nameForDiagnostics ?? ""
)
fixitMessage = SwiftSyntaxMacros.MacroExpansionFixItMessage(
"Remove '\(attribute.trimmedDescription)'"
)
}
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.

}
if let diagnosticError, let fixitMessage {
contextGenerator(Syntax(attachedTo)).addDiagnostics(
from: diagnosticError,
node: attachedTo,
fixIts: [
FixIt(
message: fixitMessage,
changes: [.replace(oldNode: Syntax(attribute), newNode: Syntax(AttributeListSyntax()))]
)
]
)
}
}

/// Visit for both the body and preamble macros.
func visitBodyAndPreambleMacros<Node: DeclSyntaxProtocol & WithOptionalCodeBlockSyntax>(
_ node: Node
Expand Down Expand Up @@ -1023,6 +1068,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 +1216,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,6 +1239,7 @@ extension MacroApplication {
} else {
newAccessorsBlock = newAccessors
}
self.expandedAttributes.append(macro.attributeNode)
}
} catch {
contextGenerator(Syntax(storage)).addDiagnostics(from: error, node: macro.attributeNode)
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
)
}
}