-
Notifications
You must be signed in to change notification settings - Fork 188
[Proposal]: #bundle
macro
#1251
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,184 @@ | ||
# Introduce `#bundle` | ||
|
||
|
||
* Proposal: [SF-0024](0024-filename.md) | ||
* Authors:[Matt Seaman](https://git.1-hub.cnmatthewseaman), [Andreas Neusuess](https://git.1-hub.cnTantalum73) | ||
* Review Manager: [Tina L](https://git.1-hub.cnitingliu) | ||
* Status: **Accepted** | ||
|
||
|
||
## Revision history | ||
|
||
* **v1** Initial version | ||
* **v1.1** Remove `#bundleDescription` and add 2 initializers to `LocalizedStringResource` | ||
|
||
## 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: | ||
|
||
```swift | ||
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: | ||
|
||
```swift | ||
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. | ||
|
||
```swift | ||
label.text = String( | ||
localized: "She didn't clean the camera!", | ||
bundle: #bundle, | ||
comment: "Comment of astonished bystander" | ||
) | ||
``` | ||
|
||
## Detailed design | ||
|
||
We propose introducing a `#bundle` macro as follows: | ||
|
||
```swift | ||
/// Returns the bundle most likely to contain resources for the calling code. | ||
/// | ||
/// Code in an app, app extension, framework, etc. will return the bundle associated with that target. | ||
/// Code in a Swift Package target will return the resource bundle associated with that target. | ||
@available(macOS 10.0, iOS 2.0, tvOS 9.0, watchOS 2.0, *) | ||
@freestanding(expression) | ||
public macro bundle() -> Bundle = #externalMacro(module: "FoundationMacros", type: "CurrentBundleMacro") | ||
``` | ||
|
||
`#bundle` would expand to: | ||
|
||
```swift | ||
{ | ||
#if SWIFT_MODULE_RESOURCE_BUNDLE_AVAILABLE | ||
return Bundle.module | ||
#elseif SWIFT_MODULE_RESOURCE_BUNDLE_UNAVAILABLE | ||
#error("No resource bundle is available for this module. If resources are included elsewhere, specify the bundle manually.") | ||
#else | ||
return Bundle(_dsoHandle: #dsohandle) ?? .main | ||
#endif | ||
}() | ||
``` | ||
|
||
This macro relies on `SWIFT_MODULE_RESOURCE_BUNDLE_AVAILABLE `, a new `-D`-defined conditional that will be passed by SwiftBuild, SwiftPM, and potential 3rd party build systems under the same conditions where `Bundle.module` would be generated. | ||
|
||
The preprocessor macro `SWIFT_MODULE_RESOURCE_BUNDLE_UNAVAILABLE` should be set by build systems when `Bundle.module` is not generated and the fallback `#dsohandle` approach would not retrieve the correct bundle for resources. A Swift Package without any resource files would be an example of this. Under this scenario, usage of `#bundle` presents an error. | ||
|
||
|
||
It calls into new API on `Bundle`, which will be back-deployed so that using the macro isn't overly limited by the project's deployment target. | ||
|
||
```swift | ||
extension Bundle { | ||
/// Creates an instance of `Bundle` from the current value for `#dsohandle`. | ||
/// | ||
/// - warning: Don't call this method directly, and use `#bundle` instead. | ||
/// | ||
/// In the context of a Swift Package or other static library, | ||
/// the result is the bundle that contains the produced binary, which may be | ||
/// different from where resources are stored. | ||
/// | ||
/// - Parameter dsoHandle: `dsohandle` of the current binary. | ||
@available(FoundationPreview 6.2, *) | ||
@_alwaysEmitIntoClient | ||
public convenience init?(_dsoHandle: UnsafeRawPointer) | ||
``` | ||
|
||
The type `LocalizedStringResource` (LSR) doesn't operate on instances of `Bundle`, but `LocalizedStringResource.BundleDescription`. They can easily be converted into each other. | ||
To make the new macro work well with LSR, we suggest adding two new initializers. We mark them as `@_alwaysEmitIntoClient` and `@_disfavoredOverload`, to avoid ambiguity over the initializers accepting a `BundleDescription` parameter: | ||
|
||
```swift | ||
@available(FoundationPreview 6.2, *) | ||
extension LocalizedStringResource { | ||
@_alwaysEmitIntoClient | ||
@_disfavoredOverload | ||
public init(_ keyAndValue: String.LocalizationValue, table: String? = nil, locale: Locale = .current, bundle: Bundle, comment: StaticString? = nil) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a reason these couldn't declare a default value of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for bringing this up! The other existing, similar initializer to this one is this: LocalizedStringResource(
_ keyAndValue: String.LocalizationValue,
table: String? = nil,
locale: Locale = .current,
bundle: LocalizedStringResource.BundleDescription = .main,
comment: StaticString? = nil
) If our new initializer only took parameters with default values, one had to be marked as disfavored, probably the new one: @_disfavoredOverload
LocalizedStringResource(
_ keyAndValue: String.LocalizationValue,
table: String? = nil,
locale: Locale = .current,
bundle: Bundle = #bundle,
comment: StaticString? = nil
) With the new initializer being disfavored, its default value would never take effect: As eluded to in Future Directions, we can't change the default value for all |
||
|
||
@_alwaysEmitIntoClient | ||
@_disfavoredOverload | ||
public init(_ key: StaticString, defaultValue: String.LocalizationValue, table: String? = nil, locale: Locale = .current, bundle: Bundle, comment: StaticString? = nil) | ||
} | ||
``` | ||
|
||
## Impact on existing code | ||
|
||
This change is purely additive. | ||
|
||
## Alternatives considered | ||
|
||
### Not using a macro | ||
|
||
We chose a macro because it gives us the most flexibility to update the implementation later. | ||
This will allow us to eventually use `#bundle` (or a wrapping macro) as the default argument for the bundle parameter, which (since [SE-0422](https://git.1-hub.cnswiftlang/swift-evolution/blob/main/proposals/0422-caller-side-default-argument-macro-expression.md)) will get expanded in the caller. | ||
|
||
Also, only a macro lets us properly implement this for Swift Package targets since we need to either call `Bundle.module` (which only exists as a code-gen'd, internal symbol in clients) or access build-time information such as the name of the target. | ||
|
||
### Not doing this change | ||
|
||
Without this macro, developers will continue to have to write extensions on `Bundle` or repeat calling `Bundle(for: )` in their code. | ||
|
||
|
||
### Using the name `#currentResourceBundle` | ||
|
||
Previously we discussed using the name `#currentResourceBundle` for the proposed new macro. It has been determined that `ResourceBundle` and `Bundle` describe the same thing in terms of loading resources. This macro will be used to load resources from the current bundle, repeating the fact that the current "resource bundle" is not necessary. | ||
|
||
### Using the name `#currentBundle` | ||
|
||
Previously we discussed using the name `#currentBundle` for the proposed new macro. It was pointed out that Swift already uses macros like `#filePath` or `#line`, which also imply "current". | ||
|
||
While `#filePath` and `#line` are unambiguous, `#bundle` could be perceived as another way to spell `Bundle.main`. Calling it `#currentBundle` would help differentiate it from `Bundle.main`. | ||
|
||
However, in the context of loading resources, `#bundle` is more accurate than `Bundle.main`, as it's correct in the majority of scenarios. Developers specifying `Bundle.main` when loading resources often want what `#bundle` offers, and calling the macro `#bundle` makes it easier to discover. | ||
|
||
We think that consistency with existing Swift macros overweighs, and that the similarity to `Bundle.main` is an advantage for discoverability. | ||
|
||
### Using a separate macro for `LocalizedStringResource.BundleDescription` | ||
An earlier version of this proposal suggested to add `#bundle` and `#bundleDescription`, to work with `String(localized: ... bundle: Bundle)` and `LocalizedStringResource(... bundle: LocalizedStringResource.BundleDescription)`. | ||
|
||
Upon closer inspection, we can make LSR work with an instance of `Bundle` and have the proposed initializer convert it to a `LocalizedStringResource.BundleDescription` internally. This way, we only have to provide one macro, which makes it easier to discover for developers. | ||
|
||
|
||
## Future Directions | ||
|
||
## Infer `currentBundle` by default | ||
|
||
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: )`). | ||
|
||
|
||
## Compute Package resource bundles without Bundle.module | ||
|
||
If we enhance `MacroExpansionContext` to include some additional information from the build system (such as target name and type), we can change the implementation of `#bundle` to compute the bundle on its own. | ||
|
||
This would be desirable so that the build system can inform Foundation about the bundle it creates on disk. Foundation's `#bundle` macro can ingest that information at build time, to produce code that loads the bundle in the current context. | ||
|
||
`Bundle.module` can't be fully removed without breaking existing code, though it could be generated as deprecated and/or gated behind a build setting. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The example of localized resources in a framework or library is a solid motivator for this feature. But if you're interested in citing other motivators, I have one to suggest:
Swift Testing allows and recommends using structs (value types) instead of classes for grouping related test functions. Often, test authors need to reference resources in their test bundle for things like fixture data files. Historically it has been straightforward to use
Bundle(forClass:)
and pass the XCTestCase subclass in the current file to accomplish this. But when using Swift Testing idiomatically, users may not have a class in the test bundle module which can conveniently serve this purpose. And of course, as its name implies,Bundle(forClass:)
does require a class meta type and doesn't accept the type of a struct. So this feature will benefit Swift Testing users and allow them to use value types for suites even more easily.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(I mean
Bundle.init(for:)
which acceptsAnyClass
, but you get the idea.)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great example, I haven't thought about Swift Testing!