[Pitch] Introduce `#bundle` Macro

Hi everyone!

We have a new pitch to introduce a macro that finds the correct bundle where resources are located: #bundle. In the context of a Swift Package, it will use Bundle.module, in a framework it will point to the framework's bundle, and in the main app, it will be equivalent to Bundle.main.
Let us know what you think!


Revision history

  • v1 Initial version

Introduction

API which loads localized strings assumes Bundle.main by default. This works for apps, but code that runs in a framework, or was defined in a Swift package, needs to specify a different bundle. The ultimate goal is to remove this requirement in the future. One step towards that goal is to provide an easy accessor to the bundle that stores localized resources: #bundle.

Motivation

Developers writing code in a framework or a Swift package need to repeat the bundle parameter for every localized string.
Without any shortcuts, loading a localized string from a framework looks like this:

label.text = String(
    localized: "She didn't clean the camera!",
    bundle: Bundle(for: MyViewController.self),
    comment: "Comment of astonished bystander"
    )

Because of its impracticalities, developers often write accessors to the framework's bundle:

private class LookupClass {}
extension Bundle {
    static let framework = Bundle(for: LookupClass.self)
    
    // Or worse yet, they lookup the bundle using its bundle identifier, which while tempting is actually rather inefficient.
}

label.text = String(
    localized: "She didn't clean the camera!",
    bundle: .framework,
    comment: "Comment of astonished bystander"
    )

While this solution requires less boilerplate, each framework target has to write some boilerplate still.

In the context of a localized Swift package, the build system takes care of creating an extension on Bundle called Bundle.module at build time. While this reduces the need for boilerplate already, it makes it complicated to move code from a framework or app target into a Swift package. Each call to a localization API needs to be audited and changed to bundle: .module.

Proposed solution and example

We propose a macro that handles locating the right bundle with localized resources. It will work in all contexts: apps, framework targets, and Swift packages.

label.text = String(
    localized: "She didn't clean the camera!",
    bundle: #bundle,
    comment: "Comment of astonished bystander"
    )

We will also introduce an equivalent macro for usage with LocalizedStringResource.BundleDescription.

let string = LocalizedStringResource(
    "She didn't clean the camera!",
    bundle: #bundleDescription,
    comment: "Comment of astonished bystander"
    )

For a full detailed design and considered alternatives, check out thefull proposal in the swift-foundation repo PR!

17 Likes

Could an overload on LocalizedStringResource that accepts a Bundle be added to avoid the #bundleDescription macro?

extension LocalizedStringResource {
    
    init(
        _ keyAndValue: String.LocalizationValue,
        table: String? = nil,
        locale: Locale = .current,
        bundle: Bundle,
        comment: StaticString? = nil
    ) {
        self.init(keyAndValue, table: table, locale: locale, bundle: .atURL(bundle.bundleURL), comment: comment)
    }
    
}

From a user point of view the need to use two different macros seems odd, specially given that the parameters have the same bundle name.

5 Likes

I wonder if there might be a way to hook in a platform-specific bundle loading mechanism. I recently posted about the issues that Android has with Foundation bundles (Overriding Bundle.module for loading resources from Android assets), and something like this might be just the solution we need if there were some means of configuring the bundle loading code.

3 Likes

Silly question, would the #bundle macro work as a function argument's default value as well?

func localizedString(
  _ keyAndValue: String.LocalizationValue,
  bundle: Bundle? = #bundle  // <--โ”
) -> String

Would that pick the current Bundle from the call site of the function?

2 Likes

Yes it would expand in the caller as of SE-0422.
This is mentioned in the future directions of the full proposal:

This change is the first step towards not having to specify a bundle at all. Ideally, localizing a string should not require more work than using a type or method call that expresses localizability (i.e. String.LocalizationValue, LocalizedStringResource, or String(localized: )).

This proposal does provide a limited mechanism for build systems to hook into the bundle loading logic (via SWIFT_MODULE_RESOURCE_BUNDLE_AVAILABLE in the short term and expanding MacroExpansionContext as a future direction), but not for user code to do so.

From reading your post there, this seems more like something that we might benefit from supporting natively in the Foundation Bundle API if this is going to be needed for all Android resource bundles. That would be orthogonal to this effort, but if that were to happen then the underlying implementation of #bundle could be modified to ensure that it works properly on Android (and all officially-supported platforms).

1 Like

That is a great idea! I updated the proposal to remove #bundleDescription and added two new initializers on LocalizedStringResource. Thanks for the input!

I agree with Matt here. Your suggestion is about how Bundle loads its resources, and offering a hook into it. That will have to be covered by new API on Bundle, and I encourage you to write a new pitch for that idea!
However, the proposed macro is a step into the right direction. Its added flexibility lets us change the bundle discovery story, and incorporate any new API that enables your use-case once it's ready. Clients who use #bundle explicitly (and in the future all localizable literal strings โ€“ see "Future Direction") will be able to benefit as soon as we can change the macro, without additional code changes on their side.

1 Like

I would love to have a standardised solution for this, which would also support static libraries.

Or rather, I would love to standardise rules for copying bundles associated with static libraries into parent frameworks/apps. It is quite a mess. E.g. checkout heuristics implemented by Tuist - tuist/Sources/TuistGenerator/Mappers/ResourcesProjectMapper.swift at bd0ad2167b0879ecf78d731005db030a8256f5b4 ยท tuist/tuist ยท GitHub.

Static libraries don't have a bundle and when they load resources, they will always depend on a bundle at a known path. It's the build system's responsibility to assemble it, and to inform the code how to access it. That's what the build system does for Swift Packages with Bundle.module.

As mentioned under Future Directions, we would like to work towards the build system emitting information about how to find the resource bundle into the macro's expansion context. This way, the build system is free in deciding the layout of the build products, and the code will only have to look at one place to load them at runtime.

Having a macro enables this, so this proposal is the first step.

2 Likes

Should the macro be named #Bundle to match its return type? (I think the guidance for macros is to use UpperCamelCase, but I'm not sure where I read that.)

Other examples in Foundation are:

I think not. It would be different if the macro was constructing a new Bundle each call, but instead it exists to discover the current bundle, so similar to the built-in #file and #line macros etc., it seems more in line to start with lower case.

1 Like

There's fairly weak guidance overall here, but generally speaking:

If it quacks like a type declaration, use UpperCamelCase:

@Test func f() {} // declaring a test
#Playground {} // declaring a playground

If it quacks like an expression, use lowerCamelCase:

#fileID // a literal containing the current file ID
#expect() // a function-call-like macro invocation
6 Likes

Sounds like a nice addition. So +1 from me?

Do I understand correctly that this will only work in the context of localized strings? In that case, I would suggest adding that info in the macro name. I.e. something like #LocalizationBundle.

Not quite, the macro #bundle can be used to load any kinds of resources too!

1 Like

Thanks for everyone's feedback. I'm accepting this proposal with this pitch.

5 Likes

Please, don't forget to add mention to #bundle in Bundle | Apple Developer Documentation since it's not that discoverable :sweat_smile:.

3 Likes