Skip to content

Commit 857111a

Browse files
authored
Merge pull request #1359 from rintaro/compiler-plugin-support
[Macros] Add executable compiler plugin support library
2 parents 540f008 + 4f4b873 commit 857111a

File tree

16 files changed

+1275
-19
lines changed

16 files changed

+1275
-19
lines changed

Examples/Package.swift

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ let package = Package(
1010
products: [
1111
.executable(name: "AddOneToIntegerLiterals", targets: ["AddOneToIntegerLiterals"]),
1212
.executable(name: "CodeGenerationUsingSwiftSyntaxBuilder", targets: ["CodeGenerationUsingSwiftSyntaxBuilder"]),
13+
.executable(name: "ExamplePlugin", targets: ["ExamplePlugin"]),
1314
],
1415
dependencies: [
1516
.package(path: "../")
@@ -20,17 +21,22 @@ let package = Package(
2021
dependencies: [
2122
.product(name: "SwiftParser", package: "swift-syntax"),
2223
.product(name: "SwiftSyntax", package: "swift-syntax"),
23-
],
24-
path: ".",
25-
exclude: ["README.md", "CodeGenerationUsingSwiftSyntaxBuilder.swift"]
24+
]
2625
),
2726
.executableTarget(
2827
name: "CodeGenerationUsingSwiftSyntaxBuilder",
2928
dependencies: [
3029
.product(name: "SwiftSyntaxBuilder", package: "swift-syntax")
31-
],
32-
path: ".",
33-
exclude: ["README.md", "AddOneToIntegerLiterals.swift"]
30+
]
31+
),
32+
.executableTarget(
33+
name: "ExamplePlugin",
34+
dependencies: [
35+
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
36+
.product(name: "SwiftSyntax", package: "swift-syntax"),
37+
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
38+
.product(name: "SwiftDiagnostics", package: "swift-syntax"),
39+
]
3440
),
3541
]
3642
)

Examples/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
Each example can be executed by navigating into this folder and running `swift run <example> <arguments>`. There is the following set of examples available:
44

5-
- [AddOneToIntegerLiterals](AddOneToIntegerLiterals.swift): Command line tool to add 1 to every integer literal in a source file
6-
- [CodeGenerationUsingSwiftSyntaxBuilder](CodeGenerationUsingSwiftSyntaxBuilder.swift): Code-generate a simple source file using SwiftSyntaxBuilder
5+
- [AddOneToIntegerLiterals](Sources/AddOneToIntegerLiterals/AddOneToIntegerLiterals.swift): Command line tool to add 1 to every integer literal in a source file
6+
- [CodeGenerationUsingSwiftSyntaxBuilder](Sources/CodeGenerationUsingSwiftSyntaxBuilder/CodeGenerationUsingSwiftSyntaxBuilder.swift): Code-generate a simple source file using SwiftSyntaxBuilder
7+
- [ExamplePlugin](Sources/ExamplePlugn): Compiler plugin executable using [`SwiftCompilerPlugin`](../Sources/SwiftCompilerPlugin)
78

89
## Some Example Usages
910

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import SwiftCompilerPlugin
2+
import SwiftSyntaxMacros
3+
4+
@main
5+
struct ThePlugin: CompilerPlugin {
6+
var providingMacros: [Macro.Type] = [
7+
EchoExpressionMacro.self,
8+
MetadataMacro.self,
9+
]
10+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import SwiftSyntax
2+
import SwiftSyntaxBuilder
3+
import SwiftSyntaxMacros
4+
5+
/// Returns the first argument prepending a comment '/* echo */'.
6+
struct EchoExpressionMacro: ExpressionMacro {
7+
static func expansion<
8+
Node: FreestandingMacroExpansionSyntax,
9+
Context: MacroExpansionContext
10+
>(
11+
of node: Node,
12+
in context: Context
13+
) throws -> ExprSyntax {
14+
let expr: ExprSyntax = node.argumentList.first!.expression
15+
return expr.with(\.leadingTrivia, [.blockComment("/* echo */")])
16+
}
17+
}
18+
19+
/// Add a static property `__metadata__`.
20+
struct MetadataMacro: MemberMacro {
21+
static func expansion<
22+
Declaration: DeclGroupSyntax,
23+
Context: MacroExpansionContext
24+
>(
25+
of node: SwiftSyntax.AttributeSyntax,
26+
providingMembersOf declaration: Declaration,
27+
in context: Context
28+
) throws -> [DeclSyntax] {
29+
guard let cls = declaration.as(ClassDeclSyntax.self) else {
30+
return []
31+
}
32+
let className = cls.identifier.trimmedDescription
33+
return [
34+
"""
35+
static var __metadata__: [String: String] { ["name": "\(raw: className)"] }
36+
"""
37+
]
38+
}
39+
}

Package.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ let package = Package(
4343
.library(name: "SwiftSyntaxParser", type: .static, targets: ["SwiftSyntaxParser"]),
4444
.library(name: "SwiftSyntaxBuilder", type: .static, targets: ["SwiftSyntaxBuilder"]),
4545
.library(name: "SwiftSyntaxMacros", type: .static, targets: ["SwiftSyntaxMacros"]),
46+
.library(name: "SwiftCompilerPlugin", type: .static, targets: ["SwiftCompilerPlugin"]),
4647
.library(name: "SwiftRefactor", type: .static, targets: ["SwiftRefactor"]),
4748
],
4849
targets: [
@@ -121,6 +122,12 @@ let package = Package(
121122
"CMakeLists.txt"
122123
]
123124
),
125+
.target(
126+
name: "SwiftCompilerPlugin",
127+
dependencies: [
128+
"SwiftSyntax", "SwiftParser", "SwiftDiagnostics", "SwiftSyntaxMacros", "SwiftOperators",
129+
]
130+
),
124131
.target(
125132
name: "SwiftRefactor",
126133
dependencies: [
@@ -193,6 +200,12 @@ let package = Package(
193200
"SwiftRefactor", "SwiftSyntaxBuilder", "_SwiftSyntaxTestSupport",
194201
]
195202
),
203+
.testTarget(
204+
name: "SwiftCompilerPluginTest",
205+
dependencies: [
206+
"SwiftCompilerPlugin"
207+
]
208+
),
196209
]
197210
)
198211

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2023 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See http://swift.org/LICENSE.txt for license information
9+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
// NOTE: This basic plugin mechanism is mostly copied from
13+
// https://github.com/apple/swift-package-manager/blob/main/Sources/PackagePlugin/Plugin.swift
14+
15+
import SwiftSyntaxMacros
16+
17+
@_implementationOnly import Foundation
18+
#if os(Windows)
19+
@_implementationOnly import ucrt
20+
#endif
21+
22+
//
23+
// This source file contains the main entry point for compiler plugins.
24+
// A plugin receives messages from the "plugin host" (typically
25+
// 'swift-frontend'), and sends back messages in return based on its actions.
26+
//
27+
// Depending on the platform, plugins are invoked in a sanbox that blocks
28+
// network access and prevents any file system changes.
29+
//
30+
// The host process and the plugin communicate using messages in the form of
31+
// length-prefixed JSON-encoded Swift enums. The host sends messages to the
32+
// plugin through its standard-input pipe, and receives messages through the
33+
// plugin's standard-output pipe. The plugin's standard-error is considered
34+
// to be free-form textual console output.
35+
//
36+
// Within the plugin process, `stdout` is redirected to `stderr` so that print
37+
// statements from the plugin are treated as plain-text output, and `stdin` is
38+
// closed so that any attemps by the plugin logic to read from console result
39+
// in errors instead of blocking the process. The original `stdin` and `stdout`
40+
// are duplicated for use as messaging pipes, and are not directly used by the
41+
// plugin logic.
42+
//
43+
// The exit code of the plugin process indicates whether the plugin invocation
44+
// is considered successful. A failure result should also be accompanied by an
45+
// emitted error diagnostic, so that errors are understandable by the user.
46+
//
47+
// Using standard input and output streams for messaging avoids having to make
48+
// allowances in the sandbox for other channels of communication, and seems a
49+
// more portable approach than many of the alternatives. This is all somewhat
50+
// temporary in any case — in the long term, something like distributed actors
51+
// or something similar can hopefully replace the custom messaging.
52+
//
53+
// Usage:
54+
// struct MyPlugin: CompilerPlugin {
55+
// var providingMacros: [Macros.Type] = [
56+
// StringifyMacro.self
57+
// ]
58+
public protocol CompilerPlugin {
59+
init()
60+
61+
var providingMacros: [Macro.Type] { get }
62+
}
63+
64+
extension CompilerPlugin {
65+
66+
/// Main entry point of the plugin — sets up a communication channel with
67+
/// the plugin host and runs the main message loop.
68+
public static func main() throws {
69+
// Duplicate the `stdin` file descriptor, which we will then use for
70+
// receiving messages from the plugin host.
71+
let inputFD = dup(fileno(stdin))
72+
guard inputFD >= 0 else {
73+
internalError("Could not duplicate `stdin`: \(describe(errno: errno)).")
74+
}
75+
76+
// Having duplicated the original standard-input descriptor, we close
77+
// `stdin` so that attempts by the plugin to read console input (which
78+
// are usually a mistake) return errors instead of blocking.
79+
guard close(fileno(stdin)) >= 0 else {
80+
internalError("Could not close `stdin`: \(describe(errno: errno)).")
81+
}
82+
83+
// Duplicate the `stdout` file descriptor, which we will then use for
84+
// sending messages to the plugin host.
85+
let outputFD = dup(fileno(stdout))
86+
guard outputFD >= 0 else {
87+
internalError("Could not dup `stdout`: \(describe(errno: errno)).")
88+
}
89+
90+
// Having duplicated the original standard-output descriptor, redirect
91+
// `stdout` to `stderr` so that all free-form text output goes there.
92+
guard dup2(fileno(stderr), fileno(stdout)) >= 0 else {
93+
internalError("Could not dup2 `stdout` to `stderr`: \(describe(errno: errno)).")
94+
}
95+
96+
// Turn off full buffering so printed text appears as soon as possible.
97+
// Windows is much less forgiving than other platforms. If line
98+
// buffering is enabled, we must provide a buffer and the size of the
99+
// buffer. As a result, on Windows, we completely disable all
100+
// buffering, which means that partial writes are possible.
101+
#if os(Windows)
102+
setvbuf(stdout, nil, _IONBF, 0)
103+
#else
104+
setvbuf(stdout, nil, _IOLBF, 0)
105+
#endif
106+
107+
// Open a message channel for communicating with the plugin host.
108+
pluginHostConnection = PluginHostConnection(
109+
inputStream: FileHandle(fileDescriptor: inputFD),
110+
outputStream: FileHandle(fileDescriptor: outputFD)
111+
)
112+
113+
// Handle messages from the host until the input stream is closed,
114+
// indicating that we're done.
115+
let instance = Self()
116+
do {
117+
while let message = try pluginHostConnection.waitForNextMessage() {
118+
try instance.handleMessage(message)
119+
}
120+
} catch {
121+
// Emit a diagnostic and indicate failure to the plugin host,
122+
// and exit with an error code.
123+
internalError(String(describing: error))
124+
}
125+
}
126+
127+
// Private function to report internal errors and then exit.
128+
fileprivate static func internalError(_ message: String) -> Never {
129+
fputs("Internal Error: \(message)\n", stderr)
130+
exit(1)
131+
}
132+
133+
// Private function to construct an error message from an `errno` code.
134+
fileprivate static func describe(errno: Int32) -> String {
135+
if let cStr = strerror(errno) { return String(cString: cStr) }
136+
return String(describing: errno)
137+
}
138+
139+
/// Handles a single message received from the plugin host.
140+
fileprivate func handleMessage(_ message: HostToPluginMessage) throws {
141+
switch message {
142+
case .getCapability:
143+
try pluginHostConnection.sendMessage(
144+
.getCapabilityResult(capability: PluginMessage.capability)
145+
)
146+
break
147+
148+
case .expandFreestandingMacro(let macro, let discriminator, let expandingSyntax):
149+
try expandFreestandingMacro(
150+
macro: macro,
151+
discriminator: discriminator,
152+
expandingSyntax: expandingSyntax
153+
)
154+
155+
case .expandAttachedMacro(let macro, let macroRole, let discriminator, let attributeSyntax, let declSyntax, let parentDeclSyntax):
156+
try expandAttachedMacro(
157+
macro: macro,
158+
macroRole: macroRole,
159+
discriminator: discriminator,
160+
attributeSyntax: attributeSyntax,
161+
declSyntax: declSyntax,
162+
parentDeclSyntax: parentDeclSyntax
163+
)
164+
}
165+
}
166+
}
167+
168+
/// Message channel for bidirectional communication with the plugin host.
169+
internal fileprivate(set) var pluginHostConnection: PluginHostConnection!
170+
171+
typealias PluginHostConnection = MessageConnection<PluginToHostMessage, HostToPluginMessage>
172+
173+
internal struct MessageConnection<TX, RX> where TX: Encodable, RX: Decodable {
174+
let inputStream: FileHandle
175+
let outputStream: FileHandle
176+
177+
func sendMessage(_ message: TX) throws {
178+
// Encode the message as JSON.
179+
let payload = try JSONEncoder().encode(message)
180+
181+
// Write the header (a 64-bit length field in little endian byte order).
182+
var count = UInt64(payload.count).littleEndian
183+
let header = Swift.withUnsafeBytes(of: &count) { Data($0) }
184+
assert(header.count == 8)
185+
186+
// Write the header and payload.
187+
try outputStream._write(contentsOf: header)
188+
try outputStream._write(contentsOf: payload)
189+
}
190+
191+
func waitForNextMessage() throws -> RX? {
192+
// Read the header (a 64-bit length field in little endian byte order).
193+
guard
194+
let header = try inputStream._read(upToCount: 8),
195+
header.count != 0
196+
else {
197+
return nil
198+
}
199+
guard header.count == 8 else {
200+
throw PluginMessageError.truncatedHeader
201+
}
202+
203+
// Decode the count.
204+
let count = header.withUnsafeBytes {
205+
UInt64(littleEndian: $0.load(as: UInt64.self))
206+
}
207+
guard count >= 2 else {
208+
throw PluginMessageError.invalidPayloadSize
209+
}
210+
211+
// Read the JSON payload.
212+
guard
213+
let payload = try inputStream._read(upToCount: Int(count)),
214+
payload.count == count
215+
else {
216+
throw PluginMessageError.truncatedPayload
217+
}
218+
219+
// Decode and return the message.
220+
return try JSONDecoder().decode(RX.self, from: payload)
221+
}
222+
223+
enum PluginMessageError: Swift.Error {
224+
case truncatedHeader
225+
case invalidPayloadSize
226+
case truncatedPayload
227+
}
228+
}
229+
230+
private extension FileHandle {
231+
func _write(contentsOf data: Data) throws {
232+
if #available(macOS 10.15.4, iOS 13.4, watchOS 6.2, tvOS 13.4, *) {
233+
return try self.write(contentsOf: data)
234+
} else {
235+
return self.write(data)
236+
}
237+
}
238+
239+
func _read(upToCount count: Int) throws -> Data? {
240+
if #available(macOS 10.15.4, iOS 13.4, watchOS 6.2, tvOS 13.4, *) {
241+
return try self.read(upToCount: count)
242+
} else {
243+
return self.readData(ofLength: 8)
244+
}
245+
}
246+
}

0 commit comments

Comments
 (0)