[Pitch] Result builder scoped unqualified lookup

I think this is a reasonable idea but the source compat break worries me quite a bit. By being a flagship feature of SwiftUI result builders are used a lot.

3 Likes

Would it be possible we make this feature explicitly opt-in by an attribute like @resultBuilderLookupable? This will also reduce the source-breaking worries from @jrose since existing codes keep unaffected.

1 Like

I agree the current behavior and workaround here are not ideal. It really is a shame implicit member syntax doesn't work in this position. :slightly_frowning_face:

I'm thinking about this pitch with regards to Doug's post from the if/switch expressions review about replacing the magic of result builders with more general source-generation features:

On its face, it seems like this would dig us deeper down the hole of 'magic' for supporting an ergonomic experience for result builders, and its not immediately clear how we'd dig ourselves back out if we wanted to achieve the goal of making result builders be 'just' a macro feature.

So I have a couple different thoughts on the pitch as-is:

  • Are there existing language language features that we could improve to work around the issues presented here? Implicit member syntax almost works, is there any way we could tweak parsing rules to get it fully working, or not?
  • If not, is there a more general language feature looming behind this extension of result builders? This feels very similar to how Kotlin lets you rebind this within a closure scope so that lookup can find names outside the strict lexical context. We don't necessarily need to make that part of this pitch, but it would be nice to know that we're not introducing a feature just for result builders that will make it significantly more difficult to generalize them later.
4 Likes

How about opting in by defining the Namespace typealias?


Directly translating this into Swift, this would look like self.h1("Chapter 1. Loomings."). Altenatively, how about something like

$builder.h1("Chapter 1. Loomings.")

Or an even shorter version:

$.h1("Chapter 1. Loomings.")

This could be a feature orthogonal to result builders introduced via a separate attribute.


Thinking about it: Even if we would settle on the pitched spelling of h1("Chapter 1. Loomings."), it would make sense to make this a feature separate from result builders that can be used in other contexts without result builders.

2 Likes

Maybe this could look something like this:

@resultBuilder
@namespaceProvider // To declare a namespace provider type
struct HTMLBuilder {
  typealias Component = any HTML
  typealias namespace = Self // This declares the actual namespace
  static func buildBlock(_ component: Component...) -> [Component] { ... }
  
  ...
}

It could be used just as pitched:

@HTMLBuilder
var body: [HTML] {
  h1("Chapter 1. Loomings.") // Alternatively: $.h1("Chapter 1. Loomings.")
  p {
    "Call me Ishmael. Some years ago"
  }
}

Types that are not result builders could use this as well:

@namespaceProvider
struct HTMLNamespace {
  typealias namespace = HTMLNames
}

enum HTMLNames {
  static func h1(_ text: String) -> HTML { ... }
}

@HTMLNamespace
var body: [HTML] {
  return [
    h1("Chapter 1. Loomings.") // Alternatively: $.h1("Chapter 1. Loomings.")
  ]
}
3 Likes

I prefer this to an attribute. Also, even with this approach, users can combine multiple namespaces, by creating protocols for each one and then sharing them.
E.g.

protocol HTMLDiv {}
extension HTMLDiv {
  static func div(@HTMLBuilder _ builder: () -> HTMLValue) -> HTMLValue { … }
} 

protocol HTMLHeaders {}
extension HTMLHeaders {
  static func h1(_ text: String) -> HTMLValue { … }
  // h2, h3, etc.
} 

@resultBuilder enum CustomHTMLBuilder {
  enum Namespace: HTMLDiv, HTMLHeaders {}
}

This reminds me of the SwiftPredicates pitch, where one of the main reasons for using macros is that operator overloading is too slow. Maybe with scoped unqualified lookup, the macro could be replaced with standard, well understood features for adding custom operators.

How will users opt out of name lookup in the result-builder namespace? Also would a type in the namespace be prioritized over a module name, e.g. ThisModule.div or Swift.OneOrMore?

+1 on this, i think this is a very elegant idea.

Or drop the $ maybe?

{ builder in 
    builder.h1("Chapter 1. Loomings.")
}

Isn't this just a tooling issue? Why can't Xcode or your IDE of choice autosuggest all possible builder elements inside a builder's context? (i.e. by pressing the escape key)

5 Likes

I don’t think this fulfills the goals of the original pitch which alludes to whole DSLs (such as SwiftUI or at least new DSLs) adopting namespacing in this way, with the goal of still being as fluent and concise as possible. If e.g. every view declaration within a ViewBuilder function mandatorily started with such a prefix token, that would be worse than just using prefixed names IMO (e.g. TableColumn vs $builder.Column).

This would be great. Currently, the relevant suggestions are drowned in a sea of global definitions…

1 Like

as i understand it, the purpose of the $ prefix is to prevent source breakage from introducing new symbols to the lookup scope of the result builder body. in my opinion, $builder is not needed, we should just have:

{
    $h1("Chapter 1. Loomings.")
}

This seems like a very interesting solution for this specific problem, it feels a very narrow solution; it made me remember how Kotlin solves this with a language feature (changing the receiver of closures) that feels more holistic.

Has something like that been explored? I know result builders covers most of the DSL needs in Swift but changing what "self" is bound to in closure context may be different solution space to look into.

If we could tell the result builder (to focus on this specific problem, but would be nice to have it in general closures) which type to use for member lookup we could have a general solution for this problem.

@resultBuilder
struct HTMLBuilder {
  static func buildBlock[receiver: HTML](_ component: Component...) -> [Component] { ... }

This would also make the feature optin which could solve the source compat concerns.

1 Like

Are these descriptions of the same feature?

This is an appealing future direction; we should, however, consider whether we can have multiple “receivers”. For example, in SwiftUI I want to be able to refer to both methods of self and SwiftUI built-in views. If self is chosen to be a, sort of, default receiver, then additional receivers (e.g. for built-in views) should probably work on top of self. Also, would static Self and the instance self be distinct receivers that (again) work on top of each other?

With the aforementioned namespace type, there’s already minimal source breakage, so I don’t this shouldn’t be a major concern.

Yes they are, apologies I missed the previous mention.

Good points. Although I'm not sure multiple receivers are needed, after all the built-in views are still accessible by the "global/module" namespace. In the same way, if I understood correctly, the pitch doesn't change name resolution for things like HStack, those are still accessible via the current naming resolution.

No worries, I’m just not familiar with Kotlin.

Yeah, I didn’t specify that I was referring to a hypothetical scenario where SwiftUI adopts this feature. I just used the SwiftUI example because it’s common to refer to built-in views (which could be added to a namespace, especially for the HTML DSLs), and instance methods/properties (which could also happen with other DSLs).

1 Like

I love this proposal idea!

I think the main two concerns so far are source breaking aspect (@jrose) and the risk of exposing the ResultBuilder implementation details accidentally; I think @codename's suggestion of a nested Namespace type, + this below, would be a nice and elegant way to solve both concerns at once:

i.e. if the ResultBuilder doesn't have a Namespace nested type (or typealias) then there won't be any unqualified lookup and it would still behave as current Swift's behavior; but if there is one, it would opt-in for the unqualified lookup behavior pitched in this proposal. :slightly_smiling_face:


The only source-breaking aspect left with this solution is if someone have created a custom ResultBuilder which already had a Namespace nested type today. That would be way more unlikely though compared to without that rule/condition about Namespace nested type existence, so I personally think this would be acceptable.

If we really want to prevent source breakage risk completely, we could also imagine a parameter to the attribute, eg:

  • @resultBuilder would behave as today's Swift
  • @resultBuilder(MyNamespace) would opt-in to the new pitched behavior and declare the name of the nested type to use for unqualified lookup.
4 Likes

Good point! I think we can exclude the buildXXX methods from code completion results because you should never call them explicitly.

I think it's valuable that everything you can write inside of a result builder body that is context specific, be inside the result builder scope. Separating the namespace from the result builder scope introduces a level of indirection that adds complexity to both the transform and the developer experience of using the result builder. Developers would have to look at the builder scope to determine what kind of statements they can write (e.g. buildEither means you can write an if statement) and then have to navigate to the type alias to determine what context specific names they can use inside the result builder. In any case, I will add this to the alternatives considered section of the proposal.

This could work in a macro that implements the result builder transform. The way the result builder transform works today is that it uses a syntactic transform to turn a result builder into a regular multi-statement closure. To implement the transform as a macro expansion, the macro will need to perform lookup into the result builder to determine how to insert the buildXXX methods. For example the syntactic transform will look different if the builder declares buildPartialBlock.

The implementation approach I'm taking here is to add to the syntactic transform. For unqualified names, the transform will perform a qualified lookup into the result builder type. If it finds a result, then it will prefix the name with that builder type. I think this is possible to implement in a macro expansion. Alternatively ...

We could explore extending this feature to regular closures that aren’t result builders. The lookup behavior is separable from result builders. However, the domain-specific nature of result builders is the primary motivation for wanting this behavior.

It's not clear to me that we should generalize this to regular closures but I will add this as a future direction to the proposal.

1 Like

AFAIK, the current proposal for expression macros requires that the macro argument be well-typed prior to the macro expansion, which won't be the case for, say, a result builder closure which relies on lookup/qualification which only happens during macro expansion, will it?

Ah, you're right, but I struggle to see how any result builder would be well-typed prior to the result builder transform being applied even without this pitch. The result builder transform today is applied mid-constraint-solving, which also means the transformed code can impact overload resolution. I have a strong suspicion that we would have to preserve this even if we implement the result builder transform as a special builtin macro.

EDIT: buildExpression alone means the ship has sailed on result builder bodies being well-typed prior to the result builder transform, because buildExpression can provide type context for components in the body.

1 Like