diff --git a/Sources/SwiftSyntaxMacros/CMakeLists.txt b/Sources/SwiftSyntaxMacros/CMakeLists.txt index f8fdc38f639..f95e55fa732 100644 --- a/Sources/SwiftSyntaxMacros/CMakeLists.txt +++ b/Sources/SwiftSyntaxMacros/CMakeLists.txt @@ -21,7 +21,9 @@ add_swift_host_library(SwiftSyntaxMacros AbstractSourceLocation.swift BasicMacroExpansionContext.swift + FunctionParameterUtils.swift MacroExpansionContext.swift + MacroReplacement.swift MacroSystem.swift Syntax+MacroEvaluation.swift ) diff --git a/Sources/SwiftSyntaxMacros/FunctionParameterUtils.swift b/Sources/SwiftSyntaxMacros/FunctionParameterUtils.swift new file mode 100644 index 00000000000..9ad940b3ff0 --- /dev/null +++ b/Sources/SwiftSyntaxMacros/FunctionParameterUtils.swift @@ -0,0 +1,32 @@ +import SwiftSyntax + +extension FunctionParameterSyntax { + /// Retrieve the name of the parameter as it is used in source. + /// + /// Example: + /// + /// func f(a: Int, _ b: Int, c see: Int) { ... } + /// + /// The parameter names for these three parameters are `a`, `b`, and `see`, + /// respectively. + var parameterName: TokenSyntax? { + // If there were two names, the second is the parameter name. + if let secondName = secondName { + if secondName.text == "_" { + return nil + } + + return secondName + } + + if let firstName = firstName { + if firstName.text == "_" { + return nil + } + + return firstName + } + + return nil + } +} diff --git a/Sources/SwiftSyntaxMacros/MacroReplacement.swift b/Sources/SwiftSyntaxMacros/MacroReplacement.swift new file mode 100644 index 00000000000..edbef9a1288 --- /dev/null +++ b/Sources/SwiftSyntaxMacros/MacroReplacement.swift @@ -0,0 +1,312 @@ +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxBuilder + +enum MacroExpanderError: DiagnosticMessage { + case undefined + case definitionNotMacroExpansion + case nonParameterReference(TokenSyntax) + case nonLiteralOrParameter(ExprSyntax) + + var message: String { + switch self { + case .undefined: + return "macro expansion requires a definition" + + case .definitionNotMacroExpansion: + return "macro definition must itself by a macro expansion expression (starting with '#')" + + case .nonParameterReference(let name): + return "reference to value '\(name.text)' that is not a macro parameter in expansion" + + case .nonLiteralOrParameter: + return "only literals and macro parameters are permitted in expansion" + } + } + + var diagnosticID: MessageID { + .init(domain: "SwiftMacros", id: "\(self)") + } + + var severity: DiagnosticSeverity { + .error + } +} + +/// Provide the definition of a macro +public enum MacroDefinition { + /// An externally-defined macro, known by its type name and the module in + /// which that type resides, which uses the deprecated syntax `A.B`. + case deprecatedExternal(node: Syntax, module: String, type: String) + + /// A macro that is defined by expansion of another macro. + /// + /// The definition has the macro expansion expression itself, along with + /// sequence of replacements for subtrees that refer to parameters of the + /// defining macro. These subtrees will need to be replaced with the text of + /// the corresponding argument to the macro, which can be accomplished with + /// `MacroDeclSyntax.expandDefinition`. + case expansion(MacroExpansionExprSyntax, replacements: [Replacement]) +} + +extension MacroDefinition { + /// A replacement that occurs as part of an expanded macro definition. + public struct Replacement { + /// A reference to a parameter as it occurs in the macro expansion expression. + public let reference: IdentifierExprSyntax + + /// The index of the parameter in the defining macro. + public let parameterIndex: Int + } +} + +fileprivate class ParameterReplacementVisitor: SyntaxAnyVisitor { + let macro: MacroDeclSyntax + var replacements: [MacroDefinition.Replacement] = [] + var diagnostics: [Diagnostic] = [] + + init(macro: MacroDeclSyntax) { + self.macro = macro + super.init(viewMode: .fixedUp) + } + + // Integer literals + override func visit(_ node: IntegerLiteralExprSyntax) -> SyntaxVisitorContinueKind { + .visitChildren + } + + // Floating point literals + override func visit(_ node: FloatLiteralExprSyntax) -> SyntaxVisitorContinueKind { + .visitChildren + } + + // nil literals + override func visit(_ node: NilLiteralExprSyntax) -> SyntaxVisitorContinueKind { + .visitChildren + } + + // String literals + override func visit(_ node: StringLiteralExprSyntax) -> SyntaxVisitorContinueKind { + .visitChildren + } + + // Array literals + override func visit(_ node: ArrayExprSyntax) -> SyntaxVisitorContinueKind { + .visitChildren + } + + // Dictionary literals + override func visit(_ node: DictionaryExprSyntax) -> SyntaxVisitorContinueKind { + .visitChildren + } + + // Tuple literals + override func visit(_ node: TupleExprSyntax) -> SyntaxVisitorContinueKind { + .visitChildren + } + + // Macro uses. + override func visit(_ node: MacroExpansionExprSyntax) -> SyntaxVisitorContinueKind { + .visitChildren + } + + // References to declarations. Only accept those that refer to a parameter + // of a macro. + override func visit(_ node: IdentifierExprSyntax) -> SyntaxVisitorContinueKind { + let identifier = node.identifier + + // FIXME: This will go away. + guard case let .functionLike(signature) = macro.signature else { + return .visitChildren + } + + let matchedParameter = signature.input.parameterList.enumerated().first { (index, parameter) in + if identifier.text == "_" { + return false + } + + guard let parameterName = parameter.parameterName else { + return false + } + + return identifier.text == parameterName.text + } + + guard let (parameterIndex, _) = matchedParameter else { + // We have a reference to something that isn't a parameter of the macro. + diagnostics.append( + Diagnostic( + node: Syntax(identifier), + message: MacroExpanderError.nonParameterReference(identifier) + ) + ) + + return .visitChildren + } + + replacements.append(.init(reference: node, parameterIndex: parameterIndex)) + return .visitChildren + } + + override func visitAny(_ node: Syntax) -> SyntaxVisitorContinueKind { + if let expr = node.as(ExprSyntax.self) { + // We have an expression that is not one of the allowed forms, so + // diagnose it. + diagnostics.append( + Diagnostic( + node: node, + message: MacroExpanderError.nonLiteralOrParameter(expr) + ) + ) + + return .skipChildren + } + + return .visitChildren + } + +} + +extension MacroDeclSyntax { + /// Check the definition of the given macro. + /// + /// Macros are defined by an expression, which must itself be a macro + /// expansion. Check the definition and produce a semantic representation of + /// it or one of the "builtin" + /// + /// Compute the sequence of parameter replacements required when expanding + /// the definition of a non-external macro. + /// + /// If there are an errors that prevent expansion, the diagnostics will be + /// wrapped into a an error that prevents expansion, that error is thrown. + public func checkDefinition() throws -> MacroDefinition { + // Cannot compute replacements for an undefined macro. + guard let originalDefinition = definition?.value else { + let undefinedDiag = Diagnostic( + node: Syntax(self), + message: MacroExpanderError.undefined + ) + + throw DiagnosticsError(diagnostics: [undefinedDiag]) + } + + /// Recognize the deprecated syntax A.B. Clients will need to + /// handle this themselves. + if let memberAccess = originalDefinition.as(MemberAccessExprSyntax.self), + let base = memberAccess.base, + let baseName = base.as(IdentifierExprSyntax.self)?.identifier + { + let memberName = memberAccess.name + return .deprecatedExternal( + node: Syntax(memberAccess), + module: baseName.trimmedDescription, + type: memberName.trimmedDescription + ) + } + + // Make sure we have a macro expansion expression. + guard let definition = originalDefinition.as(MacroExpansionExprSyntax.self) else { + let badDefinitionDiag = + Diagnostic( + node: Syntax(originalDefinition), + message: MacroExpanderError.definitionNotMacroExpansion + ) + + throw DiagnosticsError(diagnostics: [badDefinitionDiag]) + } + + let visitor = ParameterReplacementVisitor(macro: self) + visitor.walk(definition) + + if !visitor.diagnostics.isEmpty { + throw DiagnosticsError(diagnostics: visitor.diagnostics) + } + + return .expansion(definition, replacements: visitor.replacements) + } +} + +/// Syntax rewrite that performs macro expansion by textually replacing +/// uses of macro parameters with their corresponding arguments. +private final class MacroExpansionRewriter: SyntaxRewriter { + let parameterReplacements: [IdentifierExprSyntax: Int] + let arguments: [ExprSyntax] + + init(parameterReplacements: [IdentifierExprSyntax: Int], arguments: [ExprSyntax]) { + self.parameterReplacements = parameterReplacements + self.arguments = arguments + } + + override func visit(_ node: IdentifierExprSyntax) -> ExprSyntax { + guard let parameterIndex = parameterReplacements[node] else { + return super.visit(node) + } + + // Swap in the argument for this parameter + return arguments[parameterIndex].trimmed + } +} + +extension MacroDeclSyntax { + /// Expand the definition of this macro when provided with the given + /// argument list. + private func expand( + argumentList: TupleExprElementListSyntax?, + definition: MacroExpansionExprSyntax, + replacements: [MacroDefinition.Replacement] + ) -> ExprSyntax { + // FIXME: Do real call-argument matching between the argument list and the + // macro parameter list, porting over from the compiler. + let arguments: [ExprSyntax] = + argumentList?.map { element in + element.expression + } ?? [] + + return MacroExpansionRewriter( + parameterReplacements: Dictionary( + uniqueKeysWithValues: replacements.map { replacement in + (replacement.reference, replacement.parameterIndex) + } + ), + arguments: arguments + ).visit(definition) + } + + /// Given a freestanding macro expansion syntax node that references this + /// macro declaration, expand the macro by substituting the arguments from + /// the macro expansion into the parameters that are used in the definition. + public func expand( + _ node: Node, + definition: MacroExpansionExprSyntax, + replacements: [MacroDefinition.Replacement] + ) -> ExprSyntax { + return expand( + argumentList: node.argumentList, + definition: definition, + replacements: replacements + ) + } + + /// Given an attached macro expansion syntax node that references this + /// macro declaration, expand the macro by substituting the arguments from + /// the expansion into the parameters that are used in the definition. + public func expand( + _ node: AttributeSyntax, + definition: MacroExpansionExprSyntax, + replacements: [MacroDefinition.Replacement] + ) -> ExprSyntax { + // Dig out the argument list. + let argumentList: TupleExprElementListSyntax? + if case let .argumentList(argList) = node.argument { + argumentList = argList + } else { + argumentList = nil + } + + return expand( + argumentList: argumentList, + definition: definition, + replacements: replacements + ) + } +} diff --git a/Tests/SwiftSyntaxMacrosTest/MacroReplacementTests.swift b/Tests/SwiftSyntaxMacrosTest/MacroReplacementTests.swift new file mode 100644 index 00000000000..3ddf8a9d153 --- /dev/null +++ b/Tests/SwiftSyntaxMacrosTest/MacroReplacementTests.swift @@ -0,0 +1,117 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +import SwiftDiagnostics +import SwiftParser +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros +import _SwiftSyntaxTestSupport +import XCTest + +final class MacroReplacementTests: XCTestCase { + func testMacroDefinitionGood() throws { + let macro: DeclSyntax = + """ + macro expand1(a: Int, b: Int) = #otherMacro(first: b, second: ["a": a], third: [3.14159, 2.71828], fourth: 4) + """ + + let definition = try macro.as(MacroDeclSyntax.self)!.checkDefinition() + guard case let .expansion(_, replacements) = definition else { + XCTFail("not an expansion definition") + fatalError() + } + + XCTAssertEqual(replacements.count, 2) + XCTAssertEqual(replacements[0].parameterIndex, 1) + XCTAssertEqual(replacements[1].parameterIndex, 0) + } + + func testMacroDefinitionBad() throws { + let macro: DeclSyntax = + """ + macro expand1(a: Int, b: Int) = #otherMacro(first: b + 1, c) + """ + + let diags: [Diagnostic] + do { + _ = try macro.as(MacroDeclSyntax.self)!.checkDefinition() + XCTFail("should have failed with an error") + fatalError() + } catch let diagError as DiagnosticsError { + diags = diagError.diagnostics + } + + XCTAssertEqual(diags.count, 2) + XCTAssertEqual( + diags[0].diagMessage.message, + "only literals and macro parameters are permitted in expansion" + ) + XCTAssertEqual( + diags[1].diagMessage.message, + "reference to value 'c' that is not a macro parameter in expansion" + ) + } + + func testMacroUndefined() throws { + let macro: DeclSyntax = + """ + macro expand1(a: Int, b: Int) + """ + + let diags: [Diagnostic] + do { + _ = try macro.as(MacroDeclSyntax.self)!.checkDefinition() + XCTFail("should have failed with an error") + fatalError() + } catch let diagError as DiagnosticsError { + diags = diagError.diagnostics + } + + XCTAssertEqual(diags.count, 1) + XCTAssertEqual( + diags[0].diagMessage.message, + "macro expansion requires a definition" + ) + } + + func testMacroExpansion() throws { + let macro: DeclSyntax = + """ + macro expand1(a: Int, b: Int) = #otherMacro(first: b, second: ["a": a], third: [3.14159, 2.71828], fourth: 4) + """ + + let use: ExprSyntax = + """ + #expand1(a: 5, b: 17) + """ + + let macroDecl = macro.as(MacroDeclSyntax.self)! + let definition = try macroDecl.checkDefinition() + guard case let .expansion(expansion, replacements) = definition else { + XCTFail("not a normal expansion") + fatalError() + } + + let expandedSyntax = macroDecl.expand( + use.as(MacroExpansionExprSyntax.self)!, + definition: expansion, + replacements: replacements + ) + AssertStringsEqualWithDiff( + expandedSyntax.description, + """ + #otherMacro(first: 17, second: ["a": 5], third: [3.14159, 2.71828], fourth: 4) + """ + ) + } +}