diff --git a/.agents/references/code-style.md b/.agents/references/code-style.md index 19f8ec9..5caedbb 100644 --- a/.agents/references/code-style.md +++ b/.agents/references/code-style.md @@ -92,6 +92,11 @@ public and wants it first in its group — but counts an explicit **event** as p `ResXLocalization.slnx.DotSettings` has one "Explicit interface …" entry per kind and none for events: giving events one puts an explicit event ahead of a public one and breaks `SA1202`. +Public static methods have an entry of their own, ahead of "Explicit interface methods". StyleCop counts +an explicit method as a public instance method, so a public static method after it breaks `SA1204`; the +extra entry keeps a class that has both - such as `MessageArguments`, with its factory methods and +`IEnumerable.GetEnumerator` - in an order both tools accept. + ## XAML XamlStyler owns `.xaml` and `.axaml`, and CSharpier is kept away from them by `.csharpierignore`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a9e201..07fc22f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,12 @@ this project adheres to [Semantic Versioning](https://semver.org/). - `LookupFailure` and `LookupFailureReason`, which describe a failed lookup to `LookupFailed` and to `ILocalizer.LookupFailureTextBuilder`. - `ILocalizer.RefreshTriggeringRevision`, which changes on every change that can change a lookup's - result (culture, formatter, lookup-failure text builder, registered resource managers). + result (culture, formatter, lookup-failure text builder, enum key builder, registered resource + managers). +- `MessageArguments`, the arguments of a lookup, built with `FromValues`, `FromTuples`, + `FromDictionary` or `FromObject` and checked when they are built: positional, named or mixed. +- Enum lookups with format arguments: `Get(sortOrder, MessageArguments.FromValues(count))`. +- `ILocalizer.EnumKeyBuilder`, one replaceable enum key convention for code and XAML. ### Changed @@ -30,8 +35,7 @@ this project adheres to [Semantic Versioning](https://semver.org/). - **BREAKING:** `Get(key, args)` with an empty array returns the resource value unformatted: `{{x}}` stays `{{x}}`. See the [migration guide](docs/guides/migration-2.0.md). - **BREAKING:** A custom `ILocalizer` implementation must implement `LookupFailed`, - `MessageFormatter`, `RefreshTriggeringRevision`, `LookupFailureTextBuilder` and the hidden - `Get(string key, MessageArgument[] arguments, ResourceManager? resourceManager)`. See the + `MessageFormatter`, `RefreshTriggeringRevision` and `LookupFailureTextBuilder`. See the [migration guide](docs/guides/migration-2.0.md). - **BREAKING:** `ILocalizer.MissingTranslationFormat` is replaced by `ILocalizer.LookupFailureTextBuilder`, a `Func` that also produces the failure text for a formatting failure. See the @@ -39,6 +43,23 @@ this project adheres to [Semantic Versioning](https://semver.org/). - **BREAKING:** `ILocalizer.TranslationNotFound` and `TranslationNotFoundEventArgs` are replaced by `ILocalizer.LookupFailed` and `LookupFailedEventArgs`, which report formatting failures too. See the [migration guide](docs/guides/migration-2.0.md). +- **BREAKING:** Format arguments are built with `MessageArguments`: `Get("Key", a, b)` becomes + `Get("Key", MessageArguments.FromValues(a, b))`; the `params object?[]` overloads are removed. See the [migration guide](docs/guides/migration-2.0.md). +- **BREAKING:** A scoped lookup names the manager: `Get("Key", rm)` becomes + `Get("Key", resourceManager: rm)`, and `Get("Key", rm, a, b)` becomes + `Get("Key", MessageArguments.FromValues(a, b), rm)`. + See the [migration guide](docs/guides/migration-2.0.md). +- **BREAKING:** An enum key prefix is named: `Get(sortOrder, "Display_")` becomes + `Get(sortOrder, keyPrefix: "Display_")`. See the [migration guide](docs/guides/migration-2.0.md). +- **BREAKING:** A `null` resource manager means search-all and a `null` key prefix the default of + `ILocalizer.EnumKeyBuilder`, instead of throwing `ArgumentNullException`; + `LocalizeEnumExtension.KeyPrefix` and `LocalizeEnumConverter.KeyPrefix` are `string?` and default to + `null`. See the [migration guide](docs/guides/migration-2.0.md). +- **BREAKING:** Every 1.x `Get` signature is removed, including `Get(string)` and `Get(ResourceKey)`; + assemblies compiled against 1.x throw `MissingMethodException` until recompiled. See the [migration guide](docs/guides/migration-2.0.md). +- **BREAKING:** A custom `ILocalizer` implementation has exactly three `Get` overloads - string, + `ResourceKey` and enum keys, each with an optional `MessageArguments` - and implements + `EnumKeyBuilder`. See the [migration guide](docs/guides/migration-2.0.md). ## [1.1.0] - 2026-07-20 diff --git a/ResXLocalization.slnx.DotSettings b/ResXLocalization.slnx.DotSettings index 3f67319..ae90401 100644 --- a/ResXLocalization.slnx.DotSettings +++ b/ResXLocalization.slnx.DotSettings @@ -175,6 +175,18 @@ <Name /> </Entry.SortBy> </Entry> + <Entry DisplayName="Public static methods"> + <Entry.Match> + <And> + <Kind Is="Method" /> + <Access Is="Public" /> + <Static /> + </And> + </Entry.Match> + <Entry.SortBy> + <Name /> + </Entry.SortBy> + </Entry> <Entry DisplayName="Explicit interface methods"> <Entry.Match> <And> diff --git a/docs/guides/enums.md b/docs/guides/enums.md index 59f204c..f87c0aa 100644 --- a/docs/guides/enums.md +++ b/docs/guides/enums.md @@ -56,10 +56,22 @@ a language switch: Localizer.Current.Get(FileSortOrder.Ascending); // "Ascending (A-Z)" / "Aufsteigend (A-Z)" ``` +An enum label can have placeholders and take [format arguments](format-arguments.md), like any other +lookup. With a `Count_FileSortOrder_Ascending` entry of `{0} files, ascending`: + +```csharp +Localizer.Current.Get(FileSortOrder.Ascending, MessageArguments.FromValues(count), keyPrefix: "Count_"); // "3 files, ascending" +Localizer.Current.Get(FileSortOrder.Ascending, MessageArguments.FromValues(count), SortingStrings.ResourceManager, "Count_"); +``` + +The optional parameters after the value are `arguments`, `resourceManager` and `keyPrefix`; name the +ones you pass when you skip an earlier one. + ## Custom prefix and scoping -The markup extension, the converter and the code API all accept a **`KeyPrefix`** (default `Enum_`) -and an optional **`ResourceManager`** that scopes the lookup to one `.resx` file. That is what lets +The markup extension, the converter and the code API all accept a **`KeyPrefix`** (by default +`null`, which means `Enum_` - see [Changing the key convention](#changing-the-key-convention)) and an +optional **`ResourceManager`** that scopes the lookup to one `.resx` file. That is what lets the same enum carry different label sets, or keeps enum labels in a file of their own: ```xml @@ -68,7 +80,7 @@ the same enum carry different label sets, or keeps enum labels in a file of thei ``` ```csharp -Localizer.Current.Get(FileSortOrder.Ascending, SortingStrings.ResourceManager, "Display_"); +Localizer.Current.Get(FileSortOrder.Ascending, resourceManager: SortingStrings.ResourceManager, keyPrefix: "Display_"); ``` `LocalizeEnumConverter.Default` is read-only, so a converter with a custom prefix is declared as its @@ -78,6 +90,35 @@ own instance in resources: ``` +## Changing the key convention + +`ILocalizer.EnumKeyBuilder` is the function that turns an enum value and an optional key prefix into +the resource key. Set it once at startup, and it applies to every enum lookup - `Get(Enum, …)`, +`{l:LocalizeEnum}` and `LocalizeEnumConverter`: + +```csharp +Localizer.Current.EnumKeyBuilder = static (value, keyPrefix) => + (keyPrefix ?? "Text_") + value.GetType().Name + "." + value; + +Localizer.Current.Get(FileSortOrder.Ascending); // looks up "Text_FileSortOrder.Ascending" +``` + +The function receives two parameters: + +1. The enum value of the lookup; never `null`. +2. The key prefix the lookup passed - `keyPrefix` in code, `KeyPrefix` in XAML - or `null` when it + passed none. `null` means "use your own default"; a non-null value is an explicit override. + +The default is the convention above, `{keyPrefix ?? "Enum_"}{EnumTypeName}_{MemberName}`, for example +`Enum_FileSortOrder_Ascending`. `keyPrefix` and `KeyPrefix` therefore override only the prefix: they +are passed to the builder, which decides what to do with them. An application that passes the same +prefix on every enum lookup can make it the builder's default instead. + +Assigning a different function later re-resolves bound text, like a language switch; assigning the +current one again does nothing. The builder must not throw or return `null` or an empty string: an +exception it throws propagates out of the lookup, and a `null` or empty result makes the lookup throw +`InvalidOperationException`. Assigning `null` throws `ArgumentNullException`. + ## When a member has no entry An enum member with no matching resource entry resolves like any other missing key: the diff --git a/docs/guides/format-arguments.md b/docs/guides/format-arguments.md index d1b38c9..888183a 100644 --- a/docs/guides/format-arguments.md +++ b/docs/guides/format-arguments.md @@ -9,13 +9,50 @@ view model. Add the entry as usual: ## In code +Arguments are a `MessageArguments`, built with one of its static methods: + ```csharp -Localizer.Current.Get(AppStringsKeys.PeopleInvited, peopleCount); +Localizer.Current.Get(AppStringsKeys.PeopleInvited, MessageArguments.FromValues(peopleCount)); ``` +Every `Get` overload - string keys, typed keys and enum values - takes the arguments the same way, as +its optional second parameter. + The resolved text and the arguments go to the localizer's formatter - by default `string.Format` in the localizer's current culture, so a number or a date formats the way that culture writes it. +### Building arguments + +Each method returns a `MessageArguments` whose arguments have the positions 0, 1, 2, … in the order you pass +them: + +- **`MessageArguments.FromValues(a, b, …)`** - positional arguments, for `{0}`, `{1}`, … This is the one to use with the + default formatter: `Get("FilePosition", MessageArguments.FromValues(3, 10))` renders `File {0} of {1}` as + `File 3 of 10`. +- **`MessageArguments.FromTuples(("Count", 5), ("Total", 10))`** - named arguments, in declaration order. + A `null` name makes that argument positional only, so named and unnamed arguments can be mixed: + `MessageArguments.FromTuples((null, file), ("Total", 10))`. +- **`MessageArguments.FromDictionary(dictionary)`** - named arguments from an existing + `IReadOnlyDictionary`; positions follow its enumeration order. +- **`MessageArguments.FromObject(new { Count = 5, Total = 10 })`** - named arguments from the public instance properties + of the object's static type, in declaration order. It is trim and Native AOT safe; a value typed + `object` has no properties and yields no arguments. + +The methods reject an empty string as a name and two arguments with the same name (compared +ordinally), so a `MessageArguments` is always valid. It cannot be changed after it is built; +`MessageArguments.Empty` stands for no arguments. An argument's position is its +`MessageArgument.Position`: its index in the enumeration of a `MessageArguments` is not guaranteed to be +the same, so a custom formatter reads `Position`. + +**The `MessageArguments.FromValues(dictionary)` trap.** `FromValues` takes `params object?[]`, so +`MessageArguments.FromValues(dictionary)` is *one* positional argument whose value is the dictionary itself. Use +`MessageArguments.FromDictionary(dictionary)` for named arguments. + +**Names matter only to a formatter that understands them.** The default `string.Format` formatter +ignores names and uses positions, so `MessageArguments.FromObject(new { Count = 1.5, Where = "x" })` fills `{0}` and `{1}` +exactly like `MessageArguments.FromValues(1.5, "x")`. A formatter that knows names (ICU MessageFormat) finds a named +argument both under its position and under its name. + ## In XAML Bind the **`LocalizeArgs.Arg0`…`Arg8` attached properties** on the element that carries the localized @@ -40,8 +77,8 @@ place as `5 Personen eingeladen`. - **Per-argument format specifiers belong in the resource string** — `{0:N0} people invited` — where a translator can adjust them per language. - **No arguments, no formatting.** Under the default formatter, a lookup without arguments returns the - resource value unchanged - in code `Localizer.Current.Get(key, args)` with an empty array, like - `Get(key)`, and in XAML an element without any `ArgN`. A resource value containing a literal `{` or + resource value unchanged - in code `Localizer.Current.Get(key, MessageArguments.FromValues())` or + `Get(key, null)`, like `Get(key)`, and in XAML an element without any `ArgN`. A resource value containing a literal `{` or `}` keeps working without `{{` escaping. The failure text of a missing key is never formatted. - **Formatter exceptions are caught.** When a value cannot be formatted - `Broken {0`, or `File {0} of {1}` with one argument - the localizer raises `LookupFailed` and returns the @@ -53,7 +90,7 @@ place as `5 Personen eingeladen`. startup; changing it later re-resolves bound text like a language switch (`RefreshTriggeringRevision`). The default is `string.Format` in the localizer's current culture. The formatter sees **every** resolved value, in code and in XAML, also lookups without arguments - (with an empty array), so it is the one place to post-process all translated texts; a missing key's + (with empty arguments), so it is the one place to post-process all translated texts; a missing key's failure text does not pass through it. A custom formatter should return the value unchanged when `arguments` is empty, unless it means to handle plain texts: @@ -62,13 +99,18 @@ place as `5 Personen eingeladen`. sealed class MyFormatter : IMessageFormatter { - public string Format(string pattern, MessageArgument[] arguments, CultureInfo culture) => - arguments.Length == 0 + public string Format(string pattern, MessageArguments arguments, CultureInfo culture) => + arguments.Count == 0 ? pattern // no arguments: do not format, so a literal { keeps working - : string.Format(culture, pattern, arguments.Select(argument => argument.Value).ToArray()); + : string.Format( + culture, + pattern, + // Order by Position: the index in the enumeration is not guaranteed to be the position. + arguments.OrderBy(argument => argument.Position).Select(argument => argument.Value).ToArray() + ); } ``` - **Keep it simple.** For a very complex string, compose the text in the view model with - `Get(key, args…)` rather than wiring many argument slots. + `Get(key, MessageArguments.FromValues(…))` rather than wiring many argument slots. - **Composite formatting does not pluralize.** `1 people invited` is on the resource author; use separate singular and plural resources where it matters. diff --git a/docs/guides/lookup-and-fallback.md b/docs/guides/lookup-and-fallback.md index ff0c3ec..d1e6472 100644 --- a/docs/guides/lookup-and-fallback.md +++ b/docs/guides/lookup-and-fallback.md @@ -16,12 +16,25 @@ resolve. Localizer.Current.Get(AppStringsKeys.Greeting); // Scoped: this key, in this file. -Localizer.Current.Get("Greeting", AppStrings.ResourceManager); +Localizer.Current.Get("Greeting", resourceManager: AppStrings.ResourceManager); // Search-all: this key, in whichever registered file has it first. Localizer.Current.Get("Greeting"); ``` +`ILocalizer` has one `Get` per key kind - a string key, a typed `ResourceKey` and an enum value - and +each takes optional [format arguments](format-arguments.md) as its second parameter: + +```csharp +string Get(string key, MessageArguments? arguments = null, ResourceManager? resourceManager = null); +string Get(ResourceKey key, MessageArguments? arguments = null); +string Get(Enum value, MessageArguments? arguments = null, ResourceManager? resourceManager = null, string? keyPrefix = null); +``` + +A scoped lookup therefore names the manager: `Get("Greeting", resourceManager: rm)`, or +`Get("Greeting", MessageArguments.FromValues(a), rm)` with arguments. Without a manager, or with +`null`, the lookup searches all registered managers. + The same three modes exist in XAML: ```xml @@ -120,8 +133,11 @@ re-resolves bound text. ## Refreshing localized text `ILocalizer.RefreshTriggeringRevision` changes whenever something changes that can change the result -of a lookup: the culture, the formatter, the lookup-failure text builder, or the registered resource -managers (a manager registered or unregistered, or the registrations cleared). Only whether the value +of a lookup: the culture, the formatter, the lookup-failure text builder, the enum key builder +(`EnumKeyBuilder`, see [Localizing enum values](enums.md#changing-the-key-convention)), or the registered +resource managers (a manager registered or unregistered, or the registrations cleared). For the +formatter and the two builders, assigning a different instance counts; assigning the current instance +again does nothing. Only whether the value differs is meaningful, never the number itself. Bound text re-resolves on each of these changes. Strings fetched in code with `Get` are not diff --git a/docs/guides/migration-2.0.md b/docs/guides/migration-2.0.md index f3930aa..a042198 100644 --- a/docs/guides/migration-2.0.md +++ b/docs/guides/migration-2.0.md @@ -52,7 +52,7 @@ After: ## An empty arguments array no longer formats -`Localizer.Current.Get(key, args)` with an empty array returns the resource value unformatted, exactly +`Localizer.Current.Get(key, args)` with an empty array (from 2.0, `Get(key, MessageArguments.FromValues())`) returns the resource value unformatted, exactly like `Get(key)`: `{{x}}` stays `{{x}}`, and a value containing `{0}` no longer throws. Before: @@ -65,7 +65,7 @@ Localizer.Current.Get("Template", Array.Empty()); // 1.x: FormatExcepti After: ```csharp -Localizer.Current.Get("Template", Array.Empty()); // "Literal {{braces}} for {0}" +Localizer.Current.Get("Template", MessageArguments.FromValues()); // "Literal {{braces}} for {0}" ``` ## Custom `ILocalizer` implementations have new members @@ -76,8 +76,10 @@ A custom `ILocalizer` implementation must implement: - `ILocalizer.MessageFormatter`; - `ILocalizer.RefreshTriggeringRevision` - advance it and raise `PropertyChanged` on every change that can change a lookup's result; from 2.0 the XAML bindings re-resolve on it, not on `CurrentCulture`; -- the hidden `ILocalizer.Get(string key, MessageArgument[] arguments, ResourceManager? resourceManager)`, - the one entry point every lookup goes through. +- `ILocalizer.Get(string key, MessageArguments? arguments = null, ResourceManager? resourceManager = null)`, + the one entry point every lookup goes through, together with the other two `Get` overloads and + `EnumKeyBuilder` (see + [A custom `ILocalizer` implements three `Get` overloads](#a-custom-ilocalizer-implements-three-get-overloads-and-enumkeybuilder)). It also replaces `MissingTranslationFormat` with `LookupFailureTextBuilder` (see below), raises `LookupFailed` and builds the failure text for both `LookupFailureReason`s. @@ -103,7 +105,7 @@ public sealed class MyLocalizer : ILocalizer public int RefreshTriggeringRevision { get; private set; } public Func LookupFailureTextBuilder { get; set; } = failure => $"!{failure.Key}!"; - public string Get(string key, MessageArgument[] arguments, ResourceManager? resourceManager) => + public string Get(string key, MessageArguments? arguments = null, ResourceManager? resourceManager = null) => /* resolve, then MessageFormatter.Format(value, arguments, CurrentCulture); on a failure raise LookupFailed, then return LookupFailureTextBuilder(failure) */; // ... @@ -160,3 +162,148 @@ Localizer.Current.LookupFailed += (_, args) => } }; ``` + +## Format arguments are built with `MessageArguments` + +The `Get` overloads with `params object?[]` are removed. Arguments are a `MessageArguments`, built with +its static methods. A dictionary, tuple or anonymous object passed to the old overloads silently became +argument `{0}`; the new parameter accepts nothing but a `MessageArguments`. + +Before: + +```csharp +Localizer.Current.Get("FilePosition", a, b); +``` + +After: + +```csharp +Localizer.Current.Get("FilePosition", MessageArguments.FromValues(a, b)); +``` + +## A scoped lookup names the manager + +The resource manager of a scoped lookup is an optional parameter after the arguments, so it is passed +by name or after the arguments. + +Before: + +```csharp +Localizer.Current.Get("Key", rm); +Localizer.Current.Get("Key", rm, a, b); +``` + +After: + +```csharp +Localizer.Current.Get("Key", resourceManager: rm); +Localizer.Current.Get("Key", MessageArguments.FromValues(a, b), rm); +``` + +## An enum key prefix is named + +The enum overload's optional parameters are `arguments`, `resourceManager` and `keyPrefix`, in that +order. + +Before: + +```csharp +Localizer.Current.Get(sortOrder, "Display_"); +Localizer.Current.Get(sortOrder, rm, "Display_"); +``` + +After: + +```csharp +Localizer.Current.Get(sortOrder, keyPrefix: "Display_"); +Localizer.Current.Get(sortOrder, resourceManager: rm, keyPrefix: "Display_"); +``` + +## `null` no longer throws for a manager or a key prefix + +A `null` resource manager means search-all, and a `null` key prefix means the default convention of +`ILocalizer.EnumKeyBuilder`. In 1.x both threw `ArgumentNullException`. This applies in code and in +XAML: `LocalizeEnumExtension.KeyPrefix` and `LocalizeEnumConverter.KeyPrefix` are `string?` and default +to `null`, and `KeyPrefix="{x:Null}"` no longer throws. + +Before: + +```csharp +Localizer.Current.Get("Key", (ResourceManager)null!); // 1.x: ArgumentNullException +Localizer.Current.Get(sortOrder, (string)null!); // 1.x: ArgumentNullException +``` + +After: + +```csharp +Localizer.Current.Get("Key", resourceManager: null); // search-all +Localizer.Current.Get(sortOrder, keyPrefix: null); // the EnumKeyBuilder's default, Enum_ +``` + +## Recompile assemblies that call `Get` + +Every 1.x `Get` signature is removed, including `Get(string)` and `Get(ResourceKey)`, whose calls still +compile unchanged against 2.0. An assembly compiled against 1.x throws `MissingMethodException` at the +first `Get` call until it is recompiled. + +## A custom `ILocalizer` implements three `Get` overloads and `EnumKeyBuilder` + +A custom `ILocalizer` implementation replaces its `Get` overloads with exactly three, one per key kind, +and implements `EnumKeyBuilder`. + +Before: + +```csharp +public string Get(string key) => /* ... */; +public string Get(string key, ResourceManager resourceManager) => /* ... */; +public string Get(string key, params object?[] arguments) => /* ... */; +public string Get(string key, ResourceManager resourceManager, params object?[] arguments) => /* ... */; +public string Get(ResourceKey key) => /* ... */; +public string Get(ResourceKey key, params object?[] arguments) => /* ... */; +public string Get(Enum value, string keyPrefix = "Enum_") => /* ... */; +public string Get(Enum value, ResourceManager resourceManager, string keyPrefix = "Enum_") => /* ... */; +``` + +After: + +```csharp +public Func EnumKeyBuilder { get; set; } = + static (value, keyPrefix) => (keyPrefix ?? "Enum_") + value.GetType().Name + "_" + value; + +public string Get(string key, MessageArguments? arguments = null, ResourceManager? resourceManager = null) => + /* the one lookup; null arguments means none, a null manager means search-all */; + +public string Get(ResourceKey key, MessageArguments? arguments = null) => + this.Get(key.Name, arguments, key.Manager); + +public string Get(Enum value, MessageArguments? arguments = null, ResourceManager? resourceManager = null, string? keyPrefix = null) => + this.Get(this.EnumKeyBuilder(value, keyPrefix), arguments, resourceManager); +``` + +## Optional: set the enum key prefix once + +Not a break. An application that passes the same prefix on every enum lookup can make it the default +of `ILocalizer.EnumKeyBuilder` instead, for code and XAML alike. + +Before: + +```csharp +Localizer.Current.Get(sortOrder, "MyPrefix_"); +``` + +```xml + +``` + +After: + +```csharp +Localizer.Current.EnumKeyBuilder = static (value, keyPrefix) => + (keyPrefix ?? "MyPrefix_") + value.GetType().Name + "_" + value; + +Localizer.Current.Get(sortOrder); +``` + +```xml + +``` diff --git a/docs/guides/native-aot.md b/docs/guides/native-aot.md index 56bbc5b..a2245b2 100644 --- a/docs/guides/native-aot.md +++ b/docs/guides/native-aot.md @@ -5,6 +5,12 @@ IL2xxx or IL3xxx diagnostic — not from the library, not from a package in the your own call sites. There is no `[RequiresUnreferencedCode]` or `[RequiresDynamicCode]` on any public member, so nothing about a trimmed publish is a warning you have to read and dismiss. +One annotation reaches your code: `MessageArguments.FromObject` reads the public properties of `T`, +which carries `[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]`. Calling it +with a concrete type, such as an anonymous object, needs nothing. A generic method of your own that +forwards its type parameter to `FromObject` must carry the same annotation on that parameter, or the +trimmer reports IL2091 at that call. + **`ResXLocalization.WPF` is out of scope.** WPF is Windows-only and does not support Native AOT; that is a WPF limitation, not one of this library. @@ -54,7 +60,8 @@ suite passes throughout. This repository's own gate therefore publishes a package-only consumer natively and **runs** it, asserting exact strings before and after a culture change, through the typed, scoped and search-all -lookups, the enum convention, composite formatting, and the fallback to the neutral culture. See +lookups, the enum convention, composite formatting, named arguments built by reflection with +`MessageArguments.FromObject`, and the fallback to the neutral culture. See `scripts/verify-package-aot.ps1`. A check that only asserts the executable exists proves that the linker ran, and nothing else. diff --git a/docs/index.md b/docs/index.md index e927e23..e547160 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,7 +13,7 @@ installation and a quick start. These pages are the full documentation. | --- | --- | | [Lookup modes and culture fallback](guides/lookup-and-fallback.md) | Typed, scoped and search-all lookups, registration order, the fallback chain, missing-translation diagnostics, dependency injection and subscription lifetime | | [Localizing enum values](guides/enums.md) | The naming convention, item templates, the converter, custom prefixes and scoping | -| [Dynamic format arguments](guides/format-arguments.md) | `Get(key, args…)` and the `LocalizeArgs.Arg0`…`Arg8` attached properties | +| [Dynamic format arguments](guides/format-arguments.md) | `Get(key, MessageArguments.FromValues(…))`, the `MessageArguments` factory and the `LocalizeArgs.Arg0`…`Arg8` attached properties | | [Native AOT and trimming](guides/native-aot.md) | What to declare, how to publish, and why the repository's own gate runs the native binary | | [Migrating to 2.0](guides/migration-2.0.md) | Each breaking change of 2.0, with what to do | diff --git a/samples/ResXLocalization.Avalonia.Sample/ViewModels/MainWindowViewModel.cs b/samples/ResXLocalization.Avalonia.Sample/ViewModels/MainWindowViewModel.cs index 676ab2e..97e6d81 100644 --- a/samples/ResXLocalization.Avalonia.Sample/ViewModels/MainWindowViewModel.cs +++ b/samples/ResXLocalization.Avalonia.Sample/ViewModels/MainWindowViewModel.cs @@ -58,8 +58,9 @@ public MainWindowViewModel(ILocalizer localizer) /// Gets the greeting via the indexer, which is shorthand for the search-all Get(string). public string GreetingViaIndexer => this.localizer["Greeting"]; - /// Gets the greeting via the scoped overload that names one file: Get(string, ResourceManager). - public string GreetingViaScopedManager => this.localizer.Get("Greeting", ApplicationStrings.ResourceManager); + /// Gets the greeting via the scoped lookup that names one file: Get(string, resourceManager:). + public string GreetingViaScopedManager => + this.localizer.Get("Greeting", resourceManager: ApplicationStrings.ResourceManager); // ---- Code-behind string lookups (each maps to one ILocalizer member) ------------------------------ @@ -82,7 +83,8 @@ public MainWindowViewModel(ILocalizer localizer) /// Gets the branding text scoped to the internal BrandingStrings file from code-behind. XAML cannot /// name an internal ResourceManager, but ordinary C# in the same assembly can. /// - public string PoweredByViaScopedInternalManager => this.localizer.Get("PoweredBy", BrandingStrings.ResourceManager); + public string PoweredByViaScopedInternalManager => + this.localizer.Get("PoweredBy", resourceManager: BrandingStrings.ResourceManager); /// /// Gets the branding text via a typed key. This resolves even though BrandingStrings has an internal @@ -90,16 +92,21 @@ public MainWindowViewModel(ILocalizer localizer) /// public string PoweredByViaTypedKey => this.localizer.Get(BrandingStringsKeys.PoweredBy); - /// Gets the selection via Get(Enum, ResourceManager, keyPrefix): scoped, custom prefix. + /// Gets the selection via Get(Enum, resourceManager:, keyPrefix:): scoped, custom prefix. public string SortOrderViaScopedCustomPrefix => - this.localizer.Get(this.SelectedFileSortOrder, SortingStrings.ResourceManager, "Display_"); + this.localizer.Get( + this.SelectedFileSortOrder, + resourceManager: SortingStrings.ResourceManager, + keyPrefix: "Display_" + ); - /// Gets the selection via Get(Enum, ResourceManager): scoped, default prefix. + /// Gets the selection via Get(Enum, resourceManager:): scoped, default prefix. public string SortOrderViaScopedDefaultPrefix => - this.localizer.Get(this.SelectedFileSortOrder, ApplicationStrings.ResourceManager); + this.localizer.Get(this.SelectedFileSortOrder, resourceManager: ApplicationStrings.ResourceManager); - /// Gets the selection via Get(Enum, keyPrefix): search-all with a custom prefix. - public string SortOrderViaSearchAllCustomPrefix => this.localizer.Get(this.SelectedFileSortOrder, "Display_"); + /// Gets the selection via Get(Enum, keyPrefix:): search-all with a custom prefix. + public string SortOrderViaSearchAllCustomPrefix => + this.localizer.Get(this.SelectedFileSortOrder, keyPrefix: "Display_"); // ---- Code-behind enum lookups (the selected FileSortOrder via each enum Get overload) -------------- diff --git a/samples/ResXLocalization.Avalonia.Sample/Views/MainWindow.axaml b/samples/ResXLocalization.Avalonia.Sample/Views/MainWindow.axaml index 377e072..db532f7 100644 --- a/samples/ResXLocalization.Avalonia.Sample/Views/MainWindow.axaml +++ b/samples/ResXLocalization.Avalonia.Sample/Views/MainWindow.axaml @@ -695,9 +695,9 @@ + Text="Get(string, resourceManager:)" /> + Code="localizer.Get("Greeting", resourceManager: ApplicationStrings.ResourceManager)" /> + Code="localizer.Get("PoweredBy", resourceManager: BrandingStrings.ResourceManager)" /> + Text="Get(Enum, keyPrefix:)" /> + Code="localizer.Get(selectedSortOrder, keyPrefix: "Display_")" /> + Text="Get(Enum, resourceManager:)" /> + Code="localizer.Get(selectedSortOrder, resourceManager: ApplicationStrings.ResourceManager)" /> + Text="Get(Enum, resourceManager:, keyPrefix:)" /> + Code="localizer.Get(selectedSortOrder, resourceManager: SortingStrings.ResourceManager, keyPrefix: "Display_")" /> Gets the greeting via the indexer, which is shorthand for the search-all Get(string). public string GreetingViaIndexer => this.localizer["Greeting"]; - /// Gets the greeting via the scoped overload that names one file: Get(string, ResourceManager). - public string GreetingViaScopedManager => this.localizer.Get("Greeting", ApplicationStrings.ResourceManager); + /// Gets the greeting via the scoped lookup that names one file: Get(string, resourceManager:). + public string GreetingViaScopedManager => + this.localizer.Get("Greeting", resourceManager: ApplicationStrings.ResourceManager); // ---- Code-behind string lookups (each maps to one ILocalizer member) ------------------------------ @@ -82,7 +83,8 @@ public MainWindowViewModel(ILocalizer localizer) /// Gets the branding text scoped to the internal BrandingStrings file from code-behind. XAML cannot /// name an internal ResourceManager, but ordinary C# in the same assembly can. /// - public string PoweredByViaScopedInternalManager => this.localizer.Get("PoweredBy", BrandingStrings.ResourceManager); + public string PoweredByViaScopedInternalManager => + this.localizer.Get("PoweredBy", resourceManager: BrandingStrings.ResourceManager); /// /// Gets the branding text via a typed key. This resolves even though BrandingStrings has an internal @@ -90,16 +92,21 @@ public MainWindowViewModel(ILocalizer localizer) /// public string PoweredByViaTypedKey => this.localizer.Get(BrandingStringsKeys.PoweredBy); - /// Gets the selection via Get(Enum, ResourceManager, keyPrefix): scoped, custom prefix. + /// Gets the selection via Get(Enum, resourceManager:, keyPrefix:): scoped, custom prefix. public string SortOrderViaScopedCustomPrefix => - this.localizer.Get(this.SelectedFileSortOrder, SortingStrings.ResourceManager, "Display_"); + this.localizer.Get( + this.SelectedFileSortOrder, + resourceManager: SortingStrings.ResourceManager, + keyPrefix: "Display_" + ); - /// Gets the selection via Get(Enum, ResourceManager): scoped, default prefix. + /// Gets the selection via Get(Enum, resourceManager:): scoped, default prefix. public string SortOrderViaScopedDefaultPrefix => - this.localizer.Get(this.SelectedFileSortOrder, ApplicationStrings.ResourceManager); + this.localizer.Get(this.SelectedFileSortOrder, resourceManager: ApplicationStrings.ResourceManager); - /// Gets the selection via Get(Enum, keyPrefix): search-all with a custom prefix. - public string SortOrderViaSearchAllCustomPrefix => this.localizer.Get(this.SelectedFileSortOrder, "Display_"); + /// Gets the selection via Get(Enum, keyPrefix:): search-all with a custom prefix. + public string SortOrderViaSearchAllCustomPrefix => + this.localizer.Get(this.SelectedFileSortOrder, keyPrefix: "Display_"); // ---- Code-behind enum lookups (the selected FileSortOrder via each enum Get overload) -------------- diff --git a/samples/ResXLocalization.WPF.Sample/Views/MainWindow.xaml b/samples/ResXLocalization.WPF.Sample/Views/MainWindow.xaml index ccd3b4b..bbbb6e5 100644 --- a/samples/ResXLocalization.WPF.Sample/Views/MainWindow.xaml +++ b/samples/ResXLocalization.WPF.Sample/Views/MainWindow.xaml @@ -794,8 +794,8 @@ - + @@ -839,7 +839,7 @@ VerticalAlignment="Center"> - @@ -882,8 +882,8 @@ - + @@ -904,8 +904,8 @@ - + @@ -926,8 +926,8 @@ - + diff --git a/src/ResXLocalization.Avalonia/LocalizeEnumConverter.cs b/src/ResXLocalization.Avalonia/LocalizeEnumConverter.cs index c1b6714..0f9620d 100644 --- a/src/ResXLocalization.Avalonia/LocalizeEnumConverter.cs +++ b/src/ResXLocalization.Avalonia/LocalizeEnumConverter.cs @@ -4,8 +4,8 @@ namespace RentADeveloper.ResXLocalization.Avalonia; /// Multi-value converter that localizes an enumeration value bound as a real value (rather than as a /// DataContext). Use it in a MultiBinding whose first binding is the enumeration value /// and whose second binding is , so the result re-converts on -/// every culture change. The value is mapped to a resource key using the convention -/// {KeyPrefix}{EnumTypeName}_{Value}. +/// every culture change. The value is mapped to a resource key by +/// , by default {KeyPrefix}{EnumTypeName}_{Value}. /// public sealed class LocalizeEnumConverter : IMultiValueConverter { @@ -33,22 +33,22 @@ public LocalizeEnumConverter() { } public static LocalizeEnumConverter Default { get; } = new(isSharedInstance: true); /// - /// Gets or sets the prefix prepended to the generated resource key. Defaults to Enum_. + /// Gets or sets the key prefix passed to . + /// (the default) means "the decides", + /// which by default is Enum_; a value overrides the prefix for this converter. /// - /// The supplied value is . /// /// The converter is the shared instance, which is read-only. /// - public string KeyPrefix + public string? KeyPrefix { get; set { - ArgumentNullException.ThrowIfNull(value); this.ThrowIfShared(); field = value; } - } = EnumKeyConvention.DefaultEnumKeyPrefix; + } /// /// Gets or sets the resource manager that scopes the lookup to a single .resx file. When @@ -92,11 +92,11 @@ public object Convert(IList values, Type targetType, object? parameter, return string.Empty; } - var key = EnumKeyConvention.BuildEnumKey(enumValue, this.KeyPrefix); + var key = Localizer.Current.BuildEnumKey(enumValue, this.KeyPrefix); return this.ResourceManager is null ? Localizer.Current.Get(key) - : Localizer.Current.Get(key, this.ResourceManager); + : Localizer.Current.Get(key, resourceManager: this.ResourceManager); } /// Guards property setters against mutating the shared instance. diff --git a/src/ResXLocalization.Avalonia/LocalizeEnumExtension.cs b/src/ResXLocalization.Avalonia/LocalizeEnumExtension.cs index e0a33d6..540fe5b 100644 --- a/src/ResXLocalization.Avalonia/LocalizeEnumExtension.cs +++ b/src/ResXLocalization.Avalonia/LocalizeEnumExtension.cs @@ -4,15 +4,17 @@ namespace RentADeveloper.ResXLocalization.Avalonia; /// XAML markup extension that localizes the enumeration value held in the target control's /// DataContext, re-resolving live on every culture change. Intended for item templates where /// each item is an enumeration value, for example -/// <TextBlock Text="{l:LocalizeEnum}" />. The value is mapped to a resource key using the -/// convention {KeyPrefix}{EnumTypeName}_{Value}. +/// <TextBlock Text="{l:LocalizeEnum}" />. The value is mapped to a resource key by +/// , by default {KeyPrefix}{EnumTypeName}_{Value}. /// public sealed class LocalizeEnumExtension : MarkupExtension { /// - /// Gets or sets the prefix prepended to the generated resource key. Defaults to Enum_. + /// Gets or sets the key prefix passed to . + /// (the default) means "the decides", + /// which by default is Enum_; a value overrides the prefix for this extension. /// - public string KeyPrefix { get; set; } = EnumKeyConvention.DefaultEnumKeyPrefix; + public string? KeyPrefix { get; set; } /// /// Gets or sets the resource manager that scopes the lookup to a single .resx file. When diff --git a/src/ResXLocalization.Avalonia/LocalizeExtension.cs b/src/ResXLocalization.Avalonia/LocalizeExtension.cs index 8815e33..4f11145 100644 --- a/src/ResXLocalization.Avalonia/LocalizeExtension.cs +++ b/src/ResXLocalization.Avalonia/LocalizeExtension.cs @@ -71,20 +71,21 @@ public override object ProvideValue(IServiceProvider serviceProvider) { var resourceKey = this.ResourceKey.Value; resolve = () => Localizer.Current.Get(resourceKey); - resolveFormatted = arguments => Localizer.Current.Get(resourceKey, arguments); + resolveFormatted = arguments => Localizer.Current.Get(resourceKey, MessageArguments.FromValues(arguments)); } else if (this.ResourceManager is not null) { var key = this.Key; var resourceManager = this.ResourceManager; - resolve = () => Localizer.Current.Get(key, resourceManager); - resolveFormatted = arguments => Localizer.Current.Get(key, resourceManager, arguments); + resolve = () => Localizer.Current.Get(key, resourceManager: resourceManager); + resolveFormatted = arguments => + Localizer.Current.Get(key, MessageArguments.FromValues(arguments), resourceManager); } else { var key = this.Key; resolve = () => Localizer.Current.Get(key); - resolveFormatted = arguments => Localizer.Current.Get(key, arguments); + resolveFormatted = arguments => Localizer.Current.Get(key, MessageArguments.FromValues(arguments)); } // The target element is where the LocalizeArgs arguments live. ProvideValue runs during XAML diff --git a/src/ResXLocalization.Avalonia/LocalizedEnumObservable.cs b/src/ResXLocalization.Avalonia/LocalizedEnumObservable.cs index 6658858..d448463 100644 --- a/src/ResXLocalization.Avalonia/LocalizedEnumObservable.cs +++ b/src/ResXLocalization.Avalonia/LocalizedEnumObservable.cs @@ -9,14 +9,17 @@ namespace RentADeveloper.ResXLocalization.Avalonia; /// localizer lives on. /// /// The live stream of the control's DataContext values. -/// The prefix prepended to the generated resource key. +/// +/// The key prefix passed to , or to let +/// the builder decide. +/// /// /// The resource manager that scopes the lookup to a single .resx file, or /// to search all registered resource managers. /// internal sealed class LocalizedEnumObservable( IObservable dataContextSource, - string keyPrefix, + string? keyPrefix, ResourceManager? resourceManager ) : IObservable { @@ -40,7 +43,7 @@ private sealed class Subscription : IDisposable, IWeakEventSubscriberThe prefix prepended to the generated resource key. - private readonly string keyPrefix; + private readonly string? keyPrefix; /// The observer receiving the localized enumeration text. private readonly IObserver observer; @@ -63,7 +66,9 @@ private sealed class Subscription : IDisposable, IWeakEventSubscriber /// The live stream of the control's DataContext values. - /// The prefix prepended to the generated resource key. + /// + /// The key prefix passed to , or . + /// /// /// The resource manager that scopes the lookup to a single .resx file, or /// to search all registered resource managers. @@ -71,7 +76,7 @@ private sealed class Subscription : IDisposable, IWeakEventSubscriberThe observer receiving the localized enumeration text. public Subscription( IObservable dataContextSource, - string keyPrefix, + string? keyPrefix, ResourceManager? resourceManager, IObserver observer ) @@ -115,11 +120,11 @@ private void Emit() return; } - var key = EnumKeyConvention.BuildEnumKey(this.currentValue, this.keyPrefix); + var key = Localizer.Current.BuildEnumKey(this.currentValue, this.keyPrefix); this.observer.OnNext( this.resourceManager is null ? Localizer.Current.Get(key) - : Localizer.Current.Get(key, this.resourceManager) + : Localizer.Current.Get(key, resourceManager: this.resourceManager) ); } } diff --git a/src/ResXLocalization.Avalonia/PublicAPI.Unshipped.txt b/src/ResXLocalization.Avalonia/PublicAPI.Unshipped.txt index 7dc5c58..84379d4 100644 --- a/src/ResXLocalization.Avalonia/PublicAPI.Unshipped.txt +++ b/src/ResXLocalization.Avalonia/PublicAPI.Unshipped.txt @@ -1 +1,5 @@ #nullable enable +*REMOVED*RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumConverter.KeyPrefix.get -> string! +*REMOVED*RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumExtension.KeyPrefix.get -> string! +RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumConverter.KeyPrefix.get -> string? +RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumExtension.KeyPrefix.get -> string? diff --git a/src/ResXLocalization.Core/CompatibilitySuppressions.xml b/src/ResXLocalization.Core/CompatibilitySuppressions.xml index d01d718..86222a4 100644 --- a/src/ResXLocalization.Core/CompatibilitySuppressions.xml +++ b/src/ResXLocalization.Core/CompatibilitySuppressions.xml @@ -29,6 +29,62 @@ lib/net10.0/ResXLocalization.Core.dll true + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.Get(RentADeveloper.ResXLocalization.ResourceKey,System.Object[]) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.Get(RentADeveloper.ResXLocalization.ResourceKey) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.Get(System.Enum,System.Resources.ResourceManager,System.String) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.Get(System.Enum,System.String) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.Get(System.String,System.Object[]) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.Get(System.String,System.Resources.ResourceManager,System.Object[]) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.Get(System.String,System.Resources.ResourceManager) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.Get(System.String) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + CP0002 M:RentADeveloper.ResXLocalization.ILocalizer.remove_TranslationNotFound(System.EventHandler{RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs}) @@ -57,6 +113,62 @@ lib/net10.0/ResXLocalization.Core.dll true + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.Get(RentADeveloper.ResXLocalization.ResourceKey,System.Object[]) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.Get(RentADeveloper.ResXLocalization.ResourceKey) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.Get(System.Enum,System.Resources.ResourceManager,System.String) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.Get(System.Enum,System.String) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.Get(System.String,System.Object[]) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.Get(System.String,System.Resources.ResourceManager,System.Object[]) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.Get(System.String,System.Resources.ResourceManager) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.Get(System.String) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + CP0002 M:RentADeveloper.ResXLocalization.Localizer.remove_TranslationNotFound(System.EventHandler{RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs}) @@ -85,6 +197,62 @@ lib/net8.0/ResXLocalization.Core.dll true + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.Get(RentADeveloper.ResXLocalization.ResourceKey,System.Object[]) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.Get(RentADeveloper.ResXLocalization.ResourceKey) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.Get(System.Enum,System.Resources.ResourceManager,System.String) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.Get(System.Enum,System.String) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.Get(System.String,System.Object[]) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.Get(System.String,System.Resources.ResourceManager,System.Object[]) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.Get(System.String,System.Resources.ResourceManager) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.Get(System.String) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + CP0002 M:RentADeveloper.ResXLocalization.ILocalizer.remove_TranslationNotFound(System.EventHandler{RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs}) @@ -113,6 +281,62 @@ lib/net8.0/ResXLocalization.Core.dll true + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.Get(RentADeveloper.ResXLocalization.ResourceKey,System.Object[]) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.Get(RentADeveloper.ResXLocalization.ResourceKey) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.Get(System.Enum,System.Resources.ResourceManager,System.String) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.Get(System.Enum,System.String) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.Get(System.String,System.Object[]) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.Get(System.String,System.Resources.ResourceManager,System.Object[]) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.Get(System.String,System.Resources.ResourceManager) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.Get(System.String) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + CP0002 M:RentADeveloper.ResXLocalization.Localizer.remove_TranslationNotFound(System.EventHandler{RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs}) @@ -136,7 +360,28 @@ CP0006 - M:RentADeveloper.ResXLocalization.ILocalizer.Get(System.String,RentADeveloper.ResXLocalization.MessageArgument[],System.Resources.ResourceManager) + M:RentADeveloper.ResXLocalization.ILocalizer.Get(RentADeveloper.ResXLocalization.ResourceKey,RentADeveloper.ResXLocalization.MessageArguments) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0006 + M:RentADeveloper.ResXLocalization.ILocalizer.Get(System.Enum,RentADeveloper.ResXLocalization.MessageArguments,System.Resources.ResourceManager,System.String) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0006 + M:RentADeveloper.ResXLocalization.ILocalizer.Get(System.String,RentADeveloper.ResXLocalization.MessageArguments,System.Resources.ResourceManager) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0006 + P:RentADeveloper.ResXLocalization.ILocalizer.EnumKeyBuilder lib/net10.0/ResXLocalization.Core.dll lib/net10.0/ResXLocalization.Core.dll true @@ -171,7 +416,28 @@ CP0006 - M:RentADeveloper.ResXLocalization.ILocalizer.Get(System.String,RentADeveloper.ResXLocalization.MessageArgument[],System.Resources.ResourceManager) + M:RentADeveloper.ResXLocalization.ILocalizer.Get(RentADeveloper.ResXLocalization.ResourceKey,RentADeveloper.ResXLocalization.MessageArguments) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0006 + M:RentADeveloper.ResXLocalization.ILocalizer.Get(System.Enum,RentADeveloper.ResXLocalization.MessageArguments,System.Resources.ResourceManager,System.String) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0006 + M:RentADeveloper.ResXLocalization.ILocalizer.Get(System.String,RentADeveloper.ResXLocalization.MessageArguments,System.Resources.ResourceManager) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0006 + P:RentADeveloper.ResXLocalization.ILocalizer.EnumKeyBuilder lib/net8.0/ResXLocalization.Core.dll lib/net8.0/ResXLocalization.Core.dll true diff --git a/src/ResXLocalization.Core/EnumKeyBuilding.cs b/src/ResXLocalization.Core/EnumKeyBuilding.cs new file mode 100644 index 0000000..8b8aae3 --- /dev/null +++ b/src/ResXLocalization.Core/EnumKeyBuilding.cs @@ -0,0 +1,32 @@ +namespace RentADeveloper.ResXLocalization; + +/// +/// Builds the resource key of an enum lookup through . Shared +/// (via InternalsVisibleTo) by the core enum lookup and the Avalonia/WPF enum markup extensions +/// and converters, so every enum lookup applies the same rules to the builder's result. +/// +internal static class EnumKeyBuilding +{ + /// + /// Calls of and checks the + /// result, so every caller gets the same rules. + /// + /// The localizer whose builder builds the key. + /// The enumeration value to build the key for. + /// The key prefix the lookup passed, or for none. + /// The resource key; never or empty. + /// + /// The builder returned or an empty string. + /// + internal static string BuildEnumKey(this ILocalizer localizer, Enum value, string? keyPrefix) + { + var key = localizer.EnumKeyBuilder(value, keyPrefix); + + return string.IsNullOrEmpty(key) + ? throw new InvalidOperationException( + $"{nameof(ILocalizer.EnumKeyBuilder)} returned null or an empty string for the enum value " + + $"{value.GetType().FullName}.{value}. Return a non-empty resource key." + ) + : key; + } +} diff --git a/src/ResXLocalization.Core/EnumKeyConvention.cs b/src/ResXLocalization.Core/EnumKeyConvention.cs deleted file mode 100644 index 799556d..0000000 --- a/src/ResXLocalization.Core/EnumKeyConvention.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace RentADeveloper.ResXLocalization; - -/// -/// Centralizes the convention that maps an enumeration value to a resource key: -/// {keyPrefix}{EnumTypeName}_{Value}, for example Enum_Fruit_Apple. Shared (via -/// InternalsVisibleTo) by the core enum lookups and the Avalonia/WPF enum markup extensions -/// and converters, so every enum lookup builds identical keys. -/// -internal static class EnumKeyConvention -{ - /// The default prefix (Enum_) prepended to generated enumeration resource keys. - internal const string DefaultEnumKeyPrefix = "Enum_"; - - /// Builds the resource key for an enumeration value. - /// The enumeration value to build the key for. - /// The prefix prepended to the generated key. - /// The key {keyPrefix}{EnumTypeName}_{Value}, for example Enum_Fruit_Apple. - internal static string BuildEnumKey(Enum value, string keyPrefix) => keyPrefix + value.GetType().Name + "_" + value; -} diff --git a/src/ResXLocalization.Core/ILocalizer.cs b/src/ResXLocalization.Core/ILocalizer.cs index ce87435..bab29f5 100644 --- a/src/ResXLocalization.Core/ILocalizer.cs +++ b/src/ResXLocalization.Core/ILocalizer.cs @@ -9,7 +9,7 @@ namespace RentADeveloper.ResXLocalization; /// /// Lookups. Every Get overload and the indexer resolve the key in /// , with .NET's own parent and neutral fallback, and pass the resolved value -/// to together with the lookup's arguments - an empty array when the +/// to together with the lookup's arguments - empty arguments when the /// lookup has none. The default formatter () returns a value without /// arguments unchanged and formats one with arguments with /// . A or empty key returns @@ -59,13 +59,15 @@ public interface ILocalizer : INotifyPropertyChanged /// /// Gets a value that changes whenever something changes that can change the result of a lookup - - /// the culture, the formatter, the lookup-failure text builder or the registered resource managers. - /// Bind to it to re-resolve localized text; only whether the value differs is meaningful. + /// the culture, the formatter, the lookup-failure text builder, the enum key builder or the registered + /// resource managers. Bind to it to re-resolve localized text; only whether the value differs is + /// meaningful. /// /// /// /// The value advances after each real change of , - /// or , and after + /// , or + /// , and after /// , and /// when they change the registered set. Each advance raises /// for this property and then for the indexer @@ -95,6 +97,47 @@ public interface ILocalizer : INotifyPropertyChanged /// The supplied value is . CultureInfo CurrentCulture { get; set; } + /// + /// Gets or sets the function that builds the resource key of an enum lookup from the enumeration value + /// and an optional key prefix. + /// + /// + /// The function receives, in order: + /// + /// + /// + /// The enumeration value of the lookup (); never . + /// + /// + /// + /// + /// The key prefix the lookup passed (keyPrefix, KeyPrefix; ?), or + /// when it passed none. means "use your own default"; a + /// non-null value is an explicit override. + /// + /// + /// + /// + /// It returns the resource key, never or empty. The default builds + /// {keyPrefix ?? "Enum_"}{EnumTypeName}_{Value}, for example + /// Enum_FileSortOrder_Ascending. A custom convention might look like this: + /// Localizer.Current.EnumKeyBuilder = (value, keyPrefix) => (keyPrefix ?? "Text_") + value.GetType().Name + "." + value; + /// + /// + /// The builder applies to every enum lookup: Get(Enum, …), {l:LocalizeEnum} and + /// LocalizeEnumConverter. It must not throw or return or an empty + /// string: an exception it throws propagates out of the lookup, and a or empty + /// result makes the lookup throw . + /// + /// + /// Assigning a different delegate instance advances , so bound + /// text re-resolves; assigning the current instance again does nothing. + /// + /// + /// The enum key function. Defaults to the Enum_{EnumTypeName}_{Value} convention. + /// The supplied value is . + Func EnumKeyBuilder { get; set; } + /// /// Gets or sets the function that produces the failure text: the text a lookup returns instead of a /// translation when no resource file contains the key or its value cannot be formatted. @@ -164,146 +207,86 @@ public interface ILocalizer : INotifyPropertyChanged void ClearResourceManagers(); /// - /// Resolves the localized string for the specified key by searching every registered - /// in registration order. - /// - /// The resource key to resolve. - /// - /// The localized string, for a or empty key, or the - /// failure text (see ). - /// - string Get(string key); - - /// - /// Resolves the localized string for the specified key by searching every registered - /// in registration order, and formats it with the supplied arguments - - /// for example a resource value of Downloading {0} of {1}…. - /// - /// The resource key to resolve. - /// The values to format into the resolved string; empty for none. - /// - /// The formatted string, for a or empty key, or the - /// failure text (see ). - /// - /// is . - string Get(string key, params object?[] arguments); - - /// - /// Resolves the localized string for the specified key and formats it with the supplied arguments - - /// the one entry point every lookup goes through. + /// Resolves the localized string for the specified key - searching every registered + /// in registration order, or reading a single, explicit one - and + /// passes it to with the supplied arguments. /// /// - /// This member is infrastructure and hidden from IntelliSense: code callers use the ordinary - /// Get overloads, which all delegate to it. It is an interface member because - /// may hold a custom implementation and the UI packages call it - /// from other assemblies. + /// This is the lookup every other Get overload, the indexer and the XAML markup extensions go + /// through. Every resolved value passes through ; a formatting failure + /// returns the failure text and raises (see ). /// /// The resource key to resolve. /// - /// The arguments of the lookup; empty for none. Argument i must have - /// i, and a must not - /// be an empty string. + /// The arguments of the lookup, built with , for example + /// MessageArguments.FromValues(3, 10); or empty for none. /// /// - /// The single resource manager to read the key from, or to search every - /// registered resource manager in registration order. + /// The single resource manager to read the key from, or (the default) to + /// search every registered resource manager in registration order. Pass it by name: + /// Get("Key", resourceManager: rm). /// /// - /// The formatted string, for a or empty key, or the - /// failure text (see ). - /// - /// is . - /// - /// An argument's is not its index, or its - /// is an empty string. - /// - [EditorBrowsable(EditorBrowsableState.Never)] - string Get(string key, MessageArgument[] arguments, ResourceManager? resourceManager); - - /// - /// Resolves the localized string for an enumeration value by mapping it to a resource key using - /// the convention {keyPrefix}{EnumTypeName}_{Value}, then searching every registered - /// in registration order. - /// - /// The enumeration value to resolve. - /// The prefix prepended to the generated key. Defaults to Enum_. - /// The localized string, or the failure text (see ). - /// - /// or is . - /// - string Get(Enum value, string keyPrefix = EnumKeyConvention.DefaultEnumKeyPrefix); - - /// - /// Resolves the localized string for the specified key from a single, explicit - /// . - /// - /// The resource key to resolve. - /// The resource manager to read the key from. - /// - /// The localized string, for a or empty key, or the - /// failure text (see ). - /// - /// - /// is . - /// - string Get(string key, ResourceManager resourceManager); - - /// - /// Resolves the localized string for the specified key from a single, explicit - /// , and formats it with the supplied arguments. - /// - /// The resource key to resolve. - /// The resource manager to read the key from. - /// The values to format into the resolved string; empty for none. - /// - /// The formatted string, for a or empty key, or the - /// failure text (see ). + /// The localized (or formatted) string, for a or + /// empty key, or the failure text (see ) in case the lookup failed. /// - /// - /// or is . - /// - string Get(string key, ResourceManager resourceManager, params object?[] arguments); - - /// - /// Resolves the localized string for an enumeration value from a single, explicit - /// , mapping the value to a key using the convention - /// {keyPrefix}{EnumTypeName}_{Value}. - /// - /// The enumeration value to resolve. - /// The resource manager to read the generated key from. - /// The prefix prepended to the generated key. Defaults to Enum_. - /// The localized string, or the failure text (see ). - /// - /// , , or - /// is . - /// - string Get(Enum value, ResourceManager resourceManager, string keyPrefix = EnumKeyConvention.DefaultEnumKeyPrefix); + string Get(string key, MessageArguments? arguments = null, ResourceManager? resourceManager = null); /// - /// Resolves the localized string for a typed , which carries both the - /// key name and the owning . + /// Resolves the localized string for a typed , which carries both the key + /// name and the owning , and passes it to + /// with the supplied arguments. /// + /// + /// Every resolved value passes through ; a formatting failure returns + /// the failure text and raises (see ). + /// /// The typed key identifying the resource and its resource manager. - /// The localized string, or the failure text (see ). + /// + /// The arguments of the lookup, built with ; or + /// empty for none. + /// + /// The localized (or formatted) string, or the failure text (see ). /// - /// is a default or otherwise uninitialized , - /// whose is . + /// is a default or otherwise uninitialized , whose + /// is . /// - string Get(ResourceKey key); + string Get(ResourceKey key, MessageArguments? arguments = null); /// - /// Resolves the localized string for a typed , and formats it with the - /// supplied arguments. + /// Resolves the localized string for an enumeration value: builds the + /// resource key from the value, which is then resolved like a string key and passed to + /// with the supplied arguments. /// - /// The typed key identifying the resource and its resource manager. - /// The values to format into the resolved string; empty for none. - /// The formatted string, or the failure text (see ). - /// - /// is , or is a - /// default or otherwise uninitialized whose - /// is . + /// + /// Every resolved value passes through ; a formatting failure returns + /// the failure text and raises (see ). + /// + /// The enumeration value to resolve. + /// + /// The arguments of the lookup, built with , for example + /// MessageArguments.FromValues(count); or empty for none. + /// + /// + /// The single resource manager to read the key from, or (the default) to + /// search every registered resource manager in registration order. + /// + /// + /// A key prefix passed to , overriding only its default prefix (by default + /// Enum_); (the default) lets the builder decide. Pass it by name: + /// Get(sortOrder, keyPrefix: "Display_"). + /// + /// The localized (or formatted) string, or the failure text (see ). + /// is . + /// + /// returned or an empty string. /// - string Get(ResourceKey key, params object?[] arguments); + string Get( + Enum value, + MessageArguments? arguments = null, + ResourceManager? resourceManager = null, + string? keyPrefix = null + ); /// /// Discovers the cultures for which any registered ships its own diff --git a/src/ResXLocalization.Core/IMessageFormatter.cs b/src/ResXLocalization.Core/IMessageFormatter.cs index 84d1387..3fe109e 100644 --- a/src/ResXLocalization.Core/IMessageFormatter.cs +++ b/src/ResXLocalization.Core/IMessageFormatter.cs @@ -24,9 +24,13 @@ public interface IMessageFormatter /// Formats a resolved resource value with the arguments of its lookup. /// The resolved resource value. /// - /// The arguments of the lookup; never . Argument i always has - /// i. The array is empty when the lookup had no - /// arguments - Get(key), the indexer, {l:Localize} without arguments, or an empty array. + /// The arguments of the lookup; never . Read each argument's position from + /// ; an argument's index in the enumeration is not guaranteed to be + /// the same as its position. They are empty ( + /// is 0) when the lookup had no arguments: a call to ILocalizer.Get without arguments (or with + /// ), a lookup through the indexer + /// (Localizer.Current["Key"]), or a XAML lookup through the {l:Localize} markup extension of + /// the ResXLocalization.Avalonia and ResXLocalization.WPF packages without arguments. /// The default formatter then returns unchanged, and an implementation /// should do the same unless it deliberately handles values without arguments: formatting them would /// turn {{x}} into {x} and make every plain text containing {0} or a literal @@ -34,5 +38,5 @@ public interface IMessageFormatter /// /// The localizer's at the time of the lookup. /// The final string. - string Format(string pattern, MessageArgument[] arguments, CultureInfo culture); + string Format(string pattern, MessageArguments arguments, CultureInfo culture); } diff --git a/src/ResXLocalization.Core/Localizer.cs b/src/ResXLocalization.Core/Localizer.cs index 0a1bdcf..d235e4e 100644 --- a/src/ResXLocalization.Core/Localizer.cs +++ b/src/ResXLocalization.Core/Localizer.cs @@ -8,11 +8,19 @@ namespace RentADeveloper.ResXLocalization; /// /// Like the UI frameworks it serves, this class is designed for UI-thread use and is not /// synchronized: register resource managers once at startup and set , -/// and from the UI thread, so -/// change notifications reach bindings on the thread WPF and Avalonia expect. +/// , and +/// from the UI thread, so change notifications reach bindings on the thread WPF and Avalonia expect. /// public sealed class Localizer : ILocalizer { + /// + /// The default of : the convention + /// {keyPrefix ?? "Enum_"}{EnumTypeName}_{Value}. One shared instance, so every new localizer + /// starts with the same delegate. + /// + private static readonly Func DefaultEnumKeyBuilder = static (value, keyPrefix) => + (keyPrefix ?? "Enum_") + value.GetType().Name + "_" + value; + /// /// The default of : produces !key!. One shared instance, /// so every new localizer starts with the same delegate. @@ -89,6 +97,24 @@ public CultureInfo CurrentCulture } } + /// + public Func EnumKeyBuilder + { + get; + set + { + ArgumentNullException.ThrowIfNull(value); + + if (ReferenceEquals(field, value)) + { + return; + } + + field = value; + this.AdvanceRefreshTriggeringRevision(); + } + } = DefaultEnumKeyBuilder; + /// public Func LookupFailureTextBuilder { @@ -144,21 +170,9 @@ public void ClearResourceManagers() } /// - public string Get(string key) => this.Get(key, Array.Empty(), null); - - /// - public string Get(string key, params object?[] arguments) - { - ArgumentNullException.ThrowIfNull(arguments); - - return this.Get(key, ToMessageArguments(arguments), null); - } - - /// - [EditorBrowsable(EditorBrowsableState.Never)] - public string Get(string key, MessageArgument[] arguments, ResourceManager? resourceManager) + public string Get(string key, MessageArguments? arguments = null, ResourceManager? resourceManager = null) { - ValidateArguments(arguments); + arguments ??= MessageArguments.Empty; // One lookup uses one culture throughout: for the lookup, the format and the failure. var culture = this.currentCulture; @@ -195,66 +209,26 @@ public string Get(string key, MessageArgument[] arguments, ResourceManager? reso } /// - public string Get(Enum value, string keyPrefix = EnumKeyConvention.DefaultEnumKeyPrefix) + public string Get(ResourceKey key, MessageArguments? arguments = null) { - ArgumentNullException.ThrowIfNull(value); - ArgumentNullException.ThrowIfNull(keyPrefix); - - return this.Get(EnumKeyConvention.BuildEnumKey(value, keyPrefix), Array.Empty(), null); - } - - /// - public string Get(string key, ResourceManager resourceManager) - { - // Explicit: the primitive treats a null manager as search-all. - ArgumentNullException.ThrowIfNull(resourceManager); - - return this.Get(key, Array.Empty(), resourceManager); - } - - /// - public string Get(string key, ResourceManager resourceManager, params object?[] arguments) - { - ArgumentNullException.ThrowIfNull(resourceManager); - ArgumentNullException.ThrowIfNull(arguments); + // An uninitialized ResourceKey, such as default(ResourceKey), has no Manager. Without this check the + // lookup below would receive a null manager and search all registered managers instead of throwing. + ArgumentNullException.ThrowIfNull(key.Manager); - return this.Get(key, ToMessageArguments(arguments), resourceManager); + return this.Get(key.Name, arguments, key.Manager); } /// public string Get( Enum value, - ResourceManager resourceManager, - string keyPrefix = EnumKeyConvention.DefaultEnumKeyPrefix + MessageArguments? arguments = null, + ResourceManager? resourceManager = null, + string? keyPrefix = null ) { ArgumentNullException.ThrowIfNull(value); - ArgumentNullException.ThrowIfNull(resourceManager); - ArgumentNullException.ThrowIfNull(keyPrefix); - return this.Get( - EnumKeyConvention.BuildEnumKey(value, keyPrefix), - Array.Empty(), - resourceManager - ); - } - - /// - public string Get(ResourceKey key) - { - // Explicit: a default ResourceKey would otherwise fall through to search-all. - ArgumentNullException.ThrowIfNull(key.Manager); - - return this.Get(key.Name, Array.Empty(), key.Manager); - } - - /// - public string Get(ResourceKey key, params object?[] arguments) - { - ArgumentNullException.ThrowIfNull(key.Manager); - ArgumentNullException.ThrowIfNull(arguments); - - return this.Get(key.Name, ToMessageArguments(arguments), key.Manager); + return this.Get(this.BuildEnumKey(value, keyPrefix), arguments, resourceManager); } /// @@ -364,46 +338,6 @@ private static void CollectAvailableCultures(ResourceManager resourceManager, Ha private static IReadOnlyList SortCultures(HashSet cultures) => [.. cultures.OrderBy(static culture => culture.Name, StringComparer.OrdinalIgnoreCase)]; - /// Wraps positional format arguments as s without names. - /// The format arguments. - /// One per argument, with its index as position. - private static MessageArgument[] ToMessageArguments(object?[] arguments) => - [.. arguments.Select((value, index) => new MessageArgument(index, null, value))]; - - /// - /// Validates the arguments of a lookup: the array must not be , argument - /// i must have position i, and a name must not be an empty string. Duplicate names are - /// not checked here; that is the concern of whoever builds the array and of the formatter. - /// - /// The arguments to validate. - /// is . - /// An argument has a wrong position or an empty name. - private static void ValidateArguments(MessageArgument[] arguments) - { - ArgumentNullException.ThrowIfNull(arguments); - - for (var index = 0; index < arguments.Length; index++) - { - if (arguments[index].Position != index) - { - throw new ArgumentException( - $"The argument at index {index} has the position {arguments[index].Position}; " - + "an argument's position must be its index in the array.", - nameof(arguments) - ); - } - - if (arguments[index].Name is { Length: 0 }) - { - throw new ArgumentException( - $"The argument at index {index} has an empty name; an empty string is not an argument " - + "name. Use null for a positional-only argument.", - nameof(arguments) - ); - } - } - } - /// /// Advances (wrapping around harmlessly) and raises /// for it and then for the indexer, so bound text re-resolves. diff --git a/src/ResXLocalization.Core/LookupFailure.cs b/src/ResXLocalization.Core/LookupFailure.cs index fb72ba2..565a53b 100644 --- a/src/ResXLocalization.Core/LookupFailure.cs +++ b/src/ResXLocalization.Core/LookupFailure.cs @@ -36,7 +36,7 @@ public readonly record struct LookupFailure( string Key, CultureInfo Culture, ResourceManager? ResourceManager, - MessageArgument[] Arguments, + MessageArguments Arguments, string? Pattern, Exception? Exception ); diff --git a/src/ResXLocalization.Core/MessageArgument.cs b/src/ResXLocalization.Core/MessageArgument.cs index eaac74d..c06a80f 100644 --- a/src/ResXLocalization.Core/MessageArgument.cs +++ b/src/ResXLocalization.Core/MessageArgument.cs @@ -2,17 +2,18 @@ namespace RentADeveloper.ResXLocalization; /// /// One argument of a lookup: its position, an optional name and its value. The arguments of one lookup -/// are a array, in which argument i has -/// i; no arguments is an empty array. +/// are a ; no arguments is . /// /// /// is what an receives - a resolved /// value. In XAML an argument is declared as <l:Argument>, which becomes a -/// at each conversion, with its index among the children as position -/// and its Name. +/// at each conversion, with its index among the children as +/// and its Name. /// /// -/// The argument's index in the arguments array, also when it has a name. +/// The argument's position: the argument at position 0 fills placeholder {0}, and so on, also when it +/// has a name. This is the only source of an argument's position: an argument's index in the enumeration of +/// a is not guaranteed to be the same as its position. /// /// /// for a positional-only argument; otherwise the name under which a formatter diff --git a/src/ResXLocalization.Core/MessageArguments.cs b/src/ResXLocalization.Core/MessageArguments.cs new file mode 100644 index 0000000..2c023d7 --- /dev/null +++ b/src/ResXLocalization.Core/MessageArguments.cs @@ -0,0 +1,234 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace RentADeveloper.ResXLocalization; + +/// +/// The arguments for a translation lookup: positional arguments from values, named arguments from +/// name/value tuples, a dictionary or an object's properties. Build them with the static methods and pass +/// them to ILocalizer.Get, for example +/// Localizer.Current.Get("FilePosition", MessageArguments.FromValues(3, 10)). +/// +/// +/// +/// An argument's position is its , and only that: an argument's index +/// in the enumeration is not guaranteed to be the same as its . The +/// positions are 0 to minus 1, each exactly once; no name is an empty string, and no +/// name occurs twice. The static methods check this when +/// they build the arguments, and an instance cannot be changed afterwards. +/// +/// +/// The default formatter () ignores names and uses positions; a +/// formatter that understands names (ICU MessageFormat) finds a named argument under both. +/// +/// +public sealed class MessageArguments : IEnumerable +{ + /// The arguments. + private readonly MessageArgument[] arguments; + + /// Initializes a new instance over arguments that have already been checked. + /// The arguments. + private MessageArguments(MessageArgument[] arguments) => this.arguments = arguments; + + /// Gets an instance without arguments. + public static MessageArguments Empty { get; } = new([]); + + /// Gets the number of arguments. + public int Count => this.arguments.Length; + + /// + /// Builds named arguments from an existing dictionary: + /// MessageArguments.FromDictionary(new Dictionary<string, object?> { ["Count"] = 5 }). + /// The positions follow the dictionary's enumeration order: the first entry has position 0, the next 1, + /// and so on. + /// + /// The arguments, keyed by name. + /// One named argument per entry. + /// is . + /// A key is an empty string. + public static MessageArguments FromDictionary(IReadOnlyDictionary arguments) + { + ArgumentNullException.ThrowIfNull(arguments); + return Create( + [.. arguments.Select((entry, index) => new MessageArgument(index, entry.Key, entry.Value))], + nameof(arguments) + ); + } + + /// + /// Builds named arguments from the public instance properties of an object, for example an anonymous + /// one: MessageArguments.FromObject(new { Count = 5, Total = 10 }). Meant for named arguments. + /// + /// + /// + /// The properties are those of the static type , without indexers, in + /// metadata order - the declaration order for a class or an anonymous type; the first property has + /// position 0, the next 1, and so on. A value typed + /// yields no arguments. An exception thrown by a property getter propagates. + /// + /// + /// Trim and Native AOT safe: the properties of are preserved. A generic method + /// of your own that forwards its type parameter to MessageArguments.FromObject must annotate that + /// parameter with the same [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)], + /// or the trimmer reports IL2091 at that call. + /// + /// + /// The type whose public instance properties become the arguments. + /// The object to read the property values from. + /// One named argument per property. + /// is . + /// + /// Two properties have the same name, for example a base property hidden with and + /// a different property type. + /// + public static MessageArguments FromObject< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T + >(T source) + { + ArgumentNullException.ThrowIfNull(source); + return Create( + [ + .. typeof(T) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(property => property.GetIndexParameters().Length == 0) + .Select((property, index) => new MessageArgument(index, property.Name, property.GetValue(source))), + ], + nameof(source) + ); + } + + /// + /// Builds arguments from name/value tuples, in declaration order: + /// MessageArguments.FromTuples(("Count", 5), ("Total", 10)). A name makes + /// that argument positional only, so named and unnamed arguments can be mixed: + /// MessageArguments.FromTuples((null, file), ("Total", 10)). + /// + /// The name/value pairs; the first has position 0, the next 1, and so on. + /// One argument per tuple. + /// is . + /// + /// A name is an empty string, or two tuples have the same name (compared ordinally, so A and + /// a differ). + /// + public static MessageArguments FromTuples(params (string? Name, object? Value)[] arguments) + { + ArgumentNullException.ThrowIfNull(arguments); + return Create( + [.. arguments.Select((argument, index) => new MessageArgument(index, argument.Name, argument.Value))], + nameof(arguments) + ); + } + + /// + /// Builds positional arguments from values: MessageArguments.FromValues(3, 10) for + /// File {0} of {1}. + /// + /// + /// Because of , MessageArguments.FromValues(dictionary) is one positional + /// argument whose value is the dictionary itself; use for named arguments. + /// One value is MessageArguments.FromValues((object?)null). + /// + /// The argument values; the first has position 0, the next 1, and so on. + /// One unnamed argument per value. + /// is . + public static MessageArguments FromValues(params object?[] values) + { + ArgumentNullException.ThrowIfNull(values); + return new([.. values.Select((value, index) => new MessageArgument(index, null, value))]); + } + + /// + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + + /// + /// Returns an enumerator over the arguments. An argument's index in the enumeration is not guaranteed to + /// be the same as its ; read the position from + /// . + /// + /// The enumerator. + public IEnumerator GetEnumerator() => + ((IEnumerable)this.arguments).GetEnumerator(); + + /// + /// Builds arguments from values whose positions are given explicitly, in + /// any order. For tests that need arguments whose index in the enumeration differs from their + /// . + /// + /// The arguments. + /// The checked arguments. + /// is . + /// + /// An argument position is negative, not less than the number of arguments, or occurs more than once; or + /// an argument name is an empty string or occurs more than once. + /// + internal static MessageArguments FromArguments(params MessageArgument[] arguments) + { + ArgumentNullException.ThrowIfNull(arguments); + return Create([.. arguments], nameof(arguments)); + } + + /// + /// Checks the arguments and wraps them: the argument positions must be 0 to the number of arguments + /// minus 1, each exactly once, no argument name may be an empty string, and no argument name may occur + /// twice. Names are compared ordinally, as XAML compares names and as + /// ICU variable names are matched. + /// + /// The arguments to check; the new instance takes ownership of the array. + /// + /// The name of the calling method's parameter that supplied the arguments; an + /// thrown for an invalid position or name reports it as its + /// . + /// + /// The checked arguments. + /// + /// An argument position is negative, not less than the number of arguments, or occurs more than once; or + /// an argument name is an empty string or occurs more than once. + /// + private static MessageArguments Create(MessageArgument[] arguments, string parameterName) + { + var positions = new HashSet(); + + // The first position outside 0 to Count - 1, or that the set already holds, is invalid. With every + // position in range and none repeated, the positions are exactly 0 to Count - 1. + var invalidPosition = arguments + .Select(argument => (int?)argument.Position) + .FirstOrDefault(position => + position < 0 || position >= arguments.Length || !positions.Add(position!.Value) + ); + + if (invalidPosition is not null) + { + throw new ArgumentException( + $"The argument position {invalidPosition} is negative, not less than the number of arguments " + + $"({arguments.Length}), or occurs more than once; the positions must be 0 to " + + $"{arguments.Length - 1}, each exactly once.", + parameterName + ); + } + + var names = new HashSet(StringComparer.Ordinal); + + foreach (var name in arguments.Select(argument => argument.Name).OfType()) + { + if (name.Length == 0) + { + throw new ArgumentException( + "An empty string is not an argument name. Use null for a positional-only argument.", + parameterName + ); + } + + if (!names.Add(name)) + { + throw new ArgumentException( + $"The argument name '{name}' occurs more than once; every name must be unique.", + parameterName + ); + } + } + + return new(arguments); + } +} diff --git a/src/ResXLocalization.Core/PublicAPI.Unshipped.txt b/src/ResXLocalization.Core/PublicAPI.Unshipped.txt index f9c94ef..0cfb364 100644 --- a/src/ResXLocalization.Core/PublicAPI.Unshipped.txt +++ b/src/ResXLocalization.Core/PublicAPI.Unshipped.txt @@ -10,9 +10,29 @@ *REMOVED*RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs.Key.get -> string! *REMOVED*RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs.ResourceManager.get -> System.Resources.ResourceManager? *REMOVED*RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs.TranslationNotFoundEventArgs(string! key, System.Globalization.CultureInfo! culture, System.Resources.ResourceManager? resourceManager) -> void +*REMOVED*RentADeveloper.ResXLocalization.ILocalizer.Get(RentADeveloper.ResXLocalization.ResourceKey key) -> string! +*REMOVED*RentADeveloper.ResXLocalization.ILocalizer.Get(RentADeveloper.ResXLocalization.ResourceKey key, params object?[]! arguments) -> string! +*REMOVED*RentADeveloper.ResXLocalization.ILocalizer.Get(System.Enum! value, System.Resources.ResourceManager! resourceManager, string! keyPrefix = "Enum_") -> string! +*REMOVED*RentADeveloper.ResXLocalization.ILocalizer.Get(System.Enum! value, string! keyPrefix = "Enum_") -> string! +*REMOVED*RentADeveloper.ResXLocalization.ILocalizer.Get(string! key) -> string! +*REMOVED*RentADeveloper.ResXLocalization.ILocalizer.Get(string! key, System.Resources.ResourceManager! resourceManager) -> string! +*REMOVED*RentADeveloper.ResXLocalization.ILocalizer.Get(string! key, System.Resources.ResourceManager! resourceManager, params object?[]! arguments) -> string! +*REMOVED*RentADeveloper.ResXLocalization.ILocalizer.Get(string! key, params object?[]! arguments) -> string! +*REMOVED*RentADeveloper.ResXLocalization.Localizer.Get(RentADeveloper.ResXLocalization.ResourceKey key) -> string! +*REMOVED*RentADeveloper.ResXLocalization.Localizer.Get(RentADeveloper.ResXLocalization.ResourceKey key, params object?[]! arguments) -> string! +*REMOVED*RentADeveloper.ResXLocalization.Localizer.Get(System.Enum! value, System.Resources.ResourceManager! resourceManager, string! keyPrefix = "Enum_") -> string! +*REMOVED*RentADeveloper.ResXLocalization.Localizer.Get(System.Enum! value, string! keyPrefix = "Enum_") -> string! +*REMOVED*RentADeveloper.ResXLocalization.Localizer.Get(string! key) -> string! +*REMOVED*RentADeveloper.ResXLocalization.Localizer.Get(string! key, System.Resources.ResourceManager! resourceManager) -> string! +*REMOVED*RentADeveloper.ResXLocalization.Localizer.Get(string! key, System.Resources.ResourceManager! resourceManager, params object?[]! arguments) -> string! +*REMOVED*RentADeveloper.ResXLocalization.Localizer.Get(string! key, params object?[]! arguments) -> string! override RentADeveloper.ResXLocalization.LookupFailure.GetHashCode() -> int override RentADeveloper.ResXLocalization.MessageArgument.GetHashCode() -> int -RentADeveloper.ResXLocalization.ILocalizer.Get(string! key, RentADeveloper.ResXLocalization.MessageArgument[]! arguments, System.Resources.ResourceManager? resourceManager) -> string! +RentADeveloper.ResXLocalization.ILocalizer.EnumKeyBuilder.get -> System.Func! +RentADeveloper.ResXLocalization.ILocalizer.EnumKeyBuilder.set -> void +RentADeveloper.ResXLocalization.ILocalizer.Get(RentADeveloper.ResXLocalization.ResourceKey key, RentADeveloper.ResXLocalization.MessageArguments? arguments = null) -> string! +RentADeveloper.ResXLocalization.ILocalizer.Get(string! key, RentADeveloper.ResXLocalization.MessageArguments? arguments = null, System.Resources.ResourceManager? resourceManager = null) -> string! +RentADeveloper.ResXLocalization.ILocalizer.Get(System.Enum! value, RentADeveloper.ResXLocalization.MessageArguments? arguments = null, System.Resources.ResourceManager? resourceManager = null, string? keyPrefix = null) -> string! RentADeveloper.ResXLocalization.ILocalizer.LookupFailed -> System.EventHandler! RentADeveloper.ResXLocalization.ILocalizer.LookupFailureTextBuilder.get -> System.Func! RentADeveloper.ResXLocalization.ILocalizer.LookupFailureTextBuilder.set -> void @@ -20,8 +40,12 @@ RentADeveloper.ResXLocalization.ILocalizer.MessageFormatter.get -> RentADevelope RentADeveloper.ResXLocalization.ILocalizer.MessageFormatter.set -> void RentADeveloper.ResXLocalization.ILocalizer.RefreshTriggeringRevision.get -> int RentADeveloper.ResXLocalization.IMessageFormatter -RentADeveloper.ResXLocalization.IMessageFormatter.Format(string! pattern, RentADeveloper.ResXLocalization.MessageArgument[]! arguments, System.Globalization.CultureInfo! culture) -> string! -RentADeveloper.ResXLocalization.Localizer.Get(string! key, RentADeveloper.ResXLocalization.MessageArgument[]! arguments, System.Resources.ResourceManager? resourceManager) -> string! +RentADeveloper.ResXLocalization.IMessageFormatter.Format(string! pattern, RentADeveloper.ResXLocalization.MessageArguments! arguments, System.Globalization.CultureInfo! culture) -> string! +RentADeveloper.ResXLocalization.Localizer.EnumKeyBuilder.get -> System.Func! +RentADeveloper.ResXLocalization.Localizer.EnumKeyBuilder.set -> void +RentADeveloper.ResXLocalization.Localizer.Get(RentADeveloper.ResXLocalization.ResourceKey key, RentADeveloper.ResXLocalization.MessageArguments? arguments = null) -> string! +RentADeveloper.ResXLocalization.Localizer.Get(string! key, RentADeveloper.ResXLocalization.MessageArguments? arguments = null, System.Resources.ResourceManager? resourceManager = null) -> string! +RentADeveloper.ResXLocalization.Localizer.Get(System.Enum! value, RentADeveloper.ResXLocalization.MessageArguments? arguments = null, System.Resources.ResourceManager? resourceManager = null, string? keyPrefix = null) -> string! RentADeveloper.ResXLocalization.Localizer.LookupFailed -> System.EventHandler? RentADeveloper.ResXLocalization.Localizer.LookupFailureTextBuilder.get -> System.Func! RentADeveloper.ResXLocalization.Localizer.LookupFailureTextBuilder.set -> void @@ -32,18 +56,18 @@ RentADeveloper.ResXLocalization.LookupFailedEventArgs RentADeveloper.ResXLocalization.LookupFailedEventArgs.Failure.get -> RentADeveloper.ResXLocalization.LookupFailure RentADeveloper.ResXLocalization.LookupFailedEventArgs.LookupFailedEventArgs(RentADeveloper.ResXLocalization.LookupFailure failure) -> void RentADeveloper.ResXLocalization.LookupFailure -RentADeveloper.ResXLocalization.LookupFailure.Arguments.get -> RentADeveloper.ResXLocalization.MessageArgument[]! +RentADeveloper.ResXLocalization.LookupFailure.Arguments.get -> RentADeveloper.ResXLocalization.MessageArguments! RentADeveloper.ResXLocalization.LookupFailure.Arguments.init -> void RentADeveloper.ResXLocalization.LookupFailure.Culture.get -> System.Globalization.CultureInfo! RentADeveloper.ResXLocalization.LookupFailure.Culture.init -> void -RentADeveloper.ResXLocalization.LookupFailure.Deconstruct(out RentADeveloper.ResXLocalization.LookupFailureReason Reason, out string! Key, out System.Globalization.CultureInfo! Culture, out System.Resources.ResourceManager? ResourceManager, out RentADeveloper.ResXLocalization.MessageArgument[]! Arguments, out string? Pattern, out System.Exception? Exception) -> void +RentADeveloper.ResXLocalization.LookupFailure.Deconstruct(out RentADeveloper.ResXLocalization.LookupFailureReason Reason, out string! Key, out System.Globalization.CultureInfo! Culture, out System.Resources.ResourceManager? ResourceManager, out RentADeveloper.ResXLocalization.MessageArguments! Arguments, out string? Pattern, out System.Exception? Exception) -> void RentADeveloper.ResXLocalization.LookupFailure.Equals(RentADeveloper.ResXLocalization.LookupFailure other) -> bool RentADeveloper.ResXLocalization.LookupFailure.Exception.get -> System.Exception? RentADeveloper.ResXLocalization.LookupFailure.Exception.init -> void RentADeveloper.ResXLocalization.LookupFailure.Key.get -> string! RentADeveloper.ResXLocalization.LookupFailure.Key.init -> void RentADeveloper.ResXLocalization.LookupFailure.LookupFailure() -> void -RentADeveloper.ResXLocalization.LookupFailure.LookupFailure(RentADeveloper.ResXLocalization.LookupFailureReason Reason, string! Key, System.Globalization.CultureInfo! Culture, System.Resources.ResourceManager? ResourceManager, RentADeveloper.ResXLocalization.MessageArgument[]! Arguments, string? Pattern, System.Exception? Exception) -> void +RentADeveloper.ResXLocalization.LookupFailure.LookupFailure(RentADeveloper.ResXLocalization.LookupFailureReason Reason, string! Key, System.Globalization.CultureInfo! Culture, System.Resources.ResourceManager? ResourceManager, RentADeveloper.ResXLocalization.MessageArguments! Arguments, string? Pattern, System.Exception? Exception) -> void RentADeveloper.ResXLocalization.LookupFailure.Pattern.get -> string? RentADeveloper.ResXLocalization.LookupFailure.Pattern.init -> void RentADeveloper.ResXLocalization.LookupFailure.Reason.get -> RentADeveloper.ResXLocalization.LookupFailureReason @@ -64,14 +88,22 @@ RentADeveloper.ResXLocalization.MessageArgument.Position.get -> int RentADeveloper.ResXLocalization.MessageArgument.Position.init -> void RentADeveloper.ResXLocalization.MessageArgument.Value.get -> object? RentADeveloper.ResXLocalization.MessageArgument.Value.init -> void +RentADeveloper.ResXLocalization.MessageArguments +RentADeveloper.ResXLocalization.MessageArguments.Count.get -> int +RentADeveloper.ResXLocalization.MessageArguments.GetEnumerator() -> System.Collections.Generic.IEnumerator! RentADeveloper.ResXLocalization.StringFormatMessageFormatter -RentADeveloper.ResXLocalization.StringFormatMessageFormatter.Format(string! pattern, RentADeveloper.ResXLocalization.MessageArgument[]! arguments, System.Globalization.CultureInfo! culture) -> string! +RentADeveloper.ResXLocalization.StringFormatMessageFormatter.Format(string! pattern, RentADeveloper.ResXLocalization.MessageArguments! arguments, System.Globalization.CultureInfo! culture) -> string! RentADeveloper.ResXLocalization.StringFormatMessageFormatter.StringFormatMessageFormatter() -> void static readonly RentADeveloper.ResXLocalization.StringFormatMessageFormatter.Instance -> RentADeveloper.ResXLocalization.StringFormatMessageFormatter! static RentADeveloper.ResXLocalization.LookupFailure.operator !=(RentADeveloper.ResXLocalization.LookupFailure left, RentADeveloper.ResXLocalization.LookupFailure right) -> bool static RentADeveloper.ResXLocalization.LookupFailure.operator ==(RentADeveloper.ResXLocalization.LookupFailure left, RentADeveloper.ResXLocalization.LookupFailure right) -> bool static RentADeveloper.ResXLocalization.MessageArgument.operator !=(RentADeveloper.ResXLocalization.MessageArgument left, RentADeveloper.ResXLocalization.MessageArgument right) -> bool static RentADeveloper.ResXLocalization.MessageArgument.operator ==(RentADeveloper.ResXLocalization.MessageArgument left, RentADeveloper.ResXLocalization.MessageArgument right) -> bool +static RentADeveloper.ResXLocalization.MessageArguments.Empty.get -> RentADeveloper.ResXLocalization.MessageArguments! +static RentADeveloper.ResXLocalization.MessageArguments.FromDictionary(System.Collections.Generic.IReadOnlyDictionary! arguments) -> RentADeveloper.ResXLocalization.MessageArguments! +static RentADeveloper.ResXLocalization.MessageArguments.FromObject(T source) -> RentADeveloper.ResXLocalization.MessageArguments! +static RentADeveloper.ResXLocalization.MessageArguments.FromTuples(params (string? Name, object? Value)[]! arguments) -> RentADeveloper.ResXLocalization.MessageArguments! +static RentADeveloper.ResXLocalization.MessageArguments.FromValues(params object?[]! values) -> RentADeveloper.ResXLocalization.MessageArguments! ~override RentADeveloper.ResXLocalization.LookupFailure.Equals(object obj) -> bool ~override RentADeveloper.ResXLocalization.LookupFailure.ToString() -> string ~override RentADeveloper.ResXLocalization.MessageArgument.Equals(object obj) -> bool diff --git a/src/ResXLocalization.Core/ResXLocalization.Core.csproj b/src/ResXLocalization.Core/ResXLocalization.Core.csproj index a562ffe..f4656e4 100644 --- a/src/ResXLocalization.Core/ResXLocalization.Core.csproj +++ b/src/ResXLocalization.Core/ResXLocalization.Core.csproj @@ -18,9 +18,11 @@ - + + + diff --git a/src/ResXLocalization.Core/StringFormatMessageFormatter.cs b/src/ResXLocalization.Core/StringFormatMessageFormatter.cs index 8977725..1d78e24 100644 --- a/src/ResXLocalization.Core/StringFormatMessageFormatter.cs +++ b/src/ResXLocalization.Core/StringFormatMessageFormatter.cs @@ -14,7 +14,9 @@ public sealed class StringFormatMessageFormatter : IMessageFormatter /// /// Returns unchanged when is empty; /// otherwise formats it as a composite format string with the argument values in - /// . + /// . Each value fills the placeholder of its + /// : the argument at position 1 fills {1}, whatever its index + /// in the enumeration. /// /// The resolved resource value. /// The arguments of the lookup; never . @@ -24,8 +26,12 @@ public sealed class StringFormatMessageFormatter : IMessageFormatter /// is not a valid composite format string for /// . The localizer catches it and returns the failure text. /// - public string Format(string pattern, MessageArgument[] arguments, CultureInfo culture) => - arguments.Length == 0 + public string Format(string pattern, MessageArguments arguments, CultureInfo culture) => + arguments.Count == 0 ? pattern - : string.Format(culture, pattern, arguments.Select(argument => argument.Value).ToArray()); + : string.Format( + culture, + pattern, + arguments.OrderBy(argument => argument.Position).Select(argument => argument.Value).ToArray() + ); } diff --git a/src/ResXLocalization.WPF/LocalizeEnumConverter.cs b/src/ResXLocalization.WPF/LocalizeEnumConverter.cs index 6f1d8c3..43c8739 100644 --- a/src/ResXLocalization.WPF/LocalizeEnumConverter.cs +++ b/src/ResXLocalization.WPF/LocalizeEnumConverter.cs @@ -4,8 +4,8 @@ namespace RentADeveloper.ResXLocalization.WPF; /// Multi-value converter that localizes an enumeration value bound as a real value (rather than as a /// DataContext). Use it in a MultiBinding whose first binding is the enumeration value /// and whose second binding is , so the result re-converts on -/// every culture change. The value is mapped to a resource key using the convention -/// {KeyPrefix}{EnumTypeName}_{Value}. +/// every culture change. The value is mapped to a resource key by +/// , by default {KeyPrefix}{EnumTypeName}_{Value}. /// public sealed class LocalizeEnumConverter : IMultiValueConverter { @@ -33,22 +33,22 @@ public LocalizeEnumConverter() { } public static LocalizeEnumConverter Default { get; } = new(isSharedInstance: true); /// - /// Gets or sets the prefix prepended to the generated resource key. Defaults to Enum_. + /// Gets or sets the key prefix passed to . + /// (the default) means "the decides", + /// which by default is Enum_; a value overrides the prefix for this converter. /// - /// The supplied value is . /// /// The converter is the shared instance, which is read-only. /// - public string KeyPrefix + public string? KeyPrefix { get; set { - ArgumentNullException.ThrowIfNull(value); this.ThrowIfShared(); field = value; } - } = EnumKeyConvention.DefaultEnumKeyPrefix; + } /// /// Gets or sets the resource manager that scopes the lookup to a single .resx file. When @@ -92,11 +92,11 @@ public object Convert(object?[] values, Type targetType, object? parameter, Cult return string.Empty; } - var key = EnumKeyConvention.BuildEnumKey(enumValue, this.KeyPrefix); + var key = Localizer.Current.BuildEnumKey(enumValue, this.KeyPrefix); return this.ResourceManager is null ? Localizer.Current.Get(key) - : Localizer.Current.Get(key, this.ResourceManager); + : Localizer.Current.Get(key, resourceManager: this.ResourceManager); } /// Not supported; this converter is one-way. diff --git a/src/ResXLocalization.WPF/LocalizeEnumExtension.cs b/src/ResXLocalization.WPF/LocalizeEnumExtension.cs index c79656e..b348987 100644 --- a/src/ResXLocalization.WPF/LocalizeEnumExtension.cs +++ b/src/ResXLocalization.WPF/LocalizeEnumExtension.cs @@ -4,16 +4,18 @@ namespace RentADeveloper.ResXLocalization.WPF; /// XAML markup extension that localizes the enumeration value held in the target control's /// DataContext, re-resolving live on every culture change. Intended for item templates where /// each item is an enumeration value, for example -/// <TextBlock Text="{l:LocalizeEnum}" />. The value is mapped to a resource key using the -/// convention {KeyPrefix}{EnumTypeName}_{Value}. +/// <TextBlock Text="{l:LocalizeEnum}" />. The value is mapped to a resource key by +/// , by default {KeyPrefix}{EnumTypeName}_{Value}. /// [MarkupExtensionReturnType(typeof(string))] public sealed class LocalizeEnumExtension : MarkupExtension { /// - /// Gets or sets the prefix prepended to the generated resource key. Defaults to Enum_. + /// Gets or sets the key prefix passed to . + /// (the default) means "the decides", + /// which by default is Enum_; a value overrides the prefix for this extension. /// - public string KeyPrefix { get; set; } = EnumKeyConvention.DefaultEnumKeyPrefix; + public string? KeyPrefix { get; set; } /// /// Gets or sets the resource manager that scopes the lookup to a single .resx file. When diff --git a/src/ResXLocalization.WPF/LocalizeExtension.cs b/src/ResXLocalization.WPF/LocalizeExtension.cs index b372195..8836117 100644 --- a/src/ResXLocalization.WPF/LocalizeExtension.cs +++ b/src/ResXLocalization.WPF/LocalizeExtension.cs @@ -72,20 +72,21 @@ public override object ProvideValue(IServiceProvider serviceProvider) { var resourceKey = this.ResourceKey.Value; resolve = () => Localizer.Current.Get(resourceKey); - resolveFormatted = arguments => Localizer.Current.Get(resourceKey, arguments); + resolveFormatted = arguments => Localizer.Current.Get(resourceKey, MessageArguments.FromValues(arguments)); } else if (this.ResourceManager is not null) { var key = this.Key; var resourceManager = this.ResourceManager; - resolve = () => Localizer.Current.Get(key, resourceManager); - resolveFormatted = arguments => Localizer.Current.Get(key, resourceManager, arguments); + resolve = () => Localizer.Current.Get(key, resourceManager: resourceManager); + resolveFormatted = arguments => + Localizer.Current.Get(key, MessageArguments.FromValues(arguments), resourceManager); } else { var key = this.Key; resolve = () => Localizer.Current.Get(key); - resolveFormatted = arguments => Localizer.Current.Get(key, arguments); + resolveFormatted = arguments => Localizer.Current.Get(key, MessageArguments.FromValues(arguments)); } // Three constant children, regardless of how many arguments are used: diff --git a/src/ResXLocalization.WPF/PublicAPI.Unshipped.txt b/src/ResXLocalization.WPF/PublicAPI.Unshipped.txt index 7dc5c58..85c8696 100644 --- a/src/ResXLocalization.WPF/PublicAPI.Unshipped.txt +++ b/src/ResXLocalization.WPF/PublicAPI.Unshipped.txt @@ -1 +1,5 @@ #nullable enable +*REMOVED*RentADeveloper.ResXLocalization.WPF.LocalizeEnumConverter.KeyPrefix.get -> string! +*REMOVED*RentADeveloper.ResXLocalization.WPF.LocalizeEnumExtension.KeyPrefix.get -> string! +RentADeveloper.ResXLocalization.WPF.LocalizeEnumConverter.KeyPrefix.get -> string? +RentADeveloper.ResXLocalization.WPF.LocalizeEnumExtension.KeyPrefix.get -> string? diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeEnumConverterTests.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeEnumConverterTests.cs index e386036..46aa540 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeEnumConverterTests.cs +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeEnumConverterTests.cs @@ -6,6 +6,26 @@ namespace RentADeveloper.ResXLocalization.Avalonia.Sample.Tests; /// public class LocalizeEnumConverterTests { + [AvaloniaFact] + public void CustomEnumKeyBuilder_AppliesToTheConverter() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var original = Localizer.Current.EnumKeyBuilder; + Localizer.Current.EnumKeyBuilder = static (value, keyPrefix) => + (keyPrefix ?? "Text_") + value.GetType().Name + "." + value; + + try + { + Convert(new LocalizeEnumConverter(), FileSortOrder.Ascending, TestSupport.English) + .Should() + .Be("Custom ascending"); + } + finally + { + Localizer.Current.EnumKeyBuilder = original; + } + } + [AvaloniaFact] public void CustomPrefix_NoManager_ResolvesViaSearchAll() { @@ -48,6 +68,19 @@ public void Default_NoPrefix_NoManager_ResolvesViaSearchAll() Convert(LocalizeEnumConverter.Default, FileSortOrder.Ascending).Should().Be("Ascending"); } + [AvaloniaFact] + public void KeyPrefix_DefaultsToNull_AndAcceptsNull_ButNotOnTheSharedInstance() + { + var converter = new LocalizeEnumConverter(); + converter.KeyPrefix.Should().BeNull(); + + converter.KeyPrefix = "Display_"; + converter.KeyPrefix = null; + + converter.KeyPrefix.Should().BeNull(); + ((Action)(() => LocalizeEnumConverter.Default.KeyPrefix = null)).Should().Throw(); + } + [AvaloniaFact] public void MultiBinding_SwitchesLive_OnCultureChange() { diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeEnumExtensionTests.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeEnumExtensionTests.cs index ccaac9b..dab3698 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeEnumExtensionTests.cs +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeEnumExtensionTests.cs @@ -20,6 +20,26 @@ public void BoundValue_SwitchesLive_OnCultureChange() textBlock.Text.Should().Be("Absteigend"); } + [AvaloniaFact] + public void CustomEnumKeyBuilder_AppliesToTheExtension() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var original = Localizer.Current.EnumKeyBuilder; + Localizer.Current.EnumKeyBuilder = static (value, keyPrefix) => + (keyPrefix ?? "Text_") + value.GetType().Name + "." + value; + + try + { + var textBlock = TestSupport.BindLocalizedEnum(new(), FileSortOrder.Ascending); + + textBlock.Text.Should().Be("Custom ascending"); + } + finally + { + Localizer.Current.EnumKeyBuilder = original; + } + } + [AvaloniaFact] public void CustomPrefix_NoManager_ResolvesViaSearchAll() { diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/Resources/Catalog.resx b/tests/ResXLocalization.Avalonia.Sample.Tests/Resources/Catalog.resx index 4aaa74d..0c6cbd1 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/Resources/Catalog.resx +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/Resources/Catalog.resx @@ -44,4 +44,7 @@ Broken {0 + + Custom ascending + diff --git a/tests/ResXLocalization.Core.Tests/CapturingFormatter.cs b/tests/ResXLocalization.Core.Tests/CapturingFormatter.cs index 63d7b9a..dd6f212 100644 --- a/tests/ResXLocalization.Core.Tests/CapturingFormatter.cs +++ b/tests/ResXLocalization.Core.Tests/CapturingFormatter.cs @@ -9,15 +9,13 @@ namespace RentADeveloper.ResXLocalization.Core.Tests; internal sealed class CapturingFormatter : IMessageFormatter { /// Gets the recorded calls, in order. - public List<(string Pattern, MessageArgument[] Arguments, CultureInfo Culture)> Calls { get; } = []; + public List<(string Pattern, MessageArguments Arguments, CultureInfo Culture)> Calls { get; } = []; /// - public string Format(string pattern, MessageArgument[] arguments, CultureInfo culture) + public string Format(string pattern, MessageArguments arguments, CultureInfo culture) { this.Calls.Add((pattern, arguments, culture)); - return arguments.Length == 0 - ? pattern - : string.Format(culture, pattern, arguments.Select(argument => argument.Value).ToArray()); + return StringFormatMessageFormatter.Instance.Format(pattern, arguments, culture); } } diff --git a/tests/ResXLocalization.Core.Tests/LocalizerContractTests.cs b/tests/ResXLocalization.Core.Tests/LocalizerContractTests.cs index 0710a18..63f6d4e 100644 --- a/tests/ResXLocalization.Core.Tests/LocalizerContractTests.cs +++ b/tests/ResXLocalization.Core.Tests/LocalizerContractTests.cs @@ -1,3 +1,6 @@ +using System.ComponentModel; +using System.Reflection; + namespace RentADeveloper.ResXLocalization.Core.Tests; /// @@ -86,6 +89,37 @@ public void Current_IsAProcessWideSingleton() Localizer.Current.Should().BeAssignableTo(); } + [Fact] + public void GetOverloads_AreExactlyOnePerKeyKind_WithoutParamsOrEditorBrowsable() + { + string[] expected = + [ + "Get(Enum, MessageArguments, ResourceManager, String)", + "Get(ResourceKey, MessageArguments)", + "Get(String, MessageArguments, ResourceManager)", + ]; + + foreach (var type in new[] { typeof(ILocalizer), typeof(Localizer) }) + { + var methods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(method => method.Name == nameof(ILocalizer.Get)) + .ToArray(); + + methods + .Select(method => + $"Get({string.Join(", ", method.GetParameters().Select(parameter => parameter.ParameterType.Name))})" + ) + .Order(StringComparer.Ordinal) + .Should() + .Equal(expected); + methods + .SelectMany(method => method.GetParameters()) + .Should() + .NotContain(parameter => parameter.IsDefined(typeof(ParamArrayAttribute))); + methods.Should().NotContain(method => method.IsDefined(typeof(EditorBrowsableAttribute))); + } + } + [Fact] public void RegisterResourceManager_RejectsNull() { diff --git a/tests/ResXLocalization.Core.Tests/LocalizerEnumLookupTests.cs b/tests/ResXLocalization.Core.Tests/LocalizerEnumLookupTests.cs index f2cfaed..036b264 100644 --- a/tests/ResXLocalization.Core.Tests/LocalizerEnumLookupTests.cs +++ b/tests/ResXLocalization.Core.Tests/LocalizerEnumLookupTests.cs @@ -1,24 +1,116 @@ namespace RentADeveloper.ResXLocalization.Core.Tests; /// -/// Exercises the four enum lookup overloads and their argument validation. Enum values map to keys by -/// the {KeyPrefix}{EnumTypeName}_{Value} convention. +/// Exercises the enum lookup and . By default enum values map to +/// keys by the {KeyPrefix}{EnumTypeName}_{Value} convention. /// public class LocalizerEnumLookupTests { private readonly TestResources resources = new(); [Fact] - public void NullArguments_Throw() + public void Builder_DefaultIsSharedAndBuildsTheConventionKey_AndRejectsNull() + { + var first = new Localizer(); + var second = new Localizer(); + var builder = first.EnumKeyBuilder; + + second.EnumKeyBuilder.Should().BeSameAs(builder); + builder(TestSortOrder.Ascending, null).Should().Be("Enum_TestSortOrder_Ascending"); + builder(TestSortOrder.Ascending, "X_").Should().Be("X_TestSortOrder_Ascending"); + + var act = () => first.EnumKeyBuilder = null!; + + act.Should().Throw(); + first.EnumKeyBuilder.Should().BeSameAs(builder); + } + + [Fact] + public void Builder_NullOrEmptyResult_Throws_AndItsExceptionPropagates() + { + var localizer = this.resources.CreateLocalizer(); + var boom = new InvalidOperationException("boom"); + + localizer.EnumKeyBuilder = (_, _) => null!; + var nullResult = () => localizer.Get(TestSortOrder.Ascending); + nullResult + .Should() + .Throw() + .Which.Message.Should() + .Contain("EnumKeyBuilder") + .And.Contain("TestSortOrder"); + + localizer.EnumKeyBuilder = (_, _) => ""; + var emptyResult = () => localizer.Get(TestSortOrder.Ascending); + emptyResult + .Should() + .Throw() + .Which.Message.Should() + .Contain("EnumKeyBuilder") + .And.Contain("TestSortOrder"); + + localizer.EnumKeyBuilder = (_, _) => throw boom; + var throwing = () => localizer.Get(TestSortOrder.Ascending); + throwing.Should().Throw().Which.Should().BeSameAs(boom); + } + + [Fact] + public void Builder_ReceivesTheValueAndTheKeyPrefix() + { + var localizer = this.resources.CreateLocalizer(); + var calls = new List<(Enum Value, string? KeyPrefix)>(); + localizer.EnumKeyBuilder = (value, keyPrefix) => + { + calls.Add((value, keyPrefix)); + return "Enum_TestSortOrder_Ascending"; + }; + + localizer.Get(TestSortOrder.Ascending); + localizer.Get(TestSortOrder.Ascending, keyPrefix: "Display_"); + + calls.Should().Equal((TestSortOrder.Ascending, null), (TestSortOrder.Ascending, "Display_")); + } + + [Fact] + public void CustomBuilder_AppliesToEveryEnumLookup_AndKeyPrefixOverridesOnlyItsPrefix() + { + var localizer = this.resources.CreateLocalizer(); + localizer.EnumKeyBuilder = static (value, keyPrefix) => + (keyPrefix ?? "Text_") + value.GetType().Name + "." + value; + + localizer.Get(TestSortOrder.Ascending).Should().Be("Custom ascending"); + localizer.Get(TestSortOrder.Ascending, resourceManager: this.resources.Catalog).Should().Be("Custom ascending"); + localizer.Get(TestSortOrder.Ascending, MessageArguments.FromValues(3)).Should().Be("Custom ascending"); + localizer + .Get(TestSortOrder.Ascending, MessageArguments.FromValues(3), keyPrefix: "Count_") + .Should() + .Be("!Count_TestSortOrder.Ascending!"); + } + + [Fact] + public void NullKeyPrefix_MeansTheBuildersDefault() + { + var localizer = this.resources.CreateLocalizer(); + + localizer.Get(TestSortOrder.Ascending, keyPrefix: null).Should().Be("Ascending"); + localizer.Get(TestSortOrder.Ascending, null, this.resources.Catalog, null).Should().Be("Ascending"); + localizer + .Get(TestSortOrder.Ascending, MessageArguments.FromValues(3), keyPrefix: null) + .Should() + .Be("Ascending"); + } + + [Fact] + public void NullValueOrDefaultResourceKey_Throws() { var localizer = this.resources.CreateLocalizer(); ((Action)(() => localizer.Get((Enum)null!))).Should().Throw(); - ((Action)(() => localizer.Get(TestSortOrder.Ascending, (string)null!))).Should().Throw(); - ((Action)(() => localizer.Get(TestSortOrder.Ascending, (ResourceManager)null!))) + ((Action)(() => localizer.Get((Enum)null!, MessageArguments.FromValues(1)))) .Should() .Throw(); - ((Action)(() => localizer.Get(TestSortOrder.Ascending, this.resources.Catalog, null!))) + ((Action)(() => localizer.Get(default(ResourceKey)))).Should().Throw(); + ((Action)(() => localizer.Get(default(ResourceKey), MessageArguments.FromValues(1)))) .Should() .Throw(); } @@ -28,7 +120,10 @@ public void Scoped_CustomPrefix_Resolves() { var localizer = this.resources.CreateLocalizer(); - localizer.Get(TestSortOrder.Ascending, this.resources.Catalog, "Display_").Should().Be("A to Z"); + localizer + .Get(TestSortOrder.Ascending, resourceManager: this.resources.Catalog, keyPrefix: "Display_") + .Should() + .Be("A to Z"); } [Fact] @@ -36,7 +131,7 @@ public void Scoped_DefaultPrefix_Resolves() { var localizer = this.resources.CreateLocalizer(); - localizer.Get(TestSortOrder.Ascending, this.resources.Catalog).Should().Be("Ascending"); + localizer.Get(TestSortOrder.Ascending, resourceManager: this.resources.Catalog).Should().Be("Ascending"); } [Fact] @@ -44,7 +139,7 @@ public void SearchAll_CustomPrefix_Resolves() { var localizer = this.resources.CreateLocalizer(); - localizer.Get(TestSortOrder.Ascending, "Display_").Should().Be("A to Z"); + localizer.Get(TestSortOrder.Ascending, keyPrefix: "Display_").Should().Be("A to Z"); } [Fact] @@ -59,10 +154,58 @@ public void SearchAll_DefaultPrefix_ResolvesAndSwitchesLive() } [Fact] - public void UnlocalizedEnumValue_ReturnsTheConventionKeyAsSentinel() + public void UnlocalizedEnumValue_ReturnsTheConventionKeyAsFailureText() { var localizer = this.resources.CreateLocalizer(); localizer.Get(TestSortOrder.Descending).Should().Be("!Enum_TestSortOrder_Descending!"); } + + [Fact] + public void UnlocalizedEnumValue_WithArguments_ReturnsFailureText_AndReportsOneMiss() + { + var localizer = this.resources.CreateLocalizer(); + using var monitor = localizer.Monitor(); + + localizer + .Get(TestSortOrder.Descending, MessageArguments.FromValues(1)) + .Should() + .Be("!Enum_TestSortOrder_Descending!"); + + monitor + .LookupFailures() + .Should() + .ContainSingle() + .Which.Reason.Should() + .Be(LookupFailureReason.TranslationNotFound); + } + + [Fact] + public void WithArguments_AndCustomPrefix_Formats_ScopedAndLive() + { + var localizer = this.resources.CreateLocalizer(); + + localizer + .Get(TestSortOrder.Ascending, MessageArguments.FromValues(3), keyPrefix: "Count_") + .Should() + .Be("3 items, ascending"); + localizer + .Get(TestSortOrder.Ascending, MessageArguments.FromValues(3), this.resources.Catalog, "Count_") + .Should() + .Be("3 items, ascending"); + + localizer.CurrentCulture = TestResources.German; + localizer + .Get(TestSortOrder.Ascending, MessageArguments.FromValues(3), keyPrefix: "Count_") + .Should() + .Be("3 Elemente, aufsteigend"); + } + + [Fact] + public void WithArguments_AndDefaultPrefix_ReturnsTheValueWithoutPlaceholder() + { + var localizer = this.resources.CreateLocalizer(); + + localizer.Get(TestSortOrder.Ascending, MessageArguments.FromValues(3)).Should().Be("Ascending"); + } } diff --git a/tests/ResXLocalization.Core.Tests/LocalizerFormatTests.cs b/tests/ResXLocalization.Core.Tests/LocalizerFormatTests.cs index ccde062..6bf077b 100644 --- a/tests/ResXLocalization.Core.Tests/LocalizerFormatTests.cs +++ b/tests/ResXLocalization.Core.Tests/LocalizerFormatTests.cs @@ -1,6 +1,3 @@ -using System.ComponentModel; -using System.Reflection; - namespace RentADeveloper.ResXLocalization.Core.Tests; /// @@ -19,10 +16,10 @@ public void EmptyArgumentsArray_ReturnsTheValueUnformatted() var localizer = this.resources.CreateLocalizer(); using var monitor = localizer.Monitor(); - localizer.Get("EscapedBraces", Array.Empty()).Should().Be("Literal {{braces}} for {0}"); + localizer.Get("EscapedBraces", MessageArguments.FromValues()).Should().Be("Literal {{braces}} for {0}"); monitor.Should().NotRaise(nameof(ILocalizer.LookupFailed)); - localizer.Get("EscapedBraces", "x").Should().Be("Literal {braces} for x"); + localizer.Get("EscapedBraces", MessageArguments.FromValues("x")).Should().Be("Literal {braces} for x"); } [Fact] @@ -30,7 +27,7 @@ public void EmptyKey_ReturnsEmptyString() { var localizer = this.resources.CreateLocalizer(); - localizer.Get(string.Empty, 1, 2).Should().BeEmpty(); + localizer.Get(string.Empty, MessageArguments.FromValues(1, 2)).Should().BeEmpty(); } [Fact] @@ -41,7 +38,7 @@ public void FormatterException_OfAnyType_ReturnsTheFailureText_WithThatException localizer.MessageFormatter = new ThrowingFormatter(exception); using var monitor = localizer.Monitor(); - localizer.Get("Greeting", 1).Should().Be("!Greeting!"); + localizer.Get("Greeting", MessageArguments.FromValues(1)).Should().Be("!Greeting!"); monitor.LookupFailures().Should().ContainSingle().Which.Exception.Should().BeSameAs(exception); } @@ -55,7 +52,7 @@ public void FormatterException_WithoutArguments_ReturnsTheFailureText_AndRaisesL using var monitor = localizer.Monitor(); localizer.Get("Greeting").Should().Be("!Greeting!"); - localizer.Get("Greeting", this.resources.Catalog).Should().Be("!Greeting!"); + localizer.Get("Greeting", resourceManager: this.resources.Catalog).Should().Be("!Greeting!"); var raised = monitor.LookupFailures(); raised.Should().HaveCount(2); @@ -78,7 +75,7 @@ public void Formatter_ReceivesThePatternTheArgumentsAndTheCulture() var formatter = new CapturingFormatter(); localizer.MessageFormatter = formatter; - localizer.Get("ItemsFound", 1, "x"); + localizer.Get("ItemsFound", MessageArguments.FromValues(1, "x")); var call = formatter.Calls.Should().ContainSingle().Subject; call.Pattern.Should().Be("Found {0:N1} items in {1}."); @@ -93,7 +90,7 @@ public void FormattingFailure_InTheCurrentCulture_ReportsThatCultureAndPattern() localizer.CurrentCulture = TestResources.German; using var monitor = localizer.Monitor(); - localizer.Get("Malformed", 1).Should().Be("!Malformed!"); + localizer.Get("Malformed", MessageArguments.FromValues(1)).Should().Be("!Malformed!"); var failure = monitor.LookupFailures().Should().ContainSingle().Subject; failure.Culture.Should().Be(TestResources.German); @@ -107,7 +104,7 @@ public void FormattingFailure_ReturnsTheFailureText_AndRaisesLookupFailed() using var monitor = localizer.Monitor(); // The template needs two arguments; one is a resource defect. It is reported, not thrown. - localizer.Get("ItemsFound", "only-one").Should().Be("!ItemsFound!"); + localizer.Get("ItemsFound", MessageArguments.FromValues("only-one")).Should().Be("!ItemsFound!"); var failure = monitor.LookupFailures().Should().ContainSingle().Subject; failure.Reason.Should().Be(LookupFailureReason.MessageFormatFailed); @@ -125,8 +122,11 @@ public void FormattingFailure_ScopedAndTyped_ReportsTheManager() var localizer = this.resources.CreateLocalizer(); using var monitor = localizer.Monitor(); - localizer.Get("Malformed", this.resources.Catalog, 1).Should().Be("!Malformed!"); - localizer.Get(new ResourceKey("Malformed", this.resources.Catalog), 1).Should().Be("!Malformed!"); + localizer.Get("Malformed", MessageArguments.FromValues(1), this.resources.Catalog).Should().Be("!Malformed!"); + localizer + .Get(new ResourceKey("Malformed", this.resources.Catalog), MessageArguments.FromValues(1)) + .Should() + .Be("!Malformed!"); monitor .LookupFailures() @@ -155,8 +155,11 @@ public void MissingKey_ReportsTranslationNotFound_NotMessageFormatFailed() var localizer = this.resources.CreateLocalizer(); using var monitor = localizer.Monitor(); - localizer.Get("ThisKeyDoesNotExist", 1, 2).Should().Be("!ThisKeyDoesNotExist!"); - localizer.Get("ThisKeyDoesNotExist", this.resources.Catalog, 1, 2).Should().Be("!ThisKeyDoesNotExist!"); + localizer.Get("ThisKeyDoesNotExist", MessageArguments.FromValues(1, 2)).Should().Be("!ThisKeyDoesNotExist!"); + localizer + .Get("ThisKeyDoesNotExist", MessageArguments.FromValues(1, 2), this.resources.Catalog) + .Should() + .Be("!ThisKeyDoesNotExist!"); monitor .LookupFailures() @@ -166,12 +169,15 @@ public void MissingKey_ReportsTranslationNotFound_NotMessageFormatFailed() } [Fact] - public void MissingKey_ReturnsTheSentinelUnformatted() + public void MissingKey_ReturnsTheFailureTextUnformatted() { var localizer = this.resources.CreateLocalizer(); - localizer.Get("ThisKeyDoesNotExist", 1, 2).Should().Be("!ThisKeyDoesNotExist!"); - localizer.Get("ThisKeyDoesNotExist", this.resources.Catalog, 1, 2).Should().Be("!ThisKeyDoesNotExist!"); + localizer.Get("ThisKeyDoesNotExist", MessageArguments.FromValues(1, 2)).Should().Be("!ThisKeyDoesNotExist!"); + localizer + .Get("ThisKeyDoesNotExist", MessageArguments.FromValues(1, 2), this.resources.Catalog) + .Should() + .Be("!ThisKeyDoesNotExist!"); } [Fact] @@ -183,7 +189,7 @@ public void MissingKey_TheFailureText_NeverReachesTheFormatter() localizer.MessageFormatter = formatter; using var monitor = localizer.Monitor(); - localizer.Get("ThisKeyDoesNotExist", 1).Should().Be("{ThisKeyDoesNotExist}"); + localizer.Get("ThisKeyDoesNotExist", MessageArguments.FromValues(1)).Should().Be("{ThisKeyDoesNotExist}"); formatter.Calls.Should().BeEmpty(); monitor @@ -206,10 +212,10 @@ public void NoArgumentMembers_PassTheValueThroughTheFormatter_WithAnEmptyArray() [ localizer.Get("Greeting"), localizer["Greeting"], - localizer.Get("Greeting", catalog), + localizer.Get("Greeting", resourceManager: catalog), localizer.Get(new ResourceKey("Greeting", catalog)), localizer.Get(TestSortOrder.Ascending), - localizer.Get(TestSortOrder.Ascending, catalog), + localizer.Get(TestSortOrder.Ascending, resourceManager: catalog), ]; localizer.Get("ThisKeyDoesNotExist"); localizer.Get(string.Empty); @@ -236,101 +242,79 @@ public void NoArgumentMembers_PassTheValueThroughTheFormatter_WithAnEmptyArray() } [Fact] - public void NullArgumentsArray_Throws() + public void NoArguments_InEveryForm_PassEmptyArgumentsToTheFormatter() { var localizer = this.resources.CreateLocalizer(); + var formatter = new CapturingFormatter(); + localizer.MessageFormatter = formatter; - var act = () => localizer.Get("Greeting", (object?[])null!); + string[] results = + [ + localizer.Get("Greeting"), + localizer.Get("Greeting", null), + localizer.Get("Greeting", MessageArguments.Empty), + localizer.Get("Greeting", null, null), + localizer.Get("Greeting", resourceManager: this.resources.Catalog), + localizer["Greeting"], + ]; - act.Should().Throw(); + results.Should().AllBe("Hello and welcome!"); + formatter.Calls.Should().HaveCount(6).And.AllSatisfy(call => call.Arguments.Should().NotBeNull().And.BeEmpty()); } [Fact] - public void NullArgumentsArray_Throws_ForEveryOverloadWithArguments() + public void NullManager_MeansSearchAll_ButADefaultResourceKeyThrows() { var localizer = this.resources.CreateLocalizer(); - var catalog = this.resources.Catalog; - var searchAll = () => localizer.Get("Greeting", (object?[])null!); - var scoped = () => localizer.Get("Greeting", catalog, (object?[])null!); - var typed = () => localizer.Get(new ResourceKey("Greeting", catalog), (object?[])null!); - - searchAll.Should().Throw(); - scoped.Should().Throw(); - typed.Should().Throw(); - } - - [Fact] - public void NullManagerOrDefaultResourceKey_Throws_InsteadOfSearchingAll() - { - var localizer = this.resources.CreateLocalizer(); + localizer.Get("Greeting", resourceManager: null).Should().Be("Hello and welcome!"); + localizer.Get(TestSortOrder.Ascending, resourceManager: null).Should().Be("Ascending"); - var scoped = () => localizer.Get("Greeting", (ResourceManager)null!); var typed = () => localizer.Get(default(ResourceKey)); - var typedWithArguments = () => localizer.Get(default(ResourceKey), 1); - var enumScoped = () => localizer.Get(TestSortOrder.Ascending, (ResourceManager)null!, "Enum_"); + var typedWithArguments = () => localizer.Get(default(ResourceKey), MessageArguments.FromValues(1)); - scoped.Should().Throw(); typed.Should().Throw(); typedWithArguments.Should().Throw(); - enumScoped.Should().Throw(); } [Fact] - public void Primitive_DefaultFormatter_UsesPositions_AndIgnoresNames() + public void PositionalAndNamedArguments_FormatByPosition_UnderTheDefaultFormatter() { var localizer = this.resources.CreateLocalizer(); + localizer.Get("FilePosition", MessageArguments.FromValues(3, 10)).Should().Be("File 3 of 10"); localizer - .Get("ItemsFound", [new(0, "Count", 1.5), new(1, "Where", "x")], null) + .Get("ItemsFound", MessageArguments.FromObject(new { Count = 1.5, Where = "x" })) + .Should() + .Be("Found 1.5 items in x."); + localizer + .Get( + new ResourceKey("ItemsFound", this.resources.Catalog), + MessageArguments.FromTuples(("Count", 1.5), ("Where", "x")) + ) .Should() .Be("Found 1.5 items in x."); } [Fact] - public void Primitive_IsHiddenFromIntelliSense() - { - Type[] parameters = [typeof(string), typeof(MessageArgument[]), typeof(ResourceManager)]; - - foreach (var type in new[] { typeof(ILocalizer), typeof(Localizer) }) - { - type.GetMethod(nameof(ILocalizer.Get), parameters)! - .GetCustomAttribute()! - .State.Should() - .Be(EditorBrowsableState.Never); - } - } - - [Fact] - public void Primitive_RejectsNullArguments_ReturnsEmptyForAnEmptyKey_AndResolvesWithoutArguments() + public void Positions_NotTheEnumerationIndex_DecideWhichPlaceholderAValueFills() { var localizer = this.resources.CreateLocalizer(); - // The cast is required: Get("Greeting", null, null) is ambiguous with the scoped params overload. - var act = () => localizer.Get("Greeting", (MessageArgument[])null!, null); - - act.Should().Throw(); - localizer.Get(string.Empty, [new(0, null, 1)], null).Should().BeEmpty(); - localizer.Get("Greeting", Array.Empty(), null).Should().Be("Hello and welcome!"); + localizer + .Get("FilePosition", MessageArguments.FromArguments(new(1, null, 10), new(0, null, 3))) + .Should() + .Be("File 3 of 10"); } [Fact] - public void Primitive_ValidatesPositionsAndEmptyNames_Only() + public void Primitive_TreatsNullArgumentsAsNone_AndReturnsEmptyForAnEmptyKey() { var localizer = this.resources.CreateLocalizer(); - var wrongPosition = () => localizer.Get("Greeting", [new(1, null, 1)], null); - var repeatedPosition = () => localizer.Get("Greeting", [new(0, null, 1), new(0, null, 2)], null); - var emptyName = () => localizer.Get("Greeting", [new(0, "", 1)], null); - - wrongPosition.Should().Throw().WithParameterName("arguments"); - repeatedPosition.Should().Throw().WithParameterName("arguments"); - emptyName.Should().Throw().WithParameterName("arguments"); - - // Duplicate names and a name that looks like a position are not the primitive's concern, and a - // null value is fine. - localizer.Get("Greeting", [new(0, "A", 1), new(1, "A", 2)], null).Should().Be("Hello and welcome!"); - localizer.Get("Greeting", [new(0, "0", null)], null).Should().Be("Hello and welcome!"); + localizer.Get("Greeting", null, null).Should().Be("Hello and welcome!"); + localizer.Get(string.Empty, MessageArguments.FromValues(1), null).Should().BeEmpty(); + localizer.Get("Greeting", MessageArguments.Empty, null).Should().Be("Hello and welcome!"); } [Fact] @@ -338,7 +322,10 @@ public void Scoped_FormatsTheResolvedValue() { var localizer = this.resources.CreateLocalizer(); - localizer.Get("ItemsFound", this.resources.Catalog, 2.0, "Music").Should().Be("Found 2.0 items in Music."); + localizer + .Get("ItemsFound", MessageArguments.FromValues(2.0, "Music"), this.resources.Catalog) + .Should() + .Be("Found 2.0 items in Music."); } [Fact] @@ -347,11 +334,17 @@ public void SearchAll_FormatsTheResolvedValue_InTheCurrentCulture() var localizer = this.resources.CreateLocalizer(); // {0:N1} renders 1234.5 with an English decimal point... - localizer.Get("ItemsFound", 1234.5, "Downloads").Should().Be("Found 1,234.5 items in Downloads."); + localizer + .Get("ItemsFound", MessageArguments.FromValues(1234.5, "Downloads")) + .Should() + .Be("Found 1,234.5 items in Downloads."); // ...and with a German decimal comma after the switch, using the German template. localizer.CurrentCulture = TestResources.German; - localizer.Get("ItemsFound", 1234.5, "Downloads").Should().Be("1.234,5 Elemente in Downloads gefunden."); + localizer + .Get("ItemsFound", MessageArguments.FromValues(1234.5, "Downloads")) + .Should() + .Be("1.234,5 Elemente in Downloads gefunden."); } [Fact] @@ -360,7 +353,7 @@ public void TypedKey_FormatsTheResolvedValue() var localizer = this.resources.CreateLocalizer(); var key = new ResourceKey("ItemsFound", this.resources.Catalog); - localizer.Get(key, 2.0, "Music").Should().Be("Found 2.0 items in Music."); + localizer.Get(key, MessageArguments.FromValues(2.0, "Music")).Should().Be("Found 2.0 items in Music."); } /// A formatter that throws the given exception for every call. @@ -369,6 +362,6 @@ private sealed class ThrowingFormatter(Exception exception) : IMessageFormatter { private readonly Exception exception = exception; - public string Format(string pattern, MessageArgument[] arguments, CultureInfo culture) => throw this.exception; + public string Format(string pattern, MessageArguments arguments, CultureInfo culture) => throw this.exception; } } diff --git a/tests/ResXLocalization.Core.Tests/LocalizerLookupTests.cs b/tests/ResXLocalization.Core.Tests/LocalizerLookupTests.cs index 17a32a6..cfcf47c 100644 --- a/tests/ResXLocalization.Core.Tests/LocalizerLookupTests.cs +++ b/tests/ResXLocalization.Core.Tests/LocalizerLookupTests.cs @@ -23,7 +23,10 @@ public void MissingKey_ReturnsTheBangSentinel() var localizer = this.resources.CreateLocalizer(); localizer.Get("ThisKeyDoesNotExist").Should().Be("!ThisKeyDoesNotExist!"); - localizer.Get("ThisKeyDoesNotExist", this.resources.Catalog).Should().Be("!ThisKeyDoesNotExist!"); + localizer + .Get("ThisKeyDoesNotExist", resourceManager: this.resources.Catalog) + .Should() + .Be("!ThisKeyDoesNotExist!"); } [Fact] @@ -33,7 +36,7 @@ public void NullOrEmptyKey_ReturnsEmptyString() localizer.Get(string.Empty).Should().BeEmpty(); localizer.Get((string)null!).Should().BeEmpty(); - localizer.Get(string.Empty, this.resources.Catalog).Should().BeEmpty(); + localizer.Get(string.Empty, resourceManager: this.resources.Catalog).Should().BeEmpty(); } [Fact] @@ -41,29 +44,27 @@ public void ScopedLookup_ReadsExactlyTheNamedFile_EvenForACollidingKey() { var localizer = this.resources.CreateLocalizer(); - localizer.Get("Shared", this.resources.Catalog).Should().Be("CatalogShared"); - localizer.Get("Shared", this.resources.Fallback).Should().Be("FallbackShared"); + localizer.Get("Shared", resourceManager: this.resources.Catalog).Should().Be("CatalogShared"); + localizer.Get("Shared", resourceManager: this.resources.Fallback).Should().Be("FallbackShared"); } [Fact] - public void ScopedLookup_RejectsNullResourceManager() + public void ScopedLookup_SwitchesLive_OnCultureChange() { var localizer = this.resources.CreateLocalizer(); + localizer.Get("Greeting", resourceManager: this.resources.Catalog).Should().Be("Hello and welcome!"); - var act = () => localizer.Get("Greeting", (ResourceManager)null!); + localizer.CurrentCulture = TestResources.German; - act.Should().Throw(); + localizer.Get("Greeting", resourceManager: this.resources.Catalog).Should().Be("Hallo und willkommen!"); } [Fact] - public void ScopedLookup_SwitchesLive_OnCultureChange() + public void ScopedLookup_WithANullResourceManager_SearchesAll() { var localizer = this.resources.CreateLocalizer(); - localizer.Get("Greeting", this.resources.Catalog).Should().Be("Hello and welcome!"); - - localizer.CurrentCulture = TestResources.German; - localizer.Get("Greeting", this.resources.Catalog).Should().Be("Hallo und willkommen!"); + localizer.Get("FallbackOnly", resourceManager: null).Should().Be("FallbackOnlyValue"); } [Fact] diff --git a/tests/ResXLocalization.Core.Tests/LocalizerMissTests.cs b/tests/ResXLocalization.Core.Tests/LocalizerMissTests.cs index 4ad0458..9fda742 100644 --- a/tests/ResXLocalization.Core.Tests/LocalizerMissTests.cs +++ b/tests/ResXLocalization.Core.Tests/LocalizerMissTests.cs @@ -36,7 +36,10 @@ public void LookupFailureTextBuilder_CustomizesTheFailureText() localizer.LookupFailureTextBuilder = failure => $"[missing: {failure.Key}]"; localizer.Get("ThisKeyDoesNotExist").Should().Be("[missing: ThisKeyDoesNotExist]"); - localizer.Get("ThisKeyDoesNotExist", this.resources.Catalog).Should().Be("[missing: ThisKeyDoesNotExist]"); + localizer + .Get("ThisKeyDoesNotExist", resourceManager: this.resources.Catalog) + .Should() + .Be("[missing: ThisKeyDoesNotExist]"); localizer.Get(TestSortOrder.Descending).Should().Be("[missing: Enum_TestSortOrder_Descending]"); localizer.Get(string.Empty).Should().BeEmpty(); } @@ -47,7 +50,17 @@ public void LookupFailureTextBuilder_DefaultsToBangKeyBang() var first = new Localizer().LookupFailureTextBuilder; first.Should().BeSameAs(new Localizer().LookupFailureTextBuilder); - first(new(LookupFailureReason.TranslationNotFound, "Key", CultureInfo.InvariantCulture, null, [], null, null)) + first( + new( + LookupFailureReason.TranslationNotFound, + "Key", + CultureInfo.InvariantCulture, + null, + MessageArguments.Empty, + null, + null + ) + ) .Should() .Be("!Key!"); } @@ -67,7 +80,7 @@ public void LookupFailureTextBuilder_FormattingFailure_ReceivesTheFailure_AfterL return "[broken]"; }; - localizer.Get("Malformed", this.resources.Catalog, 1).Should().Be("[broken]"); + localizer.Get("Malformed", MessageArguments.FromValues(1), this.resources.Catalog).Should().Be("[broken]"); eventRaisedBeforeCall.Should().BeTrue(); var call = calls.Should().ContainSingle().Subject; @@ -86,7 +99,7 @@ public void LookupFailureTextBuilder_FormattingFailure_UsesTheDefault() { var localizer = this.resources.CreateLocalizer(); - localizer.Get("Malformed", 1).Should().Be("!Malformed!"); + localizer.Get("Malformed", MessageArguments.FromValues(1)).Should().Be("!Malformed!"); } [Fact] @@ -111,7 +124,7 @@ public void LookupFailureTextBuilder_NullResult_Throws() localizer.LookupFailureTextBuilder = _ => null!; var miss = () => localizer.Get("ThisKeyDoesNotExist"); - var formatFailure = () => localizer.Get("Malformed", 1); + var formatFailure = () => localizer.Get("Malformed", MessageArguments.FromValues(1)); miss.Should() .Throw() @@ -139,9 +152,9 @@ public void LookupFailureTextBuilder_ReceivesTheDetailsOfTheMiss() var catalog = this.resources.Catalog; localizer.Get("ThisKeyDoesNotExist"); - localizer.Get("ThisKeyDoesNotExist", catalog); + localizer.Get("ThisKeyDoesNotExist", resourceManager: catalog); localizer.Get(new ResourceKey("ThisKeyDoesNotExist", catalog)); - localizer.Get(TestSortOrder.Descending, catalog); + localizer.Get(TestSortOrder.Descending, resourceManager: catalog); calls .Select(call => (call.Key, call.Culture, call.ResourceManager)) @@ -185,7 +198,7 @@ public void LookupFailureTextBuilder_ThrowingFunction_Propagates_AfterTheEvent() using var monitor = localizer.Monitor(); var miss = () => localizer.Get("ThisKeyDoesNotExist"); - var formatFailure = () => localizer.Get("Malformed", 1); + var formatFailure = () => localizer.Get("Malformed", MessageArguments.FromValues(1)); miss.Should().Throw().Which.Should().BeSameAs(exception); monitor.LookupFailures().Should().ContainSingle(); @@ -200,7 +213,7 @@ public void MissingSatelliteEntry_FallsBackToNeutral_WithoutMissEvent() localizer.CurrentCulture = TestResources.German; using var monitor = localizer.Monitor(); - localizer.Get("NeutralOnly", this.resources.Catalog).Should().Be("Neutral fallback value"); + localizer.Get("NeutralOnly", resourceManager: this.resources.Catalog).Should().Be("Neutral fallback value"); monitor.Should().NotRaise(nameof(ILocalizer.LookupFailed)); } @@ -210,7 +223,7 @@ public void ScopedMiss_RaisesLookupFailed_WithTheManager() var localizer = this.resources.CreateLocalizer(); using var monitor = localizer.Monitor(); - localizer.Get("ThisKeyDoesNotExist", this.resources.Catalog); + localizer.Get("ThisKeyDoesNotExist", resourceManager: this.resources.Catalog); var miss = monitor.LookupFailures().Should().ContainSingle().Subject; miss.ResourceManager.Should().BeSameAs(this.resources.Catalog); @@ -237,7 +250,7 @@ public void SpecificCulture_FallsBackToParentCulture_WithoutMissEvent() localizer.CurrentCulture = new("de-DE"); using var monitor = localizer.Monitor(); - localizer.Get("Greeting", this.resources.Catalog).Should().Be("Hallo und willkommen!"); + localizer.Get("Greeting", resourceManager: this.resources.Catalog).Should().Be("Hallo und willkommen!"); monitor.Should().NotRaise(nameof(ILocalizer.LookupFailed)); } @@ -248,7 +261,7 @@ public void SuccessfulAndEmptyLookups_DoNotRaiseLookupFailed() using var monitor = localizer.Monitor(); localizer.Get("Greeting"); - localizer.Get("Greeting", this.resources.Catalog); + localizer.Get("Greeting", resourceManager: this.resources.Catalog); localizer.Get(string.Empty); _ = localizer["Greeting"]; diff --git a/tests/ResXLocalization.Core.Tests/LocalizerRegistrationTests.cs b/tests/ResXLocalization.Core.Tests/LocalizerRegistrationTests.cs index 034fdf1..2d27111 100644 --- a/tests/ResXLocalization.Core.Tests/LocalizerRegistrationTests.cs +++ b/tests/ResXLocalization.Core.Tests/LocalizerRegistrationTests.cs @@ -18,7 +18,7 @@ public void Clear_EmptiesTheSearchSet_ButLeavesScopedLookupsWorking() localizer.Get("Greeting").Should().Be("!Greeting!"); // Scoped and typed lookups never consult the registration set. - localizer.Get("Greeting", this.resources.Catalog).Should().Be("Hello and welcome!"); + localizer.Get("Greeting", resourceManager: this.resources.Catalog).Should().Be("Hello and welcome!"); localizer.Get(new ResourceKey("Greeting", this.resources.Catalog)).Should().Be("Hello and welcome!"); } diff --git a/tests/ResXLocalization.Core.Tests/MessageArgumentsTests.cs b/tests/ResXLocalization.Core.Tests/MessageArgumentsTests.cs new file mode 100644 index 0000000..cb05b0c --- /dev/null +++ b/tests/ResXLocalization.Core.Tests/MessageArgumentsTests.cs @@ -0,0 +1,146 @@ +namespace RentADeveloper.ResXLocalization.Core.Tests; + +/// +/// Exercises the factory: every argument has its index as position, +/// names come from tuples, dictionaries and properties, and duplicate names are rejected. +/// +public class MessageArgumentsTests +{ + [Fact] + public void DuplicateNames_AreRejectedOrdinally() + { + var tuples = () => MessageArguments.FromTuples(("A", 1), ("A", 2)); + var hiddenProperty = () => MessageArguments.FromObject(new Derived()); + + tuples.Should().Throw().WithParameterName("arguments").Which.Message.Should().Contain("A"); + MessageArguments + .FromTuples(("A", 1), ("a", 2)) + .Should() + .Equal(new MessageArgument(0, "A", 1), new MessageArgument(1, "a", 2)); + hiddenProperty + .Should() + .Throw() + .WithParameterName("source") + .Which.Message.Should() + .Contain("A"); + } + + [Fact] + public void EmptyNames_AreRejected() + { + var tuples = () => MessageArguments.FromTuples(("", 1)); + var dictionary = () => MessageArguments.FromDictionary(new Dictionary { [""] = 1 }); + + tuples.Should().Throw().WithParameterName("arguments"); + dictionary.Should().Throw().WithParameterName("arguments"); + } + + [Fact] + public void Empty_HasNoArguments_AndInstancesHaveNoIndexer() + { + MessageArguments.Empty.Should().BeEmpty().And.BeSameAs(MessageArguments.Empty); + + var arguments = MessageArguments.FromValues("x", 2); + + arguments.Count.Should().Be(2); + arguments.Should().NotBeAssignableTo>(); + typeof(MessageArguments).GetProperty("Item").Should().BeNull(); + } + + [Fact] + public void EveryFactory_AssignsPositionsInInputOrder() + { + MessageArguments + .FromValues(1, null, "x") + .Should() + .Equal( + new MessageArgument(0, null, 1), + new MessageArgument(1, null, null), + new MessageArgument(2, null, "x") + ); + MessageArguments.FromValues().Should().BeEmpty(); + MessageArguments + .FromTuples(("A", 1), ("B", null)) + .Should() + .Equal(new MessageArgument(0, "A", 1), new MessageArgument(1, "B", null)); + MessageArguments + .FromDictionary(new Dictionary { ["A"] = 1, ["B"] = 2 }) + .Should() + .Equal(new MessageArgument(0, "A", 1), new MessageArgument(1, "B", 2)); + MessageArguments + .FromObject(new { A = 1, B = "x" }) + .Should() + .Equal(new MessageArgument(0, "A", 1), new MessageArgument(1, "B", "x")); + } + + [Fact] + public void FromArguments_KeepsExplicitPositions_AndRejectsPositionsOutsideZeroToCountMinusOne() + { + var negative = () => MessageArguments.FromArguments(new MessageArgument(-1, null, "x")); + var gap = () => MessageArguments.FromArguments(new MessageArgument(1, null, "x")); + var repeated = () => MessageArguments.FromArguments(new(0, null, "x"), new(0, null, "y")); + + MessageArguments + .FromArguments(new(1, null, "b"), new(0, "A", "a")) + .Should() + .Equal(new MessageArgument(1, null, "b"), new MessageArgument(0, "A", "a")); + negative.Should().Throw().WithParameterName("arguments"); + gap.Should().Throw().WithParameterName("arguments"); + repeated.Should().Throw().WithParameterName("arguments"); + } + + [Fact] + public void FromObject_ReadsOnlyThePublicInstancePropertiesOfTheStaticType() + { + MessageArguments.FromObject(new WithStaticAndIndexer()).Should().Equal(new MessageArgument(0, "A", 7)); + MessageArguments.FromObject(new { A = 1 }).Should().BeEmpty(); + } + + [Fact] + public void FromTuples_MixesNamedAndPositionalArguments_WithNullNames() => + MessageArguments + .FromTuples((null, "a.txt"), ("Total", 10), (null, 3)) + .Should() + .Equal( + new MessageArgument(0, null, "a.txt"), + new MessageArgument(1, "Total", 10), + new MessageArgument(2, null, 3) + ); + + [Fact] + public void NullInput_Throws() + { + ((Action)(() => MessageArguments.FromValues((object?[])null!))).Should().Throw(); + ((Action)(() => MessageArguments.FromTuples(null!))).Should().Throw(); + ((Action)(() => MessageArguments.FromDictionary(null!))).Should().Throw(); + ((Action)(() => MessageArguments.FromObject(null!))).Should().Throw(); + } + + /// Declares A as an . + public class Base + { + /// Gets the base value. + public int A { get; } = 1; + } + + /// Hides A with a different property type, so reflection returns both. + public sealed class Derived : Base + { + /// Gets the derived value. + public new string A { get; } = "derived"; + } + + /// One instance property, one static property and an indexer. + public sealed class WithStaticAndIndexer + { + /// Gets a static value that must not become an argument. + public static int S { get; } = 2; + + /// Gets the instance value. + public int A { get; } = 7; + + /// Gets an indexed value that must not become an argument. + /// The index. + public int this[int index] => index + this.A; + } +} diff --git a/tests/ResXLocalization.Core.Tests/RefreshTriggeringRevisionTests.cs b/tests/ResXLocalization.Core.Tests/RefreshTriggeringRevisionTests.cs index ed49824..856b854 100644 --- a/tests/ResXLocalization.Core.Tests/RefreshTriggeringRevisionTests.cs +++ b/tests/ResXLocalization.Core.Tests/RefreshTriggeringRevisionTests.cs @@ -9,6 +9,16 @@ public class RefreshTriggeringRevisionTests { private readonly TestResources resources = new(); + [Fact] + public void EnumKeyBuilder_ANewInstanceAdvances_TheSameInstanceAgainDoesNot() + { + var localizer = this.resources.CreateLocalizer(); + Func builder = static (value, keyPrefix) => $"{keyPrefix}{value}"; + + AssertAdvances(localizer, () => localizer.EnumKeyBuilder = builder); + AssertDoesNotAdvance(localizer, () => localizer.EnumKeyBuilder = builder); + } + [Fact] public void EveryNoOp_DoesNotAdvance() { diff --git a/tests/ResXLocalization.Core.Tests/Resources/Catalog.de.resx b/tests/ResXLocalization.Core.Tests/Resources/Catalog.de.resx index 774caf8..6880770 100644 --- a/tests/ResXLocalization.Core.Tests/Resources/Catalog.de.resx +++ b/tests/ResXLocalization.Core.Tests/Resources/Catalog.de.resx @@ -37,4 +37,7 @@ Wörtliche {{Klammern}} für {0} + + {0} Elemente, aufsteigend + diff --git a/tests/ResXLocalization.Core.Tests/Resources/Catalog.resx b/tests/ResXLocalization.Core.Tests/Resources/Catalog.resx index fa88d97..f2b55f4 100644 --- a/tests/ResXLocalization.Core.Tests/Resources/Catalog.resx +++ b/tests/ResXLocalization.Core.Tests/Resources/Catalog.resx @@ -45,4 +45,13 @@ Literal {{braces}} for {0} + + {0} items, ascending + + + Custom ascending + + + File {0} of {1} + diff --git a/tests/ResXLocalization.WPF.Sample.Tests/LocalizeEnumConverterTests.cs b/tests/ResXLocalization.WPF.Sample.Tests/LocalizeEnumConverterTests.cs index 53d10c2..1159040 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/LocalizeEnumConverterTests.cs +++ b/tests/ResXLocalization.WPF.Sample.Tests/LocalizeEnumConverterTests.cs @@ -6,6 +6,27 @@ namespace RentADeveloper.ResXLocalization.WPF.Sample.Tests; /// public class LocalizeEnumConverterTests { + [Fact] + public void CustomEnumKeyBuilder_AppliesToTheConverter() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var original = Localizer.Current.EnumKeyBuilder; + Localizer.Current.EnumKeyBuilder = static (value, keyPrefix) => + (keyPrefix ?? "Text_") + value.GetType().Name + "." + value; + + try + { + Convert(new LocalizeEnumConverter(), FileSortOrder.Ascending, TestSupport.English) + .Should() + .Be("Custom ascending"); + } + finally + { + Localizer.Current.EnumKeyBuilder = original; + } + }); + [Fact] public void CustomPrefix_NoManager_ResolvesViaSearchAll() => WpfThread.Invoke(() => @@ -52,6 +73,22 @@ public void Default_NoPrefix_NoManager_ResolvesViaSearchAll() => Convert(LocalizeEnumConverter.Default, FileSortOrder.Ascending).Should().Be("Ascending"); }); + [Fact] + public void KeyPrefix_DefaultsToNull_AndAcceptsNull_ButNotOnTheSharedInstance() => + WpfThread.Invoke(() => + { + var converter = new LocalizeEnumConverter(); + converter.KeyPrefix.Should().BeNull(); + + converter.KeyPrefix = "Display_"; + converter.KeyPrefix = null; + + converter.KeyPrefix.Should().BeNull(); + ((Action)(() => LocalizeEnumConverter.Default.KeyPrefix = null)) + .Should() + .Throw(); + }); + [Fact] public void MultiBinding_SwitchesLive_OnCultureChange() => WpfThread.Invoke(() => diff --git a/tests/ResXLocalization.WPF.Sample.Tests/LocalizeEnumExtensionTests.cs b/tests/ResXLocalization.WPF.Sample.Tests/LocalizeEnumExtensionTests.cs index 221c73b..82111ee 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/LocalizeEnumExtensionTests.cs +++ b/tests/ResXLocalization.WPF.Sample.Tests/LocalizeEnumExtensionTests.cs @@ -21,6 +21,27 @@ public void BoundValue_SwitchesLive_OnCultureChange() => textBlock.Text.Should().Be("Absteigend"); }); + [Fact] + public void CustomEnumKeyBuilder_AppliesToTheExtension() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var original = Localizer.Current.EnumKeyBuilder; + Localizer.Current.EnumKeyBuilder = static (value, keyPrefix) => + (keyPrefix ?? "Text_") + value.GetType().Name + "." + value; + + try + { + var textBlock = TestSupport.BindLocalizedEnum(new(), FileSortOrder.Ascending); + + textBlock.Text.Should().Be("Custom ascending"); + } + finally + { + Localizer.Current.EnumKeyBuilder = original; + } + }); + [Fact] public void CustomPrefix_NoManager_ResolvesViaSearchAll() => WpfThread.Invoke(() => diff --git a/tests/ResXLocalization.WPF.Sample.Tests/Resources/Catalog.resx b/tests/ResXLocalization.WPF.Sample.Tests/Resources/Catalog.resx index 4aaa74d..0c6cbd1 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/Resources/Catalog.resx +++ b/tests/ResXLocalization.WPF.Sample.Tests/Resources/Catalog.resx @@ -44,4 +44,7 @@ Broken {0 + + Custom ascending + diff --git a/tests/package-consumption/AvaloniaConsumer/Program.cs b/tests/package-consumption/AvaloniaConsumer/Program.cs index 7ea40f0..ba7d543 100644 --- a/tests/package-consumption/AvaloniaConsumer/Program.cs +++ b/tests/package-consumption/AvaloniaConsumer/Program.cs @@ -5,7 +5,8 @@ // // This program is also the assertion set the Native AOT gate runs. Everything a trimmer can break and a // JIT cannot is here: satellite discovery, resource lookup by name, the typed keys' captured resource -// managers, the enum key convention, and composite formatting driven by an attached property. +// managers, the enum key convention, composite formatting driven by an attached property, and named +// arguments read by reflection in MessageArguments.FromObject. using System.Globalization; using Avalonia.Controls; @@ -41,11 +42,16 @@ void Check(string description, string actual, string expected) Check("search-all lookup", Localizer.Current.Get("Greeting"), "Hello from the package!"); Check( "scoped lookup into an unregistered resource manager", - Localizer.Current.Get("ScopedOnly", Catalog.ResourceManager), + Localizer.Current.Get("ScopedOnly", resourceManager: Catalog.ResourceManager), "Scoped to the catalog" ); Check("search-all cannot reach the unregistered manager", Localizer.Current.Get("ScopedOnly"), "!ScopedOnly!"); Check("enum localization by convention", Localizer.Current.Get(ConsumerSortOrder.Ascending), "Ascending"); +Check( + "named arguments from an object's properties", + Localizer.Current.Get(StringsKeys.ArgumentPair, MessageArguments.FromObject(new { A = "x", B = 2 })), + "x 2" +); // --- The compiled XAML, in English ------------------------------------------------------------------ @@ -96,7 +102,7 @@ void Check(string description, string actual, string expected) ); Check( "scoped key falls back to the neutral culture", - Localizer.Current.Get("ScopedNeutralOnly", Catalog.ResourceManager), + Localizer.Current.Get("ScopedNeutralOnly", resourceManager: Catalog.ResourceManager), "Catalog value with no German translation" ); Check("compiled XAML typed fallback", localizedView.FallbackText.Text ?? "", "Only in the neutral file"); diff --git a/tests/package-consumption/AvaloniaConsumer/Resources/Strings.resx b/tests/package-consumption/AvaloniaConsumer/Resources/Strings.resx index bfe6b1c..8287c9e 100644 --- a/tests/package-consumption/AvaloniaConsumer/Resources/Strings.resx +++ b/tests/package-consumption/AvaloniaConsumer/Resources/Strings.resx @@ -27,4 +27,7 @@ {0} people invited + + {0} {1} +