PoC: Result Builder using Body Macro

This weekend I decided to try implementing a result builder using the new function body macro and here I share my results:

So, is it possible? Yes it is, but with a few limitations, specially regardless diagnostics.

enum StringBuilder: ResultBuilder {
    
    public static func buildBlock(_ components: String...) -> String {
        return components.joined(separator: ",")
    }
    
    public static func buildExpression(_ expression: Int) -> String {
        return "\(expression)"
    }
    
    public static func buildOptional(_ component: String?) -> String {
        component ?? ""
    }
    
    public static func buildEither(first component: String) -> String {
        return component
    }
    
    public static func buildEither(second component: String) -> String {
        return component
    }
    
    public static func buildArray(_ components: [String]) -> String {
        return components.joined(separator: ",")
    }
}

@ResultBuilder<StringBuilder>
func body() -> String {
    "a"
    "b"
    "c"
    if Bool.random() {
        "true"
    } else {
        "false"
    }
    switch Int.random(in: 1...5) {
    case 1:
        "one"
    case 2:
        "two"
    case 3:
        "three"
    default:
        "default"
    }
    for i in 1...5 {
        "\(i)"
    }
}

The use is pretty much the same, but I had to make the macro generic over a type that implements a ResultBuilder protocol. I wish it was possible to instead apply the @ResultBuildermacro directly to the type that would generate a macro alias to a specialized macro.

macroalias @StringBuilder = ResultBuilderBody<StringBuilder>

The ResultBuilder protocol was necessary to ensure it's possible to call a default buildExpression. This is necessary because the main limitation of macros: The lack of information about external types.

The built-in @resultBuilder attribute relies on being able to check if the builder has the appropriate methods during the body transformation. So it only calls buildExpression if the builder supports it or it only visits a for-in statement if the builder supports buildArray, emitting a diagnostic otherwise.

Without knowing during the macro expansion which methods the builder implements it's necessary a default buildExpression, thus the protocol, and all the ifs, switchs and for-ins have to be expanded and if the builder doesn't have the appropriate method the expanded macro will fail to compile, without a custom diagnostic or fix-it like @resultBuilder can generate.

The supported methods could be specified using macro arguments:

// No `buildOptional`, `buildEither` and `buildArray`.
@ResultBuilder<IntBuilder>("buildBlock", "buildExpression") 
func body() -> [Int] {
	...
}

Not much practical, although it could be generated together with the macroalias idea above:

macroalias @StringBuilder = ResultBuilderBody<StringBuilder>("buildBlock", "buildExpression") 

This would still require all the methods to be inside the builder declaration so I think ultimately it would be necessary a way to access type information in a similar way that @resultBuilder can.

There's also the fact the the function body is not type-checked so any warning or error not related to syntax will me emitted inside the expanded macro. I believe though that these diagnostic could be moved up, just didn't try.

So, that's it! Maybe this can useful information for the future of macros. You can check my implementation here.

It was quite fun to figure out how to expand body to a compilable code that relies solely on type inference. Expansion of switch expression, for example, uses chained ternary operations:

switch Int.random(in: 1...5) {
case 1:
    "one"
case 2:
    "two"
case 3:
    "three"
default:
    "default"
}
let $component4 = {
    var $case = 0
    switch Int.random(in: 1 ... 5) {
    case 1:
        $case = 0
    case 2:
        $case = 1
    case 3:
        $case = 2
    default:
        $case = 3
    }
    let $case0_expr = {
        let $component0 = StringBuilder.buildExpression("one")
        return StringBuilder.buildBlock($component0)
    }()
    let $case1_expr = {
        let $component0 = StringBuilder.buildExpression("two")
        return StringBuilder.buildBlock($component0)
    }()
    let $case2_expr = {
        let $component0 = StringBuilder.buildExpression("three")
        return StringBuilder.buildBlock($component0)
    }()
    let $case3_expr = {
        let $component0 = StringBuilder.buildExpression("default")
        return StringBuilder.buildBlock($component0)
    }()
    let $b0 = $case0_expr
    let $b1 = $case >= 1 ? StringBuilder.buildEither(first: $case1_expr) : StringBuilder.buildEither(second: $b0)
    let $b2 = $case >= 2 ? StringBuilder.buildEither(first: $case2_expr) : StringBuilder.buildEither(second: $b1)
    let $b3 = $case >= 3 ? StringBuilder.buildEither(first: $case3_expr) : StringBuilder.buildEither(second: $b2)
    return $b3
}()
8 Likes

Virtualized Abstract Syntax Trees (ASTs)

One of the things mentioned in future directing of the result builder proposal is the virtualization of control flows so I went ahead and implemented for, if and switch.

buildVirtualFor

This one is pretty straightforward, I just added on more closure to where clauses.

static func buildVirtualFor<S: Sequence, BodyReturn>(
    _ sequence: () -> (S),
    _ body: (S.Element) -> BodyReturn,
    _ isIncluded: (S.Element) -> Bool
)

Example:

for i in 1...10 where i.isMultiple(of: 2) {
    "\(i)"
}

buildVirtualFor(
    { 1...10  }, 
    { i in "\(i)" },
    { i in i.isMultiple(of: 2) }
)

buildVirtualIf

This one is a little bit tricky because at first sight a closure that returns a Bool seems a goot fit for the if conditions but because optional binding that's not enough so I choose an approach where the conditions closure returns Void or a Tuple of N elements if there's any optional binding. If all conditions are not satisfied, the closure returns nil. Theses two scenarios are represented by a ConditionsReturn? type. A then closure receives ConditionsReturn and returns ThenReturn and else just returns ElseReturn and it's an optional closure.

static func buildVirtualIf<ConditionsReturn, ThenReturn, ElseReturn>(
    _ conditions: () -> ConditionsReturn?,
    _ then: (ConditionsReturn) -> ThenReturn,
    _ else: (() -> ElseReturn)?
)

Examples:

if Bool.random() {
    "then"
} else {
    "else"
}

buildVirtualIf(
    { if Bool.random()  { return () } else { return nil } }, 
    { () in "then" },
    {  "else" }
)
if Bool.random() {
    "then"
}

buildVirtualIf(
    { if Bool.random()  { return () } else { return nil } }, 
    { () in "then" }, 
    Optional<() -> Never>.none // nil is not sufficient here because `ElseReturn` requires a concrete type
)
let x: Int? = 0
let y: Int? = 0
if let x, let y {
    "\(x), \(y)"
}

buildVirtualIf(
    { if let x, let y  { return (x,y) } else { return nil } },
    { (x,y) in "\(x), \(y)" },
    Optional<() -> Never>.none
)

This basic buildVirtualIf can be expanded to if expressions with one or more else if using parameter packs.

static func buildVirtualIf2<each ConditionsReturn, each ThenReturn, ElseReturn>(
    _ then: repeat (conditions: () -> (each ConditionsReturn)?, body: (each ConditionsReturn) -> (each ThenReturn)),
    else: (() -> ElseReturn)?
)

buildVirtualSwitch

This one follows pretty much the same logic of a buildVirtualIf with a variadic number of conditions and bodies , but the conditions receive Subject as their input.

static func buildVirtualSwitch<Subject, each ConditionReturn, each CaseReturn, DefaultReturn>(
    _ subject: () -> Subject,
    _ cases: repeat (condition: (Subject) -> (each ConditionReturn)?, body: (each ConditionReturn) -> (each CaseReturn)),
    default: (() -> DefaultReturn)?
)

Examples:

switch Int.random(in: 1...5) {
    case 1:
        "1"
    case 2:
        "2"
    default:
        "3...5"
}

buildVirtualSwitch(
    { Int.random(in: 1...5) },
    ({ if case 1 = $0 { return () } else { return nil } }, { _ in "1" }),
    ({ if case 2 = $0 { return () } else { return nil } }, { _ in "2" }),
    default: { "3...5" }
)
switch E.random() { // `E` is a enum with `c1`, `c2`, and `c3(Int)` cases
    case .c1:
        "CASE 1"
    case .c2:
        "CASE 2"
    case .c3(let value):
        "CASE 3 = \(value)"
}

buildVirtualSwitch(
    { E.random() },
    ({ if case .c1 = $0 { return () } else { return nil } }, { _ in "CASE 1" }),
    ({ if case .c2 = $0 { return () } else { return nil } }, { _ in "CASE 2" }),
    ({ if case .c3(let value) = $0 { return (value) } else { return nil } }, { (value) in "CASE 3 = \(value)" }) , 
    default: Optional<() -> Never>.none
)

BTW, didn't know that if case x = 1 { } was a thing. It's weird but it made the implementation easy.

That's it for today! I will update the repository with these new build methods soon.

2 Likes

I've wondered whether this would be possible ever since macros were introduced. Cool to see that it actually is — great work!

Your hunch is correct: the trick is to emit #sourceLocation directives around code that's derived from the user's source. E.g. you could emit something like

    let $case0_expr = {
        let $component0 = StringBuilder.buildExpression(
          #sourceLocation(file: "Test.swift", line: 3)
    "one"
          #sourceLocation()
        )
        return StringBuilder.buildBlock($component0)
    }()

this makes it so any warnings emitted in the macro expansion have their line numbers rewritten to point to the original location. Note the weird indentation — that's to preserve column number info — you'll need to set your macro's formatMode to FormatMode.disabled for that.

I have an example here (plus some associated snapshot tests) that might help. For e2e testing you'll want to write some code that emits a diagnostic after the syntax parsing phase (so typechecking/semantic analysis/..., e.g. write a try where it isn't needed), and confirm that the warning is emitted in the right location.

Would be cool if the Swift compiler did this sort of rewriting by default someday, but until then it's pretty easy to implement yourself and unlock some major ergonomic improvements.

1 Like