I finally had an opportunity to try writing some macros. Some thoughts:
JSON literal conversion
I was inspired by the JSON example above, so I wrote one that converts a JSON string literal to nested calls to JSON case initializers. I don't think this is precisely what the author of that post had in mind (it sounds like they want to construct specific types from the literal, more like Codable
), but since we don't have the semantic info for that, I tried this simpler idea instead.
Expand for implementation
// In the macro module
public enum JSON {
case null
case string(String)
case number(Double)
case array([JSON])
case object([String: JSON])
}
public macro jsonLiteral(_ string: String) -> JSON = MacroExamplesPlugin.JSONLiteralMacro
// Plug-in implementation
import Foundation
import SwiftSyntax
import SwiftSyntaxBuilder
import _SwiftSyntaxMacros
private func jsonExpr(for jsonValue: Any) throws -> ExprSyntax {
switch jsonValue {
case is NSNull:
return "JSON.null"
case let string as String:
return "JSON.string(\(literal: string))"
case let number as Double:
return "JSON.number(\(literal: number))"
case let array as [Any]:
var elements = [ArrayElementSyntax]()
for element in array {
let elementExpr = try jsonExpr(for: element)
elements.append(
ArrayElementSyntax(
expression: elementExpr,
trailingComma: .commaToken()))
}
let arrayLiteral = ArrayExprSyntax(
elements: ArrayElementListSyntax(elements))
return "JSON.array(\(arrayLiteral))"
case let dictionary as [String: Any]:
guard !dictionary.isEmpty else {
return "JSON.object([:])"
}
var elements = [DictionaryElementSyntax]()
for (key, value) in dictionary {
let keyExpr = StringLiteralExprSyntax(content: key)
let valueExpr = try jsonExpr(for: value)
elements.append(
DictionaryElementSyntax(
keyExpression: keyExpr,
valueExpression: valueExpr,
trailingComma: .comma))
}
let dictionaryLiteral = DictionaryExprSyntax(
content: .elements(DictionaryElementListSyntax(elements)))
return "JSON.object(\(dictionaryLiteral))"
default:
throw CustomError.message("Invalid type in deserialized JSON: \(type(of: jsonValue))")
}
}
public struct JSONLiteralMacro: ExpressionMacro {
public static func expansion(
of macro: MacroExpansionExprSyntax,
in context: inout MacroExpansionContext
) throws -> ExprSyntax {
guard let firstElement = macro.argumentList.first,
let stringLiteral = firstElement.expression
.as(StringLiteralExprSyntax.self),
stringLiteral.segments.count == 1,
case let .stringSegment(jsonString)? = stringLiteral.segments.first
else {
throw CustomError.message("#jsonLiteral macro requires a string literal")
}
let json = try JSONSerialization.jsonObject(
with: jsonString.content.text.data(using: .utf8)!,
options: [.fragmentsAllowed])
let jsonCaseExpr = try jsonExpr(for: json)
if let leadingTrivia = macro.leadingTrivia {
return jsonCaseExpr.withLeadingTrivia(leadingTrivia)
}
return jsonCaseExpr
}
}
This worked really nicely (after stumbling on some SwiftSyntax corruption issues
). The following invocation produced the source code I expected:
let json: JSON = #jsonLiteral("""
{
"name": "Bojack Horseman",
"species": "horse",
"age": 59,
"friends": [
"Diane Nguyen",
"Mr. Peanutbutter",
"Todd Chavez"
],
"selfControl": null
}
""")
// JSON.object(["selfControl":JSON.null,"name":JSON.string("Bojack Horseman"),
// "species":JSON.string("horse"),"friends":JSON.array([
// JSON.string("Diane Nguyen"),JSON.string("Mr. Peanutbutter"),
// JSON.string("Todd Chavez"),]),"age":JSON.number(59.0),])
(Naturally there are some problems like key ordering being different due to JSONSerialization
and NSDictionary
but that's not relevant here.)
My takeaways here are:
- Using string interpolation to construct nodes from a combination of literal Swift code and substituted content is a joy to use.
- When you have to drop down a level to raw initializers, SwiftSyntax/SwiftSyntaxBuilder still provides nice defaults in many places for fixed structural tokens. For example, when creating an
ArrayLiteralExpr
, you don't have to provide the [
and ]
tokens manually.
- But, we should extend the builder functionality in SwiftSyntax and/or provide additional helpers to make common functionality much more approachable for users. The average macro author shouldn't have to deal with subtle things like including trailing commas in arrays/dictionaries/argument lists, nor have to be aware of the different syntax node representation used for the content of an empty dictionary vs. a dictionary with elements. If
SwiftSyntaxBuilder
already has some of this, I've missed it, in which case it's a documentation problem instead. My code is probably not the simplest form possible; since SwiftSyntax is such a large API surface, we should figure out how to strongly push users toward the simplest/cleanest APIs.
Trivia handling
I observed that in your FontLiteralMacro
implementation, you write this:
if let leadingTrivia = macro.leadingTrivia {
return initSyntax.withLeadingTrivia(leadingTrivia)
}
return initSyntax
What is the purpose of retaining the leading trivia—to preserve any comments that may precede it if the macro expansion is printed for debugging? I can see this being more important for declaration macros where you'd want to be able to scrape for documentation comments after expansion.
If trivia had to be manually preserved, I would have expected something like this to fail:
let x = #someMacro(...) + y
Where if the expansion didn't preserve the space in #someMacro(...)
's trailing trivia, you'd end up with an expansion let x = VALUE+ y
, which would fail to parse because +
is now a postfix operator instead of an infix operator. But I tested that and it appears to be still parsed as though it was let x = VALUE + y
, so I'm unclear on what the actual trivia behavior is here.
EDIT: I may be able to answer my own question here. Since the macro is applied to the already type-checked AST, it knows that #macro(...) + y
must already be an infix operator even if the trivia for the expansion changes?
Can the macro infrastructure manage trivia automatically so that <leading trivia>#macro(...)<trailing trivia>
is always transformed to <leading trivia><expanded node><trailing trivia>
, so that the macro can never remove or replace trivia? I could see us wanting to merge the original trivia with the trivia attached to the expanded node, but not allow it to be completely replaced.
In fact, I wonder if it's problematic for macro expansions to be able to see trivia at all. I wrote a really stupid macro:
// Returns `string` as an integer, but also add the number in the preceding comment if there is one.
public macro theSameNumber(_ string: String) -> Int = MacroExamplesPlugin.TheSameNumberMacro
let n1 = #theSameNumber("123")
print(n1) // 123
let n2 =
// 5
#theSameNumber("123")
print(n2) // 128
Allowing folks to have semantically significant comments feels like a bad idea, but I can also see value in having access to trivia for other kinds of macros. For example, a declaration macro could treat a preceding doc comment as a template to splat out new doc comments for the declarations that it generates. I'm not sure what's the best way to square these goals; should we only provide trivia to certain types of macros (e.g., declarations but not expressions)? Should we require any kind of macro to explicitly opt-in if it wants the trivia? Or do we just accept that people can do bizarre things with it?