Skip to content

Add support for creating a macro package #6250

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

Merged
merged 2 commits into from
Mar 9, 2023
Merged
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
1 change: 1 addition & 0 deletions Sources/Commands/PackageTools/Init.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ extension SwiftPackageTool {
tool - A package with an executable that uses
Swift Argument Parser. Use this template if you
plan to have a rich set of command-line arguments.
macro - A package that vends a macro.
empty - An empty package with a Package.swift manifest.
"""))
var initMode: InitPackage.PackageType = .library
Expand Down
214 changes: 211 additions & 3 deletions Sources/Workspace/InitPackage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public final class InitPackage {
case executable = "executable"
case tool = "tool"
case `extension` = "extension"
case macro = "macro"

public var description: String {
return rawValue
Expand Down Expand Up @@ -135,6 +136,16 @@ public final class InitPackage {

import PackageDescription

"""

if packageType == .macro {
stream <<< """
import CompilerPluginSupport

"""
}

stream <<< """
let package = Package(

"""
Expand All @@ -144,8 +155,29 @@ public final class InitPackage {
name: "\(pkgname)"
""")

var platforms = options.platforms

// Macros require macOS 10.15, iOS 13, etc.
if packageType == .macro {
func addIfMissing(_ newPlatform: SupportedPlatform) {
if platforms.contains(where: { platform in
platform.platform == newPlatform.platform
}) {
return
}

platforms.append(newPlatform)
}

addIfMissing(.init(platform: .macOS, version: .init("10.15")))
addIfMissing(.init(platform: .iOS, version: .init("13")))
addIfMissing(.init(platform: .tvOS, version: .init("13")))
addIfMissing(.init(platform: .watchOS, version: .init("6")))
addIfMissing(.init(platform: .macCatalyst, version: .init("13")))
}

var platformsParams = [String]()
for supportedPlatform in options.platforms {
for supportedPlatform in platforms {
let version = supportedPlatform.version
let platform = supportedPlatform.platform

Expand All @@ -165,7 +197,7 @@ public final class InitPackage {
}

// Package platforms
if !options.platforms.isEmpty {
if !platforms.isEmpty {
pkgParams.append("""
platforms: [\(platformsParams.joined(separator: ", "))]
""")
Expand All @@ -181,6 +213,20 @@ public final class InitPackage {
targets: ["\(pkgname)"]),
]
""")
} else if packageType == .macro {
pkgParams.append("""
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "\(pkgname)",
targets: ["\(pkgname)"]),
.executable(
name: "\(pkgname)Client",
targets: ["\(pkgname)Client"]
),
]
""")

}

// Package dependencies
Expand All @@ -190,6 +236,12 @@ public final class InitPackage {
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"),
]
""")
} else if packageType == .macro {
pkgParams.append("""
dependencies: [
.package(url: "https://github.com/apple/swift-syntax.git", branch: "main"),
Copy link
Contributor

Choose a reason for hiding this comment

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

We should make sure to have a follow-up task to change this from main once we have a concrete version that people should be using.

Copy link
Member Author

Choose a reason for hiding this comment

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

Right. We should get that once we branch for Swift 5.9.

]
""")
}

// Package targets
Expand Down Expand Up @@ -219,6 +271,32 @@ public final class InitPackage {
path: "Sources"),
]
"""
} else if packageType == .macro {
param += """
// Macro implementation, only built for the host and never part of a client program.
.macro(name: "\(pkgname)Macros",
dependencies: [
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
]
),

// Library that exposes a macro as part of its API, which is used in client programs.
.target(name: "\(pkgname)", dependencies: ["\(pkgname)Macros"]),

// A client of the library, which is able to use the macro in its
// own code.
.executableTarget(name: "\(pkgname)Client", dependencies: ["\(pkgname)"]),

// A test target used to develop the macro implementation.
.testTarget(
name: "\(pkgname)Tests",
dependencies: [
"\(pkgname)Macros",
]
),
]
"""
} else {
param += """
.target(
Expand All @@ -239,7 +317,8 @@ public final class InitPackage {
// Create a tools version with current version but with patch set to zero.
// We do this to avoid adding unnecessary constraints to patch versions, if
// the package really needs it, they should add it manually.
let version = InitPackage.newPackageToolsVersion.zeroedPatch
let version = packageType == .macro ? ToolsVersion.vNext
Copy link
Contributor

Choose a reason for hiding this comment

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

Also something to keep in mind to update later.

Copy link
Member Author

Choose a reason for hiding this comment

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

Right, we'll update this when the macro target gets a proper Swift version instead of 999.

: InitPackage.newPackageToolsVersion.zeroedPatch

// Write the current tools version.
try ToolsVersionSpecificationWriter.rewriteSpecification(
Expand Down Expand Up @@ -331,13 +410,33 @@ public final class InitPackage {
}
}
"""
case .macro:
content = """
// The Swift Programming Language
// https://docs.swift.org/swift-book
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe a more concrete reference to macros once we have it?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, makes sense


// A macro that produces both a value and a string containing the
// source code that generated the value. For example,
//
// #stringify(x + y)
//
// produces a tuple `(x + y, "x + y")`.
@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(module: "\(moduleName)Macros", type: "StringifyMacro")
"""

case .empty, .`extension`:
throw InternalError("invalid packageType \(packageType)")
}

try writePackageFile(sourceFile) { stream in
stream.write(content)
}

if packageType == .macro {
try writeMacroPluginSources(sources.appending("\(pkgname)Macros"))
try writeMacroClientSources(sources.appending("\(pkgname)Client"))
}
}

private func writeTests() throws {
Expand Down Expand Up @@ -374,6 +473,113 @@ public final class InitPackage {
}
}

private func writeMacroTestsFile(_ path: AbsolutePath) throws {
try writePackageFile(path) { stream in
stream <<< ##"""
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
import XCTest
import \##(moduleName)Macros

var testMacros: [String: Macro.Type] = [
"stringify" : StringifyMacro.self,
]

final class \##(moduleName)Tests: XCTestCase {
func testMacro() {
// XCTest Documentation
// https://developer.apple.com/documentation/xctest

// Test input is a source file containing uses of the macro.
let sf: SourceFileSyntax =
#"""
let a = #stringify(x + y)
let b = #stringify("Hello, \(name)")
"""#
let context = BasicMacroExpansionContext.init(
sourceFiles: [sf: .init(moduleName: "MyModule", fullFilePath: "test.swift")]
)

// Expand the macro to produce a new source file with the
// result of the expansion, and ensure that it has the
// expected source code.
let transformedSF = sf.expand(macros: testMacros, in: context)
XCTAssertEqual(
transformedSF.description,
#"""
let a = (x + y, "x + y")
let b = ("Hello, \(name)", #""Hello, \(name)""#)
"""#
)
}
}

"""##
}
}

private func writeMacroPluginSources(_ path: AbsolutePath) throws {
try makeDirectories(path)

try writePackageFile(path.appending("\(moduleName)Macro.swift")) { stream in
stream <<< ##"""
import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

/// Implementation of the `stringify` macro, which takes an expression
/// of any type and produces a tuple containing the value of that expression
/// and the source code that produced the value. For example
///
/// #stringify(x + y)
///
/// will expand to
///
/// (x + y, "x + y")
public struct StringifyMacro: ExpressionMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) -> ExprSyntax {
guard let argument = node.argumentList.first?.expression else {
fatalError("compiler bug: the macro does not have any arguments")
}

return "(\(argument), \(literal: argument.description))"
}
}

@main
struct \##(moduleName)Plugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [
StringifyMacro.self,
]
}

"""##
}
}

private func writeMacroClientSources(_ path: AbsolutePath) throws {
try makeDirectories(path)

try writePackageFile(path.appending("main.swift")) { stream in
stream <<< ##"""
import \##(moduleName)

let a = 17
let b = 25

let (result, code) = #stringify(a + b)

print("The value \(result) was produced by the code \"\(code)\"")

"""##
}
}

private func writeTestFileStubs(testsPath: AbsolutePath) throws {
let testModule = try AbsolutePath(validating: pkgname + Target.testModuleNameSuffix, relativeTo: testsPath)
progressReporter?("Creating \(testModule.relative(to: destinationPath))/")
Expand All @@ -384,6 +590,8 @@ public final class InitPackage {
case .empty, .`extension`, .executable, .tool: break
case .library:
try writeLibraryTestsFile(testClassFile)
case .macro:
try writeMacroTestsFile(testClassFile)
}
}
}
Expand Down