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/.agents/references/reviews/aot-package-compat.md b/.agents/references/reviews/aot-package-compat.md index efe2875..609db39 100644 --- a/.agents/references/reviews/aot-package-compat.md +++ b/.agents/references/reviews/aot-package-compat.md @@ -2,8 +2,9 @@ Use this whenever a change touches resource lookup, culture fallback, satellite discovery, the source generator's output, or the analyzer and `buildTransitive` wiring the packages carry. This is a -**standing** checklist: `ResXLocalization.Avalonia` and `ResXLocalization.Core` advertise Native AOT -support, and everything here exists to keep it from regressing. +**standing** checklist: `ResXLocalization.Core`, `ResXLocalization.Avalonia` and +`ResXLocalization.MessageFormat` advertise Native AOT support, and everything here exists to keep it +from regressing. Read [docs/guides/native-aot.md](../../../docs/guides/native-aot.md) before reviewing. @@ -13,11 +14,12 @@ This is a **review**: report findings, cite file and line, do not edit files. Trimming damage does not announce itself. A resource that is no longer found, a satellite assembly that is no longer loaded, a typed key whose `ResourceManager` was trimmed away — each produces a -binary that builds, starts, and answers with the `!key!` sentinel or the wrong language. Nothing is +binary that builds, starts, and answers with the `!key!` failure text or the wrong language. Nothing is trimmed on the just-in-time compiler, so **every test suite in this repository passes throughout**. -The only check that can see it is `scripts/verify-package-aot.ps1`, which publishes a package-only -consumer natively and runs it. A green test run is not evidence here. Neither is a native binary that +The only check that can see it is `scripts/verify-package-aot.ps1`, which publishes two package-only +consumers natively and runs them: `AvaloniaConsumer`, and `MessageFormatConsumer` for the ICU formatter +of `ResXLocalization.MessageFormat`. A green test run is not evidence here. Neither is a native binary that exists: the gate runs it and asserts exact strings for exactly this reason. ## Blocking findings @@ -31,9 +33,16 @@ exists: the gate runs it and asserts exact strings for exactly this reason. the requirement to the caller. But adding either to a public member is its own blocking finding — it changes what a consumer sees when they publish, and the packages promise they see nothing. -- **`IsAotCompatible` removed from `ResXLocalization.Core` or `ResXLocalization.Avalonia`**, or added - to `ResXLocalization.WPF`. The first two set it through `src/Directory.Build.props`; WPF is - excluded there deliberately, because WPF has no Native AOT. +- **`IsAotCompatible` removed from `ResXLocalization.Core`, `ResXLocalization.Avalonia` or + `ResXLocalization.MessageFormat`**, or added to `ResXLocalization.WPF`. The first three set it + through `src/Directory.Build.props`; WPF is excluded there deliberately, because WPF has no Native + AOT. + +- **A call to an `object args` overload of the `MessageFormat` library** in + `src/ResXLocalization.MessageFormat` - the extension `MessageFormatterExtensions.FormatMessage` or the + static `MessageFormatter.Format(string, object, CultureInfo?)`. Both read properties by reflection, + carry `[RequiresUnreferencedCode]` and lose every argument in a Native AOT binary. The package calls + only the `IReadOnlyDictionary` overload of `FormatMessage`. - **A new reflection call** — `Type.GetType`, `Assembly.Load`, `Activator.CreateInstance`, `GetMethod`, `GetProperty`, `MakeGenericType` — reachable from a public entry point without a @@ -42,8 +51,9 @@ exists: the gate runs it and asserts exact strings for exactly this reason. reflection site is a change of kind rather than of degree. - **Anything that makes a satellite assembly optional.** The German satellite is what the AOT gate - asserts on: a change that stops the consumer producing `de/AvaloniaConsumer.resources.dll`, or - stops the library finding it, is the exact failure this gate exists for. + asserts on: a change that stops a consumer producing `de/AvaloniaConsumer.resources.dll` or + `de/MessageFormatConsumer.resources.dll`, or stops the library finding it, is the exact failure this + gate exists for. ## The generator's output @@ -64,6 +74,28 @@ than published, so trimming cannot reach them — but packaging can drop them. generator carries `PrivateAssets="all"` and `ReferenceOutputAssembly="false"` for that reason; losing either leaks `Microsoft.CodeAnalysis.CSharp` into the package's dependency list. +## The build wiring on every supported SDK + +`build/ResXLocalization.Resx.targets` runs inside every consumer's build, on whichever SDK that consumer +has - the .NET 8 SDK as much as the one this repository pins - and inside WPF's temporary markup-compile +pass, a copy of the project named `__wpftmp` that WPF builds before the real one. Neither +is visible to a test suite here, which builds on the pinned SDK with the repository's own +`Directory.Build.props`. + +- **Anything the wiring or the generator derives from a project property must hold in the temporary + pass, on every supported SDK.** `MSBuildProjectName`, and every `RootNamespace` computed from it, + differ there; the whole mechanism, the fix and its limits are explained in the comment "WPF's temporary markup-compile pass" in `build/ResXLocalization.Resx.targets`. The fix + changes only `ManifestResourceName`, `LogicalName` and `CustomToolNamespace` of the targets file's own + copies of the `.resx` items. A change that removes it, widens it to `RootNamespace` or to other items + or metadata, or adds a new dependency on a property derived from the project name, is a finding. +- **A claim about what an SDK does is checked, not assumed.** Cite the targets file or the task of each + supported SDK that the claim is about, with file and line - for example + `sdk//Sdks/Microsoft.NET.Sdk.WindowsDesktop/targets/Microsoft.WinFX.targets`. A claim you + could not check is reported as unchecked, never as a fact. +- **What catches it:** the "Package consumers (.NET 8 SDK)" step of `scripts/pre-release-gate.ps1`, + and CI's "Consume packages on .NET 8 SDK" job, through `WpfConsumer` and `WpfDerivedNamespaceConsumer` + (the second also on the pinned SDK). + ## What the tooling cannot tell you - **`TrimmerSingleWarn`.** Left at its default, ILC collapses every diagnostic from an assembly into @@ -84,8 +116,9 @@ check would have caught it. Where the answer needs a build, a pack or the AOT ga name the command: ```text -pwsh -File scripts/verify-package-aot.ps1 -Pack # both frameworks, from a fresh pack -pwsh -File scripts/verify-package-aot.ps1 -Framework net8.0 # the LTS floor alone +pwsh -File scripts/verify-package-aot.ps1 -Pack # both consumers on net10.0, from a fresh pack +pwsh -File scripts/verify-package-aot.ps1 -Framework net8.0 # both consumers on the LTS floor +pwsh -File scripts/pre-release-gate.ps1 -SkipNativeAot -SkipDocumentation # every consumer, pinned SDK and .NET 8 SDK ``` Do not run them yourself, and do not fix what you found. diff --git a/.agents/references/reviews/ui-parity.md b/.agents/references/reviews/ui-parity.md index c8c5be9..08617dc 100644 --- a/.agents/references/reviews/ui-parity.md +++ b/.agents/references/reviews/ui-parity.md @@ -15,14 +15,19 @@ both, so anything that is not framework-specific belongs there rather than in on | Concern | Avalonia | WPF | | --- | --- | --- | | Localize a key | `LocalizeExtension` | `LocalizeExtension` | -| Localize an enum in a template | `LocalizeEnumExtension` | `LocalizeEnumExtension` | -| Localize an enum as a bound value | `LocalizeEnumConverter` | `LocalizeEnumConverter` | -| Composite-format arguments | `LocalizeArgs.Arg0`…`Arg8` | `LocalizeArgs.Arg0`…`Arg8` | +| Localize an enum value | `LocalizeEnumExtension` | `LocalizeEnumExtension` | +| Localize an enum in a hand-written `MultiBinding` | `LocalizeEnumConverter` | `LocalizeEnumConverter` | +| Composite-format arguments | `Argument` children in `LocalizeExtension.Arguments` | `Argument` children in `LocalizeExtension.Arguments` | +| Assigning a binding to `Argument.Value`, `LocalizeEnumExtension.Value` or `LocalizeExtension.Key` instead of applying it | `[AssignBinding]` on the property | the `XamlSetMarkupExtension` handler of `Argument`, of `LocalizeEnumExtension` and of `LocalizeExtension` | +| An argument, enum-value or key binding that is a `MultiBinding` or a `PriorityBinding` | accepted | rejected by `ProvideValue`: WPF's `MultiBinding` accepts only plain `Binding` children | +| Select the previewer's language | `DesignTime.Culture`, an attached property on `AvaloniaObject`; design mode from `Design.IsDesignMode` | `DesignTime.Culture`, an attached property on `DependencyObject`; design mode from the internal `DesignMode.IsInDesignMode` | +| A designer that supplies the target property as a CLR property (Rider's WPF previewer) | no fallback: the Avalonia previewer evaluates bindings | deliberate difference: `LocalizeExtension` and `LocalizeEnumExtension` return the lookup without arguments as a string, before validation, when `DesignMode.ShouldReturnDesignTimeText` is true | Where the frameworks genuinely differ, the difference is expected to be **local and explained**: -Avalonia binds through `AvaloniaProperty` and an observable, WPF through `DependencyProperty` and a -`MultiBinding`; Avalonia subscribes with weak events, WPF relies on its own weak binding-target -references; WPF has no Native AOT. None of those is a licence for a different public API. +`LocalizeExtension` and `LocalizeEnumExtension` build their `MultiBinding` through +`LocalizedMultiBindingBuilder` in both packages, but from framework-specific bindings (a +`CompiledBinding` in Avalonia, a `Binding` in WPF); WPF has no Native AOT. None of those is a licence for +a different public API. ## What to check diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5e1e6de..5aea91c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -8,12 +8,14 @@ body: label: Package description: > Which package is affected? ResXLocalization.Core is the shared engine both UI packages depend on; - pick it when the problem shows in a lookup made from code rather than from markup. "Not sure" is a - real answer and is fine. + pick it when the problem shows in a lookup made from code rather than from markup. Pick + ResXLocalization.MessageFormat when the problem shows only after Localizer.Current.UseMessageFormat(). + "Not sure" is a real answer and is fine. options: - ResXLocalization.Avalonia - ResXLocalization.WPF - ResXLocalization.Core + - ResXLocalization.MessageFormat - More than one, or not sure validations: required: true @@ -21,7 +23,7 @@ body: id: version attributes: label: Package version - placeholder: "1.1.0" + placeholder: "2.0.0" validations: required: true - type: dropdown @@ -34,7 +36,7 @@ body: - Scoped (Key + ResourceManager) - Search-all (key only) - Enum ({l:LocalizeEnum} / LocalizeEnumConverter) - - Dynamic format arguments (LocalizeArgs.Arg0…Arg8) + - Dynamic format arguments () - Culture fallback / satellite discovery - Source generator / build - Other / not applicable diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d02f00..1b8898d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,7 @@ env: DOTNET_CLI_TELEMETRY_OPTOUT: true DOTNET_NOLOGO: true DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + MESSAGEFORMAT_CONSUMER_PROJECT: tests/package-consumption/MessageFormatConsumer/MessageFormatConsumer.csproj NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages NUGET_CONFIG: NuGet.config PACKAGE_OUTPUT: artifacts/packages @@ -414,11 +415,22 @@ jobs: working-directory: ${{ env.CONSUMER_DIRECTORY }} run: dotnet run --project AvaloniaConsumer/AvaloniaConsumer.csproj --framework net8.0 -p:TargetFrameworks=net8.0 --configuration ${{ env.CONFIGURATION }} -p:ResXLocalizationVersion=${{ needs.package-and-docs.outputs.version }} + - name: Run MessageFormat package consumer (net8.0 SDK) + working-directory: ${{ env.CONSUMER_DIRECTORY }} + run: dotnet run --project MessageFormatConsumer/MessageFormatConsumer.csproj --framework net8.0 -p:TargetFrameworks=net8.0 --configuration ${{ env.CONFIGURATION }} -p:ResXLocalizationVersion=${{ needs.package-and-docs.outputs.version }} + - name: Run WPF package consumer (net8.0 SDK) if: runner.os == 'Windows' working-directory: ${{ env.CONSUMER_DIRECTORY }} run: dotnet run --project WpfConsumer/WpfConsumer.csproj --configuration ${{ env.CONFIGURATION }} -p:ResXLocalizationVersion=${{ needs.package-and-docs.outputs.version }} + # RootNamespace derived from the project name, which WPF's temporary markup-compile pass gets wrong; + # see the comment "WPF's temporary markup-compile pass" in build/ResXLocalization.Resx.targets. + - name: Run WPF package consumer with a derived RootNamespace (net8.0 SDK) + if: runner.os == 'Windows' + working-directory: ${{ env.CONSUMER_DIRECTORY }} + run: dotnet run --project WpfDerivedNamespaceConsumer/WpfDerivedNamespaceConsumer.csproj --configuration ${{ env.CONFIGURATION }} -p:ResXLocalizationVersion=${{ needs.package-and-docs.outputs.version }} + - name: Run combined Avalonia and WPF package consumer (net8.0 SDK) if: runner.os == 'Windows' working-directory: ${{ env.CONSUMER_DIRECTORY }} @@ -431,12 +443,12 @@ jobs: shell: bash run: rm -f "${CONSUMER_DIRECTORY}/global.json" - # The packed-package Native AOT gate. It publishes the Avalonia consumer with PublishAot, requires zero - # IL2xxx/IL3xxx diagnostics, and RUNS the native binary - a file that exists proves nothing, because - # everything trimming can break (a satellite that is no longer loaded, a resource name that no longer - # resolves) produces a binary that starts and answers wrongly. + # The packed-package Native AOT gate. It publishes AvaloniaConsumer and MessageFormatConsumer with + # PublishAot, requires zero IL2xxx/IL3xxx diagnostics, and RUNS each native binary - a file that exists + # proves nothing, because everything trimming can break (a satellite that is no longer loaded, a + # resource name that no longer resolves) produces a binary that starts and answers wrongly. # - # Three legs. The trimming behaviour under test belongs to the ILC compiler and does not vary by host, + # Three runs. The trimming behaviour under test belongs to the ILC compiler and does not vary by host, # so net8.0 - the LTS floor the packages promise - is exercised on Linux, and net10.0 on both. verify-package-aot: name: Verify packed-package Native AOT (${{ matrix.framework }}, ${{ matrix.runtime }}) @@ -466,7 +478,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: - # The 8.0 runtime is what the net8.0 leg's build-time tooling runs on. + # The 8.0 runtime is what the build-time tooling of the net8.0 run runs on. dotnet-version: | 8.0.x 10.0.x @@ -522,7 +534,8 @@ jobs: path: ${{ env.PACKAGE_OUTPUT }} # The script is the gate, not this job: it validates the artifact set from each nuspec, isolates the - # consumer package cache, publishes, requires zero IL diagnostics, and runs the binary. + # consumer package cache, then publishes both consumers, requires zero IL diagnostics, and runs each + # binary. - name: Native AOT gate run: pwsh -NoProfile -NonInteractive -File scripts/verify-package-aot.ps1 -Framework ${{ matrix.framework }} -Runtime ${{ matrix.runtime }} -PackageVersion ${{ needs.package-and-docs.outputs.version }} -Configuration ${{ env.CONFIGURATION }} @@ -611,7 +624,7 @@ jobs: run: | tag_version="${GITHUB_REF_NAME#v}" status=0 - for id in ResXLocalization.Core ResXLocalization.Avalonia ResXLocalization.WPF; do + for id in ResXLocalization.Core ResXLocalization.Avalonia ResXLocalization.MessageFormat ResXLocalization.WPF; do if [[ ! -f "${PACKAGE_OUTPUT}/${id}.${tag_version}.nupkg" ]]; then echo "::error title=Tag/version mismatch::Tag ${GITHUB_REF_NAME} expects ${id}.${tag_version}.nupkg, but the build produced:" ls -1 "${PACKAGE_OUTPUT}" diff --git a/AGENTS.md b/AGENTS.md index aaece46..2a41825 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,21 +9,26 @@ together. ## What this is ResXLocalization is a .NET library that gives XAML applications type-safe `.resx` localization with live -language switching. It ships as two NuGet packages — `ResXLocalization.Avalonia` and `ResXLocalization.WPF` — -over one shared, UI-agnostic engine. Consumers keep the `.resx` files and editors they already have. +language switching. It ships as two UI packages — `ResXLocalization.Avalonia` and `ResXLocalization.WPF` — +over one shared, UI-agnostic engine, `ResXLocalization.Core`, plus the opt-in package +`ResXLocalization.MessageFormat`, which formats every localized text as ICU MessageFormat. Consumers keep +the `.resx` files and editors they already have. Two things separate it from plain `.resx`: -- **Compile-checked keys.** A source generator turns every eligible `.resx` into a `Keys` class of - `ResourceKey` fields, each carrying both its key name and the `ResourceManager` it belongs to. A renamed or - deleted resource becomes a build error instead of a runtime surprise. +- **Compile-checked keys.** A source generator turns every neutral `.resx`, with or without a `.Designer.cs`, + into a `Keys` class with its own `ResourceManager` property and one `ResourceKey` field per string + entry, each carrying both its key name and that `ResourceManager`. A renamed or deleted resource becomes a + build error instead of a runtime surprise. - **Live culture switching.** Assigning `Localizer.Current.CurrentCulture` re-resolves every `{l:Localize}` binding in place — no window reload, no view rebuild, no restart. Around those: three lookup modes (typed, scoped, and search-all across registered resource managers) over -.NET's own culture fallback chain, enum localization by naming convention, format arguments in code and in -XAML, a `!key!` sentinel plus a `TranslationNotFound` event for missing translations, and Native AOT and -trimming support on the Avalonia side (WPF supports neither). +.NET's own culture fallback chain, enum localization by naming convention, format arguments in code +(`MessageArguments`) and in XAML (`` children of `{l:Localize}` and `{l:LocalizeEnum}`), one +replaceable message formatter for every lookup, a `!key!` failure text plus a `LookupFailed` event for +missing translations and formatting failures, and Native AOT and trimming support on the Avalonia side (WPF +supports neither). [`README.md`](README.md) is the user-facing version of the same story, with the quick start and the examples. @@ -31,12 +36,13 @@ trimming support on the Avalonia side (WPF supports neither). | Path | What it is | | --- | --- | -| `src/ResXLocalization.Core` | The UI-agnostic engine: `ILocalizer`, `Localizer`, `ResourceKey`, the events. Both UI packages depend on it, and one copy satisfies both | -| `src/ResXLocalization.SourceGenerators` | The incremental generator that emits the typed `…Keys` classes. `netstandard2.0`. Ships **inside** the UI packages as an analyzer; it is not a package of its own | +| `src/ResXLocalization.Core` | The UI-agnostic engine: `ILocalizer`, `Localizer`, `ResourceKey`, `MessageArguments`, the message formatter, the events. Both UI packages depend on it, and one copy satisfies both | +| `src/ResXLocalization.MessageFormat` | The opt-in ICU MessageFormat formatter, installed with `Localizer.Current.UseMessageFormat()`. Depends on Core and the `MessageFormat` library, not on a UI package | +| `src/ResXLocalization.SourceGenerators` | The incremental generator that emits the typed `…Keys` classes. It reads each `.resx` and the MSBuild resource name the build wiring passes for it, and needs no `.Designer.cs`. `netstandard2.0`. Ships **inside** the UI packages as an analyzer; it is not a package of its own | | `src/ResXLocalization.Avalonia` | The Avalonia markup extensions and converter, plus the packaged generator and resx wiring | | `src/ResXLocalization.WPF` | The same for WPF. Windows-only, no Native AOT | | `samples/` | Two runnable showcases, one per UI framework. Excluded from coverage | -| `tests/` | Four suites: Core (both frameworks), the generator, and one sample-test suite per UI framework | +| `tests/` | Five suites: Core and `tests/ResXLocalization.MessageFormat.Tests` (both on both frameworks), the generator, and one sample-test suite per UI framework | | `tests/package-consumption/` | Package-only consumers. The repository's `Directory.Build.*` deliberately does **not** apply there | | `build/` | The shared resx and packaging targets, and the DocFX configuration | | `scripts/` | Every check and workflow, as PowerShell 7 | @@ -103,8 +109,8 @@ change rendering rather than layout, are in `__wpftmp` name, and `_TargetAssemblyProjectName` carries the real one. The root `Directory.Build.props` resolves `_ConventionProjectName` from it, and every condition that has to hold in that pass tests that property rather than `MSBuildProjectName`. -- **Native AOT covers Core and Avalonia only.** WPF has no AOT support, and `src/Directory.Build.props` - excludes it from `IsAotCompatible` deliberately. +- **Native AOT covers Core, Avalonia and MessageFormat only.** WPF has no AOT support, and + `src/Directory.Build.props` excludes it from `IsAotCompatible` deliberately. - **The package consumers are outside everything.** `tests/package-consumption/` carries empty `Directory.Build.props` and `Directory.Build.targets` files that stop MSBuild's upward search, so those projects receive the library only from the packed packages. Do not add a project reference, a repository @@ -116,18 +122,21 @@ change rendering rather than layout, are in | --- | --- | --- | | One UI package changed | mirror it into the other, and mirror the tests | `ui_parity_reviewer` | | Resource lookup, culture fallback, satellite discovery, the generator's output, or the packaging that carries them | `pwsh -File scripts/verify-package-aot.ps1 -Pack` | `aot_package_compat_reviewer` | -| The package contents, the `buildTransitive` wiring, or a dependency version | pack, then run each project under `tests/package-consumption` | — | +| The package contents, the `buildTransitive` wiring, the generator's output, or a dependency version | `pwsh -File scripts/pre-release-gate.ps1 -SkipNativeAot -SkipDocumentation`: packs, then runs every project under `tests/package-consumption` on the pinned SDK and again on the .NET 8 SDK | `aot_package_compat_reviewer` | + +The .NET 8 SDK step needs an 8.0 SDK installed next to the pinned one. It exists because the SDKs differ in +more than their compiler - for example in WPF's temporary markup-compile pass, see the comment "WPF's temporary markup-compile pass" in `build/ResXLocalization.Resx.targets`. Nothing is trimmed on the just-in-time compiler, so **no test suite in this repository can see trimming -damage**. The AOT gate publishes a package-only consumer natively and runs it; that is the only check that -can. See [`docs/guides/native-aot.md`](docs/guides/native-aot.md). +damage**. The AOT gate publishes the package-only consumers `AvaloniaConsumer` and `MessageFormatConsumer` +natively and runs them; that is the only check that can. See [`docs/guides/native-aot.md`](docs/guides/native-aot.md). Invoke a reviewer only when its scope applies. Both are read-only by construction — see [`.agents/README.md`](.agents/README.md). ## Public API -The three runtime projects declare their public surface in `PublicAPI.Shipped.txt` and +The four runtime projects declare their public surface in `PublicAPI.Shipped.txt` and `PublicAPI.Unshipped.txt`. An undeclared public member is `RS0016`; a declared one that is gone is `RS0017`. Both are build errors. @@ -142,6 +151,26 @@ equivalent is analyzer release tracking in `AnalyzerReleases.*.md`. `Directory.Build.props`, the release date, promoting `Unshipped` to `Shipped`, and the tag are the maintainer's, at release time. +## Pending maintainer tasks for the release after 2.0.0 + +The package validation is set up for the 2.0.0 release: its baseline is 1.1.0, and +`ResXLocalization.MessageFormat`, which has no 1.1.0 version, is excluded from the baseline. When the +maintainer prepares the next release after 2.0.0 - a `` bump, a dated changelog section, or a +question about releasing - remind them of these steps, as part of `CONTRIBUTING.md`'s release step 3. Do +not do them yourself; they are the maintainer's, like the version. + +1. Set `PackageValidationBaselineVersion` in `src/Directory.Build.props` to `2.0.0`. +2. In the same file, remove `AND '$(_ConventionProjectName)' != 'ResXLocalization.MessageFormat'` from the + condition of the `PropertyGroup` that sets `PackageValidationBaselineVersion`, and remove the comment + about `ResXLocalization.MessageFormat` in it. Keep the group and the property: the condition becomes + `'$(_IsShippingPackage)' == 'true'`, so the MessageFormat package is validated against 2.0.0 like the + others. +3. Delete `CompatibilitySuppressions.xml` in `src/ResXLocalization.Core`, `src/ResXLocalization.Avalonia` + and `src/ResXLocalization.WPF`: they accept breaks against the 1.1.0 baseline only. Then run + `dotnet pack ResXLocalization.slnx -c Release` once to confirm that package validation passes without + them. +4. Remove this section from `AGENTS.md`. + ## Commits and branches [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) with a lowercase, imperative summary: diff --git a/CHANGELOG.md b/CHANGELOG.md index 8168561..28cd0bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,131 @@ this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [2.0.0] - 2026-10-20 + +### Added + +- `IMessageFormatter` and `ILocalizer.MessageFormatter`: one replaceable formatter that every resolved + value passes through, with or without arguments. The default, `StringFormatMessageFormatter`, uses + `string.Format` and does not format a value without arguments. +- `MessageArgument`, one argument of a lookup: its position, an optional name and its value. +- `ILocalizer.LookupFailed` with `LookupFailedEventArgs`, raised for every failed lookup: a key that no + resource file contains, or a value the formatter cannot format. +- `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, 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. +- Typed keys for every neutral `.resx`, with or without a `.Designer.cs`: files without a designer, + neutral files with a dot in their name and projects using `GenerateResxSource` produce keys classes. + Without a designer, the keys class takes its namespace from the file's `CustomToolNamespace` when it + is set. +- The generated `Keys.ResourceManager` property: the resource manager of that `.resx`, which every + key of the class uses. Register it for search-all lookups and pass it for scoped lookups. +- Generator warnings `RXLGEN003` (a `.resx` reached the generator without its MSBuild resource name) + and `RXLGEN004` (two neutral `.resx` files would produce the same keys class). +- Format arguments on the binding, in both UI packages: `` children of `{l:Localize}`, each + with a constant or bound `Value` and an optional `Name`. There is no upper limit, each localized + property has its own arguments, and arguments work in style setters, resources and templates. +- `{l:Localize}` re-resolves on every change of `ILocalizer.RefreshTriggeringRevision` - a new formatter, + a new lookup-failure text builder, a registered or removed resource manager - not only on a language + switch. +- Format arguments for enum labels in XAML (issue 11): `` children of `{l:LocalizeEnum}`, and + `MultiBinding` children of `LocalizeEnumConverter` from index 2 on. +- `LocalizeEnumExtension.Value`: the enum value to localize, a binding or a fixed member + (`Value={x:Static m:FileSortOrder.Ascending}`), and the shorthand `{l:LocalizeEnum {Binding SortOrder}}` + through the new constructor `LocalizeEnumExtension(object value)`. +- A bound key for `{l:Localize}`, in both UI packages: `{l:Localize {Binding Key}}` or + `Key="{Binding Key}"` takes the key from the view model, as a `ResourceKey` or a `string`. A bound value + that gives no key, such as `null`, renders an empty string instead of throwing. +- `LocalizeEnumConverter.ArgumentNames`: comma-separated names for the converter's arguments, for a + formatter that understands named arguments. +- `{l:LocalizeEnum}` re-resolves on every change of `ILocalizer.RefreshTriggeringRevision`, and so does + `LocalizeEnumConverter` with its new refresh-trigger child. +- The opt-in package `ResXLocalization.MessageFormat`: ICU MessageFormat (`plural`, `select`) through + `Localizer.Current.UseMessageFormat()`, which parses every resolved value as an ICU message, with or + without arguments, optionally with a configured `MessageFormatter` of the `MessageFormat` library. See + [ICU MessageFormat](docs/guides/message-format.md). +- `DesignTime.Culture`, an attached property in both UI packages that selects the language of XAML + previewers (``). It sets `Localizer.Current.CurrentCulture` inside + a previewer and has no effect at run time. See [Design time and previewers](docs/guides/design-time.md). +- A workaround for a defect in Rider's WPF XAML previewer (RIDER-143207): localized strings, which + rendered nothing there, now render their raw resource pattern under the default formatter, for example + `{0} people invited`. See [Design time and previewers](docs/guides/design-time.md). + +### Changed + +- **BREAKING:** A formatting failure in `Get(key, args)` and its scoped and typed siblings returns the + failure text and raises `ILocalizer.LookupFailed`; it no longer throws `FormatException`. See + the [migration guide](docs/guides/migration-2.0.md). +- **BREAKING:** In XAML, a formatting failure in `{l:Localize}` renders the failure text instead of an + Avalonia binding error or an empty WPF text. See the [migration guide](docs/guides/migration-2.0.md). +- **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` 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 + [migration guide](docs/guides/migration-2.0.md). +- **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). +- **BREAKING:** Typed keys use the generated `Keys.ResourceManager`, no longer the designer's + `.ResourceManager`; `ReferenceEquals(AppStringsKeys.Greeting.Manager, AppStrings.ResourceManager)` + is now `false`. See the [migration guide](docs/guides/migration-2.0.md). +- **BREAKING:** Files without a designer, neutral files with a dot in their name and projects using + `GenerateResxSource` now produce keys classes, which can collide with types of your own. See the + [migration guide](docs/guides/migration-2.0.md). +- **BREAKING:** Two neutral `.resx` files that produce the same keys class report `RXLGEN004` and + generate neither class, instead of merging into one partial class. See the + [migration guide](docs/guides/migration-2.0.md). +- **BREAKING:** `Localizer.RegisterResourceManager` throws `ArgumentException` naming the base name for a + plain `ResourceManager` for which no resources exist under its base name, instead of registering it and + letting every search-all lookup that reaches it throw `MissingManifestResourceException`. Fix the base + name, or register `Keys.ResourceManager`. See the [migration guide](docs/guides/migration-2.0.md). +- **BREAKING:** `LocalizeExtension.Key` is an `object?` that takes a `string` or a `ResourceKey`; a key + that is neither throws `InvalidOperationException` when the view loads instead of producing an empty + key. See the [migration guide](docs/guides/migration-2.0.md). +- **BREAKING:** Avalonia: `{l:Localize}` with arguments in a `Style` setter or a resource is formatted; + it resolved unformatted. See the [migration guide](docs/guides/migration-2.0.md). +- **BREAKING:** `LocalizeEnumConverter` reads child 0 of its `MultiBinding` as the refresh trigger + (`RefreshTriggeringRevision` of `Localizer.Current`), child 1 as the enum value and children from + index 2 on as format arguments; a `MultiBinding` with the enum first renders an empty string until it + is rewritten. See the [migration guide](docs/guides/migration-2.0.md). + +### Removed + +- The generator warning `RXLGEN002` (a `.resx` without a recognized classic designer): a designer is no + longer required. +- **BREAKING:** `LocalizeArgs` and its attached properties `Arg0`...`Arg8`, in both UI packages; give + `{l:Localize}` `` children instead. See the [migration guide](docs/guides/migration-2.0.md). +- **BREAKING:** `LocalizeExtension.ResourceKey`; set `Key` to the `ResourceKey` instead. See the [migration guide](docs/guides/migration-2.0.md). +- **BREAKING:** The `DataContext` mode of `{l:LocalizeEnum}`: it no longer reads the enum value from the + target's `DataContext`; set `Value`, as `Value={Binding}` in item templates. See the + [migration guide](docs/guides/migration-2.0.md). + ## [1.1.0] - 2026-07-20 ### Added @@ -37,6 +162,7 @@ this project adheres to [Semantic Versioning](https://semver.org/). - Available-culture discovery for language pickers: `GetAvailableCultures()`. - Native AOT / trimming support for the Avalonia package and the shared engine. -[unreleased]: https://github.com/rent-a-developer/ResXLocalization/compare/v1.1.0...HEAD +[unreleased]: https://github.com/rent-a-developer/ResXLocalization/compare/v2.0.0...HEAD +[2.0.0]: https://github.com/rent-a-developer/ResXLocalization/releases/tag/v2.0.0 [1.1.0]: https://github.com/rent-a-developer/ResXLocalization/releases/tag/v1.1.0 [1.0.0]: https://github.com/rent-a-developer/ResXLocalization/releases/tag/v1.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d8b6bea..0f00a0d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,7 +19,7 @@ This project has a [Code of Conduct](CODE_OF_CONDUCT.md); by participating, you | You need | For | | --- | --- | | **.NET 10 SDK** | Everything. It is pinned in `global.json` | -| **.NET 8 SDK** | Running the Core tests on `net8.0`, and reproducing the minimum-SDK consumer checks | +| **.NET 8 SDK** | Running the Core and MessageFormat tests on `net8.0`, and reproducing the minimum-SDK consumer checks | | **PowerShell 7** (`pwsh`) | Every script in `scripts/`. They declare `#requires -Version 7.0` and stop on the first line under an older host | | **Windows** | The WPF projects, the WPF tests, and the full package and documentation build | | **A C++ toolchain** | The Native AOT gate only. MSVC and the Windows SDK on Windows; `clang` and `zlib1g-dev` on Linux | @@ -45,14 +45,14 @@ dotnet build ResXLocalization.NonWindows.slnf -c Release ``` ```shell -# Test. One command; dotnet test discovers the suites and every target framework, so the Core suite -# runs on net8.0 and net10.0 from here. +# Test. One command; dotnet test discovers the suites and every target framework, so the Core and +# MessageFormat suites run on net8.0 and net10.0 from here. dotnet test ResXLocalization.slnx -c Release # Windows dotnet test ResXLocalization.NonWindows.slnf -c Release # Linux/macOS, without the WPF suite ``` -There are four suites: the Core engine (on both target frameworks), the source generator, and one -sample-driven suite per UI framework. The Avalonia suite runs headless; the WPF suite drives a real +There are five suites: the Core engine and the ICU MessageFormat formatter (each on both target +frameworks), the source generator, and one sample-driven suite per UI framework. The Avalonia suite runs headless; the WPF suite drives a real dispatcher on its own thread and is Windows-only. ### One optional per-clone setting @@ -88,18 +88,22 @@ The script names them, with their trigger and their command, in its own summary | Run it when the change touches | Command | | --- | --- | | Resource lookup, culture fallback, satellite discovery, the generated keys, or the packaging that carries them | `pwsh -File scripts/verify-package-aot.ps1 -Pack` | -| What the packages contain, the `buildTransitive` wiring, or a dependency version | `pwsh -File scripts/pre-release-gate.ps1 -SkipNativeAot -SkipDocumentation`, which packs and then runs every consumer, on both of AvaloniaConsumer's target frameworks | +| What the packages contain, the `buildTransitive` wiring, the generated keys, or a dependency version | `pwsh -File scripts/pre-release-gate.ps1 -SkipNativeAot -SkipDocumentation`, which packs and then runs every consumer on the pinned SDK, on both target frameworks of AvaloniaConsumer and MessageFormatConsumer, and again on the .NET 8 SDK, on net8.0 | **`pre-release-gate.ps1`** is everything CI checks that can honestly be checked on your machine, in CI's order, stopping at the first failure: the ignored-revision check, line endings, tidiness, a Release build, every test suite, the DocFX metadata and site build with `--warningsAsErrors`, the pack with package -validation, each Native AOT leg this host can run, and the package consumers against the packages just -packed. It ends in one line: `PASSED: All checks passed.` or `FAILED: Check failed. See output.` +validation, the Native AOT gate for each framework and runtime this host can build, and the package +consumers against the packages just packed - on the pinned SDK, and again on the .NET 8 SDK, the way CI's +minimum-SDK job builds them. The SDKs differ in more than their compiler - for example in WPF's +temporary markup-compile pass, see the comment "WPF's temporary markup-compile pass" in `build/ResXLocalization.Resx.targets`. It ends in one line: +`PASSED: All checks passed.` or `FAILED: Check failed. See output.` Its comment-based help lists every CI job it does **not** reproduce, and why. Read that before treating a green run as a promise that CI will be green. -`-SkipNativeAot`, `-SkipConsumers` and `-SkipDocumentation` cover the checks with heavy prerequisites. +`-SkipNativeAot`, `-SkipConsumers`, `-SkipMinimumSdk` and `-SkipDocumentation` cover the checks with heavy +prerequisites. ## Checking versus fixing @@ -164,20 +168,20 @@ Anything that is not framework-specific belongs in `ResXLocalization.Core`, wher ## Native AOT -`ResXLocalization.Avalonia` and `ResXLocalization.Core` publish with `PublishAot=true` and produce no IL2xxx -or IL3xxx diagnostic, from anywhere. Keep it that way: no reflection over resources, no new suppressed IL +`ResXLocalization.Avalonia`, `ResXLocalization.Core` and `ResXLocalization.MessageFormat` publish with +`PublishAot=true` and produce no IL2xxx or IL3xxx diagnostic, from anywhere. Keep it that way: no reflection over resources, no new suppressed IL diagnostic, and no `[RequiresUnreferencedCode]` or `[RequiresDynamicCode]` on a public member. **Nothing is trimmed on the just-in-time compiler, so no test suite here can see trimming damage.** A satellite that is no longer loaded or a resource name that no longer resolves produces a binary that builds, -starts and answers wrongly. `scripts/verify-package-aot.ps1` publishes a package-only consumer natively and -**runs** it, asserting exact strings; that is the only check that can see it. +starts and answers wrongly. `scripts/verify-package-aot.ps1` publishes the package-only consumers +`AvaloniaConsumer` and `MessageFormatConsumer` natively and **runs** them, asserting exact strings; that is the only check that can see it. The WPF package is exempt — WPF does not support Native AOT. ## Public API -The three runtime projects track their public surface with +The four runtime projects track their public surface with [PublicApiAnalyzers](https://github.com/dotnet/roslyn/tree/main/src/RoslynAnalyzers/PublicApiAnalyzers). An undeclared public member is `RS0016` and a declared one that is gone is `RS0017` — both build errors here. diff --git a/Directory.Build.props b/Directory.Build.props index b0fa9dd..bbca462 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -17,7 +17,7 @@ publish job verifies that the pushed v* tag matches this value before anything reaches NuGet.org. The samples and tests inherit it too; they are never packed, so the only effect there is that their assembly version tracks the repository's. --> - 1.1.0 + 2.0.0 diff --git a/Directory.Packages.props b/Directory.Packages.props index b03d98f..21248f4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,8 @@ + + @@ -64,8 +66,9 @@ diff --git a/PACKAGE_README.md b/PACKAGE_README.md index 6a71c4c..e8f654a 100644 --- a/PACKAGE_README.md +++ b/PACKAGE_README.md @@ -10,12 +10,13 @@ you keep the `.resx` files and editors you already use, and get **compile-checke ## Highlights - ⚡ **Live, no-reload language switching** - set one property and every bound string updates in place. -- 🔒 **Type-safe, compile-checked keys** - a source generator turns every `.resx` into strongly-typed keys, so a renamed or deleted resource becomes a **compile error**, not a runtime surprise. +- 🔒 **Type-safe, compile-checked keys** - a source generator turns every neutral `.resx` into strongly-typed keys, with or without a `.Designer.cs`, so a renamed or deleted resource becomes a **compile error**, not a runtime surprise. - 📦 **Zero configuration** - install the package and build; the source generator and MSBuild wiring are included. - 🗂️ **First-class multiple `.resx` support** - look up by typed key, scope to one file, or search across all registered files. - 🔤 **Enum localization built in** - localize enum members by naming convention. -- 🧮 **Format arguments** - `Get(key, args…)` formats in the active culture; in XAML, bind the `LocalizeArgs.Arg0`…`Arg8` attached properties. -- 🩺 **Missing-translation diagnostics** - a visible, configurable `!key!` sentinel plus a `TranslationNotFound` event. +- 🧮 **Format arguments on the binding** - in XAML, give `{l:Localize}` and `{l:LocalizeEnum}` `` children with constant or bound values, so enum labels take arguments too; in code, pass `MessageArguments.FromValues(…)` to `Localizer.Current.Get`. +- 🔢 **ICU MessageFormat, opt-in** - `plural` and `select` messages such as `1 file` / `5 files`, in code and in XAML, with the separate package `ResXLocalization.MessageFormat`. +- 🩺 **Lookup-failure diagnostics** - a visible, configurable `!key!` failure text plus a `LookupFailed` event, for a missing translation and for a value that cannot be formatted. - 🌐 **Language-picker ready** - `GetAvailableCultures()` discovers the cultures your app actually ships. - 🚀 **Native AOT & trim clean** (Avalonia package) - no reflection over your resources. @@ -36,27 +37,35 @@ source generator and the same MSBuild wiring, all of which arrive with the packa `ResXLocalization.Core` comes along as a dependency, and one copy of it satisfies both UI packages in an application that uses them together. +For messages whose wording depends on the arguments - `plural` and `select`, such as `1 file` / +`5 files` - add the opt-in package `ResXLocalization.MessageFormat` and call +`Localizer.Current.UseMessageFormat()` to format every localized text as ICU MessageFormat. + ## Requirements - An app targeting **.NET 8** or later, built with the **.NET 8 SDK or later**. - For **Avalonia**: **Avalonia 12**. For **WPF**: **Windows**. -- `.resx` files with the standard sibling `*.Designer.cs` accessor, as generated by Visual Studio's - or Rider's classic resx tooling (SDK-only `GenerateResxSource` accessors are not eligible). +- `.resx` files. A `.Designer.cs` accessor is not required: every neutral `.resx` gets typed keys, + with or without one. ## In three steps **1. Add a neutral `.resx` and a satellite file per culture** - `Resources/AppStrings.resx` and `Resources/AppStrings.de.resx`, with the same key names. -**2. Build.** The source generator emits a typed key class per eligible `.resx`, named -`Keys`, in the same namespace as your resource accessor. Only string entries become keys. +**2. Build.** The source generator emits a typed key class per neutral `.resx`, named +`Keys` - by default in the namespace of the folder the `.resx` sits in. Only string entries +become keys. ```csharp // - for AppStrings.resx: public static partial class AppStringsKeys { - public static readonly ResourceKey Greeting = new("Greeting", AppStrings.ResourceManager); - public static readonly ResourceKey WindowTitle = new("WindowTitle", AppStrings.ResourceManager); + // The resource manager of AppStrings.resx - the one instance every key of this class uses. + public static ResourceManager ResourceManager { get; } = new("YourApp.Resources.AppStrings", typeof(AppStringsKeys).Assembly); + + public static readonly ResourceKey Greeting = new("Greeting", ResourceManager); + public static readonly ResourceKey WindowTitle = new("WindowTitle", ResourceManager); } ``` @@ -89,16 +98,17 @@ xmlns:l="clr-namespace:RentADeveloper.ResXLocalization.WPF;assembly=ResXLocaliza A **typed** key carries its own `ResourceManager`, and a **scoped** lookup is given one, so neither needs any registration. Registration exists for the **search-all** lookups - `{l:Localize Greeting}`, `{l:LocalizeEnum}`, and `Get("Greeting")` - which search the registered managers in registration -order: +order. Register the generated `Keys.ResourceManager`, the instance the typed keys use: ```csharp -Localizer.Current.RegisterResourceManager(AppStrings.ResourceManager); +Localizer.Current.RegisterResourceManager(AppStringsKeys.ResourceManager); ``` ## Documentation -The full documentation - lookup modes and fallback, enum localization, dynamic format arguments, -Native AOT publishing, and which `.resx` files qualify for generated keys - is at +The full documentation - lookup modes and fallback, registering resource files, enum localization, +dynamic format arguments, ICU MessageFormat, design time and previewers, Native AOT publishing, which +`.resx` files qualify for generated keys, and migrating to 2.0 - is at [rent-a-developer.github.io/ResXLocalization](https://rent-a-developer.github.io/ResXLocalization/), which also hosts the complete [API reference](https://rent-a-developer.github.io/ResXLocalization/api/). The [project README](https://github.com/rent-a-developer/ResXLocalization#readme) has the quick start diff --git a/README.md b/README.md index acd0607..be565de 100644 --- a/README.md +++ b/README.md @@ -44,13 +44,14 @@ Localizer.Current.CurrentCulture = new CultureInfo("de"); ## Highlights - ⚡ **Live, no-reload language switching** - set one property and every bound string updates in place. No window reload, no view rebuild, no restart. -- 🔒 **Type-safe, compile-checked keys** - a source generator turns every `.resx` into strongly-typed keys with full IntelliSense. +- 🔒 **Type-safe, compile-checked keys** - a source generator turns every neutral `.resx` into strongly-typed keys with full IntelliSense, with or without a `.Designer.cs`. - 📦 **Zero configuration** - install one NuGet package and build; typed keys are generated automatically, nothing else to set up. - 🚀 **Native AOT & trim clean (Avalonia)** - no reflection over your resources; publishes with `PublishAot=true` out of the box. *(WPF is Windows-only and does not support Native AOT.)* - 🗂️ **First-class multiple `.resx`** - look up by **typed key**, **scope** to one file, or **search across all** registered files in a defined order. - 🔤 **Enum localization built in** - localize enum members by naming convention, in item templates and as bound values. -- 🧮 **Format arguments** - `Get(key, args…)` formats the translation in the active culture; in XAML, bind the `LocalizeArgs.Arg0`…`Arg8` attached properties. -- 🩺 **Missing-translation diagnostics** - a visible `!key!` sentinel (configurable) plus a `TranslationNotFound` event for logging and coverage reports. +- 🧮 **Format arguments on the binding** - in XAML, give `{l:Localize}` and `{l:LocalizeEnum}` `` children with constant or bound values, so enum labels take arguments too; in code, pass `MessageArguments.FromValues(…)` to `Localizer.Current.Get`. +- 🔢 **ICU MessageFormat, opt-in** - `plural` and `select` messages such as `1 file` / `5 files`, in code and in XAML, with the separate package `ResXLocalization.MessageFormat` - see [ICU MessageFormat](#icu-messageformat-opt-in). +- 🩺 **Lookup-failure diagnostics** - a visible `!key!` failure text (configurable) plus a `LookupFailed` event for logging and coverage reports, for a missing translation and for a value that cannot be formatted. - 🌐 **Language-picker ready** - `GetAvailableCultures()` discovers the cultures your app actually ships. - 🧩 **MVVM-friendly** - an injectable `ILocalizer` service with `INotifyPropertyChanged`, markup extensions for XAML, and a clean code-behind API. - 🪶 **Leak-safe** - discarded controls stay collectable (Avalonia uses weak events; WPF binds to the singleton through WPF's own weak binding-target references). @@ -61,9 +62,8 @@ Localizer.Current.CurrentCulture = new CultureInfo("de"); - An app targeting **.NET 8** or later, built with the **.NET 8 SDK or later**. - For **Avalonia**: **Avalonia 12**. For **WPF**: **Windows**. -- **`.resx`** resource files with the standard sibling `*.Designer.cs` accessor, as generated by - Visual Studio's or Rider's classic resx tooling. SDK-only `GenerateResxSource` accessors are not - eligible - see [Generated keys](docs/reference/generated-keys.md). +- **`.resx`** resource files. A `.Designer.cs` accessor is not required: every neutral `.resx` gets + typed keys, with or without one - see [Generated keys](docs/reference/generated-keys.md). ## Installation @@ -123,7 +123,7 @@ Add a `.resx` file the normal way - for example `Resources/AppStrings.resx` (you ### 2. Let the generator create typed keys -On every build, the source generator inspects each eligible `.resx` and emits a typed key class named `Keys` in the same namespace as your resource file. For `AppStrings.resx` you get: +On every build, the source generator inspects every neutral `.resx` (not the culture satellites such as `AppStrings.de.resx`) and emits a typed key class named `Keys`, by default in the namespace of the folder the `.resx` sits in. No `.Designer.cs` is needed. For `AppStrings.resx` you get: ```csharp // @@ -131,13 +131,16 @@ namespace YourApp.Resources; public static partial class AppStringsKeys { - public static readonly ResourceKey Greeting = new("Greeting", AppStrings.ResourceManager); - public static readonly ResourceKey WindowTitle = new("WindowTitle", AppStrings.ResourceManager); + // The resource manager of AppStrings.resx - the one instance every key of this class uses. + public static ResourceManager ResourceManager { get; } = new("YourApp.Resources.AppStrings", typeof(AppStringsKeys).Assembly); + + public static readonly ResourceKey Greeting = new("Greeting", ResourceManager); + public static readonly ResourceKey WindowTitle = new("WindowTitle", ResourceManager); // …one ResourceKey per string entry, sorted, with sanitized member names. } ``` -Each `ResourceKey` carries **both** the key name and the `ResourceManager` it belongs to - that is what makes typed lookups direct and collision-free. Which files qualify, what becomes a key, and the two generator diagnostics are in [Generated keys](docs/reference/generated-keys.md). +Each `ResourceKey` carries **both** the key name and the `ResourceManager` it belongs to - that is what makes typed lookups direct and collision-free. Which files qualify, how the namespace is chosen, what becomes a key, and the generator diagnostics are in [Generated keys](docs/reference/generated-keys.md). ### 3. Register your resources at startup @@ -150,7 +153,7 @@ using System.Globalization; using RentADeveloper.ResXLocalization; using YourApp.Resources; -Localizer.Current.RegisterResourceManager(AppStrings.ResourceManager); +Localizer.Current.RegisterResourceManager(AppStringsKeys.ResourceManager); Localizer.Current.CurrentCulture = new CultureInfo("en"); ``` @@ -167,16 +170,34 @@ public partial class App : Application protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); - Localizer.Current.RegisterResourceManager(AppStrings.ResourceManager); + Localizer.Current.RegisterResourceManager(AppStringsKeys.ResourceManager); Localizer.Current.CurrentCulture = new CultureInfo("en"); } } ``` +Register the generated `AppStringsKeys.ResourceManager`: it is the instance the typed keys use. Registration is only needed for **search-all** lookups; typed and scoped lookups need no registration. > [!TIP] -> In Avalonia, put the registration (and the initial `CurrentCulture`) inside **`BuildAvaloniaApp()`** rather than `Main`. `BuildAvaloniaApp` runs at runtime *and* under the XAML previewer, so search-all lookups like `{l:Localize Greeting}` resolve at design time too instead of showing the `!Greeting!` sentinel. +> In Avalonia, put the registration (and the initial `CurrentCulture`) inside **`BuildAvaloniaApp()`** rather than `Main`. `BuildAvaloniaApp` runs at runtime *and* under the XAML previewer, so search-all lookups like `{l:Localize Greeting}` resolve at design time too instead of showing the `!Greeting!` failure text. + +> [!TIP] +> In WPF, the designer does not call `App.OnStartup`, so search-all lookups show the failure text there. Register in a **`[ModuleInitializer]`** of the application assembly instead: it runs at runtime before `Main` *and* in the designer. See [Registering resource files](docs/guides/registration.md#wpf). +> +> ```csharp +> using System.Runtime.CompilerServices; +> +> internal static class ResourceRegistration +> { +> [ModuleInitializer] +> internal static void Register() +> { +> Localizer.Current.CurrentCulture = new CultureInfo("en"); +> Localizer.Current.RegisterResourceManager(AppStringsKeys.ResourceManager); +> } +> } +> ``` ### 4. Use it in XAML @@ -226,28 +247,95 @@ Every `{l:Localize}` binding re-resolves **immediately**. No reload, no flicker. Three lookup modes, one fallback chain: ```csharp -Localizer.Current.Get(AppStringsKeys.Greeting); // typed - compile-checked, one file -Localizer.Current.Get("Greeting", AppStrings.ResourceManager); // scoped - this key, in this file -Localizer.Current.Get("Greeting"); // search-all - registered files, in order +Localizer.Current.Get(AppStringsKeys.Greeting); // typed - compile-checked, one file +Localizer.Current.Get("Greeting", resourceManager: AppStringsKeys.ResourceManager); // scoped - this key, in this file +Localizer.Current.Get("Greeting"); // search-all - registered files, in order ``` Fallback is .NET's own: a missing `de-DE` entry falls back to `de`, then to the neutral resources. A -key that cannot be resolved anywhere renders the configurable `!key!` sentinel and raises -`TranslationNotFound`. Because fallback runs first, a key that resolves from a parent or neutral +key that cannot be resolved anywhere renders the configurable `!key!` failure text and raises +`LookupFailed`. Because fallback runs first, a key that resolves from a parent or neutral value is not reported as missing. Enum members are localized by naming convention - one `.resx` entry per member, named -`Enum__` - and composite-format resources take their arguments from -`Get(key, args…)` in code or from the `LocalizeArgs.Arg0`…`Arg8` attached properties in XAML. +`Enum__`. Composite-format resources take their arguments in code from +`Localizer.Current.Get(key, MessageArguments.FromValues(…))` - or as named arguments from +`MessageArguments.FromTuples`, `FromDictionary` and `FromObject` - and in XAML from `` +children of `{l:Localize}` and `{l:LocalizeEnum}`. + +For messages whose wording depends on the arguments, such as `1 file` / `5 files`, the opt-in package +`ResXLocalization.MessageFormat` adds ICU MessageFormat - see [ICU MessageFormat](#icu-messageformat-opt-in). + +## ICU MessageFormat (opt-in) + +`string.Format` cannot change a message's wording by its arguments: `1 file deleted`, but +`5 files deleted`. The opt-in package `ResXLocalization.MessageFormat` formats every localized text as +[ICU MessageFormat](https://unicode-org.github.io/icu/userguide/format_parse/messages/) instead, whose +`plural` and `select` choose the wording by the arguments. Add it next to your UI package: + +```shell +dotnet add package ResXLocalization.MessageFormat +``` + +Then install the ICU formatter once, at startup - in Avalonia in `BuildAvaloniaApp()`, in WPF in +`App.OnStartup` and, for the WPF designer, in the `[ModuleInitializer]` from step 3 of the quick start: + +```csharp +using RentADeveloper.ResXLocalization.MessageFormat; + +Localizer.Current.UseMessageFormat(); +``` + +Write the resource values in ICU syntax: + +| Key | `AppStrings.resx` (English) | `AppStrings.de.resx` (German) | +| -------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------- | +| `FilesDeleted` | `{Count, plural, one {# file deleted} other {# files deleted}}` | `{Count, plural, one {# Datei gelöscht} other {# Dateien gelöscht}}` | + +and pass named arguments - in code with `MessageArguments`, in XAML with named `` children: + +```csharp +Localizer.Current.Get(AppStringsKeys.FilesDeleted, MessageArguments.FromTuples(("Count", 1))); // "1 file deleted" +Localizer.Current.Get(AppStringsKeys.FilesDeleted, MessageArguments.FromObject(new { Count = 5 })); // "5 files deleted" +``` + +```xml + + + + + + + +``` + +`{l:LocalizeEnum}` takes the same `` children, so an enum label can be a `plural` or +`select` message too. Like the Avalonia package, `ResXLocalization.MessageFormat` is Native AOT and +trim clean. + +> [!IMPORTANT] +> After `UseMessageFormat()`, **every** resolved resource value is parsed as an ICU message, also a value +> looked up without arguments: `It''s ready` renders `It's ready`, a literal `{` or `}` is ICU syntax, and +> composite-format spellings such as `{0:N0}` or `{{literal}}` render the failure text and raise +> `Localizer.Current.LookupFailed`. Review your resource values before you opt in - +> [Migrating resource values](docs/guides/message-format.md#migrating-resource-values) shows what to search +> for and how to rewrite it. + +Plural categories per language, `select`, how arguments reach ICU, the format culture and number and +date styles are in [ICU MessageFormat](docs/guides/message-format.md). ## Documentation | Page | What it covers | | --- | --- | | [Lookup modes and culture fallback](docs/guides/lookup-and-fallback.md) | Typed, scoped and search-all lookups, registration order, the fallback chain, missing-translation diagnostics, dependency injection, subscription lifetime | +| [Registering resource files](docs/guides/registration.md) | What to register for search-all lookups, where - including the WPF designer and class libraries - dependency injection, resources from other assemblies | | [Localizing enum values](docs/guides/enums.md) | The naming convention, item templates, `LocalizeEnumConverter`, custom prefixes and scoping | -| [Dynamic format arguments](docs/guides/format-arguments.md) | `Get(key, args…)` and the `LocalizeArgs.Arg0`…`Arg8` attached properties | +| [Dynamic format arguments](docs/guides/format-arguments.md) | `Get(key, MessageArguments.FromValues(…))`, the `MessageArguments` factory, and `` children of `{l:Localize}` with constant or bound values, optionally named | +| [ICU MessageFormat](docs/guides/message-format.md) | The opt-in `ResXLocalization.MessageFormat` package: `plural` and `select` through `Localizer.Current.UseMessageFormat()`, argument mapping, format culture, styles, and migrating resource values to ICU syntax | +| [Design time and previewers](docs/guides/design-time.md) | `DesignTime.Culture` for the preview language, the culture a previewer uses without it, search-all lookups and ICU in the WPF designer, and what to expect in Rider's WPF previewer | | [Native AOT and trimming](docs/guides/native-aot.md) | What to declare, how to publish, and what a trimming failure looks like | +| [Migrating to 2.0](docs/guides/migration-2.0.md) | Each breaking change of 2.0, with what to do | | [Generated keys](docs/reference/generated-keys.md) | Which `.resx` files qualify, what becomes a key, the generator diagnostics, the Roslyn floor | | [API reference](https://rent-a-developer.github.io/ResXLocalization/) | Every public type and member | @@ -256,13 +344,23 @@ Enum members are localized by naming convention - one `.resx` entry per member, **A string shows up as `!key!`.** The key could not be resolved in the current culture's complete fallback chain. Check that the key exists in the neutral `.resx`, and - for search-all lookups like `{l:Localize Greeting}` or `{l:LocalizeEnum}` - that the resource manager was registered with -`RegisterResourceManager`. Subscribe to `TranslationNotFound` to log every miss. See +`Localizer.Current.RegisterResourceManager(Keys.ResourceManager)`. Subscribe to +`Localizer.Current.LookupFailed` to log every miss. See [Lookup modes and culture fallback](docs/guides/lookup-and-fallback.md). -**No typed `…Keys` class is generated.** The neutral file's name must be dot-free and a classic -`.Designer.cs` accessor must sit in the same folder; SDK `GenerateResxSource` output does not -qualify. `RXLGEN001` identifies malformed `.resx` XML, `RXLGEN002` a missing accessor. See -[Generated keys](docs/reference/generated-keys.md). +**No typed `…Keys` class is generated.** Every neutral `.resx` produces a `Keys` class, +with or without a `.Designer.cs`; culture satellites such as `AppStrings.de.resx` produce none. A +designer is not required, and there is no `RXLGEN002`. Check the generator warnings: `RXLGEN001` +reports malformed `.resx` XML, `RXLGEN003` a `.resx` whose MSBuild resource name did not reach the +generator, and `RXLGEN004` two neutral `.resx` files that would produce the same keys class - neither +class is generated then. See [Generated keys](docs/reference/generated-keys.md). + +**A string does not switch language.** It is probably read through a designer property such as +`AppStrings.Greeting`, in code or as `{x:Static res:AppStrings.Greeting}`. A designer property reads +in `CultureInfo.CurrentUICulture`, which the localizer never changes, so it does not follow +`Localizer.Current.CurrentCulture`. Read the string through a typed key, `Localizer.Current.Get` or +`{l:Localize}` instead. See the warning in +[Lookup modes and culture fallback](docs/guides/lookup-and-fallback.md#registration-is-only-for-search-all). **A translation is right when you run it and wrong after publishing.** List the cultures you ship in `SatelliteResourceLanguages`, or the satellite assemblies are not published. See @@ -274,13 +372,15 @@ normally. ## The sample applications -Two complete, runnable showcases exercise **every** feature and combination - a scrolling window with a live language `ComboBox`: +Two complete, runnable showcases, one per UI framework - a scrolling window with a live language `ComboBox` and one card each for typed keys, search-all and scoped lookups, enum values in item templates and through `LocalizeEnumConverter`, `` children, a key bound from the view model, and lookups from code-behind: ```shell dotnet run --project samples/ResXLocalization.Avalonia.Sample dotnet run --project samples/ResXLocalization.WPF.Sample # Windows only ``` +The samples use the default formatter; for ICU MessageFormat, see [ICU MessageFormat](#icu-messageformat-opt-in). + ## Building from source You need the **.NET 10 SDK** (see `global.json`); the produced packages target .NET 8 and .NET 10. diff --git a/ResXLocalization.NonWindows.slnf b/ResXLocalization.NonWindows.slnf index a375d13..bbdb2b1 100644 --- a/ResXLocalization.NonWindows.slnf +++ b/ResXLocalization.NonWindows.slnf @@ -3,11 +3,13 @@ "path": "ResXLocalization.slnx", "projects": [ "src\\ResXLocalization.Core\\ResXLocalization.Core.csproj", + "src\\ResXLocalization.MessageFormat\\ResXLocalization.MessageFormat.csproj", "src\\ResXLocalization.SourceGenerators\\ResXLocalization.SourceGenerators.csproj", "src\\ResXLocalization.Avalonia\\ResXLocalization.Avalonia.csproj", "samples\\ResXLocalization.Avalonia.Sample\\ResXLocalization.Avalonia.Sample.csproj", "tests\\ResXLocalization.Avalonia.Sample.Tests\\ResXLocalization.Avalonia.Sample.Tests.csproj", "tests\\ResXLocalization.Core.Tests\\ResXLocalization.Core.Tests.csproj", + "tests\\ResXLocalization.MessageFormat.Tests\\ResXLocalization.MessageFormat.Tests.csproj", "tests\\ResXLocalization.SourceGenerators.Tests\\ResXLocalization.SourceGenerators.Tests.csproj" ] } diff --git a/ResXLocalization.slnx b/ResXLocalization.slnx index d68cfce..57df655 100644 --- a/ResXLocalization.slnx +++ b/ResXLocalization.slnx @@ -2,6 +2,7 @@ + @@ -12,6 +13,7 @@ + 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/SECURITY.md b/SECURITY.md index 6863f6f..430d7d5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,7 @@ | Version | Supported | | ------- | --------- | -| 1.x | ✅ | +| 2.x | ✅ | ## Reporting a Vulnerability diff --git a/build/ResXLocalization.Resx.targets b/build/ResXLocalization.Resx.targets index 769a968..233ac57 100644 --- a/build/ResXLocalization.Resx.targets +++ b/build/ResXLocalization.Resx.targets @@ -6,8 +6,17 @@ @(EmbeddedResource) / @(Compile) are populated by the SDK default-item globs, so this covers each project's resources automatically and is benign for projects with none. - Done in a target (not a bare ItemGroup) so the per-item metadata Conditions batch correctly and run - before the compiler collects @(AdditionalFiles). + The generator also needs MSBuild's name for each .resx: ManifestResourceName (or LogicalName, which + replaces it) is the name the resources are embedded under, and WithCulture says whether the file is a + satellite. CustomToolNamespace, which only the IDE's designer tools read otherwise, names the + namespace of a keys class whose .resx has no designer. A generator cannot read MSBuild items. The only + channel is the analyzer config file that GenerateMSBuildEditorConfigFile writes into obj/, as a + snapshot of every CompilerVisibleItemMetadata value of the @(AdditionalFiles) that exist when it runs. + So the target below: + * depends on PrepareResourceNames, the target that computes ManifestResourceName and WithCulture; + * runs before GenerateMSBuildEditorConfigFileShouldRun, so the items are in the snapshot. An item + added later (for example before CoreCompile) reaches the generator without its metadata. + Done in a target (not a bare ItemGroup) so the per-item metadata Conditions batch correctly. SINGLE SOURCE OF TRUTH. This file is consumed two ways: * In-repo: Directory.Build.targets s it (the sample/tests use ProjectReference and so do @@ -16,11 +25,71 @@ file to buildTransitive/.targets (the file name must equal the PackageId so NuGet auto-imports it for direct and transitive consumers). --> + + + + + + + + + + BeforeTargets="GenerateMSBuildEditorConfigFileShouldRun" + DependsOnTargets="PrepareResourceNames"> + + + + <_ResXLocalizationWpfTmpSuffix>$(MSBuildProjectName.Substring($(_TargetAssemblyProjectName.Length))) + + + - + <_ResXLocalizationResx Include="@(EmbeddedResource)" + Condition="'%(Extension)' == '.resx'" /> + <_ResXLocalizationResx Condition="'$(_ResXLocalizationWpfTmpSuffix)' != ''"> + $([System.String]::Copy('%(_ResXLocalizationResx.ManifestResourceName)').Replace('$(_ResXLocalizationWpfTmpSuffix)', '')) + $([System.String]::Copy('%(_ResXLocalizationResx.LogicalName)').Replace('$(_ResXLocalizationWpfTmpSuffix)', '')) + $([System.String]::Copy('%(_ResXLocalizationResx.CustomToolNamespace)').Replace('$(_ResXLocalizationWpfTmpSuffix)', '')) + + + <_ResXLocalizationResx Remove="@(_ResXLocalizationResx)" /> + + + +``` + +**WPF:** + +```xml + + + +``` + +`xmlns:l` is the same namespace import that `{l:Localize}` uses. + +- **Process-wide.** Inside a previewer, the property assigns `Localizer.Current.CurrentCulture`. Strings + that a view model looks up in code, such as `Localizer.Current.Get(AppStringsKeys.Greeting)`, therefore + agree with the XAML. +- **No run-time effect.** Outside a previewer, setting the property only stores the value; the culture of + `Localizer.Current` stays as it is. Nothing has to be hidden with `mc:Ignorable`. +- **Re-renders on edit.** Changing the value, for example from `de` to `en`, re-renders the preview in the + new language. +- **Removing the attribute keeps the language.** When you delete `l:DesignTime.Culture`, nothing assigns + the culture, so the previewer keeps showing the last language it was set to. To go back just close the preview + and re-open it. +- **Typing is safe.** An empty value and a name that `CultureInfo` rejects, such as a half-typed one, leave + the culture unchanged; nothing throws. + +## The culture a previewer uses without it + +Without `DesignTime.Culture`, a previewer uses the machine's UI culture - the default of +`Localizer.Current.CurrentCulture` is `CultureInfo.CurrentUICulture` - unless initialization that the +previewer executes selects another culture. + +- **Avalonia:** the previewer runs `BuildAvaloniaApp`, so a culture assigned there applies in the previewer + too. +- **WPF:** the designer does not call `App.OnStartup`, so a culture assigned there does not apply at design + time. A `[ModuleInitializer]` of the application assembly does run in the designer; the WPF sample + application selects English in its module initializer for this reason (see the next section). + +`DesignTime.Culture` overrides a culture selected by such initialization: with +`l:DesignTime.Culture="de"`, the WPF sample's views preview in German although its module initializer +selects English. + +## Initialization for the WPF designer: a module initializer + +Because the WPF designer does not call `App.OnStartup`, a search-all lookup such as +`{l:Localize Greeting}` renders the failure text there when the resource files are registered only in +`OnStartup`. The same holds for `Localizer.Current.UseMessageFormat()` of the +`ResXLocalization.MessageFormat` package: called only in `OnStartup`, it does not apply in the designer, +which then formats with the default formatter. + +Do this initialization in a `[ModuleInitializer]` of the application assembly instead. It runs before the +first use of the assembly - at run time before `Main`, and in the WPF designer before the first view is +rendered: + +```csharp +using System.Globalization; +using System.Runtime.CompilerServices; +using RentADeveloper.ResXLocalization; +using RentADeveloper.ResXLocalization.MessageFormat; // only with the MessageFormat package +using YourApp.Resources; + +internal static class LocalizationInitializer +{ + [ModuleInitializer] + internal static void Initialize() + { + Localizer.Current.CurrentCulture = new CultureInfo("en"); + Localizer.Current.RegisterResourceManager(AppStringsKeys.ResourceManager); + Localizer.Current.UseMessageFormat(); // only with the MessageFormat package + } +} +``` + +[Registering resource files](registration.md#wpf) covers registration in a module initializer in more +detail, including views in a class library; [ICU MessageFormat](message-format.md#opting-in) covers +`UseMessageFormat()`. + +## Rider's WPF previewer + +**The running application, the Visual Studio WPF designer and the Avalonia previewer (in Visual Studio +and in Rider) are not affected by anything in this section.** + +Rider's XAML previewer has a number of defects that prevent ResXLocalization from working correctly in +it, and the same defects break many other WPF libraries - this is not a ResXLocalization shortcoming or a +support decision. + +Rider's WPF previewer supplies a target property to a markup extension as a CLR property instead of a WPF +dependency property, and WPF then throws for every binding a markup extension returns. In Rider's WPF +previewer only, `{l:Localize}` and `{l:LocalizeEnum}` therefore return a plain string instead of a +binding, outside style setters, so the window renders (style setters are covered below). Which text you +see there depends on how the key is written, because Rider delivers some keys to the markup extension and +not others. + +**Rendered: the raw resource value.** A key written as the first value inside the braces, or as the +`Key` attribute of an `` element, renders its resource value as written, under the default +formatter. The value is looked up without arguments, because the `` bindings have no data +context yet when the markup extension produces the string: + +```xml + + + + + + + + + + +``` + +**Rendered empty.** Rider does not deliver a key written with `{x:Static}` inside the braces, a key +assigned with `Key=` inside the braces, or a bound key: + +```xml + + + + +``` + +**Enum values.** The same rules apply to the enum value of `{l:LocalizeEnum}`, its `Value` property; here +`xmlns:m` is the namespace of the enum type `FileSortOrder`. A fixed enum member written as the `Value` +attribute of an `` element renders its text; a fixed member inside the braces and a bound +enum value render empty: + +```xml + + + + + + + + + + +``` + +More differences in Rider's WPF previewer: + +- **Style setters** keep a live binding, as at run time, when Rider delivers the key. When Rider does not + deliver it, the setter's text renders empty, and Rider's previewer reports the error + `{l:Localize} has no Key` for the setter's `Value` property, although the key is written correctly; the + running application and the Visual Studio designer render the text. This error is a consequence of the + Rider defect, not of your markup: + + ```xml + + + + + + ``` + +- **Texts bound to a view model** render empty when Rider cannot create the design-time view model, for + example one without a parameterless constructor. +- **Errors in the markup** outside style setters, such as a `{l:Localize}` without a key, are not reported + in Rider's previewer; the running application and the Visual Studio designer report them. +- **`DesignTime.Culture`** changes the culture, but the previewer silently shows the neutral resources: + Rider loads the project assemblies in a way that prevents .NET from finding the satellite assemblies of + other languages. The lookup succeeds with the neutral value, so neither the failure text nor + `Localizer.Current.LookupFailed` reports it. + +With ICU MessageFormat installed through `Localizer.Current.UseMessageFormat()`, the previewer's lookup +without arguments is still parsed as ICU: escaping such as `It''s ready` is interpreted, and a value that +needs a variable, such as `Hello {Name}`, renders the failure text and raises +`Localizer.Current.LookupFailed` - see [Every resolved value is ICU](message-format.md#every-resolved-value-is-icu). + +The defects are reported to JetBrains: + +- [RIDER-143207](https://youtrack.jetbrains.com/issue/RIDER-143207/WPF-XAML-previewer-supplies-RuntimePropertyInfo-instead-of-DependencyProperty-to-a-custom-markup-extension-causing-exceptions): + the previewer supplies a CLR property instead of a dependency property to a markup extension. +- [RIDER-143052](https://youtrack.jetbrains.com/issue/RIDER-143052/WPF-previewer-fails-to-resolve-ResX-satellite-assemblies-when-loading-project-assemblies-from-bytes): + the previewer does not resolve the satellite assemblies of other languages. +- [RIDER-143208](https://youtrack.jetbrains.com/issue/RIDER-143208): the previewer reports that it is not + in design mode for the root `Window` and supplies no target property for `Window.Title`. + +Further defects were found that are not on this list. When JetBrains fixes the previewer so that it +supplies dependency properties, `{l:Localize}` and `{l:LocalizeEnum}` return live bindings there too, +without a change to your application or to ResXLocalization. diff --git a/docs/guides/enums.md b/docs/guides/enums.md index 7037676..b8b39ac 100644 --- a/docs/guides/enums.md +++ b/docs/guides/enums.md @@ -16,70 +16,252 @@ public enum FileSortOrder { Unsorted, Ascending, Descending } Like every other lookup, enum labels update live when the culture changes, and the same three usages work identically in Avalonia and WPF. -## In item templates: `{l:LocalizeEnum}` +## In XAML: `{l:LocalizeEnum}` -Inside a `ComboBox` or `ListBox` item template each item *is* the enum value — it is the -`DataContext` — so the markup extension localizes it directly: +`{l:LocalizeEnum}` localizes the enum value you give it in `Value` - a binding or a fixed member: + +```xml + + + +``` + +The text follows the bound value: when `SortOrder` changes, so does the text. A bound value that is +not an enum - for example a `null` property while the view loads - renders an empty string. + +A fixed member is written with `{x:Static}`. `Value=Ascending` is the **string** `"Ascending"`, not the +enum member, and `{l:LocalizeEnum}` throws `InvalidOperationException` for it, as it does when `Value` +is missing. + +`Value` accepts the same bindings as an `` `Value`: in Avalonia any binding, in WPF a plain +`{Binding}` only - a `MultiBinding` or a `PriorityBinding` throws `InvalidOperationException`. See +[Dynamic format arguments](format-arguments.md#worth-knowing). + +### In item templates + +Inside a `ComboBox` or `ListBox` item template each item *is* the enum value - it is the +`DataContext` - so `Value={Binding}`, a binding without a path, localizes it: ```xml - + ``` -## As a bound value: `LocalizeEnumConverter` +### With format arguments -When the enum is a bound property rather than the `DataContext`, use `LocalizeEnumConverter` in a -`MultiBinding`. The second binding — to the current culture — is what re-triggers the conversion on -a language switch: +An enum label can have placeholders and take [format arguments](format-arguments.md), as +`` children - the same as for `{l:Localize}`. With a `Count_FileSortOrder_Ascending` entry +of `{0} files, ascending`: + +```xml + + + + + + + + +``` + +The enum value itself is not an argument. A text that needs it, for example under a name, binds it +explicitly: + +```xml + + + +``` + +Under the default formatter, a text that needs a plural - `1 file` and `3 files` - needs one enum +member per plural case: the default formatter is `string.Format` and does not choose between forms. +With the ICU MessageFormat formatter of the `ResXLocalization.MessageFormat` package, one member and a +`plural` pattern suffice, for example `{0, plural, one {# file} other {# files}}`; see +[ICU MessageFormat](message-format.md). + +## Where a markup extension is not accepted: `LocalizeEnumConverter` + +A markup extension does not work everywhere a binding is expected - for example in a property of type +`Binding` or `BindingBase`, such as `DataGridTextColumn.Binding`. A hand-written `MultiBinding` with +`LocalizeEnumConverter` does. Its children are, in this order: + +1. The refresh trigger: `RefreshTriggeringRevision` of `Localizer.Current`, which re-runs the + conversion whenever a lookup's result can change, such as on a language switch. +2. The enum value. +3. From index 2 on, the format arguments, in order. ```xml + - ``` +A `MultiBinding` that puts the enum value first renders an empty string, and nothing reports it; check +the order whenever a converter shows nothing. + +### With format arguments + +Every child from index 2 on is one positional argument: child 2 fills `{0}`, child 3 fills `{1}`, and so +on. A converter with a key prefix is declared as a resource; with a `Count_FileSortOrder_Ascending` entry +of `{0} files, ascending`: + +```xml + + + + + + + + + + + + + + +``` + +Two placeholders take two argument children, in placeholder order. With a `Range_FileSortOrder_Ascending` +entry of `{0} of {1} files, ascending`: + +```xml + + + + + + + + + + + + + + + +``` + +### Named arguments: `ArgumentNames` + +A `MultiBinding` child has no name. A formatter that understands named arguments, such as ICU +MessageFormat, gets the names from `ArgumentNames` on the converter: one comma-separated string whose +entry `i` names the argument at child `2 + i`. + +With such a formatter assigned to `ILocalizer.MessageFormatter`, the entry can use names. With a +`Range_FileSortOrder_Ascending` entry of `{Shown} of {Total} files, ascending`: + +```xml + + + + + + + + + + + + + + + +``` + +The default formatter, `string.Format`, ignores names and knows only positions: under it the entry must +use `{0}` and `{1}`, as in the previous example. `{Shown}` is not a valid composite format item, so it +renders the failure text and raises `LookupFailed`. + +Entries are trimmed, and an empty entry leaves that argument unnamed: `",Total"` names only the second. +Two entries with the same name throw `ArgumentException`. Extra names are ignored, and arguments without +an entry are unnamed. Every argument keeps its position whether it is named or not, and the default +formatter ignores names. + ## In code ```csharp Localizer.Current.Get(FileSortOrder.Ascending); // "Ascending (A-Z)" / "Aufsteigend (A-Z)" ``` +With format arguments, and with a resource manager and a key prefix: + +```csharp +Localizer.Current.Get(FileSortOrder.Ascending, MessageArguments.FromValues(count), keyPrefix: "Count_"); // "3 files, ascending" +Localizer.Current.Get(FileSortOrder.Ascending, MessageArguments.FromValues(count), SortingStringsKeys.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 - + ``` ```csharp -Localizer.Current.Get(FileSortOrder.Ascending, SortingStrings.ResourceManager, "Display_"); +Localizer.Current.Get(FileSortOrder.Ascending, resourceManager: SortingStringsKeys.ResourceManager, keyPrefix: "Display_"); ``` -`LocalizeEnumConverter.Default` is read-only, so a converter with a custom prefix is declared as its -own instance in resources: +`LocalizeEnumConverter.Default` is read-only, so a converter with a custom prefix, a resource manager or +argument names is declared as its own instance in resources: ```xml ``` +## 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 -`!Enum_FileSortOrder_Ascending!` sentinel, and a `TranslationNotFound` event. See +`!Enum_FileSortOrder_Ascending!` failure text, and a `LookupFailed` event. See [Lookup modes and culture fallback](lookup-and-fallback.md). diff --git a/docs/guides/format-arguments.md b/docs/guides/format-arguments.md index 025a334..1a749ec 100644 --- a/docs/guides/format-arguments.md +++ b/docs/guides/format-arguments.md @@ -9,40 +9,181 @@ 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)); ``` -The resolved text and the arguments go to `string.Format` in the localizer's current culture, so a -number or a date formats the way that culture writes it. +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")`. The ICU MessageFormat formatter of the +`ResXLocalization.MessageFormat` package finds a named argument both under its position and under its name; +see [ICU MessageFormat](message-format.md). ## In XAML -Bind the **`LocalizeArgs.Arg0`…`Arg8` attached properties** on the element that carries the localized -property — the same XAML in Avalonia and WPF: +Give `{l:Localize}` one **`` child** per argument - the same XAML in Avalonia and WPF: ```xml - + + + + + + + ``` The rendered text re-formats **live** whenever a bound argument changes *and* whenever the language switches: `PeopleCount = 5` renders `5 people invited`, and switching to German re-renders it in place as `5 Personen eingeladen`. +`Arguments` is the content property of `{l:Localize}`, so the `` children need no wrapper; +the explicit form is ``. The first `` is `{0}`, the second `{1}`, and +so on. There is no upper limit, and each localized property has its own arguments - a localized `Text` +and a localized `ToolTip` on the same element can format different values. + +`{l:LocalizeEnum}` takes the same `` children, and `LocalizeEnumConverter` takes its +arguments as extra `MultiBinding` children; see [Localizing enum values](enums.md#with-format-arguments). + +### Bound and constant values + +`Value` takes a binding or a constant: + +```xml + + + + + + +``` + +A constant is a literal, `{x:Static}`, `{StaticResource}`, `{x:Null}`, or a typed property element such +as `` in Avalonia or `` in WPF. A value that is not resolved yet - for example +before the `DataContext` arrives - is passed as `null`, and the default formatter (`StringFormatMessageFormatter`) renders `null` as empty. + +### Named arguments + +An argument can have a name, in XAML and in code alike: + +```xml + + + + +``` + +```csharp +Localizer.Current.Get("FilePosition", MessageArguments.FromTuples((null, index), ("Total", count))); +``` + +Both become `MessageArgument`s - a position, an optional name and the resolved value - which is what a +custom formatter receives. The position of an `` is its index among the children, whether it +has a name or not; the default formatter ignores names, so `File {0} of {1}` renders `File 3 of 10` +for `Index` 3 and `Count` 10. The ICU MessageFormat formatter of the `ResXLocalization.MessageFormat` +package finds a named argument under its name too, so `File {0} of {Total}` works there; see +[ICU MessageFormat](message-format.md). + ## Worth knowing -- **Nine slots, `Arg0` through `Arg8`.** Arguments above the highest set slot are trimmed; a set slot - with unset slots below it — say, only `Arg2` — formats the gaps as `null`, which renders empty. -- **Arguments are per element.** Two localized properties on the same element, for example a - localized `Text` and a localized `ToolTip`, share the one argument set. Give each its own element - when they need different arguments. +- **Arguments always need element syntax.** A localized text with arguments is written as a + `` element with `` children; the attribute form `{l:Localize Key}` has no + arguments. +- **Order is meaning.** Under the default formatter, reordering the `` elements changes the + output, no matter whether names are set on the arguments. +- **`Value="5"` is a string.** The default formatter renders it like the number 5; a constant that must + be a number is written `{x:Static}`, `` (Avalonia) or `` (WPF). +- **`{x:Null}` passes `null`; a forgotten `Value` throws.** `` without a `Value`, an empty-string + `Name`, and two arguments with the same `Name` are rejected with an `InvalidOperationException` when + the view loads. +- **Supported binding kinds.** An argument binding behaves like an ordinary binding in the same place. + Avalonia: `{Binding}`, `{CompiledBinding}`, `{ReflectionBinding}`, `#element` and `$parent` paths, + `MultiBinding`, `{DynamicResource}` and `{TemplateBinding}`. WPF: a plain `{Binding}`, including + `ElementName` and `RelativeSource`; a `MultiBinding` or `PriorityBinding` is rejected with an + `InvalidOperationException`, and `{DynamicResource}` fails with WPF's own `XamlParseException`. +- **Missing `x:DataType` inference.** Rider and Visual Studio with ReSharper may not infer `x:DataType` + for a binding nested in ``, so completion and navigation are lost there; the binding works + at run time. Add a `d:DataContext`, or write `{CompiledBinding ...}`. +- **Trim safety is your binding setting.** In Avalonia, `Value="{Binding ...}"` is a compiled binding + when `AvaloniaUseCompiledBindingsByDefault` is `true` (or under `x:CompileBindings="True"`), and a + reflection binding otherwise. Compiled bindings are trim and Native AOT safe. +- **No target element needed.** Arguments live on the binding, so `{l:Localize}` with arguments works as + a `Style` setter value, in a resource and in a `DataTemplate`, and re-renders on every refresh there. - **Per-argument format specifiers belong in the resource string** — `{0:N0} people invited` — where a translator can adjust them per language. -- **No arguments set, no formatting.** An element without any `ArgN` resolves without composite - formatting at all, so a resource value containing a literal `{` or `}` keeps working without `{{` - escaping. The missing-key sentinel is likewise never formatted. +- **No arguments, no formatting.** Under the default formatter, a lookup without arguments returns the + resource value unchanged - in code `Localizer.Current.Get(key, MessageArguments.Empty)` or + `Get(key, null)`, like `Get(key)`, and in XAML a `{l:Localize}` without `` children. + 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 + failure text (by default `!key!`), in code and in XAML alike, provided the event handlers and + `LookupFailureTextBuilder` complete successfully. An exception from an event handler or from + `LookupFailureTextBuilder`, and a `null` result of the builder, propagate. See + [Lookup modes and culture fallback](lookup-and-fallback.md#failure-text). +- **The formatter is replaceable.** Assign `Localizer.Current.MessageFormatter = ...`, usually at + 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 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: + + ```csharp + Localizer.Current.MessageFormatter = new MyFormatter(); + + sealed class MyFormatter : IMessageFormatter + { + 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, + // 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. -- **Composite formatting does not pluralize.** `1 people invited` is on the resource author; use - separate singular and plural resources where it matters. + `Get(key, MessageArguments.FromValues(…))` rather than writing many `` elements. +- **Composite formatting does not pluralize.** Under the default formatter, `1 people invited` is on the + resource author; use separate singular and plural resources where it matters, or install the ICU + MessageFormat formatter of the `ResXLocalization.MessageFormat` package, whose `plural` chooses the form + by the count: see [ICU MessageFormat](message-format.md). diff --git a/docs/guides/lookup-and-fallback.md b/docs/guides/lookup-and-fallback.md index 532f887..2048175 100644 --- a/docs/guides/lookup-and-fallback.md +++ b/docs/guides/lookup-and-fallback.md @@ -16,24 +16,87 @@ resolve. Localizer.Current.Get(AppStringsKeys.Greeting); // Scoped: this key, in this file. -Localizer.Current.Get("Greeting", AppStrings.ResourceManager); +Localizer.Current.Get("Greeting", resourceManager: AppStringsKeys.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 - + ``` +## A key from the view model + +When the view model decides *which* message to show - e.g. a status that can be "Connecting", "Connected" +or "Failed" - bind the key instead of writing it in the XAML: + +```xml + + + + + + +``` + +```csharp +public ResourceKey StatusKey => this.IsConnected ? AppStringsKeys.Connected : AppStringsKeys.Connecting; +``` + +Bind a `ResourceKey`. The view model then picks one of the generated keys, so renaming or deleting a +resource is still a build error. Binding a `string` key works too, but a typo in that string is not +caught by the compiler: the lookup fails, and the text shows the [failure text](#failure-text) +(`!key!` by default). + +`{l:Localize}` reads the bound value again whenever the view model reports a change of the bound +property through `INotifyPropertyChanged`, and whenever `Localizer.Current.RefreshTriggeringRevision` +changes - for example on a language switch. Depending on the bound value, the text is: + +| Bound value | Text shown | +| --- | --- | +| a non-empty `string` | the translation of that key, looked up exactly like a key written in the XAML: in the file that `ResourceManager` names if `ResourceManager` is set, otherwise in all registered files (search-all) | +| a `ResourceKey` | the translation of that key, looked up in the file the `ResourceKey` belongs to; `ResourceManager` is ignored | +| an empty string (`""`) | an empty string - the same result as `Localizer.Current.Get("")` in code | +| nothing yet, because the element has no `DataContext` yet | an empty string | +| `null` | an empty string. A view model can therefore leave the key property `null` while it has nothing to show - for example before the first connection attempt, when there is no status yet - and the element simply stays empty | +| `default(ResourceKey)`, or a value of any other type, such as an `int` | an empty string; `Localizer.Current.LookupFailed` is not raised | + +No bound value makes `{l:Localize}` throw an exception: a value that is neither a non-empty `string` +nor a `ResourceKey` always renders an empty string. Format arguments (`` children) +work with a bound key exactly as with a key written in the XAML. + +Worth knowing: + +- **WPF accepts a plain `{Binding}` only.** In WPF, a `MultiBinding` or `PriorityBinding` as the key is + rejected with an `InvalidOperationException` when the view loads. Avalonia accepts every binding kind. +- **Missing `x:DataType` inference.** Rider and Visual Studio with ReSharper may not infer the + `x:DataType` for a binding nested inside `{l:Localize}`. Code completion and go-to-definition navigation + for the bound property then do not work in the editor; the binding itself still works at run time. + To get them back, add a `d:DataContext` to the view, or write `{CompiledBinding ...}` instead of + `{Binding ...}`. + ## Registration is only for search-all ```csharp -Localizer.Current.RegisterResourceManager(AppStrings.ResourceManager); +Localizer.Current.RegisterResourceManager(AppStringsKeys.ResourceManager); ``` Search-all inspects the registered managers **in registration order** and takes the first hit, so @@ -43,16 +106,27 @@ and scoped lookups need no registration at all — they already name the file. `UnregisterResourceManager` and `ClearResourceManagers` remove managers again, for an application that loads resources dynamically. +Register the generated `Keys.ResourceManager`, the instance the typed keys use. Where to +register, class libraries, resources from other assemblies and the WPF designer are covered in +[Registering resource files](registration.md). + +> [!WARNING] +> A designer property such as `AppStrings.Greeting` - in code, or through +> `{x:Static res:AppStrings.Greeting}` - reads in `CultureInfo.CurrentUICulture`, which the localizer +> deliberately never changes. Such values do not follow `Localizer.CurrentCulture` or live switching, +> and can show a different language from the localized bindings next to them. Read strings through +> typed keys, `Get` or the markup extensions. + > [!TIP] > In Avalonia, put the registration and the initial `CurrentCulture` inside `BuildAvaloniaApp()` > rather than `Main`. `BuildAvaloniaApp` runs at run time *and* under the XAML previewer, so -> search-all lookups resolve at design time too instead of showing the `!Greeting!` sentinel. +> search-all lookups resolve at design time too instead of showing the `!Greeting!` failure text. ## Culture fallback Fallback is .NET's own `ResourceManager` behaviour, unchanged. The localizer sits on top of it. -| Situation | Result | Raises `TranslationNotFound` | +| Situation | Result | Raises `LookupFailed` | | --- | --- | --- | | `de-DE` entry exists | the `de-DE` value | No | | Missing in `de-DE`, exists in `de` | the parent `de` value | No | @@ -60,21 +134,77 @@ Fallback is .NET's own `ResourceManager` behaviour, unchanged. The localizer sit | Missing across the complete fallback chain | `!key!` by default | Yes | Because fallback runs first, a key that resolves from a parent or from the neutral file does **not** -count as missing. The sentinel and the event report keys that cannot be resolved at all — they are -not a report of incomplete per-language coverage. +count as missing. The failure text and the event report keys that cannot be resolved at all — they +are not a report of incomplete per-language coverage. ## Missing-translation diagnostics ```csharp -// Change the sentinel, or make it invisible in production. -Localizer.MissingTranslationFormat = "«{0}»"; +// Log every key that cannot be resolved, and every value that cannot be formatted. +Localizer.Current.LookupFailed += (_, args) => +{ + var failure = args.Failure; + + if (failure.Reason == LookupFailureReason.TranslationNotFound) + { + logger.LogWarning("No translation for {Key} in {Culture}", failure.Key, failure.Culture); + } + else + { + logger.LogWarning(failure.Exception, "Cannot format {Key} in {Culture}: {Pattern}", failure.Key, failure.Culture, failure.Pattern); + } +}; +``` + +`LookupFailed` reports both kinds of failure, and `LookupFailure.Reason` tells them apart: +`TranslationNotFound` for a key that no resource file contains, `MessageFormatFailed` for a key that +was found but whose value could not be formatted - for example `Broken {0`, or `File {0} of {1}` with +one argument. Both lookups return the failure text. + +## Failure text + +The failure text is what a lookup returns instead of a translation when it cannot produce one: the key +is in no resource file, or its value could not be formatted. It is produced by the function +`LookupFailureTextBuilder`, which receives the same `LookupFailure` that `LookupFailed` reports - the +reason, the key, the culture, the resource manager of a scoped or typed lookup, the arguments, and for a +formatting failure the pattern and the exception. + +```csharp +// Wrap the key differently, or make it invisible in production. +Localizer.Current.LookupFailureTextBuilder = failure => $"«{failure.Key}»"; -// Log every unresolvable key. -Localizer.Current.TranslationNotFound += (_, args) => logger.LogWarning("No translation for {Key}", args.Key); +// Show only the last segment of a dotted key. +Localizer.Current.LookupFailureTextBuilder = failure => failure.Key[(failure.Key.LastIndexOf('.') + 1)..]; + +// Tell the two reasons apart. +Localizer.Current.LookupFailureTextBuilder = failure => + failure.Reason == LookupFailureReason.TranslationNotFound + ? $"[missing: {failure.Key}]" + : $"[broken: {failure.Key}]"; ``` -The default sentinel is `!key!`, which is deliberately ugly: a missing translation should be -noticeable in a screenshot. +The default produces `!key!`, which is deliberately ugly: a missing translation should be noticeable +in a screenshot. A formatting failure uses the same function. + +The function is called on every failed lookup, so keep it cheap. It must not throw or return `null`: +an exception it throws propagates out of the lookup, and a `null` result throws +`InvalidOperationException`. An empty string is fine and shows nothing. Changing the function +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, 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 +updated; re-fetch them when the localizer raises +`PropertyChanged(nameof(ILocalizer.RefreshTriggeringRevision))`. Make these changes on the UI thread +once views exist. ## Available cultures @@ -90,6 +220,8 @@ registered resource manager which satellite assemblies exist. It is what a langu ## Dependency injection +The recommended form hands the ambient instance to the container: + ```csharp services.AddSingleton(_ => Localizer.Current); ``` @@ -98,6 +230,11 @@ Alternatively, assign a container-owned implementation to `Localizer.Current` be created. The property rejects `null`, and the markup extensions always read its current value, so a replacement is picked up everywhere. +Registrations belong to the instance they were made on and do not move with the assignment. An +application that assigns its own instance to `Localizer.Current` must register its resource managers +on **that** instance. A registration in a `[ModuleInitializer]` of the executable always lands in the +default instance, because it runs before `Main`. + ## Lifetime and subscriptions `Localizer.Current` lives for the whole process, so a strong `CultureChanged` subscription keeps its diff --git a/docs/guides/message-format.md b/docs/guides/message-format.md new file mode 100644 index 0000000..462c1f4 --- /dev/null +++ b/docs/guides/message-format.md @@ -0,0 +1,437 @@ +# ICU MessageFormat + +The default formatter of this library is `StringFormatMessageFormatter`, which uses `string.Format`: a resource value with arguments is a composite format string, and its wording cannot depend on the arguments. The opt-in package `ResXLocalization.MessageFormat` replaces it with [ICU MessageFormat](https://unicode-org.github.io/icu/userguide/format_parse/messages/), whose `plural` and `select` choose the wording by the arguments: + +```text +{Count, plural, one {# file deleted} other {# files deleted}} +{Gender, select, female {She} male {He} other {They}} replied. +``` + +The package formats with the [MessageFormat](https://github.com/jeffijoe/messageformat.net) library by Jeff Hansen (NuGet package `MessageFormat`, +namespace `Jeffijoe.MessageFormat`). + +## Opting in + +Add the package next to your UI package: + +```shell +dotnet add package ResXLocalization.MessageFormat +``` + +Then install the ICU formatter once, at startup: + +```csharp +using RentADeveloper.ResXLocalization.MessageFormat; + +Localizer.Current.UseMessageFormat(); +``` + +`UseMessageFormat()` assigns the package's ICU formatter to `Localizer.Current.MessageFormatter`, so it applies to every lookup in code +and in XAML, and makes bound text re-resolve like a language switch. + +- **Avalonia:** call it in `BuildAvaloniaApp`, next to the registration of your resource files, so the + Avalonia previewer formats with ICU too. +- **WPF:** call it in `App.OnStartup` for the running application. The WPF designer runs no startup code; + to format with ICU there too, call it in a `[ModuleInitializer]` of the application assembly, as + [Registering resource files](registration.md#wpf) shows for the registration. + +Wherever `UseMessageFormat()` did not run, the default formatter is in place: a lookup without +arguments shows the resource value as written, for example `{Count, plural, one {# file deleted} other +{# files deleted}}`, and a lookup with arguments whose value `string.Format` cannot read returns the +failure text and raises `Localizer.Current.LookupFailed`. + +## Every resolved value is ICU + +After `UseMessageFormat()`, **every** resolved resource value is parsed as an ICU message, with or without +arguments: `Localizer.Current.Get(key)`, `Localizer.Current.Get(key, arguments: null)`, +`Localizer.Current.Get(key, MessageArguments.Empty)`, the indexer `Localizer.Current["Key"]` and +`{l:Localize Key}` alike. So, also without arguments: + +- `It''s ready` renders `It's ready`; +- `'{literal}'` renders `{literal}`; +- `Hello {Name}`, looked up without arguments, returns the failure text and raises + `Localizer.Current.LookupFailed`, because the variable `Name` is missing. + +When you opt in, review every resource value for ICU syntax, including the texts without arguments - see +[Migrating resource values](#migrating-resource-values). A plain text needs no dummy argument and no +wrapper. The default formatter (`StringFormatMessageFormatter`) keeps returning a value without arguments +unchanged. + +> [!NOTE] +> In Rider's WPF previewer, `{l:Localize}` and `{l:LocalizeEnum}` look up their text without arguments +> (see [Design time and previewers](design-time.md#riders-wpf-previewer)). With ICU installed there, +> escaping is interpreted, and a value that needs a variable renders the failure text. + +## `plural` and `select` + +| Key | `AppStrings.resx` (English) | `AppStrings.de.resx` (German) | +| --- | --- | --- | +| `FilesDeleted` | `{Count, plural, one {# file deleted} other {# files deleted}}` | `{Count, plural, one {# Datei gelöscht} other {# Dateien gelöscht}}` | + +In code, build named arguments with `MessageArguments` and pass them to any `Localizer.Current.Get` +overload: + +```csharp +Localizer.Current.Get(AppStringsKeys.FilesDeleted, MessageArguments.FromTuples(("Count", 1))); // "1 file deleted" +Localizer.Current.Get(AppStringsKeys.FilesDeleted, MessageArguments.FromObject(new { Count = 5 })); // "5 files deleted" +``` + +In XAML, name the `` children of `{l:Localize}`: + +```xml + + + + + + + +``` + +Which plural categories exist - `zero`, `one`, `two`, `few`, `many`, `other` - depends on the language. +`=0` matches an exact value: +`{Count, plural, =0 {No files deleted} one {# file deleted} other {# files deleted}}` renders +`No files deleted` for 0. `selectordinal` chooses by ordinal rules: +`{0, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} place` renders `2nd place` for 2. + +`select` chooses by a value's text. A partition display that shows the volume label when there is one: + +```text +{HasLabel, select, True {{Label} ({Letter}:)} other {Local Disk ({Letter}:)}} +``` + +```xml + + + + + +``` + +It renders `Data (D:)` for a partition labeled `Data` on drive `D`, and `Local Disk (C:)` for an +unlabeled one on drive `C`. A `bool` matches the `select` keys `True` and `False` - .NET's text for a +`bool` - not `true` and `false`. + +An enum label needs only one enum member and a `plural` pattern: give `{l:LocalizeEnum}` the same +`` children, see [Localizing enum values](enums.md#with-format-arguments). + +## Arguments + +Every argument reaches ICU under its position - `"0"`, `"1"`, and so on - and, if it has a name, under its +name as well. A name overwrites a position with the same text: + +| Arguments | ICU receives | +| --- | --- | +| `` with `Count` = 5 | `{ "0": 5 }`, so `{0, plural, ...}` works | +| `` and `` | `{ "0": "Data", "1": "D", "Letter": "D" }`, so the pattern may use `{0} ({Letter}:)` or `{0} ({1}:)` | +| `` and `` | `{ "0": B, "1": B }`: the name wins, silently | + +In code, `MessageArguments.FromTuples(("Count", 5))`, `MessageArguments.FromDictionary(dictionary)` and +`MessageArguments.FromObject(new { Count = 5 })` build named arguments, and +`MessageArguments.FromValues(5)` positional ones. Named arguments are reachable as `{0}`, `{1}` too, in the +order the method produced them; in a pattern used with named arguments, write the names. + +**A missing variable always fails.** A pattern that references a variable the lookup does not supply +returns the failure text and raises `Localizer.Current.LookupFailed` with the reason +`LookupFailureReason.MessageFormatFailed`. The MessageFormat library has no setting that renders a +missing variable empty. + +### `null`, strings and other values + +A `null` value never fails: + +| Pattern | Renders for `null` | +| --- | --- | +| `{0, plural, one {# file} other {# files}}` | `0 files` | +| `{0, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}` | `0th` | +| `{0, select, a {A} other {O}}` | `O` - the `other` branch | +| `{0}`, `{0, number}`, `{0, date}`, `{0, time}` | an empty string | + +An argument binding that is not resolved yet passes `null`, so a view that loads before its `DataContext` +briefly shows `0 people invited` for `{Count, plural, one {# person invited} other {# people invited}}`, +without `LookupFailed`; a view-model value that is `null` renders the same. The default formatter renders +every `null` argument as an empty string. + +- **A numeric string** behaves like the number in `plural`: `"5"` renders `5 files` and `"1"` renders + `1 file`, so the constant `Value="5"` in XAML works. +- **A non-numeric string** in `plural`, such as `"abc"`, fails with a `FormatException`: failure text + and `LookupFailed`. `{0, number}` renders a string unchanged (`abc`), and `select` and `{0}` take any + string. +- **A value that is neither a number nor a string**, such as a plain `object`, fails in `plural` with an + `InvalidCastException`: failure text and `LookupFailed`. + +## The format culture + +ICU formats in the lookup's culture - `Localizer.Current.CurrentCulture` when the lookup runs - with one +exception: the invariant culture formats as `en`. The MessageFormat library chooses plural rules by the +culture's name, and for the invariant culture, whose name is empty, it uses CLDR's root rules, which know +only `other`: `{0, plural, one {# item} other {# items}}` would render `1 items`. The neutral resources +of most applications are English, so `en` is the better guess. Where `en` cannot be created, as in +[globalization-invariant mode](#globalization-invariant-mode), the invariant culture stays, with the root +rules. + +Plural rules for every other culture come from CLDR rules generated into the MessageFormat library, which +follow the culture name's chain down to the root rules by themselves (`de-CH`, then `de`). A culture whose +name CLDR does not know at all gets the root rules: `other` only. Numbers and dates use .NET's data for +the culture: + +| Culture | Plural category of `1` | `{0, number}` with `1234.5` | +| --- | --- | --- | +| `CultureInfo.InvariantCulture` (formats as `en`) | `one` | `1,234.5` | +| `en` | `one` | `1,234.5` | +| `de-DE` | `one` | `1.234,5` | +| `iu-Latn-CA` | `one` | the culture's own format | +| `zh-Hant-TW` | `other` - Chinese has no `one` | the culture's own format | + +### Keep the thread culture in sync + +A few places of the MessageFormat library do not use the lookup's culture but the thread's +`CultureInfo.CurrentCulture`, which `Localizer.Current.CurrentCulture` deliberately does not change: + +- `#` in a `plural` branch renders the number in the thread culture (see + [`#` uses the thread culture, without grouping](#-uses-the-thread-culture-without-grouping)); +- `select` compares a number or date argument with its keys as the thread culture writes it, so with the + thread culture `de-DE` the value 1.5 does not match the key `1.5`; +- decimal exact-value keys and offsets, such as `=1.5` and `offset:1.5`, are read in the thread culture, + so with the thread culture `de-DE` the key `=1.5` does not match the value 1.5. Whole-number keys and + offsets, such as `=0`, `=1` and `offset:1`, do not depend on any culture. + +It is therefore recommended to keep `CultureInfo.CurrentCulture` in sync with +`Localizer.Current.CurrentCulture` yourself, for example in a `Localizer.Current.CultureChanged` handler +that you subscribe before the first assignment of `Localizer.Current.CurrentCulture`: + +```csharp +Localizer.Current.CultureChanged += (_, e) => +{ + CultureInfo.CurrentCulture = e.NewCulture; // the thread that switches the language, normally the UI thread + CultureInfo.DefaultThreadCurrentCulture = e.NewCulture; // threads without a culture of their own +}; + +Localizer.Current.CurrentCulture = new CultureInfo("de-DE"); +``` + +The thread culture also decides how the rest of your code formats numbers and dates, for example +`ToString()` and `string.Format` without a format provider, so this changes those to the selected +language too - in a localized application usually what you want. + +## Styles + +Numbers, dates and times are formatted with .NET's culture data, not with CLDR's, and only these styles +exist (examples in `en-US`): + +| Pattern | Renders | +| --- | --- | +| `{0, number}` | the culture's number format, grouped: `1,234.5`; `1,234,567` for an integer | +| `{0, number, integer}` | rounded and **not** grouped: `1234568` for `1234567.6` | +| `{0, number, currency}` | .NET's `C` format with the culture's own currency: `$3.50`; `3,50 €` in `de-DE` | +| `{0, number, percent}` | .NET's `P0` format: `26%` for `0.256` | +| `{0, date}` and `{0, date, short}` | .NET's `d` format: `1/31/2026` | +| `{0, date, full}` | `Saturday, January 31, 2026` | +| `{0, time, short}` | `2:05 PM` | +| `{0, time}` and `{0, time, medium}` | `2:05:09 PM` | + +A neutral culture such as `en` or `de` has no currency, so `{0, number, currency}` renders the generic +currency sign there: `¤3.50`. Use a specific culture such as `en-US` or `de-DE` where amounts of money +are shown. + +Every other style returns the failure text and raises `LookupFailed` with an +`UnsupportedFormatStyleException`: `{0, date, medium}`, `{0, date, long}`, and number skeletons such as +`{0, number, ::currency/EUR}`. Add the styles you need with `CustomValueFormatter`, see +[Configuring the formatter of the MessageFormat library](#configuring-the-formatter-of-the-messageformat-library). + +### `#` uses the thread culture, without grouping + +In a `plural` branch, `#` renders the number with `CultureInfo.CurrentCulture` - the thread's culture, +which `Localizer.Current.CurrentCulture` deliberately does not change - and without grouping. With the +thread culture `en-US` and the lookup culture `de-DE`, +`{Count, plural, one {# Datei} other {# Dateien}}` renders `1234567 Dateien` for 1234567 and `1.5 Dateien` +for 1.5. Integer counts below 1,000 are therefore right in every culture, and fractional counts are right +when you [keep the thread culture in sync](#keep-the-thread-culture-in-sync). + +For larger or fractional numbers, write `{Count, number}` in the branch instead of `#`; it uses the +lookup culture: `{Count, plural, one {{Count, number} Datei} other {{Count, number} Dateien}}` renders +`1.234.567 Dateien`. Nothing reports a `#` that renders differently from the lookup culture. + +## Configuring the formatter of the MessageFormat library + +`UseMessageFormat` takes an optional `Jeffijoe.MessageFormat.MessageFormatter`, the extension point of the +MessageFormat library: custom formatters (`Formatters`), custom plural rules (`CardinalPluralizers`, +`OrdinalPluralizers`), and `CustomValueFormatter` for further `number`, `date` and `time` styles. This one +adds `date, medium`: + +```csharp +using System.Globalization; +using RentADeveloper.ResXLocalization; +using RentADeveloper.ResXLocalization.MessageFormat; +using JeffijoeMessageFormatter = Jeffijoe.MessageFormat.MessageFormatter; + +var messageFormatter = new JeffijoeMessageFormatter( + customValueFormatter: new Jeffijoe.MessageFormat.CustomValueFormatters + { + // Handles "date, medium"; returning false keeps the built-in behavior for every other style. + Date = (CultureInfo culture, object? value, string? style, out string? formatted) => + { + if (style == "medium" && value is DateTime date) + { + formatted = date.ToString("d MMM yyyy", culture); + return true; + } + + formatted = null; + return false; + }, + } +); + +Localizer.Current.UseMessageFormat(messageFormatter); + +// {0, date, medium} with new DateTime(2026, 1, 31) now renders "31 Jan 2026" in en. +``` + +- **It is used as given.** `UseMessageFormat` does not copy, wrap or reconfigure it. `null`, the default, + means a new `MessageFormatter` with the library's defaults. +- **It is shared by every culture and every thread.** Formatting with it is thread-safe, but its + configuration is not: `Formatters` is a list, the plural rules are plain dictionaries, and + `CustomValueFormatter` has a setter. Configure it completely before calling `UseMessageFormat`, and do + not modify it afterwards. +- **Its `Culture` property has no effect**, because every lookup passes its own culture. +- **Its parse cache** keeps one parsed pattern per distinct pattern text and is never trimmed. Resource + values bound it; if you format generated patterns, pass `new JeffijoeMessageFormatter(useCache: false)`. +- **An exception your extensions throw** while formatting is a formatting failure like any other: the + lookup returns the failure text and raises `LookupFailed`. Whether your extensions are trim and Native + AOT safe is yours to check. +- **Use an alias.** Both `Jeffijoe.MessageFormat` and `RentADeveloper.ResXLocalization` declare an + `IMessageFormatter`, so importing both namespaces in one file makes `IMessageFormatter` ambiguous. Import + `Jeffijoe.MessageFormat.MessageFormatter` under an alias, as above. + +## Globalization-invariant mode + +`UseMessageFormat()` works in globalization-invariant mode and does not throw there. But an application +built with `InvariantGlobalization=true` - a common setting for Native AOT and for containers - or run +with `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1` cannot create named cultures at all: +`new CultureInfo("de")` throws a `CultureNotFoundException`. Only the invariant culture is left, and it +formats with the root plural rules, `other` only. + +With `PredefinedCulturesOnly=false` as well, named cultures can be created and their plural rules follow +their names (`de-CH` chooses `one` for 1), but numbers, dates and currencies format invariantly: `1,234.5` +in `de-DE`, and `3.50` for a currency. Neither setup is recommended for a localized application. + +## Migrating resource values + +Only a resource value that contains a brace (`{` or `}`), a doubled apostrophe (`''`) or an apostrophe +before a number sign (`'#`) can render differently under ICU, or fail. Every other value renders as +written, including texts with single apostrophes such as `It's ready` or `l'utilisateur`, and texts with +angle brackets. To find the values to review, search your `.resx` files for the regular expression +`[{}]|''|'#` - with the find-in-files search of your IDE, or with PowerShell: + +```powershell +Get-ChildItem -Recurse -Filter *.resx | Select-String -Pattern "[{}]|''|'#" +``` + +Every value with arguments contains braces, so those values are among the matches too; the tables below +show what to look for. + +ICU syntax is not compatible with composite format strings. These spellings fail under ICU - they return +the failure text and raise `LookupFailed`, so at least they are reported: + +| Composite format | ICU | +| --- | --- | +| `{0:N0}` | `{0, number}` (grouped); `{0, number, integer}` rounds without grouping | +| `{1:d}` | `{1, date, short}` | +| `{0:C}` | `{0, number, currency}` | +| `{{literal}}` | `'{'literal'}'` | + +ICU parses a resource value whenever it formats one, so a literal brace is syntax even in a text without +arguments: `Use { and } for blocks` fails, because `and` is read as a missing variable. Write +`Use '{' and '}' for blocks`. + +These values change **silently** - nothing reports them, not even `LookupFailed`: + +| Resource value | ICU renders, for the argument value "3" | The default formatter renders | +| --- | --- | --- | +| `'{0}' was not found` | `{0} was not found` | `'3' was not found` | +| `Datei '{0}' wurde gelöscht` | `Datei {0} wurde gelöscht` | `Datei '3' wurde gelöscht` | +| `Can't find '{Name}'` | `Can't find {Name}` | - | +| `'{0}'s file` | `{0}s file` | `'3's file` | +| `''{0}''` | `'3'` | `''3''` | +| `It''s {0}` | `It's 3` | `It''s 3` | +| `'#' in text {0}` | `# in text 3` | `'#' in text 3` | + +- **`'{0}'` renders the literal text `{0}`**: an apostrophe before a brace starts quoted text. To put an + argument in apostrophes, write `''{0}''`, which renders `'3'`. +- **`''` becomes one `'`.** +- **Apostrophes in ordinary text need no change**: elisions (`l'utilisateur {0}`), contractions + (`Don't delete {0}`) and possessives (`It's {0}'s turn`) render as written, inside a `plural` branch + too. + +Angle brackets are plain text: `{0} saved` renders `3 saved`, and `Press to {0}` +renders `Press to 3`. Some other ICU engines read tags as markup; this one does not. + +## Using another ICU engine + +The formatter is replaceable: `IMessageFormatter` and `ILocalizer.MessageFormatter` are public. If you +need what this engine does not offer - CLDR-exact number and date formats, number skeletons, `medium` and +`long` dates, durations, rich-text tags - implement `IMessageFormatter` over another engine and assign it +to `Localizer.Current.MessageFormatter` **instead of** calling `UseMessageFormat()`. + +This adapter formats with [Rh.MessageFormat](https://github.com/Rheopyrin/Rh.Messageformat) (NuGet +package `Rh.MessageFormat`) and keeps the three rules of this package: + +```csharp +using System.Globalization; +using RentADeveloper.ResXLocalization; +using Rh.MessageFormat.Caches; +using Rh.MessageFormat.Options; + +public sealed class RhMessageFormatter : IMessageFormatter +{ + // One Rh.MessageFormat formatter per locale, created on first use. A locale Rh.MessageFormat has no + // data for falls back to en instead of throwing an InvalidLocaleException. + private readonly MessageFormatterCachedProvider formatters = new( + new MessageFormatterOptions { DefaultFallbackLocale = "en" } + ); + + public string Format(string pattern, MessageArguments arguments, CultureInfo culture) + { + // 1. Every argument under its position, then every named argument under its name. + var values = new Dictionary(StringComparer.Ordinal); + + foreach (var argument in arguments) + { + values[argument.Position.ToString(CultureInfo.InvariantCulture)] = argument.Value; + } + + foreach (var argument in arguments) + { + if (argument.Name is { } name) + { + values[name] = argument.Value; + } + } + + // 2. Always parse, also without arguments: then the dictionary is empty. + // 3. The dictionary overload of FormatMessage, never FormatMessage(string, object), which reads the + // object's properties by reflection and is not trim or Native AOT safe. + // The invariant culture has no name, so it formats as en, as UseMessageFormat() does. + var locale = culture.Name.Length == 0 ? "en" : culture.Name; + return this.formatters.GetFormatter(locale).FormatMessage(pattern, values); + } +} +``` + +```csharp +Localizer.Current.MessageFormatter = new RhMessageFormatter(); // instead of UseMessageFormat() +``` + +Do not import the namespace `Rh.MessageFormat.Abstractions.Interfaces` in the adapter's file: it declares +an `IMessageFormatter` of its own, which makes `IMessageFormatter` ambiguous. + +Two warnings: + +- **Engines do not accept the same dialect.** Tags, missing variables and styles differ between ICU + engines, so resource values written for one engine can render differently, or fail, under another. For + example, under Rh.MessageFormat `Hello {Name}` looked up without arguments renders `Hello ` instead of + the failure text, and `{0, date, medium}` renders `Jan 31, 2026` instead of the failure text. +- **Such an adapter is your code.** ResXLocalization does not ship or support it, and its size and its + Native AOT safety are yours to check. Rh.MessageFormat carries CLDR data for more than 200 locales: + measured with Rh.MessageFormat 1.2.0, it adds about 15 MB to a Native AOT binary. diff --git a/docs/guides/migration-2.0.md b/docs/guides/migration-2.0.md new file mode 100644 index 0000000..5e5a046 --- /dev/null +++ b/docs/guides/migration-2.0.md @@ -0,0 +1,575 @@ +# Migrating to 2.0 + +2.0 is a major version; each section below is one breaking change, with what to do, starting with the +changes that affect the most applications. Two sections need no action: the optional section on the enum +key prefix, and the last section, a design-time change. + +## `LocalizeArgs` is replaced by `` + +The attached properties `LocalizeArgs.Arg0`...`Arg8` are removed from both packages. Format arguments +are `` children of `{l:Localize}`, each with a constant or bound `Value` and an optional +`Name`. A localized text with arguments is written in element syntax. + +Before: + +```xml + +``` + +After: + +```xml + + + + + + + +``` + +## `LocalizeExtension.ResourceKey` is replaced by `Key` + +`LocalizeExtension.ResourceKey` is removed. `Key` takes a `string` or a `ResourceKey`; a `ResourceKey` +resolves through its own resource manager and ignores `ResourceManager`. Code that read +`LocalizeExtension.Key` as a `string` reads an `object?`. A constructor argument or a `Key` that is +neither a `string` nor a `ResourceKey` throws an `InvalidOperationException` when the view loads; +before, it produced an empty key. + +Before: + +```xml + +``` + +After: + +```xml + + +``` + +## 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_"); +``` + +## 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 + +``` + +## `{l:LocalizeEnum}` takes its value from `Value` + +`{l:LocalizeEnum}` no longer reads the enum value from the target's `DataContext`; `Value` is required, +and a `{l:LocalizeEnum}` without it throws `InvalidOperationException` when the view loads. In an item +template, where the item is the `DataContext`, write `Value={Binding}`; for a view-model property, bind +it; for a fixed member, use `{x:Static}`. + +Before: + +```xml + +``` + +After: + +```xml + + + +``` + +## `LocalizeEnumConverter` has a new child order + +`LocalizeEnumConverter` reads child 0 as the refresh trigger, `RefreshTriggeringRevision` of +`Localizer.Current`, child 1 as the enum value, and children from index 2 on as format arguments. A +`MultiBinding` in the old order - the enum first, then `CurrentCulture` - renders an empty string until +it is rewritten, and nothing reports it, so search for every use of `LocalizeEnumConverter`. Extra +children that served only to re-trigger the conversion are now format arguments; remove them. + +Before: + +```xml + + + + +``` + +After: + +```xml + + + + +``` + +## Typed keys use the generated `ResourceManager` + +Every generated `Keys` class has its own `ResourceManager` property, created from the name the +`.resx` is embedded under, and every key of the class uses it instead of the designer's +`.ResourceManager`. Lookups return the same text. Registering or scoping with the designer's +instance still works, but it is a second instance of the same file with its own cache; use +`Keys.ResourceManager`. + +The generator reads that name from the analyzer config file the SDK writes into `obj/`, so every keys +class - with or without a designer - now depends on it. A project that sets +`GenerateMSBuildEditorConfigFile` to `false` gets `RXLGEN003` and no keys classes; remove that setting. + +Before: + +```csharp +ReferenceEquals(AppStringsKeys.Greeting.Manager, AppStrings.ResourceManager); // true + +Localizer.Current.RegisterResourceManager(AppStrings.ResourceManager); +Localizer.Current.Get("Greeting", resourceManager: AppStrings.ResourceManager); +``` + +After: + +```csharp +ReferenceEquals(AppStringsKeys.Greeting.Manager, AppStringsKeys.ResourceManager); // true +ReferenceEquals(AppStringsKeys.Greeting.Manager, AppStrings.ResourceManager); // false + +Localizer.Current.RegisterResourceManager(AppStringsKeys.ResourceManager); +Localizer.Current.Get("Greeting", resourceManager: AppStringsKeys.ResourceManager); +``` + +## More `.resx` files produce keys classes + +Every neutral `.resx` now produces a keys class: a file without a `.Designer.cs`, a neutral file with a +dot in its name, and the files of a project that uses the SDK's `GenerateResxSource`. `RXLGEN002` is no +longer reported. If a new `Keys` class collides with a type of your own, rename the `.resx`. + +Before: + +```text +Resources/Errors.resx (no designer) -> no ErrorsKeys, warning RXLGEN002 +Resources/My.Strings.resx -> no keys class (taken for a satellite) +``` + +After: + +```text +Resources/Errors.resx (no designer) -> ErrorsKeys +Resources/My.Strings.resx -> My_StringsKeys +``` + +## Two files that produce the same keys class are no longer merged + +Two neutral `.resx` files that produce the same namespace and class name - for example two +`Strings.resx` in different folders whose designers declare the same namespace - were merged into one +partial class. They now report `RXLGEN004`, and neither class is generated. Rename one of the files, or +change its designer's namespace or its `CustomToolNamespace`. + +Before: + +```text +A/Strings.resx + B/Strings.resx, both designers in namespace MyApp -> one partial MyApp.StringsKeys +``` + +After: + +```text +A/Strings.resx + B/Strings.resx, both designers in namespace MyApp -> RXLGEN004 at each file, no StringsKeys +B/Strings.resx renamed to B/MoreStrings.resx -> MyApp.StringsKeys and MyApp.MoreStringsKeys +``` + +## A wrong base name fails at registration + +`Localizer.RegisterResourceManager` checks a plain `ResourceManager` when it is registered. If no +resources exist under its base name - usually a hand-written base name with a typo - it throws an +`ArgumentException` that names the base name, and the manager is not registered. Before, the manager +was registered, and every search-all lookup that reached it threw `MissingManifestResourceException`. +A subclass of `ResourceManager` is registered without that check, as before. + +Fix the base name, or register the generated `Keys.ResourceManager`. + +Before: + +```csharp +Localizer.Current.RegisterResourceManager(new ResourceManager("MyApp.Strngs", typeof(App).Assembly)); // registered +Localizer.Current.Get("Greeting"); // MissingManifestResourceException +``` + +After: + +```csharp +Localizer.Current.RegisterResourceManager(new ResourceManager("MyApp.Strngs", typeof(App).Assembly)); +// ArgumentException: No resources exist under the base name 'MyApp.Strngs'. ... + +Localizer.Current.RegisterResourceManager(AppStringsKeys.ResourceManager); +``` + +## `TranslationNotFound` is replaced by `LookupFailed` + +`ILocalizer.TranslationNotFound` and `TranslationNotFoundEventArgs` are replaced by +`ILocalizer.LookupFailed` and `LookupFailedEventArgs`. The new event reports every failed lookup - a key +that no resource file contains, and, new in 2.0, a value that cannot be formatted. Its +`LookupFailedEventArgs.Failure` is a `LookupFailure`, the same value `LookupFailureTextBuilder` +receives; `Failure.Reason` tells the two cases apart. To keep logging only missing translations, check +the reason. + +Before: + +```csharp +Localizer.Current.TranslationNotFound += (_, args) => + logger.LogWarning("No translation for {Key} in {Culture}", args.Key, args.Culture); +``` + +After: + +```csharp +Localizer.Current.LookupFailed += (_, args) => +{ + if (args.Failure.Reason == LookupFailureReason.TranslationNotFound) + { + logger.LogWarning("No translation for {Key} in {Culture}", args.Failure.Key, args.Failure.Culture); + } +}; +``` + +## `MissingTranslationFormat` is replaced by `LookupFailureTextBuilder` + +`ILocalizer.MissingTranslationFormat`, a composite format string, is replaced by +`ILocalizer.LookupFailureTextBuilder`, a `Func`. It produces the failure text for +a missing translation and, new in 2.0, for a formatting failure; `LookupFailure.Reason` tells them +apart. The compiler reports every use of the old property; code that read the format string calls the +function instead. The `ArgumentException` for a malformed format no longer exists. The documentation +calls the text the "failure text" instead of the "sentinel". + +Before: + +```csharp +Localizer.Current.MissingTranslationFormat = "[{0}]"; +``` + +After: + +```csharp +Localizer.Current.LookupFailureTextBuilder = failure => $"[{failure.Key}]"; +``` + +See [Lookup modes and culture fallback](lookup-and-fallback.md#failure-text) for more examples. + +## A formatting failure returns the failure text instead of throwing + +A resource value that cannot be formatted with the supplied arguments - `Broken {0`, or +`File {0} of {1}` with one argument - no longer throws `FormatException` from +`Localizer.Current.Get(key, args)` and its scoped and typed siblings. The lookup returns the failure +text (by default `!key!`) and raises `ILocalizer.LookupFailed` with +`LookupFailureReason.MessageFormatFailed`. Code that caught `FormatException` subscribes to the event +instead and checks `LookupFailure.Reason`, because the event reports missing translations too. + +Before: + +```csharp +try +{ + text = Localizer.Current.Get(AppStringsKeys.FilesCopied, copied); +} +catch (FormatException exception) +{ + logger.LogWarning(exception, "Broken translation"); +} +``` + +After: + +```csharp +Localizer.Current.LookupFailed += (_, args) => +{ + if (args.Failure.Reason == LookupFailureReason.MessageFormatFailed) + { + logger.LogWarning(args.Failure.Exception, "Broken translation {Key} in {Culture}", args.Failure.Key, args.Failure.Culture); + } +}; + +text = Localizer.Current.Get(AppStringsKeys.FilesCopied, MessageArguments.FromValues(copied)); +``` + +## A formatting failure in XAML renders the failure text + +`{l:Localize}` with arguments whose resource value cannot be formatted renders the +failure text, in both frameworks, and raises `LookupFailed`. In 1.x, Avalonia reported a binding +error and kept the previous text, and WPF showed an empty text. + +Before: + +```xml + + + +``` + +After: + +```xml + + + + + + + + + +``` + +## Avalonia: arguments in a style setter or a resource are formatted + +In Avalonia, a `{l:Localize}` with arguments in a `Style` setter or in a resource resolved unformatted, +because the arguments were read from a target element that a setter or a resource does not have. The +arguments now live on the binding, so the text is formatted there too. Check such texts after +upgrading. + +Before: + +```xml + + + +``` + +After: + +```xml + + +``` + +## An empty arguments array no longer formats + +`Localizer.Current.Get(key, args)` with an empty array (from 2.0, `Get(key, MessageArguments.Empty)`) returns the resource value unformatted, exactly +like `Get(key)`: `{{x}}` stays `{{x}}`, and a value containing `{0}` no longer throws. + +Before: + +```csharp +// Resource "Template" = "Literal {{braces}} for {0}" +Localizer.Current.Get("Template", Array.Empty()); // 1.x: FormatException +``` + +After: + +```csharp +Localizer.Current.Get("Template", MessageArguments.Empty); // "Literal {{braces}} for {0}" +``` + +## `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. + +Before: + +```text +MyLibrary.dll, compiled against 1.x, calls Localizer.Current.Get("Greeting") under 2.0 + -> MissingMethodException: Method not found: 'System.String RentADeveloper.ResXLocalization.ILocalizer.Get(System.String)'. +``` + +After: + +```text +MyLibrary.dll, recompiled against 2.0 without a source change, calls Localizer.Current.Get("Greeting") + -> the translation of "Greeting", as with 1.x +``` + +## Custom `ILocalizer` implementations have new members + +A custom `ILocalizer` implementation must implement: + +- `ILocalizer.LookupFailed`, replacing `TranslationNotFound` (see + [`TranslationNotFound` is replaced by `LookupFailed`](#translationnotfound-is-replaced-by-lookupfailed)); +- `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`; +- `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 +[`MissingTranslationFormat` is replaced by `LookupFailureTextBuilder`](#missingtranslationformat-is-replaced-by-lookupfailuretextbuilder)), raises +`LookupFailed` and builds the failure text for both `LookupFailureReason`s. + +Before: + +```csharp +public sealed class MyLocalizer : ILocalizer +{ + public event EventHandler? TranslationNotFound; + public string MissingTranslationFormat { get; set; } = "!{0}!"; + // ... +} +``` + +After: + +```csharp +public sealed class MyLocalizer : ILocalizer +{ + public event EventHandler? LookupFailed; + public IMessageFormatter MessageFormatter { get; set; } = StringFormatMessageFormatter.Instance; + public int RefreshTriggeringRevision { get; private set; } + public Func LookupFailureTextBuilder { get; set; } = failure => $"!{failure.Key}!"; + + 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) */; + // ... +} +``` + +## 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); +``` + +## Rider's WPF previewer renders raw patterns (not a breaking change) + +Listed for completeness; nothing to do. In Rider's WPF XAML previewer, localized strings rendered nothing, +because of a defect in the previewer (JetBrains +[RIDER-143207](https://youtrack.jetbrains.com/issue/RIDER-143207/WPF-XAML-previewer-supplies-RuntimePropertyInfo-instead-of-DependencyProperty-to-a-custom-markup-extension-causing-exceptions)). +As a workaround, they now render their raw resource pattern there under the default formatter, for +example `{0} people invited`. With ICU MessageFormat installed, that lookup without arguments interprets +escaping and returns the failure text for a variable it is not given. The running application, the Visual +Studio WPF designer and the Avalonia previewer are unchanged. See +[Design time and previewers](design-time.md#riders-wpf-previewer). diff --git a/docs/guides/native-aot.md b/docs/guides/native-aot.md index 56bbc5b..07566d0 100644 --- a/docs/guides/native-aot.md +++ b/docs/guides/native-aot.md @@ -1,10 +1,16 @@ # Native AOT and trimming -`ResXLocalization.Avalonia` and `ResXLocalization.Core` publish with `PublishAot=true` and produce no -IL2xxx or IL3xxx diagnostic — not from the library, not from a package in the closure, and not at -your own call sites. There is no `[RequiresUnreferencedCode]` or `[RequiresDynamicCode]` on any +`ResXLocalization.Avalonia`, `ResXLocalization.Core` and `ResXLocalization.MessageFormat` publish with +`PublishAot=true` and produce no IL2xxx or IL3xxx diagnostic — not from the library, not from a package +in the closure, and not at 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. @@ -35,6 +41,24 @@ Two more are worth setting for an Avalonia application in general: ``` +Format arguments in XAML are trim safe when their bindings are compiled bindings: in Avalonia, +`Value="{Binding ...}"` of an `` is a compiled binding under +`AvaloniaUseCompiledBindingsByDefault` or `x:CompileBindings="True"`, and a reflection binding +otherwise. See [Dynamic format arguments](format-arguments.md#worth-knowing). + +## ICU MessageFormat + +The opt-in package `ResXLocalization.MessageFormat` adds about 160 KB to a Native AOT binary, the +`MessageFormat` library included. It produces no IL diagnostic on `net8.0` or `net10.0`, because it +calls only the `IReadOnlyDictionary` overload of the library's `FormatMessage`; the library's overloads +that take an `object` read the properties of that object by reflection and are not called. See +[ICU MessageFormat](message-format.md). + +`InvariantGlobalization=true` is often recommended for a small Native AOT binary, but it leaves a +localized application with the invariant culture only — with `PredefinedCulturesOnly=false` as well, +with named cultures whose numbers and dates format invariantly. See +[Globalization-invariant mode](message-format.md#globalization-invariant-mode). + ## Publishing ```shell @@ -52,13 +76,15 @@ longer loaded, a typed key whose resource manager was trimmed away — each of t that builds, starts, and answers wrongly. Nothing is trimmed on the just-in-time compiler, so a test 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 -`scripts/verify-package-aot.ps1`. A check that only asserts the executable exists proves that the +This repository's own gate therefore publishes two package-only consumers natively and **runs** them, +asserting exact strings before and after a culture change. The first covers the typed, scoped and +search-all lookups, the enum convention, composite formatting, named arguments built by reflection with +`MessageArguments.FromObject`, and the fallback to the neutral culture. The second covers the ICU +formatter: `plural` patterns with a named argument from a compiled binding, in code and in XAML, and +the invariant culture formatted with English plural rules. See `scripts/verify-package-aot.ps1`. A check that only asserts the executable exists proves that the linker ran, and nothing else. -## If a lookup returns the sentinel only after publishing +## If a lookup returns the failure text only after publishing In order of likelihood: diff --git a/docs/guides/registration.md b/docs/guides/registration.md new file mode 100644 index 0000000..667187e --- /dev/null +++ b/docs/guides/registration.md @@ -0,0 +1,180 @@ +# Registering resource files + +A search-all lookup - a key without a resource manager - searches the resource files you registered. +This page covers what to register, where, and in which order. + +## Registration is only for search-all + +Registration is needed only for lookups that name no resource manager: + +```csharp +Localizer.Current.Get("Greeting"); // search-all +Localizer.Current.Get(FileSortOrder.Ascending); // search-all, enum key +``` + +```xml + + +``` + +Typed and scoped lookups need none - they already name the file: + +```csharp +Localizer.Current.Get(AppStringsKeys.Greeting); +Localizer.Current.Get("Greeting", resourceManager: AppStringsKeys.ResourceManager); +``` + +With nothing registered, every search-all lookup returns the failure text, `!Greeting!` by default. + +## Register the generated resource manager + +```csharp +Localizer.Current.RegisterResourceManager(AppStringsKeys.ResourceManager); +``` + +`AppStringsKeys.ResourceManager` is the instance every typed key of `AppStrings.resx` uses (see +[Generated keys](../reference/generated-keys.md)). Registering the designer's +`AppStrings.ResourceManager` works too, but it is a second instance of the same file with its own +cache, and `UnregisterResourceManager` finds only the instance that was registered. + +`RegisterResourceManager` checks a hand-written `ResourceManager` once, when it is registered: if no +resources exist under its base name, it throws an `ArgumentException` that names the base name, and +the manager is not registered. A subclass of `ResourceManager` is registered without that check. + +## Order + +Search-all searches the registered files **in registration order**, and the first match wins. When two +files carry the same key name, the one registered first decides. Registering the same instance twice +has no effect. + +To keep a file out of search-all - an internal branding file, say - do not register it. Its typed keys +and scoped lookups still work. + +## Where to register + +### Avalonia + +Register in `BuildAvaloniaApp`. It runs at run time and in the Avalonia previewer, so search-all +lookups resolve in the previewer too. + +```csharp +internal static class Program +{ + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + + private static AppBuilder BuildAvaloniaApp() + { + Localizer.Current.RegisterResourceManager(AppStringsKeys.ResourceManager); + Localizer.Current.CurrentCulture = new CultureInfo("en"); + + return AppBuilder.Configure().UsePlatformDetect(); + } +} +``` + +### WPF + +At run time, `App.OnStartup` is enough: + +```csharp +public partial class App : Application +{ + protected override void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + Localizer.Current.RegisterResourceManager(AppStringsKeys.ResourceManager); + Localizer.Current.CurrentCulture = new CultureInfo("en"); + } +} +``` + +The WPF designer runs no startup code: `App.OnStartup` is not called there, so search-all lookups render +the failure text in the designer. A module initializer does run there - in the Visual Studio designer and +in Rider's previewer. To make search-all lookups work in the designer too, register in a +`[ModuleInitializer]` of the application assembly instead: + +```csharp +using System.Runtime.CompilerServices; + +internal static class ResourceRegistration +{ + [ModuleInitializer] + internal static void Register() + { + Localizer.Current.CurrentCulture = new CultureInfo("en"); + Localizer.Current.RegisterResourceManager(AppStringsKeys.ResourceManager); + } +} +``` + +A module initializer runs before `Main`, so it always registers into the default `Localizer.Current` +(see [Dependency injection](#dependency-injection)). + +### Views in a class library + +The WPF designer loads the library that contains the view, not the executable, so the application's +module initializer does not run for the library's previews. Give the library its own module initializer +that registers only the library's own files. + +Under the JIT, a library's module initializer runs before the application's when the application's +initializer references the library's types, so the library's files come first in the search order. A +file registered by both is harmless: registering the same instance twice has no effect. + +`CA2255` reports a `[ModuleInitializer]` in a library. Suppress it there, on the method, with a +justification: + +```csharp +[ModuleInitializer] +[SuppressMessage("Usage", "CA2255", Justification = "Registers this library's resources for the WPF designer.")] +internal static void Register() => Localizer.Current.RegisterResourceManager(LibraryStringsKeys.ResourceManager); +``` + +## Dependency injection + +Register into the instance your application uses for lookups. With +`services.AddSingleton(_ => Localizer.Current)` that is `Localizer.Current`, and nothing +else is needed. An application that assigns its own instance to `Localizer.Current` registers on that +instance; see [Lookup modes and culture fallback](lookup-and-fallback.md#dependency-injection). + +## Resources in other assemblies + +| The other assembly | Register | +| --- | --- | +| A library of yours with the UI package, so it has its own keys classes | `Lib.Resources.StringsKeys.ResourceManager` | +| A library with a public designer | `Lib.Resources.Strings.ResourceManager` | +| Neither - for example a NuGet package or DLL with internal designers only | `new ResourceManager("", typeof().Assembly)` | + +For the last case, `RegisterResourceManager` rejects a wrong embedded name at startup. An assembly with +no public type at all can be reached with a constant type name: + +```csharp +var assembly = Type.GetType("Vendor.Texts.SomeType, Vendor.Texts", throwOnError: true)!.Assembly; +Localizer.Current.RegisterResourceManager(new ResourceManager("Vendor.Texts.Strings", assembly)); +``` + +That works under Native AOT without trim warnings, and so do that assembly's satellites for the cultures +you list in `SatelliteResourceLanguages` (see +[Native AOT and trimming](native-aot.md#what-you-have-to-declare)). `Assembly.Load("Vendor.Texts")` +throws under Native AOT; do not use it. + +## A forgotten registration + +A file that was never registered shows as the failure text in every search-all lookup for its keys. +Subscribe to `LookupFailed` to log every miss (see +[Missing-translation diagnostics](lookup-and-fallback.md#missing-translation-diagnostics)). + +## Why the library registers nothing itself + +Nothing registers a resource file automatically - neither the library nor the source generator: + +- A generated module initializer would register into whatever `Localizer.Current` is when the assembly + is first touched. In an executable that is before `Main`, so an application that assigns its own + `ILocalizer` to `Localizer.Current` at startup would always lose the registrations. +- Module initializers of several assemblies run in touch order under the JIT, but eagerly and in + dependency order under Native AOT, so the search order across assemblies would differ between a JIT + run and a native one. +- The generator sees one project at a time and cannot see the resources of referenced projects, and + automatic discovery would also pick up third-party files nobody meant to search. + +Registration in your own code has none of these problems, and the search order is where you write it. diff --git a/docs/index.md b/docs/index.md index b74e32c..1b33cd2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,9 +12,13 @@ installation and a quick start. These pages are the full documentation. | Guide | What it covers | | --- | --- | | [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 | +| [Registering resource files](guides/registration.md) | What to register for search-all lookups, where - including the WPF designer and class libraries - dependency injection, resources from other assemblies | | [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 `` children of `{l:Localize}` with constant or bound values, optionally named | +| [ICU MessageFormat](guides/message-format.md) | The opt-in `ResXLocalization.MessageFormat` package: `plural` and `select` through `Localizer.Current.UseMessageFormat()`, argument mapping, format culture, styles, and migrating resource values to ICU syntax | +| [Design time and previewers](guides/design-time.md) | `DesignTime.Culture` for the preview language, the culture a previewer uses without it, search-all lookups and ICU in the WPF designer, and what to expect in Rider's WPF previewer | | [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 | ## Reference @@ -31,6 +35,7 @@ The generated reference covers the runtime assemblies: | [`RentADeveloper.ResXLocalization`](xref:RentADeveloper.ResXLocalization) | `ResXLocalization.Core` | `ILocalizer`, `Localizer`, `ResourceKey` — the shared engine | | [`RentADeveloper.ResXLocalization.Avalonia`](xref:RentADeveloper.ResXLocalization.Avalonia) | `ResXLocalization.Avalonia` | The Avalonia markup extensions and converter | | [`RentADeveloper.ResXLocalization.WPF`](xref:RentADeveloper.ResXLocalization.WPF) | `ResXLocalization.WPF` | The WPF markup extensions and converter | +| [`RentADeveloper.ResXLocalization.MessageFormat`](xref:RentADeveloper.ResXLocalization.MessageFormat) | `ResXLocalization.MessageFormat` | `UseMessageFormat`, which installs the ICU MessageFormat formatter | ## The packages @@ -39,10 +44,12 @@ The generated reference covers the runtime assemblies: | `ResXLocalization.Avalonia` | Avalonia 12 | `net8.0`, `net10.0` | Supported | | `ResXLocalization.WPF` | WPF | `net8.0-windows`, `net10.0-windows` | Not supported — a WPF limitation | | `ResXLocalization.Core` | none | `net8.0`, `net10.0` | Supported | +| `ResXLocalization.MessageFormat` | none | `net8.0`, `net10.0` | Supported | Install the package for your UI framework; it brings the engine, the source generator and the build wiring with it. Core arrives as a dependency, and one copy of it satisfies both UI packages in an -application that uses them together. +application that uses them together. `ResXLocalization.MessageFormat` is optional: add it next to the UI +package to format with ICU MessageFormat, see [ICU MessageFormat](guides/message-format.md). ## Project diff --git a/docs/reference/generated-keys.md b/docs/reference/generated-keys.md index 78df7fe..65226ff 100644 --- a/docs/reference/generated-keys.md +++ b/docs/reference/generated-keys.md @@ -5,18 +5,19 @@ that feeds it. Nothing has to be configured for it to run. ## Which files qualify -A `.resx` file produces a typed key class when **all** of these hold: +Every **neutral** `.resx` file produces a typed key class: `AppStrings.resx`, not `AppStrings.de.resx`. +The neutral file defines the key set; a satellite translates it. -| Requirement | Why | -| --- | --- | -| It is the **neutral** file — `AppStrings.resx`, not `AppStrings.de.resx` | The neutral file defines the key set; a satellite translates it | -| Its name has no dot before the extension | A dot is how a culture is named, so `My.Strings.resx` cannot be told apart from a satellite of `My.resx` | -| A **classic** `*.Designer.cs` accessor sits in the same folder | That file is where the generator reads the namespace and the `ResourceManager` property it must reference | +Whether a file is neutral or a satellite is MSBuild's own culture detection, the one that decides +which files go into satellite assemblies. So a neutral file with a dot in its name, such as +`My.Strings.resx`, qualifies, while `Strings.de.resx` does not. + +No `.Designer.cs` accessor is needed. Files with a designer, files without one, and the files of a +project that uses the SDK's `GenerateResxSource` all qualify. -The accessor is the one Visual Studio's and Rider's classic resx tooling writes — the -`PublicResXFileCodeGenerator` or `ResXFileCodeGenerator` custom tool. The SDK's `GenerateResxSource` -alternative writes its accessor into `obj/`, where it is build output rather than a file the -generator can pair a `.resx` with, so a project using it gets no typed keys. +The generator learns each file's resource name - the name MSBuild embeds its resources under - from +the build, through the analyzer config file the SDK writes into `obj/`. The packages' build wiring +passes it; a `.resx` that reaches the generator any other way gets no keys and reports `RXLGEN003`. ## What becomes a key @@ -25,16 +26,24 @@ image, a colour, an icon — is skipped, because its value is not a string a loo Resource names that are not valid C# identifiers are sanitized into ones that are, and a collision that sanitizing would create is resolved by appending a number. The class is named after the file, -with `Keys` appended, and lands in the same namespace as the resource accessor. +with `Keys` appended and sanitized the same way: `My.Strings.resx` produces `My_StringsKeys`. ```csharp -// - for Resources/AppStrings.resx -namespace YourApp.Resources; +// - for Resources/AppStrings.resx, with or without AppStrings.Designer.cs +namespace MyApp.Resources; +/// Typed, compile-validated keys generated from AppStrings.resx. public static partial class AppStringsKeys { - public static readonly ResourceKey Greeting = new("Greeting", AppStrings.ResourceManager); - public static readonly ResourceKey WindowTitle = new("WindowTitle", AppStrings.ResourceManager); + /// + /// The resource manager of AppStrings.resx - the instance every key of this class uses. Register this + /// instance for search-all lookups, and pass it for scoped lookups. + /// + public static global::System.Resources.ResourceManager ResourceManager { get; } = + new("MyApp.Resources.AppStrings", typeof(AppStringsKeys).Assembly); + + public static readonly global::RentADeveloper.ResXLocalization.ResourceKey Greeting = new("Greeting", ResourceManager); + public static readonly global::RentADeveloper.ResXLocalization.ResourceKey WindowTitle = new("WindowTitle", ResourceManager); // …one ResourceKey per string entry, sorted by name. } ``` @@ -43,17 +52,83 @@ Each `ResourceKey` carries **both** the key name and the `ResourceManager` it be what makes a typed lookup direct: it never searches, and two files may use the same key name without colliding. +`ResourceManager` is the one instance of that file every key uses. Use it wherever the file's +resource manager is needed: + +```csharp +Localizer.Current.RegisterResourceManager(AppStringsKeys.ResourceManager); // search-all +Localizer.Current.Get("Greeting", resourceManager: AppStringsKeys.ResourceManager); // scoped +``` + +A resource key named `ResourceManager` gets a numeric suffix (`ResourceManager2`), like any other +name collision. The resource manager is created from the file's exact resource name, without +reflection, so the generated code is trimming and Native AOT safe. + The class is `partial`, so you can add members of your own to it in a file of your own. +## The namespace + +| The file | Namespace of the keys class | +| --- | --- | +| has a paired classic designer | the designer's namespace | +| has none, and sets `CustomToolNamespace` | its `CustomToolNamespace` | +| has none, and no `CustomToolNamespace` | the resource name without its last segment | + +A designer pairs when it is a `.Designer.cs` in the same folder with the same base name that declares +a type with a static `ResourceManager` property — the accessor Visual Studio's and Rider's classic resx +tooling writes. So a project with designers keeps its keys classes where they are, including with +`CustomToolNamespace`. + +Without a designer, `CustomToolNamespace` decides when the project sets it on the `.resx` - the same +setting the designer tools use for the designer's namespace: + +```xml + + + +``` + +It changes only the namespace: the resource manager still loads the name MSBuild embeds the resources +under. With a designer, the designer's namespace wins, which is normally the same value. A blank +`CustomToolNamespace` counts as not set. + +Otherwise the resource name decides. MSBuild names `Resources/AppStrings.resx` in a project +whose root namespace is `MyApp` `MyApp.Resources.AppStrings`, so the class lands in `MyApp.Resources`. +A `LogicalName` replaces that name: with `LogicalName="Totally.Custom.Name.resources"` the resource +manager loads `Totally.Custom.Name` and the class lands in `Totally.Custom`. Each namespace segment is +sanitized into a valid identifier (`event` becomes `@event`, `My-App` becomes `My_App`), and so is each +segment of a `CustomToolNamespace`; a resource name without a dot means the global namespace. + +| File, resource name | Namespace | Class | `ResourceManager` base name | +| --- | --- | --- | --- | +| `Resources/Strings.resx`, `N.Resources.Strings`, no designer | `N.Resources` | `StringsKeys` | `N.Resources.Strings` | +| same, designer declares `namespace Custom.Ns` | `Custom.Ns` | `StringsKeys` | `N.Resources.Strings` | +| same, no designer, `CustomToolNamespace=Texts.Custom` | `Texts.Custom` | `StringsKeys` | `N.Resources.Strings` | +| `Logical.resx`, `LogicalName=Totally.Custom.Name.resources` | `Totally.Custom` | `LogicalKeys` | `Totally.Custom.Name` | +| `My.Strings.resx`, `N.My.Strings` | `N.My` | `My_StringsKeys` | `N.My.Strings` | + +**WPF projects.** If the XAML build of a WPF project cannot find a keys class of a `.resx` without a +designer - CS0234 or CS0103, reported for a project named `__wpftmp` - the project +computes its `RootNamespace` in a way the build cannot map back to the real one, for example with +`ToUpper()`. Set a literal `RootNamespace`, or a `CustomToolNamespace` on the `.resx`. + +## Projects with designers + +Designers keep working, and the generator uses them for the namespace only. Their string properties, +such as `AppStrings.Greeting`, read in `CultureInfo.CurrentUICulture`, so they do not follow +`Localizer.CurrentCulture` or live language switching. Use the typed keys instead: +`Localizer.Current.Get(AppStringsKeys.Greeting)`. + ## Diagnostics | Id | Meaning | | --- | --- | -| `RXLGEN001` | An eligible `.resx` file is not well-formed XML. The diagnostic points at the file | -| `RXLGEN002` | An eligible `.resx` file has no recognizable classic accessor in its folder | +| `RXLGEN001` | A neutral `.resx` file is not well-formed XML. The diagnostic points at the file | +| `RXLGEN003` | A `.resx` file reached the generator without its MSBuild resource name, so no keys were generated. Include it as an `EmbeddedResource` of a project that references `ResXLocalization.Avalonia` or `ResXLocalization.WPF` | +| `RXLGEN004` | Two neutral `.resx` files produce the same keys class - for example two `Strings.resx` in different folders whose designers declare the same namespace. Both files report it and neither class is generated. Rename one of the files, or change its designer's namespace or its `CustomToolNamespace` | -`RXLGEN002` is the one to expect when a project uses `GenerateResxSource`, or when the `.Designer.cs` -file was deleted and the custom tool was never re-run. +A file reports at most one of them, checked in this order: `RXLGEN003`, then `RXLGEN004`, then +`RXLGEN001`. There is no `RXLGEN002`: a `.resx` no longer needs a designer. ## The compiler floor diff --git a/docs/toc.yml b/docs/toc.yml index 26e4dda..6f082b6 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -4,12 +4,20 @@ items: - name: Lookup modes and culture fallback href: guides/lookup-and-fallback.md + - name: Registering resource files + href: guides/registration.md - name: Localizing enum values href: guides/enums.md - name: Dynamic format arguments href: guides/format-arguments.md + - name: ICU MessageFormat + href: guides/message-format.md + - name: Design time and previewers + href: guides/design-time.md - name: Native AOT and trimming href: guides/native-aot.md + - name: Migrating to 2.0 + href: guides/migration-2.0.md - name: Reference items: - name: Generated keys diff --git a/samples/ResXLocalization.Avalonia.Sample/Program.cs b/samples/ResXLocalization.Avalonia.Sample/Program.cs index 9e6fa42..4a48954 100644 --- a/samples/ResXLocalization.Avalonia.Sample/Program.cs +++ b/samples/ResXLocalization.Avalonia.Sample/Program.cs @@ -12,19 +12,19 @@ internal static class Program /// The configured application builder. private static AppBuilder BuildAvaloniaApp() { - // Register every resource file with the ambient localizer once. This runs from Main at runtime - // AND from the Avalonia XAML previewer at design time, so search-all lookups ({l:Localize Greeting}) - // resolve in the previewer too instead of rendering the miss sentinel. Only the key-only - // ("search-all") lookups consult this set; typed-key and scoped lookups need no registration. - // Managers are searched in registration order on a first-match-wins basis. + // Register every resource file with the ambient localizer once, through the generated + // Keys.ResourceManager - the instance the typed keys use. This runs from Main at run time AND + // from the Avalonia XAML previewer at design time, so search-all lookups ({l:Localize Greeting}) + // resolve in the previewer too instead of rendering the failure text. Only the search-all lookups + // consult this set; typed-key and scoped lookups need no registration. Managers are searched in + // registration order, and the first match wins. // - // BrandingStrings has an *internal* accessor: registering it here works because Program is in the - // same assembly, and so does the typed-key path ({l:Localize {x:Static res:BrandingStringsKeys.X}}) - // because the generated key class is always public. Only XAML scoping via - // {x:Static res:BrandingStrings.ResourceManager} is unavailable for an internal accessor. - Localizer.Current.RegisterResourceManager(ApplicationStrings.ResourceManager); - Localizer.Current.RegisterResourceManager(SortingStrings.ResourceManager); - Localizer.Current.RegisterResourceManager(BrandingStrings.ResourceManager); + // BrandingStrings has an *internal* designer accessor, which plays no part here: the generated + // BrandingStringsKeys class and its ResourceManager are public, so registration, typed keys and + // XAML scoping via {x:Static res:BrandingStringsKeys.ResourceManager} all work. + Localizer.Current.RegisterResourceManager(ApplicationStringsKeys.ResourceManager); + Localizer.Current.RegisterResourceManager(SortingStringsKeys.ResourceManager); + Localizer.Current.RegisterResourceManager(BrandingStringsKeys.ResourceManager); // Pick the starting language explicitly so the sample always opens in English, regardless of the // machine's culture. The default would otherwise be CultureInfo.CurrentUICulture. diff --git a/samples/ResXLocalization.Avalonia.Sample/ResXLocalization.Avalonia.Sample.csproj b/samples/ResXLocalization.Avalonia.Sample/ResXLocalization.Avalonia.Sample.csproj index 6c6bc1c..731667d 100644 --- a/samples/ResXLocalization.Avalonia.Sample/ResXLocalization.Avalonia.Sample.csproj +++ b/samples/ResXLocalization.Avalonia.Sample/ResXLocalization.Avalonia.Sample.csproj @@ -53,8 +53,7 @@ - + diff --git a/samples/ResXLocalization.Avalonia.Sample/Resources/ApplicationStrings.Designer.cs b/samples/ResXLocalization.Avalonia.Sample/Resources/ApplicationStrings.Designer.cs index 3413c8d..ce3361d 100644 --- a/samples/ResXLocalization.Avalonia.Sample/Resources/ApplicationStrings.Designer.cs +++ b/samples/ResXLocalization.Avalonia.Sample/Resources/ApplicationStrings.Designer.cs @@ -114,6 +114,24 @@ public static string PeopleInvited { } } + /// + /// Looks up a localized string similar to Connected. + /// + public static string StatusConnected { + get { + return ResourceManager.GetString("StatusConnected", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Connecting.... + /// + public static string StatusConnecting { + get { + return ResourceManager.GetString("StatusConnecting", resourceCulture); + } + } + /// /// Looks up a localized string similar to ResXLocalization Showcase. /// diff --git a/samples/ResXLocalization.Avalonia.Sample/Resources/ApplicationStrings.de.resx b/samples/ResXLocalization.Avalonia.Sample/Resources/ApplicationStrings.de.resx index d91f68f..3001f81 100644 --- a/samples/ResXLocalization.Avalonia.Sample/Resources/ApplicationStrings.de.resx +++ b/samples/ResXLocalization.Avalonia.Sample/Resources/ApplicationStrings.de.resx @@ -129,6 +129,12 @@ {0} Personen eingeladen + + Verbindung wird hergestellt... + + + Verbunden + Unsortiert diff --git a/samples/ResXLocalization.Avalonia.Sample/Resources/ApplicationStrings.resx b/samples/ResXLocalization.Avalonia.Sample/Resources/ApplicationStrings.resx index 03669e9..cea5366 100644 --- a/samples/ResXLocalization.Avalonia.Sample/Resources/ApplicationStrings.resx +++ b/samples/ResXLocalization.Avalonia.Sample/Resources/ApplicationStrings.resx @@ -129,6 +129,12 @@ {0} people invited + + Connecting... + + + Connected + Unsorted diff --git a/samples/ResXLocalization.Avalonia.Sample/ViewModels/MainWindowViewModel.cs b/samples/ResXLocalization.Avalonia.Sample/ViewModels/MainWindowViewModel.cs index 676ab2e..7d16649 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: ApplicationStringsKeys.ResourceManager); // ---- Code-behind string lookups (each maps to one ILocalizer member) ------------------------------ @@ -79,10 +80,12 @@ public MainWindowViewModel(ILocalizer localizer) public int[] PeopleCountOptions { get; } = [0, 1, 2, 5, 10]; /// - /// 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. + /// Gets the branding text scoped to the BrandingStrings file, whose designer accessor is internal, + /// from code-behind. The scope is the public generated BrandingStringsKeys.ResourceManager, which + /// XAML can name through {x:Static} too. /// - public string PoweredByViaScopedInternalManager => this.localizer.Get("PoweredBy", BrandingStrings.ResourceManager); + public string PoweredByViaScopedInternalManager => + this.localizer.Get("PoweredBy", resourceManager: BrandingStringsKeys.ResourceManager); /// /// Gets the branding text via a typed key. This resolves even though BrandingStrings has an internal @@ -90,28 +93,57 @@ 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: SortingStringsKeys.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: ApplicationStringsKeys.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) -------------- /// Gets the selection via Get(Enum): search-all with the default Enum_ prefix. public string SortOrderViaSearchAllDefaultPrefix => this.localizer.Get(this.SelectedFileSortOrder); + /// + /// Gets the key of the connection status shown by the bound-key demo, as a generated + /// : StatusConnected while is + /// , otherwise StatusConnecting. The view binds it as the key of + /// {l:Localize {Binding StatusKey}}, so the shown text follows this property and the language. + /// + public ResourceKey StatusKey => + this.IsConnected ? ApplicationStringsKeys.StatusConnected : ApplicationStringsKeys.StatusConnecting; + + /// + /// Gets the name of as a plain , for the bound-key demo of a + /// string key: {l:Localize Key={Binding StatusKeyName}}. + /// + public string StatusKeyName => this.StatusKey.Name; + /// Gets the languages offered in the language switcher. public CultureInfo[] SupportedLanguages { get; } = [new("en"), new("de")]; + /// + /// Whether the simulated connection of the bound-key demo is established. Changing it changes + /// and , the keys the view binds. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(StatusKey))] + [NotifyPropertyChangedFor(nameof(StatusKeyName))] + public partial bool IsConnected { get; set; } + /// /// The people count formatted into the PeopleInvited resource by the dynamic - /// format-arguments demo: the view binds it to LocalizeArgs.Arg0, so every change + /// format-arguments demo: the view binds it to the Value of an Argument, so every change /// re-formats the localized text live. /// [ObservableProperty] diff --git a/samples/ResXLocalization.Avalonia.Sample/Views/MainWindow.axaml b/samples/ResXLocalization.Avalonia.Sample/Views/MainWindow.axaml index 377e072..4f0b446 100644 --- a/samples/ResXLocalization.Avalonia.Sample/Views/MainWindow.axaml +++ b/samples/ResXLocalization.Avalonia.Sample/Views/MainWindow.axaml @@ -26,16 +26,16 @@ + ResourceManager="{x:Static res:ApplicationStringsKeys.ResourceManager}" /> + ResourceManager="{x:Static res:SortingStringsKeys.ResourceManager}" /> @@ -154,7 +154,7 @@ + Text="The source generator emits one ResourceKey per string entry in each neutral .resx, with or without a designer. Each key carries the ResourceManager of its generated keys class, so the lookup is direct and a renamed or deleted resource becomes a compile error." /> @@ -181,15 +181,15 @@ + Text="Key property with a typed key" /> + Code="{}{l:Localize Key={x:Static res:ApplicationStringsKeys.Greeting}}" /> + Text="{l:Localize Key={x:Static res:ApplicationStringsKeys.Greeting}}" /> @@ -286,7 +286,7 @@ + Text="Pass a ResourceManager alongside the key to read from exactly one file, bypassing the search. In XAML, name the generated <Name>Keys.ResourceManager via x:Static - it is public even when the designer accessor is internal." /> @@ -297,14 +297,14 @@ + Code="{}{l:Localize Greeting, ResourceManager={x:Static res:ApplicationStringsKeys.ResourceManager}}" /> + ResourceManager={x:Static res:ApplicationStringsKeys.ResourceManager}}" /> @@ -316,14 +316,14 @@ + Code="{}{l:Localize Key=SortingHint, ResourceManager={x:Static res:SortingStringsKeys.ResourceManager}}" /> + ResourceManager={x:Static res:SortingStringsKeys.ResourceManager}}" /> @@ -336,7 +336,7 @@ + Text="In an item template each item is the DataContext, so {l:LocalizeEnum Value={Binding}} localizes it. The two combo boxes below show the very same FileSortOrder values with two different label sets, chosen by KeyPrefix." /> @@ -348,14 +348,14 @@ + Code="{}{l:LocalizeEnum Value={Binding}}" /> - + @@ -366,14 +366,14 @@ + Code="{}{l:LocalizeEnum Value={Binding}, KeyPrefix=Display_}" /> - + @@ -385,7 +385,7 @@ - + @@ -394,14 +394,14 @@ + Code="{}{l:LocalizeEnum Value={Binding}}" /> + Text="{l:LocalizeEnum Value={Binding}}" /> @@ -413,14 +413,15 @@ + Code="{}{l:LocalizeEnum Value={Binding}, KeyPrefix=Display_}" /> + Text="{l:LocalizeEnum Value={Binding}, + KeyPrefix=Display_}" /> @@ -432,14 +433,15 @@ + Code="{}{l:LocalizeEnum Value={Binding}, ResourceManager={x:Static res:ApplicationStringsKeys.ResourceManager}}" /> + Text="{l:LocalizeEnum Value={Binding}, + ResourceManager={x:Static res:ApplicationStringsKeys.ResourceManager}}" /> @@ -451,15 +453,16 @@ + Code="{}{l:LocalizeEnum Value={Binding}, KeyPrefix=Display_, ResourceManager={x:Static res:SortingStringsKeys.ResourceManager}}" /> + Text="{l:LocalizeEnum Value={Binding}, + KeyPrefix=Display_, + ResourceManager={x:Static res:SortingStringsKeys.ResourceManager}}" /> @@ -472,7 +475,7 @@ + Text="Where a markup extension is not accepted - for example a Binding-typed property such as DataGridTextColumn.Binding - use LocalizeEnumConverter inside a MultiBinding [RefreshTriggeringRevision, enumValue, arguments...]. The first child re-triggers the conversion whenever a lookup can change, such as on a language switch; children from index 2 on are format arguments." /> @@ -483,7 +486,7 @@ + Code="<MultiBinding Converter="{x:Static l:LocalizeEnumConverter.Default}"><Binding Path="RefreshTriggeringRevision" Source="{x:Static core:Localizer.Current}" /><Binding Path="SelectedFileSortOrder" /></MultiBinding>" /> - - + @@ -508,7 +511,7 @@ + Code="<MultiBinding Converter="{StaticResource EnumConverterCustomPrefixSearchAll}"><Binding Path="RefreshTriggeringRevision" Source="{x:Static core:Localizer.Current}" /><Binding Path="SelectedFileSortOrder" /></MultiBinding>" /> @@ -518,9 +521,9 @@ - - + @@ -535,9 +538,9 @@ + Code="<MultiBinding Converter="{StaticResource EnumConverterScopedDefaultPrefix}"><Binding Path="RefreshTriggeringRevision" Source="{x:Static core:Localizer.Current}" /><Binding Path="SelectedFileSortOrder" /></MultiBinding>" /> + Code="<l:LocalizeEnumConverter x:Key="EnumConverterScopedDefaultPrefix" ResourceManager="{x:Static res:ApplicationStringsKeys.ResourceManager}" />" /> - - + @@ -562,9 +565,9 @@ + Code="<MultiBinding Converter="{StaticResource EnumConverterScopedCustomPrefix}"><Binding Path="RefreshTriggeringRevision" Source="{x:Static core:Localizer.Current}" /><Binding Path="SelectedFileSortOrder" /></MultiBinding>" /> + Code="<l:LocalizeEnumConverter x:Key="EnumConverterScopedCustomPrefix" KeyPrefix="Display_" ResourceManager="{x:Static res:SortingStringsKeys.ResourceManager}" />" /> - - + @@ -583,20 +586,20 @@ - + + Text="6. Dynamic format arguments - Argument" /> + Text="A resource value can be a composite format string - here PeopleInvited is '{0} people invited'. Add <l:Argument> children to <l:Localize> to supply the arguments - each a constant or a binding, optionally named; the text re-formats live when a bound argument or the language changes." /> + Text="People count (the view-model property bound to the Value of the Argument):" /> + Text="Argument bound to a view-model property" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Code="{}{l:Localize Key={Binding StatusKeyName}}" /> - + - + + Text="8. Code-behind API - ILocalizer.Get and the indexer" /> @@ -695,9 +759,9 @@ + Text="Get(string, resourceManager:)" /> + Code="localizer.Get("Greeting", resourceManager: ApplicationStringsKeys.ResourceManager)" /> + Code="localizer.Get("PoweredBy", resourceManager: BrandingStringsKeys.ResourceManager)" /> + Text="Get(Enum, keyPrefix:)" /> + Code="localizer.Get(selectedSortOrder, keyPrefix: "Display_")" /> + Text="Get(Enum, resourceManager:)" /> + Code="localizer.Get(selectedSortOrder, resourceManager: ApplicationStringsKeys.ResourceManager)" /> + Text="Get(Enum, resourceManager:, keyPrefix:)" /> + Code="localizer.Get(selectedSortOrder, resourceManager: SortingStringsKeys.ResourceManager, keyPrefix: "Display_")" /> - + diff --git a/samples/ResXLocalization.WPF.Sample/ResourceRegistration.cs b/samples/ResXLocalization.WPF.Sample/ResourceRegistration.cs new file mode 100644 index 0000000..b5a58d6 --- /dev/null +++ b/samples/ResXLocalization.WPF.Sample/ResourceRegistration.cs @@ -0,0 +1,37 @@ +using System.Runtime.CompilerServices; +using RentADeveloper.ResXLocalization.WPF.Sample.Resources; + +namespace RentADeveloper.ResXLocalization.WPF.Sample; + +/// +/// Selects the starting language and registers the sample's resource files with the ambient localizer when +/// the assembly loads - at run time and in the WPF designer. +/// +internal static class ResourceRegistration +{ + /// + /// Runs once, before any other code of this assembly. The Visual Studio WPF designer runs no startup code + /// (App.OnStartup is not called there), but it does run module initializers - as does Rider's + /// previewer - so search-all lookups + /// such as {l:Localize Greeting} resolve in the designer too instead of rendering the failure text. + /// An application that does not care about the designer can do the same in App.OnStartup. + /// + [ModuleInitializer] + internal static void Register() + { + // Pick the starting language explicitly so the sample always opens in English, regardless of the + // machine's culture. The default would otherwise be CultureInfo.CurrentUICulture. + Localizer.Current.CurrentCulture = new("en"); + + // Register every resource file once, through the generated Keys.ResourceManager - the instance + // the typed keys use. Only the search-all lookups consult this set; typed-key and scoped lookups need + // no registration. Managers are searched in registration order, and the first match wins. + // + // BrandingStrings has an *internal* designer accessor, which plays no part here: the generated + // BrandingStringsKeys class and its ResourceManager are public, so registration, typed keys and + // XAML scoping via {x:Static res:BrandingStringsKeys.ResourceManager} all work. + Localizer.Current.RegisterResourceManager(ApplicationStringsKeys.ResourceManager); + Localizer.Current.RegisterResourceManager(SortingStringsKeys.ResourceManager); + Localizer.Current.RegisterResourceManager(BrandingStringsKeys.ResourceManager); + } +} diff --git a/samples/ResXLocalization.WPF.Sample/Resources/ApplicationStrings.Designer.cs b/samples/ResXLocalization.WPF.Sample/Resources/ApplicationStrings.Designer.cs index 96e76ad..c67af6a 100644 --- a/samples/ResXLocalization.WPF.Sample/Resources/ApplicationStrings.Designer.cs +++ b/samples/ResXLocalization.WPF.Sample/Resources/ApplicationStrings.Designer.cs @@ -114,6 +114,24 @@ public static string PeopleInvited { } } + /// + /// Looks up a localized string similar to Connected. + /// + public static string StatusConnected { + get { + return ResourceManager.GetString("StatusConnected", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Connecting.... + /// + public static string StatusConnecting { + get { + return ResourceManager.GetString("StatusConnecting", resourceCulture); + } + } + /// /// Looks up a localized string similar to ResXLocalization Showcase. /// diff --git a/samples/ResXLocalization.WPF.Sample/Resources/ApplicationStrings.de.resx b/samples/ResXLocalization.WPF.Sample/Resources/ApplicationStrings.de.resx index 25db295..f8fd061 100644 --- a/samples/ResXLocalization.WPF.Sample/Resources/ApplicationStrings.de.resx +++ b/samples/ResXLocalization.WPF.Sample/Resources/ApplicationStrings.de.resx @@ -24,6 +24,12 @@ {0} Personen eingeladen + + Verbindung wird hergestellt... + + + Verbunden + Unsortiert diff --git a/samples/ResXLocalization.WPF.Sample/Resources/ApplicationStrings.resx b/samples/ResXLocalization.WPF.Sample/Resources/ApplicationStrings.resx index 3e6d874..29f2f1e 100644 --- a/samples/ResXLocalization.WPF.Sample/Resources/ApplicationStrings.resx +++ b/samples/ResXLocalization.WPF.Sample/Resources/ApplicationStrings.resx @@ -24,6 +24,12 @@ {0} people invited + + Connecting... + + + Connected + Unsorted diff --git a/samples/ResXLocalization.WPF.Sample/ViewModels/MainWindowViewModel.cs b/samples/ResXLocalization.WPF.Sample/ViewModels/MainWindowViewModel.cs index f006d5a..1c68ed3 100644 --- a/samples/ResXLocalization.WPF.Sample/ViewModels/MainWindowViewModel.cs +++ b/samples/ResXLocalization.WPF.Sample/ViewModels/MainWindowViewModel.cs @@ -33,6 +33,14 @@ public sealed partial class MainWindowViewModel : ObservableObject, IDisposable private readonly ILocalizer localizer; + /// + /// Initializes a new instance of the class over + /// . XAML designers need a parameterless constructor to create the + /// design-time data context that d:DesignInstance in declares. + /// + public MainWindowViewModel() + : this(Localizer.Current) { } + /// /// Initializes a new instance of the class. /// @@ -58,8 +66,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: ApplicationStringsKeys.ResourceManager); // ---- Code-behind string lookups (each maps to one ILocalizer member) ------------------------------ @@ -79,10 +88,12 @@ public MainWindowViewModel(ILocalizer localizer) public int[] PeopleCountOptions { get; } = [0, 1, 2, 5, 10]; /// - /// 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. + /// Gets the branding text scoped to the BrandingStrings file, whose designer accessor is internal, + /// from code-behind. The scope is the public generated BrandingStringsKeys.ResourceManager, which + /// XAML can name through {x:Static} too. /// - public string PoweredByViaScopedInternalManager => this.localizer.Get("PoweredBy", BrandingStrings.ResourceManager); + public string PoweredByViaScopedInternalManager => + this.localizer.Get("PoweredBy", resourceManager: BrandingStringsKeys.ResourceManager); /// /// Gets the branding text via a typed key. This resolves even though BrandingStrings has an internal @@ -90,28 +101,57 @@ 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: SortingStringsKeys.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: ApplicationStringsKeys.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) -------------- /// Gets the selection via Get(Enum): search-all with the default Enum_ prefix. public string SortOrderViaSearchAllDefaultPrefix => this.localizer.Get(this.SelectedFileSortOrder); + /// + /// Gets the key of the connection status shown by the bound-key demo, as a generated + /// : StatusConnected while is + /// , otherwise StatusConnecting. The view binds it as the key of + /// {l:Localize {Binding StatusKey}}, so the shown text follows this property and the language. + /// + public ResourceKey StatusKey => + this.IsConnected ? ApplicationStringsKeys.StatusConnected : ApplicationStringsKeys.StatusConnecting; + + /// + /// Gets the name of as a plain , for the bound-key demo of a + /// string key: {l:Localize Key={Binding StatusKeyName}}. + /// + public string StatusKeyName => this.StatusKey.Name; + /// Gets the languages offered in the language switcher. public CultureInfo[] SupportedLanguages { get; } = [new("en"), new("de")]; + /// + /// Whether the simulated connection of the bound-key demo is established. Changing it changes + /// and , the keys the view binds. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(StatusKey))] + [NotifyPropertyChangedFor(nameof(StatusKeyName))] + public partial bool IsConnected { get; set; } + /// /// The people count formatted into the PeopleInvited resource by the dynamic - /// format-arguments demo: the view binds it to LocalizeArgs.Arg0, so every change + /// format-arguments demo: the view binds it to the Value of an Argument, so every change /// re-formats the localized text live. /// [ObservableProperty] diff --git a/samples/ResXLocalization.WPF.Sample/Views/MainWindow.xaml b/samples/ResXLocalization.WPF.Sample/Views/MainWindow.xaml index ccd3b4b..c8bd940 100644 --- a/samples/ResXLocalization.WPF.Sample/Views/MainWindow.xaml +++ b/samples/ResXLocalization.WPF.Sample/Views/MainWindow.xaml @@ -11,7 +11,8 @@ Title="{l:Localize {x:Static res:ApplicationStringsKeys.WindowTitle}}" Width="900" Height="857" - d:DataContext="{d:DesignInstance viewModels:MainWindowViewModel}" + d:DataContext="{d:DesignInstance viewModels:MainWindowViewModel, + IsDesignTimeCreatable=True}" Background="White" Icon="../Assets/logo.ico" WindowStartupLocation="CenterScreen" @@ -27,16 +28,16 @@ + ResourceManager="{x:Static res:ApplicationStringsKeys.ResourceManager}" /> + ResourceManager="{x:Static res:SortingStringsKeys.ResourceManager}" /> + @@ -444,7 +445,7 @@ VerticalAlignment="Center"> - @@ -452,7 +453,7 @@ Text="Result" /> + Text="{l:LocalizeEnum Value={Binding}}" /> @@ -467,7 +468,7 @@ VerticalAlignment="Center"> - @@ -475,7 +476,8 @@ Text="Result" /> + Text="{l:LocalizeEnum Value={Binding}, + KeyPrefix=Display_}" /> @@ -490,7 +492,7 @@ VerticalAlignment="Center"> - @@ -498,7 +500,8 @@ Text="Result" /> + Text="{l:LocalizeEnum Value={Binding}, + ResourceManager={x:Static res:ApplicationStringsKeys.ResourceManager}}" /> @@ -513,7 +516,7 @@ VerticalAlignment="Center"> - @@ -521,8 +524,9 @@ Text="Result" /> + Text="{l:LocalizeEnum Value={Binding}, + KeyPrefix=Display_, + ResourceManager={x:Static res:SortingStringsKeys.ResourceManager}}" /> @@ -535,7 +539,7 @@ + Text="Where a markup extension is not accepted - for example a Binding-typed property such as DataGridTextColumn.Binding - use LocalizeEnumConverter inside a MultiBinding [RefreshTriggeringRevision, enumValue, arguments...]. The first child re-triggers the conversion whenever a lookup can change, such as on a language switch; children from index 2 on are format arguments." /> @@ -549,7 +553,7 @@ VerticalAlignment="Center"> - @@ -558,9 +562,9 @@ - - + @@ -578,7 +582,7 @@ VerticalAlignment="Center"> - @@ -589,9 +593,9 @@ - - + @@ -609,9 +613,9 @@ VerticalAlignment="Center"> - - @@ -620,9 +624,9 @@ - - + @@ -640,9 +644,9 @@ VerticalAlignment="Center"> - - @@ -651,9 +655,9 @@ - - + @@ -662,20 +666,20 @@ - + + Text="6. Dynamic format arguments - Argument" /> + Text="A resource value can be a composite format string - here PeopleInvited is '{0} people invited'. Add <l:Argument> children to <l:Localize> to supply the arguments - each a constant or a binding, optionally named; the text re-formats live when a bound argument or the language changes." /> + Text="People count (the view-model property bound to the Value of the Argument):" /> - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + Text="8. Code-behind API - ILocalizer.Get and the indexer" /> @@ -794,8 +867,8 @@ - + @@ -839,7 +912,7 @@ VerticalAlignment="Center"> - @@ -882,8 +955,8 @@ - + @@ -904,8 +977,8 @@ - + @@ -926,8 +999,8 @@ - + diff --git a/scripts/pre-commit-gate.ps1 b/scripts/pre-commit-gate.ps1 index 6bb87e3..6311cca 100644 --- a/scripts/pre-commit-gate.ps1 +++ b/scripts/pre-commit-gate.ps1 @@ -26,11 +26,12 @@ Run: pwsh -File scripts/verify-package-aot.ps1 -Pack Package consumers Trigger: any change to the package contents, the buildTransitive wiring, the - generator's compiler floor, or a dependency version. They install the packages + generator's output or compiler floor, or a dependency version. They install the packages the way a stranger would, which a project-referenced test cannot. Run: pwsh -File scripts/pre-release-gate.ps1 -SkipNativeAot -SkipDocumentation - which packs and then runs all of them, on both of AvaloniaConsumer's - target frameworks. + which packs and then runs all of them on the pinned SDK, on both of + the target frameworks of AvaloniaConsumer and MessageFormatConsumer, + and again on the .NET 8 SDK. .PARAMETER Fix Apply style, formatting and member ordering to the working tree before the checks, instead of only @@ -323,7 +324,7 @@ if ($failures.Count -gt 0) Write-Output 'PASSED. Not covered here, and not needed for every change:' Write-Output ' - the Native AOT gate, after a change to resource lookup, the generated keys or the packaging' Write-Output ' (pwsh -File scripts/verify-package-aot.ps1 -Pack)' -Write-Output ' - the package consumers, after a change to what the packages contain' +Write-Output ' - the package consumers on the pinned and the .NET 8 SDK, after a change to what the packages contain' Write-Output ' (pwsh -File scripts/pre-release-gate.ps1 -SkipNativeAot -SkipDocumentation)' exit 0 diff --git a/scripts/pre-release-gate.ps1 b/scripts/pre-release-gate.ps1 index b186177..dac5edc 100644 --- a/scripts/pre-release-gate.ps1 +++ b/scripts/pre-release-gate.ps1 @@ -7,7 +7,7 @@ Run this before pushing a release - or any branch you want CI to go green on. It is the pre-commit gate plus the jobs that gate a publish: the documentation build with warnings as errors, the pack with package validation, the Native AOT gate against the packed packages, and the package consumers against - those same packages. + those same packages - once on the SDK the repository pins and once on the .NET 8 SDK. It stops at the first failure. Later steps consume what earlier ones produce - the AOT gate and the consumers run against the packages the pack step wrote - so continuing past a failure would only @@ -24,14 +24,16 @@ publishes anything. The GitHub release Needs the tag and the GitHub API. The other operating system CI runs the Avalonia verification, the AOT gate and the minimum-SDK - consumers on Linux and on Windows. This runs whichever legs the machine - you are on can run, and says which ones it skipped. + consumers on Linux and on Windows. This runs only what the machine you + are on can run, and says what it skipped. The WPF jobs, off Windows The WPF projects do not load anywhere else, so on Linux or macOS the WPF build, the WPF tests and the WPF and combined consumers are not run. - The .NET-8-SDK-only leg CI installs the 8.0 SDK alone to prove the generator's Roslyn floor. - This machine resolves the SDK the root global.json pins, so the consumer - steps here prove the packages work - not that they work with nothing but - an 8.0 SDK installed. + An 8.0 SDK on its own CI's minimum-SDK job runs on a machine with only the .NET 8 SDK + installed. This gate also runs the consumers on an 8.0 SDK, selected + the same way, so that SDK's compiler and build targets are used. But + the SDK the root global.json pins is installed here too, so a pass here + does not prove the packages work on a machine that has only the .NET 8 + SDK. A dirty working tree is reported, not rejected: CI tests the commit you push, so anything uncommitted is untested by definition, but you may well still be iterating. @@ -52,6 +54,10 @@ .PARAMETER SkipDocumentation Skip the DocFX metadata and site build. It needs the docfx local tool. +.PARAMETER SkipMinimumSdk + Skip running the package consumers a second time on the .NET 8 SDK. That step needs an 8.0 SDK + installed next to the one the repository pins. + .PARAMETER Configuration Build configuration. Release by default, because that is what CI uses and what gets published. @@ -68,6 +74,7 @@ param( [Switch] $SkipNativeAot, [Switch] $SkipConsumers, [Switch] $SkipDocumentation, + [Switch] $SkipMinimumSdk, [String] $Configuration = 'Release' ) @@ -82,6 +89,7 @@ $onWindows = $IsWindows $solutionFileName = if ($onWindows) { 'ResXLocalization.slnx' } else { 'ResXLocalization.NonWindows.slnf' } $consumerDirectory = Join-Path $repositoryRoot 'tests/package-consumption' +$consumerSdkPin = Join-Path $consumerDirectory 'global.json' $packageOutput = Join-Path $repositoryRoot 'artifacts/packages' $docfxConfiguration = 'build/docfx/docfx.json' @@ -188,6 +196,25 @@ else Write-Output 'Clean.' } +# --- Consumer SDK pin --------------------------------------------------------------------------------- +# The step that runs the consumers on the .NET 8 SDK writes a global.json into the consumer directory and +# removes it again. One left behind - by an interrupted run, or by hand - would quietly choose the SDK for +# everything that runs from there: the Native AOT gate's publish and every consumer. An 8.0 SDK cannot +# build for net10.0, so the net10.0 runs would fail with a message that does not mention the pin. Checked +# first, so the failure names the cause. + +Invoke-Step 'Consumer SDK pin' { + if (Test-Path -LiteralPath $consumerSdkPin) + { + Write-Output "FAILED - $consumerSdkPin exists and would choose the SDK for the AOT gate and the consumers. Delete it and re-run." + $script:stepSucceeded = $false + + return + } + + Write-Output 'None.' +} + # --- Release identity --------------------------------------------------------------------------------- # CI verifies both of these immediately before it publishes. A tag that does not match the packed version # publishes stale packages or, thanks to --skip-duplicate, nothing at all; a missing changelog section @@ -416,6 +443,10 @@ Invoke-Step 'Pack' { Write-Output '' Write-Output 'NOT PACKED: ResXLocalization.WPF. Release packaging happens on Windows.' } + + # The consumers below build against the packages just written, under the same version as the last + # pack. A reused MSBuild node keeps evaluating the previous package's targets, so stop it here. + & dotnet build-server shutdown 2>&1 | Out-Null } # --- Native AOT gate ---------------------------------------------------------------------------------- @@ -460,29 +491,40 @@ Invoke-Step 'Native AOT gate' { # project-referenced test can see. NUGET_PACKAGES and RestoreConfigFile are overridden so a run cannot # quietly resolve a ResXLocalization assembly that did not come out of the packages just packed. -Invoke-Step 'Package consumers' { - if ($SkipConsumers) - { - Write-Output 'Skipped (-SkipConsumers).' - - return - } +function Invoke-PackageConsumers +{ + <# + Runs each consumer from the consumer directory - so a global.json there decides the SDK - and sets + $script:stepSucceeded to $false on the first one that fails. Each entry is @{ Name; Arguments }. + #> + param([Parameter(Mandatory)] [Object[]] $Consumers) $packageVersion = Get-DeclaredVersion $originalNuGetPackages = $env:NUGET_PACKAGES $consumerNuGetConfig = Join-Path $consumerDirectory 'nuget.config' - # AvaloniaConsumer multi-targets, so the framework is named explicitly - `dotnet run` refuses to - # guess, and a leg that guessed would not be the leg it claims to be. Both of the packages' target - # frameworks are exercised. WpfConsumer and CombinedConsumer target one framework each. - $consumers = @( - @{ Name = 'AvaloniaConsumer'; Framework = 'net8.0' } - @{ Name = 'AvaloniaConsumer'; Framework = 'net10.0' } - ) - if ($onWindows) + # NuGet resolves by version and not by content, so a ResXLocalization package an earlier run left in the + # consumers' cache would satisfy the reference instead of the one just packed - the Native AOT gate empties + # that cache, but it can be skipped. Remove our packages from it here, whatever ran before; the build + # server goes first because one of its nodes can still hold a package assembly open. NuGet extracts + # read-only, hence the attribute reset. + & dotnet build-server shutdown 2>&1 | Out-Null + + try + { + Get-ChildItem -LiteralPath (Join-Path $consumerDirectory '.packages') -Directory -Filter 'resxlocalization.*' -ErrorAction SilentlyContinue | + ForEach-Object { + Get-ChildItem -LiteralPath $_.FullName -Recurse -Force -File | ForEach-Object { $_.IsReadOnly = $false } + Remove-Item -LiteralPath $_.FullName -Recurse -Force + } + } + catch { - $consumers += @{ Name = 'WpfConsumer'; Framework = $null } - $consumers += @{ Name = 'CombinedConsumer'; Framework = $null } + Write-Output "FAILED - the cached ResXLocalization packages could not be removed: $($_.Exception.Message)" + Write-Output 'Running against them would test whatever was cached last. Close anything holding a file there and re-run.' + $script:stepSucceeded = $false + + return } Push-Location -LiteralPath $consumerDirectory @@ -490,15 +532,19 @@ Invoke-Step 'Package consumers' { { $env:NUGET_PACKAGES = Join-Path $consumerDirectory '.packages' - foreach ($consumer in $consumers) + foreach ($consumer in $Consumers) { $name = $consumer.Name - $framework = @(if ($consumer.Framework) { @('--framework', $consumer.Framework) } else { @() }) + $arguments = @($consumer.Arguments) Write-Output '' - Write-Output "--- $name$(if ($consumer.Framework) { " ($($consumer.Framework))" }) ---" + Write-Output "--- $((@($name) + $arguments) -join ' ') ---" - & dotnet run --project "$name/$name.csproj" -c $Configuration @framework ` + # A build left by the previous run - possibly by the other SDK - could let MSBuild and WPF's + # markup compiler decide that nothing changed and skip exactly the passes under test. + Remove-Item -LiteralPath (Join-Path $name 'bin'), (Join-Path $name 'obj') -Recurse -Force -ErrorAction SilentlyContinue + + & dotnet run --project "$name/$name.csproj" -c $Configuration @arguments ` -p:RestoreConfigFile=$consumerNuGetConfig -p:ResXLocalizationVersion=$packageVersion if ($LASTEXITCODE -ne 0) @@ -512,7 +558,7 @@ Invoke-Step 'Package consumers' { if (-not $onWindows) { Write-Output '' - Write-Output 'NOT RUN: WpfConsumer and CombinedConsumer. They need Windows.' + Write-Output 'NOT RUN: WpfConsumer, WpfDerivedNamespaceConsumer and CombinedConsumer. They need Windows.' } } finally @@ -522,6 +568,125 @@ Invoke-Step 'Package consumers' { } } +# The WPF consumers target one framework each. WpfDerivedNamespaceConsumer derives its RootNamespace from +# its project name; see the comment "WPF's temporary markup-compile pass" in +# build/ResXLocalization.Resx.targets. +$wpfConsumers = @( + @{ Name = 'WpfConsumer'; Arguments = @() } + @{ Name = 'WpfDerivedNamespaceConsumer'; Arguments = @() } + @{ Name = 'CombinedConsumer'; Arguments = @() } +) + +Invoke-Step 'Package consumers' { + if ($SkipConsumers) + { + Write-Output 'Skipped (-SkipConsumers).' + + return + } + + # AvaloniaConsumer and MessageFormatConsumer target net8.0 and net10.0, and `dotnet run` runs a project + # with several target frameworks only when one is named, so each consumer runs once per framework with + # `--framework`. That way both of the packages' target frameworks are exercised. + $consumers = @( + @{ Name = 'AvaloniaConsumer'; Arguments = @('--framework', 'net8.0') } + @{ Name = 'AvaloniaConsumer'; Arguments = @('--framework', 'net10.0') } + @{ Name = 'MessageFormatConsumer'; Arguments = @('--framework', 'net8.0') } + @{ Name = 'MessageFormatConsumer'; Arguments = @('--framework', 'net10.0') } + ) + + if ($onWindows) + { + $consumers += $wpfConsumers + } + + Invoke-PackageConsumers -Consumers $consumers +} + +# --- Package consumers on the .NET 8 SDK --------------------------------------------------------------- +# The same consumers, built by the oldest SDK the packages support - what CI's "Consume packages on .NET 8 +# SDK" job does, with the same temporary pin. The SDKs differ in more than the compiler - for example in +# WPF's temporary markup-compile pass, which only WpfConsumer built by the .NET 8 SDK gets wrong without +# the fix; see the comment "WPF's temporary markup-compile pass" in build/ResXLocalization.Resx.targets. + +Invoke-Step 'Package consumers (.NET 8 SDK)' { + if ($SkipConsumers -or $SkipMinimumSdk) + { + Write-Output "Skipped ($(if ($SkipConsumers) { '-SkipConsumers' } else { '-SkipMinimumSdk' }))." + + return + } + + if (-not (& dotnet --list-sdks | Where-Object { $_ -like '8.*' })) + { + Write-Output 'FAILED - no .NET 8 SDK is installed. Install one (see CONTRIBUTING.md), or pass -SkipMinimumSdk.' + $script:stepSucceeded = $false + + return + } + + if (Test-Path -LiteralPath $consumerSdkPin) + { + Write-Output "FAILED - $consumerSdkPin already exists; this step writes and removes its own. Delete it and re-run." + $script:stepSucceeded = $false + + return + } + + # The pin covers the consumer directory only: `dotnet` resolves global.json from the current directory + # upward, and Invoke-PackageConsumers runs from there. It is gitignored, and removed again below. + Set-Content -LiteralPath $consumerSdkPin -Value '{ "sdk": { "version": "8.0.100", "rollForward": "latestFeature" } }' + + try + { + Push-Location -LiteralPath $consumerDirectory + try + { + $activeSdk = & dotnet --version + } + finally + { + Pop-Location + } + + Write-Output "Active SDK: $activeSdk" + + if ($activeSdk -notlike '8.*') + { + Write-Output 'FAILED - the pin did not select an 8.0 SDK.' + $script:stepSucceeded = $false + + return + } + + # -p:TargetFrameworks as well as --framework: restore evaluates every entry in TargetFrameworks, and + # an 8.0 SDK cannot evaluate net10.0 (NETSDK1045). + $consumers = @( + @{ Name = 'AvaloniaConsumer'; Arguments = @('--framework', 'net8.0', '-p:TargetFrameworks=net8.0') } + @{ Name = 'MessageFormatConsumer'; Arguments = @('--framework', 'net8.0', '-p:TargetFrameworks=net8.0') } + ) + + if ($onWindows) + { + $consumers += $wpfConsumers + } + + Invoke-PackageConsumers -Consumers $consumers + } + finally + { + # Never let a failed delete escape: the summary line must still be written, and the leftover is + # named so the next run's first step does not have to explain it. + Remove-Item -LiteralPath $consumerSdkPin -Force -ErrorAction SilentlyContinue + + if (Test-Path -LiteralPath $consumerSdkPin) + { + Write-Output "FAILED - the temporary pin $consumerSdkPin could not be removed. Delete it by hand." + $script:stepSucceeded = $false + } + } +} + # --- Summary ------------------------------------------------------------------------------------------ Write-Section 'Pre-release gate summary' diff --git a/scripts/tidy-code.ps1 b/scripts/tidy-code.ps1 index ded5188..29e656d 100644 --- a/scripts/tidy-code.ps1 +++ b/scripts/tidy-code.ps1 @@ -341,6 +341,29 @@ function Invoke-StyleFix $verify = if ($VerifyOnly) { @('--verify-no-changes') } else { @() } + # Build the source generator first, and that is not optional either. `dotnet format` runs the source + # generators in memory, from the analyzer path the project reference resolves to: the generator's + # bin/ output. When that DLL does not exist, the analyzer is dropped without a warning, + # no Keys class exists in the compilation, and IDE0005 DELETES every using that only the + # generated keys classes need. The disposable copy has no bin/ at all. + # + # Deliberately no `-c`: `dotnet format` takes its configuration from the environment - Debug by + # default, Release in CI, whose `CONFIGURATION: Release` becomes the MSBuild property Configuration - + # and a build without `-c` resolves the same one. A hard-coded configuration is right on one of them + # and wrong on the other. + $generatorProject = [System.IO.Path]::Combine( + 'src', + 'ResXLocalization.SourceGenerators', + 'ResXLocalization.SourceGenerators.csproj' + ) + $output = Invoke-Tool -Arguments @('build', $generatorProject, '-v', 'q') -WorkingDirectory $Root + if ($LASTEXITCODE -ne 0) + { + $failures.Add("dotnet build (source generator):`n$output") + + return + } + if (-not $Files) { if ($VerifyOnly) diff --git a/scripts/update-public-api.ps1 b/scripts/update-public-api.ps1 index e4457ec..fa3c11d 100644 --- a/scripts/update-public-api.ps1 +++ b/scripts/update-public-api.ps1 @@ -13,7 +13,7 @@ PublicAPI.Unshipped.txt. It creates the two files first where they are missing: dotnet format applies the fix but will not create the files, and without them the analyzer reports nothing at all. - Only the three runtime projects are covered. ResXLocalization.SourceGenerators ships inside the UI + Only the four runtime projects are covered. ResXLocalization.SourceGenerators ships inside the UI packages as an analyzer, has no package identity of its own and no tracked public API; its equivalent is analyzer release tracking in AnalyzerReleases.*.md, which is a separate file and a separate step. @@ -27,7 +27,7 @@ maintainer step - see -MarkShipped - and is never part of an ordinary contribution. .PARAMETER Project - One or more project files to update. Defaults to the three runtime projects under src/. + One or more project files to update. Defaults to the four runtime projects under src/. .PARAMETER MarkShipped The MAINTAINER's release step, not the edit step: fold PublicAPI.Unshipped.txt into @@ -60,7 +60,7 @@ $repositoryRoot = (Resolve-Path -LiteralPath (Split-Path -Parent $PSScriptRoot)) # The runtime projects, named rather than globbed: a glob over src/ would also pick up the source # generator, which has no public API to track. -$runtimeProjectNames = @('ResXLocalization.Core', 'ResXLocalization.Avalonia', 'ResXLocalization.WPF') +$runtimeProjectNames = @('ResXLocalization.Core', 'ResXLocalization.Avalonia', 'ResXLocalization.WPF', 'ResXLocalization.MessageFormat') if (-not $Project -or $Project.Count -eq 0) { diff --git a/scripts/verify-package-aot.ps1 b/scripts/verify-package-aot.ps1 index 50a07bb..d956890 100644 --- a/scripts/verify-package-aot.ps1 +++ b/scripts/verify-package-aot.ps1 @@ -1,7 +1,7 @@ <# .SYNOPSIS - Publishes the Avalonia package consumer with Native AOT, gates its IL diagnostics, and runs the native - binary. + Publishes the two Native AOT package consumers - AvaloniaConsumer and MessageFormatConsumer - with + Native AOT, gates their IL diagnostics, and runs each native binary. .DESCRIPTION This is the only check in the repository that can catch silent trimming damage. The library finds @@ -10,16 +10,20 @@ trimmed on the JIT, which is why the whole test suite passes while a trimmed application finds no translation at all. - The consumer reaches the library through PackageReference, never through a project reference. That + AvaloniaConsumer covers ResXLocalization.Core and ResXLocalization.Avalonia. MessageFormatConsumer + covers ResXLocalization.MessageFormat and its MessageFormat dependency next to them: ICU plural rules, + named arguments from a compiled binding, and the format culture. + + Each consumer reaches the library through PackageReference, never through a project reference. That matters: the packed source generator, the buildTransitive resx wiring that feeds it, the satellite assemblies and the trimming metadata all have to survive packing, and a project-referenced test would pass even if packing dropped every one of them. - Five things have to hold, and all five are gated here: + Five things have to hold, and all five are gated here, for each consumer in turn: 1. The expected packages exist locally, at the expected version, by the id and version in their own nuspec - not by their file name. - 2. They restore into a consumer that has nothing but a PackageReference, from the local feed and never + 2. They restore into a consumer that has nothing but PackageReferences, from the local feed and never from nuget.org. 3. The Native AOT publish succeeds and produces the native binary and the German satellite assembly. 4. NO IL2xxx/IL3xxx diagnostic is reported, from anywhere - not from the library, not from a package in @@ -45,8 +49,8 @@ .PARAMETER Pack Pack first, into artifacts/packages. On Windows that is every shipping project; elsewhere the WPF - project cannot build, so Core and Avalonia are packed on their own - which is all this gate consumes. - Full release packaging stays on Windows. + project cannot build, so Core, Avalonia and MessageFormat are packed on their own - which is all the + two consumers of this gate consume. Full release packaging stays on Windows. CI does not use this: it downloads the exact packages the publish job produced. @@ -95,15 +99,16 @@ if ($Runtime -notin $supportedRuntimes) # scripts/ - the repository root is one level up, whatever the current directory is. Every path # below is anchored to it. $repositoryRoot = (Resolve-Path -LiteralPath (Split-Path -Parent $PSScriptRoot)).Path -$consumerDirectory = Join-Path $repositoryRoot 'tests/package-consumption/AvaloniaConsumer' $packageDirectory = Join-Path $repositoryRoot 'artifacts/packages' $packageCache = Join-Path $repositoryRoot 'tests/package-consumption/.packages' $consumerNuGetConfig = Join-Path $repositoryRoot 'tests/package-consumption/nuget.config' -$publishDirectory = Join-Path $repositoryRoot "artifacts/package-aot/$Framework-$Runtime" $logDirectory = Join-Path $repositoryRoot 'artifacts/package-aot/logs' -# The Avalonia gate consumes these two. WPF is not among them and never will be. -$requiredPackageIds = @('ResXLocalization.Core', 'ResXLocalization.Avalonia') +# The consumers this gate publishes and runs, in this order, under tests/package-consumption. +$consumerNames = @('AvaloniaConsumer', 'MessageFormatConsumer') + +# The two consumers consume these three. WPF is not among them and never will be. +$requiredPackageIds = @('ResXLocalization.Core', 'ResXLocalization.Avalonia', 'ResXLocalization.MessageFormat') if (-not $PackageVersion) { @@ -158,9 +163,9 @@ try } else { - # ResXLocalization.WPF targets net*-windows and cannot build here. Packing the two this gate + # ResXLocalization.WPF targets net*-windows and cannot build here. Packing the three this gate # needs is the honest thing to do; a full release pack stays on Windows. - Write-Host "Packing ResXLocalization.Core and ResXLocalization.Avalonia ($PackageVersion) - WPF needs Windows..." -ForegroundColor Cyan + Write-Host "Packing $($requiredPackageIds -join ', ') ($PackageVersion) - WPF needs Windows..." -ForegroundColor Cyan foreach ($packageId in $requiredPackageIds) { @@ -196,9 +201,9 @@ try # The build server is asked to stop before EVERY attempt, not once. It is the usual holder - an # MSBuild or ILC node from the previous run keeps package assemblies loaded - and when this gate - # runs both frameworks back to back, the first leg's nodes are still exiting while the second - # leg starts. One shutdown up front is not enough for that; measured, it needs a few seconds - # and another ask. + # runs both frameworks back to back, the nodes of the first framework's run are still exiting + # while the second run starts. One shutdown up front is not enough for that; measured, it needs a + # few seconds and another ask. $removed = $false for ($attempt = 1; $attempt -le 8; $attempt++) @@ -232,6 +237,12 @@ try exit 1 } } + else + { + # No cache to empty, so the loop above did not stop the build server. Stop it anyway: a reused + # MSBuild node keeps evaluating the targets of a previous pack of the same version. + & dotnet build-server shutdown 2>&1 | Out-Null + } # --- The artifact set, validated from the nuspec -------------------------------------------------- # @@ -320,9 +331,6 @@ try Write-Host "Every package this gate needs is present at $PackageVersion, by their nuspec metadata." -ForegroundColor Cyan - Write-Host "Publishing the Avalonia package consumer ($Framework, $Runtime, packages $PackageVersion)..." ` - -ForegroundColor Cyan - # NUGET_PACKAGES is set explicitly, and to the CONSUMER's isolated cache. The environment variable takes # precedence over the globalPackagesFolder setting in tests/package-consumption/nuget.config, so an # inherited one - CI sets NUGET_PACKAGES to a workspace-wide cache - would silently defeat the isolation @@ -330,168 +338,177 @@ try # never came out of these packages. It is restored in the finally block at the end of the script. $env:NUGET_PACKAGES = $packageCache - # PublishAot is passed here rather than set in the project file, so that an ordinary `dotnet run` of the - # consumer stays a genuine JIT baseline. - # - # RestoreConfigFile names the CONSUMER's NuGet configuration explicitly rather than relying on NuGet's - # upward search finding it. That file is what maps ResXLocalization.* to the local artifact feed and - # s the sources first - so a missing local package fails instead of resolving from nuget.org, - # where a published package of the same version exists and would look like a pass. - # - # TrimmerSingleWarn=false is what makes the gate meaningful: left at its default, ILC collapses every - # diagnostic from one assembly into a single IL2104 "assembly produced trim warnings" line, and the - # individual IL2xxx codes this script counts never appear. - # - # Run from the consumer directory, which is where a consumer would run it. - Push-Location -LiteralPath $consumerDirectory - try + foreach ($consumerName in $consumerNames) { - $publishOutput = & dotnet publish 'AvaloniaConsumer.csproj' ` - --configuration $Configuration ` - --framework $Framework ` - --runtime $Runtime ` - --self-contained true ` - -p:RestoreConfigFile=$consumerNuGetConfig ` - -p:PublishAot=true ` - -p:TrimmerSingleWarn=false ` - -p:SuppressTrimAnalysisWarnings=false ` - -p:ResXLocalizationVersion=$PackageVersion ` - --output $publishDirectory 2>&1 - } - finally - { - Pop-Location - } - - $publishExitCode = $LASTEXITCODE + $consumerDirectory = Join-Path $repositoryRoot "tests/package-consumption/$consumerName" + $publishDirectory = Join-Path $repositoryRoot "artifacts/package-aot/$consumerName-$Framework-$Runtime" - $publishOutput | ForEach-Object { Write-Host $_ } - - # The log is kept whatever happens, because a failure here is the one worth reading twice and the - # console scrolls. - New-Item -ItemType Directory -Force -Path $logDirectory | Out-Null - $publishLog = Join-Path $logDirectory "publish-$Framework-$Runtime.log" - $publishOutput | Out-File -LiteralPath $publishLog -Encoding utf8 - - if ($publishExitCode -ne 0) - { Write-Host '' - Write-Host "FAILED. The Native AOT publish exited with code $publishExitCode. Log: $publishLog" -ForegroundColor Red - - exit 1 - } - - # --- The warning gate ----------------------------------------------------------------------------- - - $diagnostics = $publishOutput | - Select-String -Pattern 'IL[23]\d{3}' | - ForEach-Object { - # Every diagnostic line ends with the MSBuild project suffix "[...csproj::TargetFramework=...]", - # which names this project no matter which assembly the diagnostic came from. Strip it before - # deciding where the diagnostic originated, or everything would look like it came from the - # consumer. - $origin = $_.Line.Trim() -replace '\s*\[[^\[\]]*\]\s*$', '' - - [PSCustomObject] @{ - Origin = $origin - Code = [Regex]::Match($_.Line, 'IL[23]\d{3}').Value - Text = $_.Line.Trim() - } + Write-Host "Publishing $consumerName ($Framework, $Runtime, packages $PackageVersion)..." -ForegroundColor Cyan + + # PublishAot is passed here rather than set in the project file, so that an ordinary `dotnet run` of + # the consumer stays a genuine JIT baseline. + # + # RestoreConfigFile names the CONSUMER's NuGet configuration explicitly rather than relying on NuGet's + # upward search finding it. That file is what maps ResXLocalization.* to the local artifact feed and + # s the sources first - so a missing local package fails instead of resolving from nuget.org, + # where a published package of the same version exists and would look like a pass. + # + # TrimmerSingleWarn=false is what makes the gate meaningful: left at its default, ILC collapses every + # diagnostic from one assembly into a single IL2104 "assembly produced trim warnings" line, and the + # individual IL2xxx codes this script counts never appear. + # + # Run from the consumer directory, which is where a consumer would run it. + Push-Location -LiteralPath $consumerDirectory + try + { + $publishOutput = & dotnet publish "$consumerName.csproj" ` + --configuration $Configuration ` + --framework $Framework ` + --runtime $Runtime ` + --self-contained true ` + -p:RestoreConfigFile=$consumerNuGetConfig ` + -p:PublishAot=true ` + -p:TrimmerSingleWarn=false ` + -p:SuppressTrimAnalysisWarnings=false ` + -p:ResXLocalizationVersion=$PackageVersion ` + --output $publishDirectory 2>&1 + } + finally + { + Pop-Location } - # The gate is ZERO diagnostics, from anywhere. The packages advertise Native AOT support without - # [RequiresUnreferencedCode] or [RequiresDynamicCode] on any public member, so a consumer publishing - # this way sees nothing - and a warning appearing anywhere is a regression against that promise. - # - # The split below only shapes the failure message, because "the library started warning" and "our own - # call site started warning" have different causes. The same site is reported twice, once by the Roslyn - # analyzer and once by ILC, so the list is de-duplicated. - $fromConsumer = @($diagnostics | Where-Object { $_.Origin.StartsWith($consumerDirectory, [StringComparison]::OrdinalIgnoreCase) }) - $fromElsewhere = @($diagnostics | Where-Object { -not $_.Origin.StartsWith($consumerDirectory, [StringComparison]::OrdinalIgnoreCase) }) + $publishExitCode = $LASTEXITCODE - Write-Host '' + $publishOutput | ForEach-Object { Write-Host $_ } - if ($diagnostics.Count -eq 0) - { - Write-Host 'IL diagnostics: none, from anywhere. A consumer publishing this way sees no warnings.' -ForegroundColor Cyan - } - else - { - Write-Host 'FAILED. The publish reported IL diagnostics, and the gate is zero:' -ForegroundColor Red + # The log is kept whatever happens, because a failure here is the one worth reading twice and the + # console scrolls. + New-Item -ItemType Directory -Force -Path $logDirectory | Out-Null + $publishLog = Join-Path $logDirectory "publish-$consumerName-$Framework-$Runtime.log" + $publishOutput | Out-File -LiteralPath $publishLog -Encoding utf8 - if ($fromElsewhere.Count -gt 0) + if ($publishExitCode -ne 0) { Write-Host '' - Write-Host " From the library or a package ($($fromElsewhere.Count) before de-duplication):" -ForegroundColor Red - $fromElsewhere | Sort-Object Text -Unique | ForEach-Object { Write-Host " $($_.Text)" -ForegroundColor Red } + Write-Host "FAILED. The Native AOT publish exited with code $publishExitCode. Log: $publishLog" -ForegroundColor Red + + exit 1 } - if ($fromConsumer.Count -gt 0) - { - Write-Host '' - Write-Host " At the consumer's own call sites ($($fromConsumer.Count) before de-duplication):" -ForegroundColor Red - $fromConsumer | Sort-Object Origin -Unique | Group-Object Code | Sort-Object Name | ForEach-Object { - Write-Host (" {0,-8} {1} call site(s)" -f $_.Name, $_.Count) -ForegroundColor Red + # --- The warning gate ------------------------------------------------------------------------- + + $diagnostics = $publishOutput | + Select-String -Pattern 'IL[23]\d{3}' | + ForEach-Object { + # Every diagnostic line ends with the MSBuild project suffix + # "[...csproj::TargetFramework=...]", which names this project no matter which assembly the + # diagnostic came from. Strip it before deciding where the diagnostic originated, or + # everything would look like it came from the consumer. + $origin = $_.Line.Trim() -replace '\s*\[[^\[\]]*\]\s*$', '' + + [PSCustomObject] @{ + Origin = $origin + Code = [Regex]::Match($_.Line, 'IL[23]\d{3}').Value + Text = $_.Line.Trim() + } } - Write-Host '' - Write-Host ' A diagnostic here means a public API started carrying [RequiresUnreferencedCode] or' -ForegroundColor Red - Write-Host ' [RequiresDynamicCode], which the packages promise their consumers they do not.' -ForegroundColor Red - } + + # The gate is ZERO diagnostics, from anywhere. The packages advertise Native AOT support without + # [RequiresUnreferencedCode] or [RequiresDynamicCode] on any public member, so a consumer publishing + # this way sees nothing - and a warning appearing anywhere is a regression against that promise. + # + # The split below only shapes the failure message, because "the library started warning" and "our + # own call site started warning" have different causes. The same site is reported twice, once by the + # Roslyn analyzer and once by ILC, so the list is de-duplicated. + $fromConsumer = @($diagnostics | Where-Object { $_.Origin.StartsWith($consumerDirectory, [StringComparison]::OrdinalIgnoreCase) }) + $fromElsewhere = @($diagnostics | Where-Object { -not $_.Origin.StartsWith($consumerDirectory, [StringComparison]::OrdinalIgnoreCase) }) Write-Host '' - Write-Host "Log: $publishLog" -ForegroundColor Red - Write-Host 'Do not silence these at the call site or with NoWarn. An IL2xxx warning is the only' -ForegroundColor Red - Write-Host 'build-time evidence that a reflection path survives trimming - restructure the code, or' -ForegroundColor Red - Write-Host 'answer the diagnostic where it occurs with a justified, tested suppression.' -ForegroundColor Red - exit 1 - } + if ($diagnostics.Count -eq 0) + { + Write-Host 'IL diagnostics: none, from anywhere. A consumer publishing this way sees no warnings.' -ForegroundColor Cyan + } + else + { + Write-Host 'FAILED. The publish reported IL diagnostics, and the gate is zero:' -ForegroundColor Red - # --- The published output ------------------------------------------------------------------------- + if ($fromElsewhere.Count -gt 0) + { + Write-Host '' + Write-Host " From the library or a package ($($fromElsewhere.Count) before de-duplication):" -ForegroundColor Red + $fromElsewhere | Sort-Object Text -Unique | ForEach-Object { Write-Host " $($_.Text)" -ForegroundColor Red } + } - $executableName = $IsWindows ? 'AvaloniaConsumer.exe' : 'AvaloniaConsumer' - $executable = Join-Path $publishDirectory $executableName - $germanSatellite = Join-Path $publishDirectory 'de/AvaloniaConsumer.resources.dll' + if ($fromConsumer.Count -gt 0) + { + Write-Host '' + Write-Host " At the consumer's own call sites ($($fromConsumer.Count) before de-duplication):" -ForegroundColor Red + $fromConsumer | Sort-Object Origin -Unique | Group-Object Code | Sort-Object Name | ForEach-Object { + Write-Host (" {0,-8} {1} call site(s)" -f $_.Name, $_.Count) -ForegroundColor Red + } + Write-Host '' + Write-Host ' A diagnostic here means a public API started carrying [RequiresUnreferencedCode] or' -ForegroundColor Red + Write-Host ' [RequiresDynamicCode], which the packages promise their consumers they do not.' -ForegroundColor Red + } - foreach ($expected in @($executable, $germanSatellite)) - { - if (-not (Test-Path -LiteralPath $expected)) - { Write-Host '' - Write-Host "FAILED. The publish did not produce $expected." -ForegroundColor Red Write-Host "Log: $publishLog" -ForegroundColor Red + Write-Host 'Do not silence these at the call site or with NoWarn. An IL2xxx warning is the only' -ForegroundColor Red + Write-Host 'build-time evidence that a reflection path survives trimming - restructure the code, or' -ForegroundColor Red + Write-Host 'answer the diagnostic where it occurs with a justified, tested suppression.' -ForegroundColor Red exit 1 } - } - # --- Running the native binary -------------------------------------------------------------------- - # The file existing is not the check. Everything that can go wrong under trimming - a resource that is - # no longer found, a satellite that is no longer loaded, a typed key whose ResourceManager was trimmed - # away - produces a binary that exists, starts, and answers wrongly. + # --- The published output --------------------------------------------------------------------- - Write-Host '' - Write-Host 'Running the native binary...' -ForegroundColor Cyan - Write-Host '' + $executableName = $IsWindows ? "$consumerName.exe" : $consumerName + $executable = Join-Path $publishDirectory $executableName + $germanSatellite = Join-Path $publishDirectory "de/$consumerName.resources.dll" - $runOutput = & $executable 2>&1 - $runExitCode = $LASTEXITCODE + foreach ($expected in @($executable, $germanSatellite)) + { + if (-not (Test-Path -LiteralPath $expected)) + { + Write-Host '' + Write-Host "FAILED. The publish did not produce $expected." -ForegroundColor Red + Write-Host "Log: $publishLog" -ForegroundColor Red - $runOutput | ForEach-Object { Write-Host $_ } + exit 1 + } + } - $runLog = Join-Path $logDirectory "run-$Framework-$Runtime.log" - $runOutput | Out-File -LiteralPath $runLog -Encoding utf8 + # --- Running the native binary ---------------------------------------------------------------- + # The file existing is not the check. Everything that can go wrong under trimming - a resource that is + # no longer found, a satellite that is no longer loaded, a typed key whose ResourceManager was trimmed + # away - produces a binary that exists, starts, and answers wrongly. - Write-Host '' + Write-Host '' + Write-Host "Running the native $consumerName binary..." -ForegroundColor Cyan + Write-Host '' - if ($runExitCode -ne 0) - { - Write-Host "FAILED. The native binary exited with code $runExitCode. Log: $runLog" -ForegroundColor Red + $runOutput = & $executable 2>&1 + $runExitCode = $LASTEXITCODE - exit 1 + $runOutput | ForEach-Object { Write-Host $_ } + + $runLog = Join-Path $logDirectory "run-$consumerName-$Framework-$Runtime.log" + $runOutput | Out-File -LiteralPath $runLog -Encoding utf8 + + Write-Host '' + + if ($runExitCode -ne 0) + { + Write-Host "FAILED. The native binary exited with code $runExitCode. Log: $runLog" -ForegroundColor Red + + exit 1 + } } - Write-Host "PASSED. $Framework/$Runtime published from the packages with no IL diagnostics at all, and every assertion passed." ` + Write-Host "PASSED. $($consumerNames -join ' and ') published for $Framework/$Runtime from the packages with no IL diagnostics at all, and every assertion passed." ` -ForegroundColor Green exit 0 diff --git a/src/Directory.Build.props b/src/Directory.Build.props index e796194..d40b776 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -100,6 +100,11 @@ hold in that pass too. The baseline is the version currently on nuget.org. At a release it moves to the version being replaced, alongside the bump in the repository-root Directory.Build.props. --> true + + + + 1.1.0 diff --git a/src/ResXLocalization.Avalonia/Argument.cs b/src/ResXLocalization.Avalonia/Argument.cs new file mode 100644 index 0000000..6fdbd09 --- /dev/null +++ b/src/ResXLocalization.Avalonia/Argument.cs @@ -0,0 +1,48 @@ +namespace RentADeveloper.ResXLocalization.Avalonia; + +/// +/// One format argument of , written as an <l:Argument> child +/// element: +/// <l:Localize Key="{x:Static res:StringsKeys.PeopleInvited}"><l:Argument Value="{Binding PeopleCount}" /></l:Localize>. +/// +/// +/// declares an argument. At each conversion it becomes a +/// whose position is its index among the +/// <l:Argument> children, with its and the resolved - +/// what the formatter receives. The code counterpart is MessageArguments.FromTuples, with a +/// name for an unnamed argument (or MessageArguments.FromValues without +/// names). +/// +public sealed class Argument +{ + /// + /// Gets or sets the name of the argument. Unset, the argument is positional only. Set, the argument is + /// also available under this name to a formatter that understands names, such as ICU MessageFormat; the + /// default formatter ignores names and uses the position. + /// + public string? Name { get; set; } + + /// + /// Gets or sets the value of the argument: a constant or a binding. Constants are a literal + /// (Value="Text", which is the string "Text"), {x:Static}, {StaticResource}, + /// {x:Null} or a typed property element such as <x:Int32>. A binding is any Avalonia + /// binding, for example {Binding PeopleCount}, {CompiledBinding} or + /// {DynamicResource}. A value that is not resolved yet is . + /// + [AssignBinding] + public object? Value + { + get; + set + { + field = value; + this.IsValueSet = true; + } + } + + /// + /// Gets a value indicating whether was assigned, so that a forgotten + /// differs from Value="{x:Null}". + /// + internal bool IsValueSet { get; private set; } +} diff --git a/src/ResXLocalization.Avalonia/CompatibilitySuppressions.xml b/src/ResXLocalization.Avalonia/CompatibilitySuppressions.xml new file mode 100644 index 0000000..7366b3d --- /dev/null +++ b/src/ResXLocalization.Avalonia/CompatibilitySuppressions.xml @@ -0,0 +1,60 @@ + + + + + CP0001 + T:RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs + lib/net10.0/ResXLocalization.Avalonia.dll + lib/net10.0/ResXLocalization.Avalonia.dll + true + + + CP0001 + T:RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs + lib/net8.0/ResXLocalization.Avalonia.dll + lib/net8.0/ResXLocalization.Avalonia.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Avalonia.LocalizeExtension.get_Key + lib/net10.0/ResXLocalization.Avalonia.dll + lib/net10.0/ResXLocalization.Avalonia.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Avalonia.LocalizeExtension.get_ResourceKey + lib/net10.0/ResXLocalization.Avalonia.dll + lib/net10.0/ResXLocalization.Avalonia.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Avalonia.LocalizeExtension.set_ResourceKey(System.Nullable{RentADeveloper.ResXLocalization.ResourceKey}) + lib/net10.0/ResXLocalization.Avalonia.dll + lib/net10.0/ResXLocalization.Avalonia.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Avalonia.LocalizeExtension.get_Key + lib/net8.0/ResXLocalization.Avalonia.dll + lib/net8.0/ResXLocalization.Avalonia.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Avalonia.LocalizeExtension.get_ResourceKey + lib/net8.0/ResXLocalization.Avalonia.dll + lib/net8.0/ResXLocalization.Avalonia.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Avalonia.LocalizeExtension.set_ResourceKey(System.Nullable{RentADeveloper.ResXLocalization.ResourceKey}) + lib/net8.0/ResXLocalization.Avalonia.dll + lib/net8.0/ResXLocalization.Avalonia.dll + true + + \ No newline at end of file diff --git a/src/ResXLocalization.Avalonia/DesignTime.cs b/src/ResXLocalization.Avalonia/DesignTime.cs new file mode 100644 index 0000000..54ba7f7 --- /dev/null +++ b/src/ResXLocalization.Avalonia/DesignTime.cs @@ -0,0 +1,83 @@ +using Avalonia.Controls; + +namespace RentADeveloper.ResXLocalization.Avalonia; + +/// +/// Hosts the DesignTime.Culture attached property, which selects the language the Avalonia XAML +/// previewer shows. Set it on the root element: <Window ... l:DesignTime.Culture="de">. +/// +/// +/// +/// The property is for design time only and has no effect at run time. Inside the previewer it assigns +/// of the process-wide , so strings +/// that view models look up in code agree with the XAML. It overrides a culture selected by initialization +/// that the previewer executes, such as a culture assigned in BuildAvaloniaApp. +/// +/// +/// Without it, the previewer uses the machine's UI culture, unless initialization that the previewer +/// executes selects another culture. +/// +/// +/// An empty name and a name that rejects, such as a half-typed one, leave the +/// culture unchanged. +/// +/// +public sealed class DesignTime : AvaloniaObject +{ + /// + /// Identifies the DesignTime.Culture attached property: the name of the culture the previewer + /// shows, such as de. + /// + public static readonly AttachedProperty CultureProperty = AvaloniaProperty.RegisterAttached< + DesignTime, + AvaloniaObject, + string? + >("Culture"); + + /// + /// Hooks the change handler of once, for the lifetime of the process. + /// + static DesignTime() => + _ = CultureProperty.Changed.AddClassHandler( + static (_, e) => OnCultureChanged(e.NewValue.GetValueOrDefault()) + ); + + private DesignTime() { } + + /// Gets the culture name set on for the previewer. + /// The element to read the value from. + /// The culture name, or if none is set. + public static string? GetCulture(AvaloniaObject element) => element.GetValue(CultureProperty); + + /// + /// Sets the culture name the previewer shows. Inside the previewer this assigns + /// of ; at run time it only stores + /// the value. + /// + /// The element to set the value on, usually the root element of the view. + /// The culture name, such as de. + public static void SetCulture(AvaloniaObject element, string? value) => element.SetValue(CultureProperty, value); + + /// + /// Assigns the new culture to of + /// inside the previewer; does nothing at run time, for an empty name and for a name that + /// rejects. + /// + /// The new culture name. + private static void OnCultureChanged(string? newValue) + { + if (!Design.IsDesignMode || newValue is not { Length: > 0 } name) + { + return; + } + + try + { + Localizer.Current.CurrentCulture = new(name); + } + catch (CultureNotFoundException) + { + // A half-typed culture name must not take the preview down. + } + } +} diff --git a/src/ResXLocalization.Avalonia/GlobalUsings.cs b/src/ResXLocalization.Avalonia/GlobalUsings.cs index 8b434b5..66a3dc2 100644 --- a/src/ResXLocalization.Avalonia/GlobalUsings.cs +++ b/src/ResXLocalization.Avalonia/GlobalUsings.cs @@ -1,8 +1,8 @@ +global using System.Collections.ObjectModel; global using System.Globalization; global using System.Resources; global using Avalonia; global using Avalonia.Data; global using Avalonia.Data.Converters; global using Avalonia.Markup.Xaml; -global using Avalonia.Reactive; -global using Avalonia.Utilities; +global using Avalonia.Metadata; diff --git a/src/ResXLocalization.Avalonia/LocalizeArgs.cs b/src/ResXLocalization.Avalonia/LocalizeArgs.cs deleted file mode 100644 index 818038a..0000000 --- a/src/ResXLocalization.Avalonia/LocalizeArgs.cs +++ /dev/null @@ -1,282 +0,0 @@ -namespace RentADeveloper.ResXLocalization.Avalonia; - -/// -/// Hosts the Arg0…Arg8 attached properties that supply dynamic composite-format -/// arguments to . Bind them on the element that carries the -/// localized property, for example -/// <TextBlock l:LocalizeArgs.Arg0="{Binding PeopleCount}" Text="{l:Localize {x:Static res:StringsKeys.PeopleInvited}}" /> -/// with a resource value such as {0} people invited; the rendered text re-formats live -/// whenever a bound argument or the culture of the ambient changes. -/// -/// -/// While no argument is set on the element, the resource resolves exactly as without this class - -/// no composite formatting runs, so resource values containing literal braces keep working. As soon -/// as at least one argument is set, the resolved value is treated as a composite format string: -/// the arguments Arg0 up to the highest set slot are passed to the formatting -/// ILocalizer.Get overloads, with unset slots below the highest formatting as -/// . The arguments are per element - two localized properties on the same -/// element share the one argument set. -/// -public sealed class LocalizeArgs : AvaloniaObject -{ - /// Identifies the LocalizeArgs.Arg0 attached property: format argument {0}. - public static readonly AttachedProperty Arg0Property = AvaloniaProperty.RegisterAttached< - LocalizeArgs, - AvaloniaObject, - object? - >("Arg0", UnsetSentinel.Value); - - /// Identifies the LocalizeArgs.Arg1 attached property: format argument {1}. - public static readonly AttachedProperty Arg1Property = AvaloniaProperty.RegisterAttached< - LocalizeArgs, - AvaloniaObject, - object? - >("Arg1", UnsetSentinel.Value); - - /// Identifies the LocalizeArgs.Arg2 attached property: format argument {2}. - public static readonly AttachedProperty Arg2Property = AvaloniaProperty.RegisterAttached< - LocalizeArgs, - AvaloniaObject, - object? - >("Arg2", UnsetSentinel.Value); - - /// Identifies the LocalizeArgs.Arg3 attached property: format argument {3}. - public static readonly AttachedProperty Arg3Property = AvaloniaProperty.RegisterAttached< - LocalizeArgs, - AvaloniaObject, - object? - >("Arg3", UnsetSentinel.Value); - - /// Identifies the LocalizeArgs.Arg4 attached property: format argument {4}. - public static readonly AttachedProperty Arg4Property = AvaloniaProperty.RegisterAttached< - LocalizeArgs, - AvaloniaObject, - object? - >("Arg4", UnsetSentinel.Value); - - /// Identifies the LocalizeArgs.Arg5 attached property: format argument {5}. - public static readonly AttachedProperty Arg5Property = AvaloniaProperty.RegisterAttached< - LocalizeArgs, - AvaloniaObject, - object? - >("Arg5", UnsetSentinel.Value); - - /// Identifies the LocalizeArgs.Arg6 attached property: format argument {6}. - public static readonly AttachedProperty Arg6Property = AvaloniaProperty.RegisterAttached< - LocalizeArgs, - AvaloniaObject, - object? - >("Arg6", UnsetSentinel.Value); - - /// Identifies the LocalizeArgs.Arg7 attached property: format argument {7}. - public static readonly AttachedProperty Arg7Property = AvaloniaProperty.RegisterAttached< - LocalizeArgs, - AvaloniaObject, - object? - >("Arg7", UnsetSentinel.Value); - - /// Identifies the LocalizeArgs.Arg8 attached property: format argument {8}. - public static readonly AttachedProperty Arg8Property = AvaloniaProperty.RegisterAttached< - LocalizeArgs, - AvaloniaObject, - object? - >("Arg8", UnsetSentinel.Value); - - /// - /// The single "arguments changed" signal subscribes to: every - /// ArgN change increments this version on the element, so one subscription per binding - /// suffices instead of nine. - /// - internal static readonly AttachedProperty ArgsVersionProperty = AvaloniaProperty.RegisterAttached< - LocalizeArgs, - AvaloniaObject, - int - >("ArgsVersion"); - - /// The nine argument slots in slot order, for . - private static readonly AttachedProperty[] ArgumentProperties = CollectArgumentsAndHookChanges(); - - /// Prevents instantiation: the class only hosts attached properties. - private LocalizeArgs() { } - - /// Gets the value of the attached property. - /// The element holding the argument. - /// The argument value, or when the argument is not set. - public static object? GetArg0(AvaloniaObject element) => GetArgument(element, Arg0Property); - - /// Gets the value of the attached property. - /// The element holding the argument. - /// The argument value, or when the argument is not set. - public static object? GetArg1(AvaloniaObject element) => GetArgument(element, Arg1Property); - - /// Gets the value of the attached property. - /// The element holding the argument. - /// The argument value, or when the argument is not set. - public static object? GetArg2(AvaloniaObject element) => GetArgument(element, Arg2Property); - - /// Gets the value of the attached property. - /// The element holding the argument. - /// The argument value, or when the argument is not set. - public static object? GetArg3(AvaloniaObject element) => GetArgument(element, Arg3Property); - - /// Gets the value of the attached property. - /// The element holding the argument. - /// The argument value, or when the argument is not set. - public static object? GetArg4(AvaloniaObject element) => GetArgument(element, Arg4Property); - - /// Gets the value of the attached property. - /// The element holding the argument. - /// The argument value, or when the argument is not set. - public static object? GetArg5(AvaloniaObject element) => GetArgument(element, Arg5Property); - - /// Gets the value of the attached property. - /// The element holding the argument. - /// The argument value, or when the argument is not set. - public static object? GetArg6(AvaloniaObject element) => GetArgument(element, Arg6Property); - - /// Gets the value of the attached property. - /// The element holding the argument. - /// The argument value, or when the argument is not set. - public static object? GetArg7(AvaloniaObject element) => GetArgument(element, Arg7Property); - - /// Gets the value of the attached property. - /// The element holding the argument. - /// The argument value, or when the argument is not set. - public static object? GetArg8(AvaloniaObject element) => GetArgument(element, Arg8Property); - - /// Sets the value of the attached property. - /// The element to hold the argument. - /// The argument value; is a valid, set value. - public static void SetArg0(AvaloniaObject element, object? value) => element.SetValue(Arg0Property, value); - - /// Sets the value of the attached property. - /// The element to hold the argument. - /// The argument value; is a valid, set value. - public static void SetArg1(AvaloniaObject element, object? value) => element.SetValue(Arg1Property, value); - - /// Sets the value of the attached property. - /// The element to hold the argument. - /// The argument value; is a valid, set value. - public static void SetArg2(AvaloniaObject element, object? value) => element.SetValue(Arg2Property, value); - - /// Sets the value of the attached property. - /// The element to hold the argument. - /// The argument value; is a valid, set value. - public static void SetArg3(AvaloniaObject element, object? value) => element.SetValue(Arg3Property, value); - - /// Sets the value of the attached property. - /// The element to hold the argument. - /// The argument value; is a valid, set value. - public static void SetArg4(AvaloniaObject element, object? value) => element.SetValue(Arg4Property, value); - - /// Sets the value of the attached property. - /// The element to hold the argument. - /// The argument value; is a valid, set value. - public static void SetArg5(AvaloniaObject element, object? value) => element.SetValue(Arg5Property, value); - - /// Sets the value of the attached property. - /// The element to hold the argument. - /// The argument value; is a valid, set value. - public static void SetArg6(AvaloniaObject element, object? value) => element.SetValue(Arg6Property, value); - - /// Sets the value of the attached property. - /// The element to hold the argument. - /// The argument value; is a valid, set value. - public static void SetArg7(AvaloniaObject element, object? value) => element.SetValue(Arg7Property, value); - - /// Sets the value of the attached property. - /// The element to hold the argument. - /// The argument value; is a valid, set value. - public static void SetArg8(AvaloniaObject element, object? value) => element.SetValue(Arg8Property, value); - - /// - /// Reads the format arguments currently set on , applying the - /// trimming rules: arguments above the highest set slot are dropped, unset slots below it - /// become . - /// - /// The element to read the arguments from. - /// - /// The arguments to format with, or when no argument is set on the - /// element - the caller must then resolve without composite formatting. - /// - internal static object?[]? GetArguments(AvaloniaObject element) - { - var lastSetIndex = -1; - - for (var index = ArgumentProperties.Length - 1; index >= 0; index--) - { - if (!ReferenceEquals(element.GetValue(ArgumentProperties[index]), UnsetSentinel.Value)) - { - lastSetIndex = index; - break; - } - } - - if (lastSetIndex < 0) - { - return null; - } - - var arguments = new object?[lastSetIndex + 1]; - - for (var index = 0; index <= lastSetIndex; index++) - { - var value = element.GetValue(ArgumentProperties[index]); - arguments[index] = ReferenceEquals(value, UnsetSentinel.Value) ? null : value; - } - - return arguments; - } - - /// - /// Collects the nine argument slots and hooks their change notification: every ArgN - /// change bumps on the changed element. Invoked from the - /// initializer (rather than a static constructor) so the - /// attached-property fields stay simple static initializers. - /// - /// The nine argument slots in slot order. - private static AttachedProperty[] CollectArgumentsAndHookChanges() - { - AttachedProperty[] properties = - [ - Arg0Property, - Arg1Property, - Arg2Property, - Arg3Property, - Arg4Property, - Arg5Property, - Arg6Property, - Arg7Property, - Arg8Property, - ]; - - foreach (var property in properties) - { - _ = property.Changed.AddClassHandler( - static (element, _) => element.SetValue(ArgsVersionProperty, element.GetValue(ArgsVersionProperty) + 1) - ); - } - - return properties; - } - - /// Reads one argument slot, mapping the unset sentinel to . - /// The element holding the argument. - /// The argument slot to read. - /// The argument value, or when the argument is not set. - private static object? GetArgument(AvaloniaObject element, AttachedProperty property) - { - var value = element.GetValue(property); - return ReferenceEquals(value, UnsetSentinel.Value) ? null : value; - } - - /// - /// Holds the "argument never set" default of the ArgN properties. A nested type keeps the - /// sentinel initialized before the attached-property fields above it, regardless of textual order. - /// - private static class UnsetSentinel - { - /// The sentinel instance; compared by reference. - internal static readonly object Value = new(); - } -} diff --git a/src/ResXLocalization.Avalonia/LocalizeEnumConverter.cs b/src/ResXLocalization.Avalonia/LocalizeEnumConverter.cs index c1b6714..9f72398 100644 --- a/src/ResXLocalization.Avalonia/LocalizeEnumConverter.cs +++ b/src/ResXLocalization.Avalonia/LocalizeEnumConverter.cs @@ -1,17 +1,26 @@ 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}. +/// Multi-value converter that localizes an enumeration value in a hand-written MultiBinding, for places +/// where a markup extension such as is not accepted, for example a +/// Binding-typed property. The children of the MultiBinding are: 0, the refresh trigger +/// <Binding Path="RefreshTriggeringRevision" Source="{x:Static core:Localizer.Current}" />, which +/// re-runs the conversion on every change of ; 1, the +/// enumeration value; 2 and up, the format arguments, in order. The value is mapped to a resource key by +/// , by default {KeyPrefix}{EnumTypeName}_{Value}. /// +/// +/// A MultiBinding whose first child is the enumeration value, followed by CurrentCulture, renders +/// an empty string; the enumeration value must be child 1, after the refresh trigger. +/// public sealed class LocalizeEnumConverter : IMultiValueConverter { /// Indicates whether this is the read-only shared instance. private readonly bool isSharedInstance; + /// The argument names parsed from , one per argument, in order. + private string?[] argumentNames = []; + /// Initializes a new instance of the class. public LocalizeEnumConverter() { } @@ -28,27 +37,75 @@ public LocalizeEnumConverter() { } /// Gets the shared, search-all converter instance with default settings. Reference it from XAML /// as {x:Static l:LocalizeEnumConverter.Default} when no file scoping is required. The /// shared instance is read-only - create your own converter to customize - /// or . + /// , or . /// 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 names of the format arguments, as one comma-separated string: entry i names the + /// argument at child 2 + i. For example, ArgumentNames="Count,Total" names child 2 + /// Count and child 3 Total. Entries are trimmed; an empty entry leaves that argument unnamed + /// (",Total" names only the second). (the default) or an empty string means + /// no names. Every argument keeps its position whether it is named or not, and names matter only to a + /// formatter that understands them, such as ICU MessageFormat; the default formatter ignores them. Extra + /// names are ignored, and arguments without an entry are unnamed. /// - /// The supplied value is . + /// Two entries have the same name, compared ordinally. /// /// The converter is the shared instance, which is read-only. /// - public string KeyPrefix + public string? ArgumentNames { get; set { - ArgumentNullException.ThrowIfNull(value); this.ThrowIfShared(); + + string?[] names = string.IsNullOrEmpty(value) + ? [] + : + [ + .. value + .Split(',') + .Select(static name => name.Trim()) + .Select(static name => name.Length == 0 ? null : name), + ]; + + var duplicate = names + .OfType() + .GroupBy(static name => name, StringComparer.Ordinal) + .FirstOrDefault(static group => group.Skip(1).Any()) + ?.Key; + if (duplicate is not null) + { + throw new ArgumentException( + $"ArgumentNames contains the name '{duplicate}' twice. Argument names must be unique.", + nameof(value) + ); + } + + this.argumentNames = names; field = value; } - } = EnumKeyConvention.DefaultEnumKeyPrefix; + } + + /// + /// 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 converter is the shared instance, which is read-only. + /// + public string? KeyPrefix + { + get; + set + { + this.ThrowIfShared(); + field = value; + } + } /// /// Gets or sets the resource manager that scopes the lookup to a single .resx file. When @@ -67,13 +124,10 @@ public ResourceManager? ResourceManager } } - /// - /// Converts the bound enumeration value to its localized string. - /// + /// Converts the enumeration value to its localized, formatted string. /// - /// The bound values. The first element is expected to be the value to - /// localize; any further bindings (such as the current culture) exist only to trigger - /// re-conversion. + /// The bound values: 0, the refresh trigger, whose value is ignored; 1, the value to + /// localize; 2 and up, the format arguments, named by . /// /// The type of the binding target property. /// An optional converter parameter; not used. @@ -82,21 +136,37 @@ public ResourceManager? ResourceManager /// taken from ). /// /// - /// The localized string for the enumeration value, or when no value - /// is supplied or the first value is not an . + /// The localized string for the enumeration value, or when there are fewer than + /// two values or value 1 is not an . /// public object Convert(IList values, Type targetType, object? parameter, CultureInfo culture) { - if (values.Count == 0 || values[0] is not Enum enumValue) + if (values.Count < 2 || values[1] is not Enum enumValue) { return string.Empty; } - var key = EnumKeyConvention.BuildEnumKey(enumValue, this.KeyPrefix); + // Only two values means there are no arguments; every further value becomes (Name, Value), positioned from child 2 on. + var arguments = + values.Count == 2 + ? null + : MessageArguments.FromTuples([ + .. values + .Skip(2) + .Select( + (value, index) => + ( + index < this.argumentNames.Length ? this.argumentNames[index] : null, + value == AvaloniaProperty.UnsetValue ? null : value + ) + ), + ]); - return this.ResourceManager is null - ? Localizer.Current.Get(key) - : Localizer.Current.Get(key, this.ResourceManager); + return Localizer.Current.Get( + Localizer.Current.BuildEnumKey(enumValue, this.KeyPrefix), + arguments, + this.ResourceManager + ); } /// Guards property setters against mutating the shared instance. @@ -109,7 +179,7 @@ private void ThrowIfShared() { throw new InvalidOperationException( "The shared LocalizeEnumConverter.Default instance is read-only; " - + "create your own LocalizeEnumConverter to customize KeyPrefix or ResourceManager." + + "create your own LocalizeEnumConverter to customize KeyPrefix, ResourceManager or ArgumentNames." ); } } diff --git a/src/ResXLocalization.Avalonia/LocalizeEnumExtension.cs b/src/ResXLocalization.Avalonia/LocalizeEnumExtension.cs index e0a33d6..dfa47c5 100644 --- a/src/ResXLocalization.Avalonia/LocalizeEnumExtension.cs +++ b/src/ResXLocalization.Avalonia/LocalizeEnumExtension.cs @@ -1,18 +1,43 @@ 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}. +/// XAML markup extension that localizes an enumeration value, re-resolving automatically on every change of +/// the value and of of . +/// The value is mapped to a resource key by , by default +/// {KeyPrefix}{EnumTypeName}_{Value}. Usage in XAML: {l:LocalizeEnum Value={Binding SortOrder}} +/// (or the shorthand {l:LocalizeEnum {Binding SortOrder}}) for a bound value, +/// {l:LocalizeEnum Value={Binding}} in an item template whose item is the enumeration value, or +/// {l:LocalizeEnum Value={x:Static m:FileSortOrder.Ascending}} for a fixed member. When the resource +/// value is a composite format string, supply its arguments as child elements: +/// <l:LocalizeEnum Value="{Binding SortOrder}" KeyPrefix="Count_"><l:Argument Value="{Binding FileCount}" /></l:LocalizeEnum>. /// public sealed class LocalizeEnumExtension : MarkupExtension { + /// Initializes a new instance of the class. + public LocalizeEnumExtension() { } + + /// + /// Initializes a new instance of the class and sets + /// : {l:LocalizeEnum {Binding SortOrder}} is + /// {l:LocalizeEnum Value={Binding SortOrder}}. + /// + /// The enumeration value: a binding or a constant member. + public LocalizeEnumExtension(object value) => this.Value = value; + /// - /// Gets or sets the prefix prepended to the generated resource key. Defaults to Enum_. + /// Gets the format arguments, in declaration order: the child elements. The first + /// argument is {0}, the second {1}, and so on, whether it has a name or not. The enumeration + /// value itself is not an argument; bind it as an when the text needs it. /// - public string KeyPrefix { get; set; } = EnumKeyConvention.DefaultEnumKeyPrefix; + [Content] + public Collection Arguments { get; } = []; + + /// + /// 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; } /// /// Gets or sets the resource manager that scopes the lookup to a single .resx file. When @@ -21,27 +46,64 @@ public sealed class LocalizeEnumExtension : MarkupExtension public ResourceManager? ResourceManager { get; set; } /// - /// Provides the value supplied to the target property: a binding that yields the localized text - /// for the control's current DataContext enumeration value, updated live on every culture - /// change. + /// Gets or sets the enumeration value to localize: a binding, such as {Binding SortOrder} or + /// {Binding} in an item template, or a constant member written as + /// {x:Static m:FileSortOrder.Ascending}. A bound value that is not an , such as + /// , renders an empty string. + /// + // [AssignBinding] assigns a binding written for Value as its value instead of applying it. + [AssignBinding] + public object? Value { get; set; } + + /// + /// Provides the value supplied to the target property: a binding whose value is the localized string for + /// , formatted with , updated live on every change of the + /// value, of the arguments and of . /// - /// The service provider supplied by the XAML loader. - /// - /// An Avalonia binding that yields the localized enumeration text, or - /// when the target is not an . - /// + /// The service provider supplied by the XAML loader; not used. + /// An Avalonia multi-binding that yields the localized string. + /// + /// is or a constant that is not an ; or an + /// has no , an empty , or + /// the same name as another one. + /// public override object ProvideValue(IServiceProvider serviceProvider) { - // The target is the per-item control being built (e.g. the TextBlock in the template). - var target = serviceProvider.GetService(typeof(IProvideValueTarget)) as IProvideValueTarget; - if (target?.TargetObject is not AvaloniaObject control) + this.ValidateValue(); + LocalizedMultiBindingBuilder.ValidateArguments(this.Arguments); + + return LocalizedMultiBindingBuilder.Build( + this.Value!, + this.Arguments, + this.ResourceManager, + isEnumKey: true, + enumKeyPrefix: this.KeyPrefix + ); + } + + /// Checks that is a binding or an member. + /// + /// is or a constant that is not an . + /// + private void ValidateValue() + { + switch (this.Value) { - return string.Empty; - } + case null: + throw new InvalidOperationException( + "{l:LocalizeEnum} has no Value. Set it to a binding, as Value={Binding ...}, " + + "or to a fixed member, as Value={x:Static ...}." + ); - // Live stream of this control's DataContext (the enum value). - var dataContext = control.GetObservable(StyledElement.DataContextProperty); + case BindingBase: + case Enum: + return; - return new LocalizedEnumObservable(dataContext, this.KeyPrefix, this.ResourceManager).ToBinding(); + default: + throw new InvalidOperationException( + $"Value of {{l:LocalizeEnum}} must be a binding or an Enum, but is a {this.Value.GetType().FullName}. " + + "A fixed member is written Value={x:Static ...}." + ); + } } } diff --git a/src/ResXLocalization.Avalonia/LocalizeExtension.cs b/src/ResXLocalization.Avalonia/LocalizeExtension.cs index 8815e33..fc88a12 100644 --- a/src/ResXLocalization.Avalonia/LocalizeExtension.cs +++ b/src/ResXLocalization.Avalonia/LocalizeExtension.cs @@ -1,13 +1,17 @@ namespace RentADeveloper.ResXLocalization.Avalonia; /// -/// XAML markup extension that produces a binding to a localized string, re-resolving automatically -/// whenever changes culture. Usage in XAML: -/// {l:Localize {x:Static res:StringsKeys.Greeting}} for a typed key, -/// {l:Localize SomeKey} for a key-only lookup across all registered resource managers, or -/// {l:Localize Key=SomeKey, ResourceManager={x:Static res:Strings.ResourceManager}} to scope -/// the lookup to one file. When the resource value is a composite format string, supply its -/// arguments by binding the attached properties on the target element. +/// XAML markup extension that produces a binding to a localized string, re-resolving automatically on every +/// change of of - a +/// language switch, a new formatter, a new lookup-failure text builder or a change of the registered resource +/// managers. Usage in XAML: {l:Localize {x:Static res:StringsKeys.Greeting}} for a typed key, +/// {l:Localize Greeting} for a key-only lookup across all registered resource managers, or +/// {l:Localize Key=Greeting, ResourceManager={x:Static res:StringsKeys.ResourceManager}} to scope the +/// lookup to one file. A key chosen by the view model is bound: {l:Localize {Binding StatusKey}} or +/// Key="{Binding StatusKey}"; bind a to keep +/// the key compile-checked. When the resource value is a composite format string, supply its arguments as +/// child elements: +/// <l:Localize Key="{x:Static res:StringsKeys.PeopleInvited}"><l:Argument Value="{Binding PeopleCount}" /></l:Localize>. /// public sealed class LocalizeExtension : MarkupExtension { @@ -15,90 +19,110 @@ public sealed class LocalizeExtension : MarkupExtension public LocalizeExtension() { } /// - /// Initializes a new instance of the class from a single positional - /// argument. A typed (for example from - /// {x:Static res:StringsKeys.Greeting}) becomes ; a - /// becomes ; any other value leaves empty. - /// A single object-typed constructor is used instead of overloads so Avalonia's positional-argument - /// resolution is never ambiguous, which also keeps the XAML previewer working. + /// Initializes a new instance of the class and sets : + /// {l:Localize Greeting} is {l:Localize Key=Greeting}, and {l:Localize {Binding StatusKey}} + /// is {l:Localize Key={Binding StatusKey}}. A single object-typed constructor is used instead of + /// overloads so Avalonia's positional-argument resolution is never ambiguous, which also keeps the XAML + /// previewer working. /// - /// A or a key string. - public LocalizeExtension(object key) - { - if (key is ResourceKey resourceKey) - { - this.ResourceKey = resourceKey; - } - else - { - this.Key = key as string ?? string.Empty; - } - } + /// + /// The key: a , a , or a + /// binding that yields one of them. + /// + public LocalizeExtension(object key) => this.Key = key; /// - /// Gets or sets the resource key to resolve. Used when is not set. If - /// is also set, the lookup is scoped to that file; otherwise every - /// registered resource manager is searched. + /// Gets the format arguments, in declaration order: the child elements. The first + /// argument is {0}, the second {1}, and so on, whether it has a name or not. /// - public string Key { get; set; } = string.Empty; + [Content] + public Collection Arguments { get; } = []; /// - /// Gets or sets a typed, file-scoped key. When set, it takes precedence over - /// and and resolves through its own resource manager. + /// Gets or sets the key to resolve: a , scoped to if + /// that is set and otherwise searched across all registered resource managers, or a + /// , which resolves through its own resource + /// manager; or a binding, such as Key="{Binding StatusKey}", whose value is resolved by the same + /// rules on every update. Prefer binding a + /// chosen by the view model, which keeps the key compile-checked; a bound key is an + /// escape hatch where a typo silently becomes the failure text. A bound value that gives no key, such as + /// , an empty string or a value of another type, renders an empty string. /// - public ResourceKey? ResourceKey { get; set; } + // [AssignBinding] assigns a binding written for Key as its value instead of applying it, so it becomes + // the key child of the multi-binding this extension produces. + [AssignBinding] + public object? Key { get; set; } /// - /// Gets or sets the resource manager that scopes a lookup to a single - /// .resx file. Ignored when is set; when both this and - /// are unset, the key is searched across all registered resource managers. + /// Gets or sets the resource manager that scopes a to a single + /// .resx file. Ignored when is a + /// ; when unset, a key is + /// searched across all registered resource managers. /// public ResourceManager? ResourceManager { get; set; } /// - /// Provides the value supplied to the target property: a binding whose value is the localized - /// string for the configured key, updated live on every culture change - and, when the target is - /// an , on every change of its arguments. + /// Provides the value supplied to the target property: a binding whose value is the localized string for + /// , formatted with , updated live on every change of the + /// arguments and of . It needs no target element, so + /// it also works in a style setter, in a resource and in a template. /// - /// The service provider supplied by the XAML loader. - /// An Avalonia binding that yields the localized string. + /// The service provider supplied by the XAML loader; not used. + /// An Avalonia multi-binding that yields the localized string. + /// + /// is , or a constant that is neither a nor + /// a ; or an has no + /// , an empty , or the same name as another one. + /// + /// + /// is a without a resource + /// manager. + /// public override object ProvideValue(IServiceProvider serviceProvider) { - Func resolve; - Func resolveFormatted; + this.ValidateKey(); + LocalizedMultiBindingBuilder.ValidateArguments(this.Arguments); - if (this.ResourceKey.HasValue) - { - var resourceKey = this.ResourceKey.Value; - resolve = () => Localizer.Current.Get(resourceKey); - resolveFormatted = arguments => Localizer.Current.Get(resourceKey, 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); - } - else + return LocalizedMultiBindingBuilder.Build(this.Key!, this.Arguments, this.ResourceManager); + } + + /// + /// Checks that is a , a usable resource key or a binding. + /// + /// + /// is , or a constant that is neither a nor + /// a . + /// + /// + /// is a without a resource + /// manager. + /// + private void ValidateKey() + { + switch (this.Key) { - var key = this.Key; - resolve = () => Localizer.Current.Get(key); - resolveFormatted = arguments => Localizer.Current.Get(key, arguments); - } + case null: + throw new InvalidOperationException( + "{l:Localize} has no Key. Set the key as {l:Localize Greeting} or as Key=\"...\"." + ); - // The target element is where the LocalizeArgs arguments live. ProvideValue runs during XAML - // instantiation, before argument bindings deliver values, so the produced binding re-reads the - // arguments on every args-version change instead of reading them once here. Without an - // AvaloniaObject target (including a null service provider), argument support is unavailable - // and the culture-only binding preserves the exact pre-LocalizeArgs behavior. - var target = serviceProvider?.GetService(typeof(IProvideValueTarget)) as IProvideValueTarget; + case string: + return; - if (target?.TargetObject is AvaloniaObject element) - { - return new LocalizedFormattedTextObservable(element, resolve, resolveFormatted).ToBinding(); - } + case ResourceKey { Manager: null }: + throw new ArgumentNullException( + message: "The ResourceKey assigned to Key of {l:Localize} has no ResourceManager.", + innerException: null + ); - return new LocalizedTextObservable(resolve).ToBinding(); + case ResourceKey: + case BindingBase: + return; + + default: + throw new InvalidOperationException( + $"Key of {{l:Localize}} must be a string, a ResourceKey or a binding, but is a {this.Key.GetType().FullName}." + ); + } } } diff --git a/src/ResXLocalization.Avalonia/LocalizedEnumObservable.cs b/src/ResXLocalization.Avalonia/LocalizedEnumObservable.cs deleted file mode 100644 index 6658858..0000000 --- a/src/ResXLocalization.Avalonia/LocalizedEnumObservable.cs +++ /dev/null @@ -1,126 +0,0 @@ -namespace RentADeveloper.ResXLocalization.Avalonia; - -/// -/// The observable behind the binding that produces: follows -/// the target control's DataContext (expected to be an enumeration value) and emits its -/// localized text, re-emitting whenever the value or the culture of the ambient -/// changes. Culture changes are observed through -/// , so a bound control can be collected while the process-wide -/// localizer lives on. -/// -/// The live stream of the control's DataContext values. -/// The prefix prepended to the generated resource key. -/// -/// 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, - ResourceManager? resourceManager -) : IObservable -{ - /// - /// Pushes the localized text for the control's current DataContext value to - /// and keeps it updated on every value or culture change. - /// - /// The observer receiving the localized enumeration text. - /// A subscription that stops the updates when disposed. - public IDisposable Subscribe(IObserver observer) => - new Subscription(dataContextSource, keyPrefix, resourceManager, observer); - - /// - /// One observer's subscription: tracks the latest DataContext enumeration value, weakly - /// subscribes to , and emits the freshly - /// localized text on every change of either. - /// - private sealed class Subscription : IDisposable, IWeakEventSubscriber - { - /// The subscription following the control's DataContext. - private readonly IDisposable dataContextSubscription; - - /// The prefix prepended to the generated resource key. - private readonly string keyPrefix; - - /// The observer receiving the localized enumeration text. - private readonly IObserver observer; - - /// - /// The resource manager that scopes the lookup to a single .resx file, or - /// to search all registered resource managers. - /// - private readonly ResourceManager? resourceManager; - - /// - /// The latest DataContext enumeration value, or when the - /// DataContext is missing or not an enumeration value. - /// - private Enum? currentValue; - - /// - /// Initializes a new instance of the class, subscribing it to - /// the DataContext stream (which emits the current value immediately) and to culture - /// changes of the ambient localizer. - /// - /// The live stream of the control's DataContext values. - /// The prefix prepended to the generated resource key. - /// - /// The resource manager that scopes the lookup to a single .resx file, or - /// to search all registered resource managers. - /// - /// The observer receiving the localized enumeration text. - public Subscription( - IObservable dataContextSource, - string keyPrefix, - ResourceManager? resourceManager, - IObserver observer - ) - { - this.observer = observer; - this.keyPrefix = keyPrefix; - this.resourceManager = resourceManager; - this.dataContextSubscription = dataContextSource.Subscribe( - new AnonymousObserver(value => - { - this.currentValue = value as Enum; - this.Emit(); - }) - ); - LocalizerWeakEvents.CultureChanged.Subscribe(Localizer.Current, this); - } - - /// Unsubscribes from the DataContext stream and the culture-change weak event. - public void Dispose() - { - this.dataContextSubscription.Dispose(); - LocalizerWeakEvents.CultureChanged.Unsubscribe(Localizer.Current, this); - } - - /// Handles a culture change by re-emitting the localized text. - /// The localizer that raised the event. - /// The weak event delivering the notification. - /// The event data carrying the previous and current culture. - public void OnEvent(object? sender, WeakEvent ev, CultureChangedEventArgs e) => this.Emit(); - - /// - /// Emits the localized text for the tracked enumeration value, or - /// when the current DataContext is not an enumeration - /// value (for example while it is still during template setup). - /// - private void Emit() - { - if (this.currentValue is null) - { - this.observer.OnNext(string.Empty); - return; - } - - var key = EnumKeyConvention.BuildEnumKey(this.currentValue, this.keyPrefix); - this.observer.OnNext( - this.resourceManager is null - ? Localizer.Current.Get(key) - : Localizer.Current.Get(key, this.resourceManager) - ); - } - } -} diff --git a/src/ResXLocalization.Avalonia/LocalizedFormattedTextObservable.cs b/src/ResXLocalization.Avalonia/LocalizedFormattedTextObservable.cs deleted file mode 100644 index 514210d..0000000 --- a/src/ResXLocalization.Avalonia/LocalizedFormattedTextObservable.cs +++ /dev/null @@ -1,117 +0,0 @@ -namespace RentADeveloper.ResXLocalization.Avalonia; - -/// -/// The observable behind the binding that produces when it knows -/// its target element: emits the resolved localized string - formatted with the -/// arguments currently set on the element - and re-emits it whenever an -/// argument or the culture of the ambient changes. Culture changes -/// are observed through , so a bound control can be collected -/// while the process-wide localizer lives on. -/// -/// The target element carrying the arguments. -/// Resolves the localized string without composite formatting. -/// Resolves the localized string formatted with the supplied arguments. -internal sealed class LocalizedFormattedTextObservable( - AvaloniaObject element, - Func resolve, - Func resolveFormatted -) : IObservable -{ - /// - /// Pushes the currently resolved string to and keeps it updated on - /// every argument or culture change. - /// - /// The observer receiving the localized string. - /// A subscription that stops the updates when disposed. - public IDisposable Subscribe(IObserver observer) => - new Subscription(element, resolve, resolveFormatted, observer); - - /// - /// One observer's subscription: follows the element's args-version property (whose stream emits - /// immediately, producing the initial value), weakly subscribes to - /// , and emits the freshly resolved string on - /// every change of either. - /// - private sealed class Subscription : IDisposable, IWeakEventSubscriber - { - /// The subscription following the element's args-version property. - private readonly IDisposable argsVersionSubscription; - - /// The target element carrying the arguments. - private readonly AvaloniaObject element; - - /// The observer receiving each freshly resolved string. - private readonly IObserver observer; - - /// Resolves the localized string without composite formatting. - private readonly Func resolve; - - /// Resolves the localized string formatted with the supplied arguments. - private readonly Func resolveFormatted; - - /// - /// Initializes a new instance of the class, subscribing it to the - /// element's args-version stream and to culture changes of the ambient localizer. - /// - /// The target element carrying the arguments. - /// Resolves the localized string without composite formatting. - /// Resolves the localized string formatted with the supplied arguments. - /// The observer receiving each freshly resolved string. - public Subscription( - AvaloniaObject element, - Func resolve, - Func resolveFormatted, - IObserver observer - ) - { - this.element = element; - this.resolve = resolve; - this.resolveFormatted = resolveFormatted; - this.observer = observer; - this.argsVersionSubscription = element - .GetObservable(LocalizeArgs.ArgsVersionProperty) - .Subscribe(new AnonymousObserver(_ => this.Emit())); - LocalizerWeakEvents.CultureChanged.Subscribe(Localizer.Current, this); - } - - /// Unsubscribes from the args-version stream and the culture-change weak event. - public void Dispose() - { - this.argsVersionSubscription.Dispose(); - LocalizerWeakEvents.CultureChanged.Unsubscribe(Localizer.Current, this); - } - - /// Handles a culture change by re-resolving the string and emitting it to the observer. - /// The localizer that raised the event. - /// The weak event delivering the notification. - /// The event data carrying the previous and current culture. - public void OnEvent(object? sender, WeakEvent ev, CultureChangedEventArgs e) => this.Emit(); - - /// - /// Emits the freshly resolved string: formatted with the element's current - /// arguments when at least one is set, otherwise resolved plainly - /// so the no-argument behavior stays byte-for-byte unchanged. - /// - private void Emit() - { - object? value; - - try - { - var arguments = LocalizeArgs.GetArguments(this.element); - value = arguments is null ? this.resolve() : this.resolveFormatted(arguments); - } - catch (FormatException exception) - { - // Mirrors WPF, whose binding engine catches converter exceptions and reports a binding - // error: a format string that does not (yet) match the supplied arguments - routine - // while multi-argument bindings still deliver their values one by one - must not throw - // into the property system mid-delivery. It surfaces as a binding error instead and - // resolves cleanly on the next argument or culture change. - value = new BindingNotification(exception, BindingErrorType.Error); - } - - this.observer.OnNext(value); - } - } -} diff --git a/src/ResXLocalization.Avalonia/LocalizedMessageConverter.cs b/src/ResXLocalization.Avalonia/LocalizedMessageConverter.cs new file mode 100644 index 0000000..64a2e85 --- /dev/null +++ b/src/ResXLocalization.Avalonia/LocalizedMessageConverter.cs @@ -0,0 +1,92 @@ +namespace RentADeveloper.ResXLocalization.Avalonia; + +/// +/// The multi-value converter of the that +/// builds: turns its values into one call of ILocalizer.Get(string, MessageArguments?, ResourceManager?) +/// on . Value 0 is the refresh trigger and is ignored; value 1 is the key, or +/// the enumeration value whose key builds; values 2 and up are the +/// argument values. +/// +/// The argument names, one per argument value, in order. +/// +/// The resource manager that scopes a key, or to search all +/// registered resource managers. +/// +/// +/// when value 1 is an enumeration value whose key +/// builds. +/// +/// +/// The key prefix passed to , or to let the +/// builder decide. +/// +internal sealed class LocalizedMessageConverter( + string?[] argumentNames, + ResourceManager? resourceManager, + bool isEnumKey, + string? enumKeyPrefix +) : IMultiValueConverter +{ + /// The argument names, one per argument value, in order. + private readonly string?[] argumentNames = argumentNames; + + /// The key prefix passed to . + private readonly string? enumKeyPrefix = enumKeyPrefix; + + /// Whether value 1 is an enumeration value rather than a key. + private readonly bool isEnumKey = isEnumKey; + + /// The resource manager that scopes a key. + private readonly ResourceManager? resourceManager = resourceManager; + + /// Returns the localized, formatted text for the current values. + /// The refresh trigger, the key or the enumeration value, and the argument values. + /// The type of the binding target property; not used. + /// An optional converter parameter; not used. + /// + /// The culture supplied by the binding; not used (the lookup uses the culture of + /// ). + /// + /// + /// + /// The localized text. + /// + /// The failure text (see ) when the lookup fails. + /// + /// + /// An empty string when value 1 gives no key to look up. When value 1 is a key (isEnumKey is + /// ), that is the case when it is neither a non-empty nor a + /// that has a resource manager. When value 1 is an enumeration value + /// (isEnumKey is ), that is the case when it is not an . + /// Examples are and a binding that has no value yet. + /// + /// + /// + public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) + { + // Never throws for a key value: a key that cannot be looked up renders empty. + var (key, keyResourceManager) = (this.isEnumKey, values[1]) switch + { + (true, Enum value) => (Localizer.Current.BuildEnumKey(value, this.enumKeyPrefix), this.resourceManager), + (false, string { Length: > 0 } name) => (name, this.resourceManager), + (false, ResourceKey { Manager: not null } resourceKey) => (resourceKey.Name, resourceKey.Manager), + _ => ((string?)null, (ResourceManager?)null), + }; + + if (key is null) + { + return string.Empty; + } + + // Every argument value becomes (Name, Value); FromTuples gives each its index as position. + var arguments = MessageArguments.FromTuples([ + .. values + .Skip(2) + .Select( + (value, index) => (this.argumentNames[index], value == AvaloniaProperty.UnsetValue ? null : value) + ), + ]); + + return Localizer.Current.Get(key, arguments, keyResourceManager); + } +} diff --git a/src/ResXLocalization.Avalonia/LocalizedMultiBindingBuilder.cs b/src/ResXLocalization.Avalonia/LocalizedMultiBindingBuilder.cs new file mode 100644 index 0000000..a329622 --- /dev/null +++ b/src/ResXLocalization.Avalonia/LocalizedMultiBindingBuilder.cs @@ -0,0 +1,156 @@ +namespace RentADeveloper.ResXLocalization.Avalonia; + +/// +/// Builds the behind and +/// and validates its entries. The children of the +/// multi-binding are: 0, the refresh trigger, bound to of +/// ; 1, the key or the enumeration value; 2 and up, the argument values in +/// declaration order. Every child is a binding: a constant is wrapped in a +/// binding whose source is the constant, so reads every value the +/// same way. +/// +internal static class LocalizedMultiBindingBuilder +{ + /// Builds the multi-binding that yields the localized, formatted text. + /// + /// The key: a or a ; with , the + /// enumeration value. A binding or a constant. + /// + /// The arguments, already validated by . + /// + /// The resource manager that scopes a key, or to search all + /// registered resource managers. + /// + /// + /// when is an enumeration value whose key + /// builds. + /// + /// + /// The key prefix passed to , or to let the + /// builder decide. Used only with . + /// + /// The multi-binding. + internal static MultiBinding Build( + object key, + Collection arguments, + ResourceManager? resourceManager, + bool isEnumKey = false, + string? enumKeyPrefix = null + ) + { + var multiBinding = new MultiBinding + { + Converter = new LocalizedMessageConverter( + [.. arguments.Select(argument => argument.Name)], + resourceManager, + isEnumKey, + enumKeyPrefix + ), + Mode = BindingMode.OneWay, + }; + + // 0 = refresh trigger, 1 = key or enumeration value, 2.. = argument values + multiBinding.Bindings.Add( + CompiledBinding.Create(localizer => localizer.RefreshTriggeringRevision, Localizer.Current) + ); + multiBinding.Bindings.Add(CreateBinding(key)); + + foreach (var argument in arguments) + { + multiBinding.Bindings.Add(CreateBinding(argument.Value)); + } + + return multiBinding; + } + + /// + /// Checks the arguments: every entry has a , no + /// is an empty string, and no two entries have the same name (compared + /// ordinally). + /// + /// The arguments to check. + /// An argument breaks one of the rules. + internal static void ValidateArguments(Collection arguments) + { + var names = new HashSet(StringComparer.Ordinal); + + for (var position = 0; position < arguments.Count; position++) + { + var argument = arguments[position]; + + if (!argument.IsValueSet) + { + var name = argument.Name is null ? string.Empty : $" (Name '{argument.Name}')"; + throw new InvalidOperationException( + $"The at position {position}{name} has no Value. Set Value to a constant or a " + + "binding; to pass null, write Value=\"{x:Null}\"." + ); + } + + if (argument.Name is null) + { + continue; + } + + if (argument.Name.Length == 0) + { + throw new InvalidOperationException( + $"The at position {position} has an empty Name. An empty name is not allowed; " + + "leave Name unset for a positional argument." + ); + } + + if (!names.Add(argument.Name)) + { + throw new InvalidOperationException( + $"Two entries have the Name '{argument.Name}'. Argument names must be unique." + ); + } + } + } + + /// Returns a binding unchanged and wraps a constant in a binding whose source is the constant. + /// A binding or a constant. + /// The binding. + private static BindingBase CreateBinding(object? value) => + value switch + { + BindingBase binding => binding, + + // A constant null cannot be the source of a binding: a binding whose source is null has no source + // and reads the DataContext instead. So this binding reads the DataContext like any other, and + // ToNullConverter turns whatever it reads into the constant null. + null => CompiledBinding.Create(source => source, converter: ToNullConverter.Instance), + _ => CompiledBinding.Create(source => source, value), + }; + + /// + /// Turns every value into . uses it for an argument + /// whose constant value is : that argument's binding reads the DataContext, + /// because a binding with a source has no source, and this converter replaces + /// what it reads with . + /// + private sealed class ToNullConverter : IValueConverter + { + /// The shared instance. + internal static readonly ToNullConverter Instance = new(); + + /// Returns . + /// The bound value; ignored. + /// The type of the binding target property; ignored. + /// An optional converter parameter; ignored. + /// The culture supplied by the binding; ignored. + /// . + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => null; + + /// Not supported; the converter is one-way. + /// The value produced by the binding target. + /// The type to convert back to. + /// An optional converter parameter; not used. + /// The culture supplied by the binding; not used. + /// Never returns; always throws. + /// Always thrown; the converter is one-way. + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => + throw new NotSupportedException(); + } +} diff --git a/src/ResXLocalization.Avalonia/LocalizedTextObservable.cs b/src/ResXLocalization.Avalonia/LocalizedTextObservable.cs deleted file mode 100644 index c18d581..0000000 --- a/src/ResXLocalization.Avalonia/LocalizedTextObservable.cs +++ /dev/null @@ -1,61 +0,0 @@ -namespace RentADeveloper.ResXLocalization.Avalonia; - -/// -/// The observable behind the binding that produces: emits the -/// resolved localized string immediately on subscription and re-emits it on every culture change of -/// the ambient . Culture changes are observed through -/// , so a bound control can be collected while the process-wide -/// localizer lives on. -/// -/// Produces the localized string for the current culture on demand. -internal sealed class LocalizedTextObservable(Func valueFactory) : IObservable -{ - /// - /// Pushes the currently resolved string to and keeps it updated on - /// every culture change. - /// - /// The observer receiving the localized string. - /// A subscription that stops the culture-change updates when disposed. - public IDisposable Subscribe(IObserver observer) - { - observer.OnNext(valueFactory()); - return new Subscription(valueFactory, observer); - } - - /// - /// One observer's subscription: weakly subscribes to - /// and forwards every culture change as a - /// freshly resolved string. - /// - private sealed class Subscription : IDisposable, IWeakEventSubscriber - { - /// The observer receiving each freshly resolved string. - private readonly IObserver observer; - - /// Produces the localized string for the current culture on demand. - private readonly Func valueFactory; - - /// - /// Initializes a new instance of the class and subscribes it to - /// culture changes of the ambient localizer. - /// - /// Produces the localized string for the current culture on demand. - /// The observer receiving each freshly resolved string. - public Subscription(Func valueFactory, IObserver observer) - { - this.valueFactory = valueFactory; - this.observer = observer; - LocalizerWeakEvents.CultureChanged.Subscribe(Localizer.Current, this); - } - - /// Unsubscribes from the culture-change weak event. - public void Dispose() => LocalizerWeakEvents.CultureChanged.Unsubscribe(Localizer.Current, this); - - /// Handles a culture change by re-resolving the string and emitting it to the observer. - /// The localizer that raised the event. - /// The weak event delivering the notification. - /// The event data carrying the previous and current culture. - public void OnEvent(object? sender, WeakEvent ev, CultureChangedEventArgs e) => - this.observer.OnNext(this.valueFactory()); - } -} diff --git a/src/ResXLocalization.Avalonia/LocalizerWeakEvents.cs b/src/ResXLocalization.Avalonia/LocalizerWeakEvents.cs deleted file mode 100644 index 16bfe99..0000000 --- a/src/ResXLocalization.Avalonia/LocalizerWeakEvents.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace RentADeveloper.ResXLocalization.Avalonia; - -/// -/// Hosts the registration for . -/// The localized-value observables subscribe through it, so the long-lived ambient localizer never -/// keeps their (per-control) subscriptions - and thus the bound controls - alive. -/// -internal static class LocalizerWeakEvents -{ - /// The weak-event wrapper around . - internal static readonly WeakEvent CultureChanged = WeakEvent.Register< - ILocalizer, - CultureChangedEventArgs - >( - static (localizer, handler) => localizer.CultureChanged += handler, - static (localizer, handler) => localizer.CultureChanged -= handler - ); -} diff --git a/src/ResXLocalization.Avalonia/PublicAPI.Shipped.txt b/src/ResXLocalization.Avalonia/PublicAPI.Shipped.txt index 0255ede..5da2b6c 100644 --- a/src/ResXLocalization.Avalonia/PublicAPI.Shipped.txt +++ b/src/ResXLocalization.Avalonia/PublicAPI.Shipped.txt @@ -1,54 +1,41 @@ #nullable enable +override RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumExtension.ProvideValue(System.IServiceProvider! serviceProvider) -> object! +override RentADeveloper.ResXLocalization.Avalonia.LocalizeExtension.ProvideValue(System.IServiceProvider! serviceProvider) -> object! +RentADeveloper.ResXLocalization.Avalonia.Argument +RentADeveloper.ResXLocalization.Avalonia.Argument.Argument() -> void +RentADeveloper.ResXLocalization.Avalonia.Argument.Name.get -> string? +RentADeveloper.ResXLocalization.Avalonia.Argument.Name.set -> void +RentADeveloper.ResXLocalization.Avalonia.Argument.Value.get -> object? +RentADeveloper.ResXLocalization.Avalonia.Argument.Value.set -> void +RentADeveloper.ResXLocalization.Avalonia.DesignTime RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumConverter +RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumConverter.ArgumentNames.get -> string? +RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumConverter.ArgumentNames.set -> void RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumConverter.Convert(System.Collections.Generic.IList! values, System.Type! targetType, object? parameter, System.Globalization.CultureInfo! culture) -> object! -RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumConverter.KeyPrefix.get -> string! +RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumConverter.KeyPrefix.get -> string? RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumConverter.KeyPrefix.set -> void RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumConverter.LocalizeEnumConverter() -> void RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumConverter.ResourceManager.get -> System.Resources.ResourceManager? RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumConverter.ResourceManager.set -> void RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumExtension -RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumExtension.KeyPrefix.get -> string! +RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumExtension.Arguments.get -> System.Collections.ObjectModel.Collection! +RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumExtension.KeyPrefix.get -> string? RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumExtension.KeyPrefix.set -> void RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumExtension.LocalizeEnumExtension() -> void +RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumExtension.LocalizeEnumExtension(object! value) -> void RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumExtension.ResourceManager.get -> System.Resources.ResourceManager? RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumExtension.ResourceManager.set -> void +RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumExtension.Value.get -> object? +RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumExtension.Value.set -> void RentADeveloper.ResXLocalization.Avalonia.LocalizeExtension -RentADeveloper.ResXLocalization.Avalonia.LocalizeExtension.Key.get -> string! +RentADeveloper.ResXLocalization.Avalonia.LocalizeExtension.Arguments.get -> System.Collections.ObjectModel.Collection! +RentADeveloper.ResXLocalization.Avalonia.LocalizeExtension.Key.get -> object? RentADeveloper.ResXLocalization.Avalonia.LocalizeExtension.Key.set -> void RentADeveloper.ResXLocalization.Avalonia.LocalizeExtension.LocalizeExtension() -> void RentADeveloper.ResXLocalization.Avalonia.LocalizeExtension.LocalizeExtension(object! key) -> void -RentADeveloper.ResXLocalization.Avalonia.LocalizeExtension.ResourceKey.get -> RentADeveloper.ResXLocalization.ResourceKey? -RentADeveloper.ResXLocalization.Avalonia.LocalizeExtension.ResourceKey.set -> void RentADeveloper.ResXLocalization.Avalonia.LocalizeExtension.ResourceManager.get -> System.Resources.ResourceManager? RentADeveloper.ResXLocalization.Avalonia.LocalizeExtension.ResourceManager.set -> void -override RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumExtension.ProvideValue(System.IServiceProvider! serviceProvider) -> object! -override RentADeveloper.ResXLocalization.Avalonia.LocalizeExtension.ProvideValue(System.IServiceProvider! serviceProvider) -> object! +static readonly RentADeveloper.ResXLocalization.Avalonia.DesignTime.CultureProperty -> Avalonia.AttachedProperty! +static RentADeveloper.ResXLocalization.Avalonia.DesignTime.GetCulture(Avalonia.AvaloniaObject! element) -> string? +static RentADeveloper.ResXLocalization.Avalonia.DesignTime.SetCulture(Avalonia.AvaloniaObject! element, string? value) -> void static RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumConverter.Default.get -> RentADeveloper.ResXLocalization.Avalonia.LocalizeEnumConverter! -RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs -static readonly RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.Arg0Property -> Avalonia.AttachedProperty! -static readonly RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.Arg1Property -> Avalonia.AttachedProperty! -static readonly RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.Arg2Property -> Avalonia.AttachedProperty! -static readonly RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.Arg3Property -> Avalonia.AttachedProperty! -static readonly RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.Arg4Property -> Avalonia.AttachedProperty! -static readonly RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.Arg5Property -> Avalonia.AttachedProperty! -static readonly RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.Arg6Property -> Avalonia.AttachedProperty! -static readonly RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.Arg7Property -> Avalonia.AttachedProperty! -static readonly RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.Arg8Property -> Avalonia.AttachedProperty! -static RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.GetArg0(Avalonia.AvaloniaObject! element) -> object? -static RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.GetArg1(Avalonia.AvaloniaObject! element) -> object? -static RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.GetArg2(Avalonia.AvaloniaObject! element) -> object? -static RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.GetArg3(Avalonia.AvaloniaObject! element) -> object? -static RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.GetArg4(Avalonia.AvaloniaObject! element) -> object? -static RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.GetArg5(Avalonia.AvaloniaObject! element) -> object? -static RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.GetArg6(Avalonia.AvaloniaObject! element) -> object? -static RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.GetArg7(Avalonia.AvaloniaObject! element) -> object? -static RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.GetArg8(Avalonia.AvaloniaObject! element) -> object? -static RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.SetArg0(Avalonia.AvaloniaObject! element, object? value) -> void -static RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.SetArg1(Avalonia.AvaloniaObject! element, object? value) -> void -static RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.SetArg2(Avalonia.AvaloniaObject! element, object? value) -> void -static RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.SetArg3(Avalonia.AvaloniaObject! element, object? value) -> void -static RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.SetArg4(Avalonia.AvaloniaObject! element, object? value) -> void -static RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.SetArg5(Avalonia.AvaloniaObject! element, object? value) -> void -static RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.SetArg6(Avalonia.AvaloniaObject! element, object? value) -> void -static RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.SetArg7(Avalonia.AvaloniaObject! element, object? value) -> void -static RentADeveloper.ResXLocalization.Avalonia.LocalizeArgs.SetArg8(Avalonia.AvaloniaObject! element, object? value) -> void diff --git a/src/ResXLocalization.Core/CompatibilitySuppressions.xml b/src/ResXLocalization.Core/CompatibilitySuppressions.xml new file mode 100644 index 0000000..86222a4 --- /dev/null +++ b/src/ResXLocalization.Core/CompatibilitySuppressions.xml @@ -0,0 +1,466 @@ + + + + + CP0001 + T:RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0001 + T:RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.add_TranslationNotFound(System.EventHandler{RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs}) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.get_MissingTranslationFormat + lib/net10.0/ResXLocalization.Core.dll + 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}) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.set_MissingTranslationFormat(System.String) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.add_TranslationNotFound(System.EventHandler{RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs}) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.get_MissingTranslationFormat + lib/net10.0/ResXLocalization.Core.dll + 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}) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.set_MissingTranslationFormat(System.String) + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.add_TranslationNotFound(System.EventHandler{RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs}) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.get_MissingTranslationFormat + lib/net8.0/ResXLocalization.Core.dll + 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}) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.ILocalizer.set_MissingTranslationFormat(System.String) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.add_TranslationNotFound(System.EventHandler{RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs}) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.get_MissingTranslationFormat + lib/net8.0/ResXLocalization.Core.dll + 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}) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.Localizer.set_MissingTranslationFormat(System.String) + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0006 + E:RentADeveloper.ResXLocalization.ILocalizer.LookupFailed + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0006 + 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 + + + CP0006 + P:RentADeveloper.ResXLocalization.ILocalizer.LookupFailureTextBuilder + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0006 + P:RentADeveloper.ResXLocalization.ILocalizer.MessageFormatter + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0006 + P:RentADeveloper.ResXLocalization.ILocalizer.RefreshTriggeringRevision + lib/net10.0/ResXLocalization.Core.dll + lib/net10.0/ResXLocalization.Core.dll + true + + + CP0006 + E:RentADeveloper.ResXLocalization.ILocalizer.LookupFailed + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0006 + 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 + + + CP0006 + P:RentADeveloper.ResXLocalization.ILocalizer.LookupFailureTextBuilder + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0006 + P:RentADeveloper.ResXLocalization.ILocalizer.MessageFormatter + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + + CP0006 + P:RentADeveloper.ResXLocalization.ILocalizer.RefreshTriggeringRevision + lib/net8.0/ResXLocalization.Core.dll + lib/net8.0/ResXLocalization.Core.dll + true + + \ No newline at end of file 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 ad76309..6443c04 100644 --- a/src/ResXLocalization.Core/ILocalizer.cs +++ b/src/ResXLocalization.Core/ILocalizer.cs @@ -6,9 +6,31 @@ namespace RentADeveloper.ResXLocalization; /// culture changes - without reloading views. /// /// -/// Implementations are intended for UI-thread use: register resource managers at startup and switch -/// from the UI thread, so change notifications reach bindings on the -/// thread WPF and Avalonia expect. +/// +/// 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 - 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 +/// without calling the formatter and without raising an event. +/// +/// +/// The failure text. When no resource file contains the key, or the formatter throws, a lookup +/// raises and then returns the failure text produced by +/// , by default !key!. The failure text never passes +/// through the formatter. +/// +/// +/// Exceptions. A lookup catches every exception the formatter throws. Exceptions from the +/// resource lookup itself, from event handlers and from propagate +/// unchanged; an event handler that throws prevents the failure text from being built. +/// +/// +/// Threading. Implementations are intended for UI-thread use: register resource managers at +/// startup and switch from the UI thread, so change notifications reach +/// bindings on the thread WPF and Avalonia expect. +/// /// public interface ILocalizer : INotifyPropertyChanged { @@ -19,17 +41,49 @@ public interface ILocalizer : INotifyPropertyChanged event EventHandler CultureChanged; /// - /// Occurs whenever a lookup misses - no resource file could resolve the key for the current - /// culture and the sentinel (see ) is about to be - /// returned. Normal .NET resource fallback runs first; subscribe to log keys that remain - /// unresolved across the complete parent and neutral fallback chain. + /// Occurs when a lookup fails, before it returns the failure text: no resource file contains the key, + /// or threw while formatting its value. + /// tells the two apart. Subscribe to log missing or broken + /// translations. /// - event EventHandler TranslationNotFound; + /// + /// + /// Normal .NET resource fallback runs first, so a key that resolves from a parent or the neutral + /// resources is not reported. A formatting failure is not specific to ICU MessageFormat: the + /// default reports one for Broken {0, or for + /// File {0} of {1} with one argument. + /// + /// Handlers must not throw (see ). + /// + event EventHandler LookupFailed; + + /// + /// 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, 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 + /// , and + /// when they change the registered set. Each advance raises + /// for this property and then for the indexer + /// (Item[]). Strings previously returned by Get are not updated. + /// + /// + /// Make these changes on the UI thread once views exist. In XAML, the trigger is + /// <Binding Path="RefreshTriggeringRevision" Source="{x:Static core:Localizer.Current}" />. + /// + /// + int RefreshTriggeringRevision { get; } /// /// Gets or sets the culture used to resolve resources. Setting a different value raises - /// and , causing - /// bound localized values to re-resolve. + /// , advances + /// and raises . /// /// /// Setting this property changes only how this localizer resolves translations; it deliberately @@ -44,190 +98,200 @@ public interface ILocalizer : INotifyPropertyChanged CultureInfo CurrentCulture { get; set; } /// - /// Gets or sets the composite format string that produces the sentinel returned for a key no - /// lookup could resolve, where {0} is the key. Defaults to !{0}!, which renders a - /// missing Greeting as !Greeting!. + /// Gets or sets the function that builds the resource key of an enum lookup from the enumeration value + /// and an optional key prefix. /// /// - /// Configure this once at startup, before views load: changing it later does not re-resolve - /// values that are already bound (only a culture change does that). + /// 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 sentinel format. Defaults to !{0}!. + /// The enum key function. Defaults to the Enum_{EnumTypeName}_{Value} convention. /// The supplied value is . - /// - /// The supplied value is not a valid composite format string for a single argument (the key) - for - /// example {1}. It is validated on assignment so the defect surfaces here rather than on the - /// first miss. - /// - string MissingTranslationFormat { get; set; } + Func EnumKeyBuilder { get; set; } /// - /// Gets the localized string for the specified key by searching every registered - /// in registration order. + /// 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. /// - /// The resource key to resolve. - /// - /// The localized string for in ; - /// if is or empty; - /// or the configured missing-translation sentinel (by default !key!) if no registered - /// resource manager contains the key. - /// - string this[string key] { get; } + /// + /// + /// The function receives a describing the failed lookup; + /// tells the two cases apart. It returns the text shown instead + /// of the translation; an empty string shows nothing. + /// + /// Examples: + /// + /// failure => $"[{failure.Key}]" + /// failure => failure.Key[(failure.Key.LastIndexOf('.') + 1)..] + /// failure => failure.Culture.TwoLetterISOLanguageName == "de" ? $"[fehlt: {failure.Key}]" : $"[missing: {failure.Key}]" + /// failure => failure.Reason == LookupFailureReason.TranslationNotFound ? $"[missing: {failure.Key}]" : $"[broken: {failure.Key}]" + /// + /// + /// The function is called on every failed lookup, so it should be cheap. It must not throw or return + /// : an exception it throws propagates out of the lookup, and a + /// result makes the lookup throw . + /// + /// + /// Assigning a different delegate instance advances ; + /// assigning the current instance again does nothing. + /// + /// + /// The failure-text function. Defaults to failure => $"!{failure.Key}!". + /// The supplied value is . + Func LookupFailureTextBuilder { get; set; } /// - /// Removes every registered from the search-all set. Typed and - /// scoped lookups are unaffected - they never consult the registration set. + /// Gets or sets the formatter that turns each resolved resource value and the arguments of its lookup + /// into the final string. /// - void ClearResourceManagers(); + /// + /// + /// Every lookup that resolves a value calls the formatter, also a lookup without arguments, so it is + /// the one hook for all translated texts (see ). + /// + /// + /// Localizer.Current.UseMessageFormat() from the ResXLocalization.MessageFormat package installs an + /// ICU MessageFormat formatter here (plural, select). That formatter parses every resolved + /// value as an ICU message, including the values of lookups without arguments. + /// + /// + /// Assigning a different instance advances ; assigning the + /// current instance again does nothing. Exceptions thrown by + /// handlers propagate from the assignment. + /// + /// + /// The formatter. Defaults to . + /// The supplied value is . + IMessageFormatter MessageFormatter { get; set; } /// - /// Resolves the localized string for the specified key by searching every registered + /// Gets the localized string for the specified key by searching every registered /// in registration order. /// /// The resource key to resolve. /// - /// The localized string for in ; - /// if is or empty; - /// or the configured missing-translation sentinel (by default !key!) if no registered - /// resource manager contains the key. - /// - string Get(string key); - - /// - /// Resolves the localized string for the specified key by searching every registered - /// in registration order, then formats it as a composite format - /// string with the supplied arguments in - for example a resource - /// value of Downloading {0} of {1}…. - /// - /// The resource key to resolve. - /// The values to format into the resolved string. - /// - /// The formatted, localized string; if is - /// or empty; or the configured missing-translation sentinel (by default - /// !key!), without applying , if no registered resource manager - /// contains the key. + /// The localized string, for a or empty key, or the + /// failure text (see ). /// - /// is . - /// - /// The resolved resource value is not a valid composite format string for - /// . That is a defect in the resource file you want to surface - /// during development, not a missing translation, so it fails loudly. - /// - string Get(string key, params object?[] arguments); - - /// - /// 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 for the generated key in , or the configured - /// missing-translation sentinel (by default !key!) if no registered resource manager - /// contains it. - /// - /// - /// or is . - /// - string Get(Enum value, string keyPrefix = EnumKeyConvention.DefaultEnumKeyPrefix); + string this[string key] { get; } /// - /// Resolves the localized string for the specified key from a single, explicit - /// . + /// Removes every registered from the search-all set. Typed and + /// scoped lookups are unaffected - they never consult the registration set. When at least one + /// manager was registered, advances . /// - /// The resource key to resolve. - /// The resource manager to read the key from. - /// - /// The localized string for in ; - /// if is or empty; - /// or the configured missing-translation sentinel (by default !key!) if - /// does not contain the key. - /// - /// - /// is . - /// - string Get(string key, ResourceManager resourceManager); + void ClearResourceManagers(); /// - /// Resolves the localized string for the specified key from a single, explicit - /// , then formats it as a composite format string with the supplied - /// arguments in . + /// 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 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 resource manager to read the key from. - /// The values to format into the resolved 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 (the default) to + /// search every registered resource manager in registration order. Pass it by name: + /// Get("Key", resourceManager: rm). + /// /// - /// The formatted, localized string; if is - /// or empty; or the configured missing-translation sentinel (by default - /// !key!), without applying , if - /// does not contain the key. + /// The localized (or formatted) string, for a or + /// empty key, or the failure text (see ) in case the lookup failed. /// - /// - /// or is . - /// - /// - /// The resolved resource value is not a valid composite format string for - /// . - /// - string Get(string key, ResourceManager resourceManager, params object?[] arguments); + string Get(string key, MessageArguments? arguments = null, ResourceManager? resourceManager = null); /// - /// 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 for the generated key in , or the configured - /// missing-translation sentinel (by default !key!) if - /// does not contain it. - /// - /// - /// , , or - /// is . - /// - string Get(Enum value, ResourceManager resourceManager, string keyPrefix = EnumKeyConvention.DefaultEnumKeyPrefix); - - /// - /// 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 for in , or the - /// configured missing-translation sentinel (by default !key!) if the resource manager does - /// not contain it. - /// + /// + /// 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 , then formats it as a - /// composite format string with the supplied arguments in . + /// 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. - /// - /// The formatted, localized string, or the configured missing-translation sentinel (by default - /// !key!), without applying , if the resource manager does not - /// contain the key. - /// - /// - /// is , or is a - /// default or otherwise uninitialized whose - /// is . - /// - /// - /// The resolved resource value is not a valid composite format string for - /// . + /// + /// 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 @@ -268,10 +332,35 @@ public interface ILocalizer : INotifyPropertyChanged IReadOnlyList GetAvailableCultures(ResourceManager resourceManager); /// - /// Registers a to be searched by the key-only and enum lookups. - /// Managers are searched in registration order on a first-match-wins basis; registering the same - /// manager more than once has no effect. + /// Registers a to be searched by search-all lookups. Managers are + /// searched in registration order, and the first match wins; registering the same instance more than + /// once has no effect. Adding a manager advances . /// + /// + /// + /// Registration is needed only for search-all lookups - lookups without a resource manager: + /// {l:Localize Key} and {l:LocalizeEnum} without ResourceManager in the UI packages, + /// ILocalizer.Get("Key") and ILocalizer.Get(enumValue). Typed and scoped lookups need + /// none. + /// + /// + /// Register the generated <Name>Keys.ResourceManager, the instance the typed keys use. + /// Registering the designer's <Name>.ResourceManager works too, but it is a second instance + /// of the same file with its own cache, and finds only the + /// instance that was registered. + /// + /// + /// Register at application startup - in BuildAvaloniaApp in Avalonia, in App.OnStartup in + /// WPF - or in a [ModuleInitializer] when search-all lookups must work in the WPF designer, which + /// runs no startup code. See + /// Registering resource files. + /// + /// + /// rejects a manager of exactly the type for which + /// no resources exist under its base name, with an ; a custom + /// implementation need not follow that rule. + /// + /// /// The resource manager to add to the search set. /// /// is . @@ -281,8 +370,14 @@ public interface ILocalizer : INotifyPropertyChanged /// /// Removes a previously registered from the search-all set, for /// example when the plugin or module that owns its strings is unloaded. Typed and scoped lookups - /// are unaffected - they never consult the registration set. + /// are unaffected - they never consult the registration set. Removing a manager advances + /// . /// + /// + /// Managers are compared by reference: pass the instance that was registered, normally the generated + /// <Name>Keys.ResourceManager. Another instance of the same file, such as the designer's + /// <Name>.ResourceManager, is not found. + /// /// The resource manager to remove from the search set. /// /// when the manager was registered and has been removed; diff --git a/src/ResXLocalization.Core/IMessageFormatter.cs b/src/ResXLocalization.Core/IMessageFormatter.cs new file mode 100644 index 0000000..3fe109e --- /dev/null +++ b/src/ResXLocalization.Core/IMessageFormatter.cs @@ -0,0 +1,42 @@ +namespace RentADeveloper.ResXLocalization; + +/// +/// Turns a resolved resource value and the arguments of its lookup - possibly none - into the final +/// string. Assign an implementation to to replace the default +/// . +/// +/// +/// +/// The localizer calls for every lookup that resolves a resource value, +/// whether or not the lookup has arguments, so a formatter is the one hook every translated text passes +/// through. It is never called for a key no resource file contains, and never for a +/// or empty key. +/// +/// +/// An implementation signals a malformed pattern or unusable arguments by throwing. The +/// localizer catches the exception, raises and returns the +/// failure text. Because every lookup calls the formatter, a formatter that throws for values without +/// arguments turns plain texts into the failure text too. +/// +/// +public interface IMessageFormatter +{ + /// Formats a resolved resource value with the arguments of its lookup. + /// The resolved resource value. + /// + /// 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 + /// { fail. + /// + /// The localizer's at the time of the lookup. + /// The final string. + string Format(string pattern, MessageArguments arguments, CultureInfo culture); +} diff --git a/src/ResXLocalization.Core/Localizer.cs b/src/ResXLocalization.Core/Localizer.cs index 7b49c06..20b3094 100644 --- a/src/ResXLocalization.Core/Localizer.cs +++ b/src/ResXLocalization.Core/Localizer.cs @@ -7,19 +7,26 @@ 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 +/// 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. /// public sealed class Localizer : ILocalizer { - /// Cached event args announcing a change. - private static readonly PropertyChangedEventArgs CurrentCultureChangedArgs = new(nameof(CurrentCulture)); + /// + /// 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; /// - /// Cached event args announcing an indexer change (Item[]), which makes bindings through - /// the indexer re-resolve after a culture change. + /// The default of : produces !key!. One shared instance, + /// so every new localizer starts with the same delegate. /// - private static readonly PropertyChangedEventArgs IndexerChangedArgs = new("Item[]"); + private static readonly Func DefaultLookupFailureTextBuilder = static failure => + $"!{failure.Key}!"; /// Backing field of . private CultureInfo currentCulture = CultureInfo.CurrentUICulture; @@ -41,15 +48,16 @@ public Localizer() { } /// public event EventHandler? CultureChanged; + /// + public event EventHandler? LookupFailed; + /// - /// Occurs when a property value changes, including and the indexer, - /// so that localized bindings re-resolve after a culture change. + /// Occurs when a property value changes, including , + /// and the indexer, so that localized bindings re-resolve + /// after a change that can change the result of a lookup. /// public event PropertyChangedEventHandler? PropertyChanged; - /// - public event EventHandler? TranslationNotFound; - /// /// Gets or sets the shared, ambient localizer instance used throughout the application. Register resource /// managers on it at startup and set to switch languages. Applications @@ -83,131 +91,146 @@ public CultureInfo CurrentCulture var oldCulture = this.currentCulture; this.currentCulture = value; - this.PropertyChanged?.Invoke(this, CurrentCultureChangedArgs); - this.PropertyChanged?.Invoke(this, IndexerChangedArgs); + this.PropertyChanged?.Invoke(this, new(nameof(this.CurrentCulture))); + this.AdvanceRefreshTriggeringRevision(); this.CultureChanged?.Invoke(this, new(oldCulture, value)); } } /// - public string MissingTranslationFormat + public Func EnumKeyBuilder { get; set { ArgumentNullException.ThrowIfNull(value); - // Validate eagerly with a probe key so a bad format (e.g. "{1}", which references a second - // argument that never exists) fails right here, at the faulty assignment - not later, deep - // inside the first cache miss where the FormatException would be far from its cause. - try + if (ReferenceEquals(field, value)) { - _ = string.Format(CultureInfo.InvariantCulture, value, "probe"); - } - catch (FormatException exception) - { - throw new ArgumentException( - "The missing-translation format must be a composite format string with at most one " - + "placeholder ({0}, the key).", - nameof(value), - exception - ); + return; } field = value; + this.AdvanceRefreshTriggeringRevision(); } - } = "!{0}!"; + } = DefaultEnumKeyBuilder; /// - public string this[string key] => this.Get(key); - - /// - public void ClearResourceManagers() => this.resourceManagers = []; - - /// - public string Get(string key) + public Func LookupFailureTextBuilder { - if (string.IsNullOrEmpty(key)) + get; + set { - return string.Empty; - } + ArgumentNullException.ThrowIfNull(value); - return this.FindInRegisteredManagers(key) ?? this.Miss(key, null); - } + if (ReferenceEquals(field, value)) + { + return; + } + + field = value; + this.AdvanceRefreshTriggeringRevision(); + } + } = DefaultLookupFailureTextBuilder; /// - public string Get(string key, params object?[] arguments) + public IMessageFormatter MessageFormatter { - ArgumentNullException.ThrowIfNull(arguments); - - if (string.IsNullOrEmpty(key)) + get; + set { - return string.Empty; + ArgumentNullException.ThrowIfNull(value); + + if (ReferenceEquals(field, value)) + { + return; + } + + field = value; + this.AdvanceRefreshTriggeringRevision(); } + } = StringFormatMessageFormatter.Instance; - var value = this.FindInRegisteredManagers(key); + /// + public int RefreshTriggeringRevision { get; private set; } - return value is null ? this.Miss(key, null) : this.FormatValue(value, arguments); - } + /// + public string this[string key] => this.Get(key); /// - public string Get(Enum value, string keyPrefix = EnumKeyConvention.DefaultEnumKeyPrefix) + public void ClearResourceManagers() { - ArgumentNullException.ThrowIfNull(value); - ArgumentNullException.ThrowIfNull(keyPrefix); + if (this.resourceManagers.IsEmpty) + { + return; + } - return this.Get(EnumKeyConvention.BuildEnumKey(value, keyPrefix)); + this.resourceManagers = []; + this.AdvanceRefreshTriggeringRevision(); } /// - public string Get(string key, ResourceManager resourceManager) + public string Get(string key, MessageArguments? arguments = null, ResourceManager? resourceManager = null) { - ArgumentNullException.ThrowIfNull(resourceManager); + arguments ??= MessageArguments.Empty; + + // One lookup uses one culture throughout: for the lookup, the format and the failure. + var culture = this.currentCulture; if (string.IsNullOrEmpty(key)) { return string.Empty; } - return resourceManager.GetString(key, this.currentCulture) ?? this.Miss(key, resourceManager); - } + var value = resourceManager is null + ? this.FindInRegisteredResourceManagers(key, culture) + : resourceManager.GetString(key, culture); - /// - public string Get(string key, ResourceManager resourceManager, params object?[] arguments) - { - ArgumentNullException.ThrowIfNull(resourceManager); - ArgumentNullException.ThrowIfNull(arguments); + if (value is null) + { + return this.FailLookup( + new(LookupFailureReason.TranslationNotFound, key, culture, resourceManager, arguments, null, null) + ); + } - if (string.IsNullOrEmpty(key)) + try { - return string.Empty; + // Also without arguments: the formatter is the one hook for every resolved value. + return this.MessageFormatter.Format(value, arguments, culture); + } + catch (Exception exception) + { + // Every exception type, deliberately: one bad translation in one language must not take + // down a screen, and formatters from other packages throw their own exception types. + return this.FailLookup( + new(LookupFailureReason.MessageFormatFailed, key, culture, resourceManager, arguments, value, exception) + ); } + } - var value = resourceManager.GetString(key, this.currentCulture); + /// + public string Get(ResourceKey key, MessageArguments? arguments = null) + { + // 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 value is null ? this.Miss(key, resourceManager) : this.FormatValue(value, arguments); + 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), resourceManager); + return this.Get(this.BuildEnumKey(value, keyPrefix), arguments, resourceManager); } - /// - public string Get(ResourceKey key) => this.Get(key.Name, key.Manager); - - /// - public string Get(ResourceKey key, params object?[] arguments) => this.Get(key.Name, key.Manager, arguments); - /// public IReadOnlyList GetAvailableCultures() { @@ -232,7 +255,35 @@ public IReadOnlyList GetAvailableCultures(ResourceManager resourceM return SortCultures(cultures); } - /// + /// + /// Registers a to be searched by search-all lookups. Managers are + /// searched in registration order, and the first match wins; registering the same instance more than + /// once has no effect. Adding a manager advances . + /// + /// + /// + /// Which lookups need registration, which instance to register and where to register it is described + /// on . + /// + /// + /// The registrations belong to this instance. A localizer assigned to later does + /// not inherit them; register the resource managers on the instance that serves the lookups. + /// + /// + /// A manager whose type is exactly is checked once, when it is first + /// registered: if no resources exist under its , it is rejected + /// and not registered. A subclass of is registered without that check. + /// + /// + /// The resource manager to add to the search set. + /// + /// is . + /// + /// + /// is exactly of type , and no resources + /// exist under its base name - usually a hand-written base name that does not match the embedded + /// resource. + /// public void RegisterResourceManager(ResourceManager resourceManager) { ArgumentNullException.ThrowIfNull(resourceManager); @@ -242,11 +293,37 @@ public void RegisterResourceManager(ResourceManager resourceManager) return; } + // Only the exact type: a subclass may resolve strings without manifest resources, and the base + // class's probe can fail for it for unrelated reasons. The probe loads the neutral resource set, + // which the first lookup would load anyway, and the manager caches it. + if (resourceManager.GetType() == typeof(ResourceManager)) + { + try + { + _ = resourceManager.GetResourceSet( + CultureInfo.InvariantCulture, + createIfNotExists: true, + tryParents: true + ); + } + catch (MissingManifestResourceException exception) + { + throw new ArgumentException( + $"No resources exist under the base name '{resourceManager.BaseName}'. The usual cause is a " + + "hand-written base name that does not match the embedded resource; register the generated " + + "Keys.ResourceManager instead.", + nameof(resourceManager), + exception + ); + } + } + // Copy-on-write: register/unregister/clear swap the whole immutable array atomically, so a // lookup that reads the field once (foreach captures the struct value) never enumerates a // collection being mutated - even from a stray background thread - while keeping the // documented UI-thread contract for culture changes. this.resourceManagers = this.resourceManagers.Add(resourceManager); + this.AdvanceRefreshTriggeringRevision(); } /// @@ -262,6 +339,7 @@ public bool UnregisterResourceManager(ResourceManager resourceManager) } this.resourceManagers = updated; + this.AdvanceRefreshTriggeringRevision(); return true; } @@ -313,17 +391,49 @@ private static void CollectAvailableCultures(ResourceManager resourceManager, Ha private static IReadOnlyList SortCultures(HashSet cultures) => [.. cultures.OrderBy(static culture => culture.Name, StringComparer.OrdinalIgnoreCase)]; + /// + /// Advances (wrapping around harmlessly) and raises + /// for it and then for the indexer, so bound text re-resolves. + /// + private void AdvanceRefreshTriggeringRevision() + { + this.RefreshTriggeringRevision = unchecked(this.RefreshTriggeringRevision + 1); + + this.PropertyChanged?.Invoke(this, new(nameof(this.RefreshTriggeringRevision))); + this.PropertyChanged?.Invoke(this, new("Item[]")); + } + + /// + /// Reports a failed lookup through , then produces its failure text by + /// calling . Exceptions from event handlers and from the + /// function propagate unchanged. + /// + /// The failed lookup. + /// The failure text. + /// The function returned . + private string FailLookup(LookupFailure failure) + { + this.LookupFailed?.Invoke(this, new(failure)); + + return this.LookupFailureTextBuilder(failure) + ?? throw new InvalidOperationException( + $"{nameof(this.LookupFailureTextBuilder)} returned null for the lookup failure " + + $"{failure.Reason} of the key '{failure.Key}'. Return a string; an empty string shows nothing." + ); + } + /// /// Searches every registered resource manager in registration order and returns the first match, /// or when no registered manager contains the key. /// /// The resource key to resolve. + /// The culture of the lookup. /// The first matching value, or . - private string? FindInRegisteredManagers(string key) + private string? FindInRegisteredResourceManagers(string key, CultureInfo culture) { foreach (var resourceManager in this.resourceManagers) { - var value = resourceManager.GetString(key, this.currentCulture); + var value = resourceManager.GetString(key, culture); if (value is not null) { return value; @@ -332,27 +442,4 @@ private static IReadOnlyList SortCultures(HashSet cult return null; } - - /// Formats a resolved value with in the current culture. - /// The resolved resource value, used as the composite format string. - /// The format arguments. - /// The formatted string. - private string FormatValue(string value, object?[] arguments) => - string.Format(this.currentCulture, value, arguments); - - /// - /// Handles a lookup no resource file could satisfy: raises - /// and produces the sentinel from . - /// - /// The unresolved key. - /// - /// The single manager of a scoped or typed lookup, or for search-all. - /// - /// The miss sentinel, by default !key!. - private string Miss(string key, ResourceManager? resourceManager) - { - this.TranslationNotFound?.Invoke(this, new(key, this.currentCulture, resourceManager)); - - return string.Format(CultureInfo.InvariantCulture, this.MissingTranslationFormat, key); - } } diff --git a/src/ResXLocalization.Core/LookupFailedEventArgs.cs b/src/ResXLocalization.Core/LookupFailedEventArgs.cs new file mode 100644 index 0000000..708796a --- /dev/null +++ b/src/ResXLocalization.Core/LookupFailedEventArgs.cs @@ -0,0 +1,9 @@ +namespace RentADeveloper.ResXLocalization; + +/// Provides the failed lookup reported through . +/// The failed lookup. +public sealed class LookupFailedEventArgs(LookupFailure failure) : EventArgs +{ + /// Gets the failed lookup. + public LookupFailure Failure { get; } = failure; +} diff --git a/src/ResXLocalization.Core/LookupFailure.cs b/src/ResXLocalization.Core/LookupFailure.cs new file mode 100644 index 0000000..565a53b --- /dev/null +++ b/src/ResXLocalization.Core/LookupFailure.cs @@ -0,0 +1,42 @@ +namespace RentADeveloper.ResXLocalization; + +/// +/// Describes a failed lookup. reports it, and +/// receives it to produce the failure text. +/// +/// +/// Why the lookup failed: or +/// . +/// +/// +/// The key as looked up, including an enum lookup's full convention key (for example +/// Enum_FileSortOrder_Ascending); never or empty. +/// +/// +/// The culture of the lookup - the read once at its start; +/// never . +/// +/// +/// The resource manager of a scoped or typed lookup (for a typed lookup, +/// ); for a search-all lookup. +/// +/// +/// The arguments of the lookup; never , empty when the lookup had none. +/// +/// +/// For , the resolved resource value that failed to +/// format; for , . +/// +/// +/// For , what the formatter threw; for +/// , . +/// +public readonly record struct LookupFailure( + LookupFailureReason Reason, + string Key, + CultureInfo Culture, + ResourceManager? ResourceManager, + MessageArguments Arguments, + string? Pattern, + Exception? Exception +); diff --git a/src/ResXLocalization.Core/LookupFailureReason.cs b/src/ResXLocalization.Core/LookupFailureReason.cs new file mode 100644 index 0000000..fb03c71 --- /dev/null +++ b/src/ResXLocalization.Core/LookupFailureReason.cs @@ -0,0 +1,13 @@ +namespace RentADeveloper.ResXLocalization; + +/// Why a lookup failed; see . +public enum LookupFailureReason +{ + /// + /// No resource file contains the key, across the complete parent and neutral fallback chain. + /// + TranslationNotFound = 0, + + /// The value was found, but the threw. + MessageFormatFailed = 1, +} diff --git a/src/ResXLocalization.Core/MessageArgument.cs b/src/ResXLocalization.Core/MessageArgument.cs new file mode 100644 index 0000000..dae9121 --- /dev/null +++ b/src/ResXLocalization.Core/MessageArgument.cs @@ -0,0 +1,28 @@ +namespace RentADeveloper.ResXLocalization; + +/// +/// One argument of a lookup: its position, an optional name and its value. The arguments of one lookup +/// 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 +/// and its 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 +/// that understands names (ICU MessageFormat) also finds the argument. The default +/// () formatter uses positions only and +/// ignores names. The ICU MessageFormat formatter of the ResXLocalization.MessageFormat package makes every +/// argument reachable under its position ("0", "1", and so on) and, if it has a name, under +/// that name as well; a name overwrites a position with the same text, so the argument named "0" +/// replaces the argument at position 0 as {0}. +/// +/// The argument's value; may be . +public readonly record struct MessageArgument(int Position, string? Name, object? Value); 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.Shipped.txt b/src/ResXLocalization.Core/PublicAPI.Shipped.txt index fac2a13..013412d 100644 --- a/src/ResXLocalization.Core/PublicAPI.Shipped.txt +++ b/src/ResXLocalization.Core/PublicAPI.Shipped.txt @@ -1,4 +1,13 @@ #nullable enable +~override RentADeveloper.ResXLocalization.LookupFailure.Equals(object obj) -> bool +~override RentADeveloper.ResXLocalization.LookupFailure.ToString() -> string +~override RentADeveloper.ResXLocalization.MessageArgument.Equals(object obj) -> bool +~override RentADeveloper.ResXLocalization.MessageArgument.ToString() -> string +~override RentADeveloper.ResXLocalization.ResourceKey.Equals(object obj) -> bool +~override RentADeveloper.ResXLocalization.ResourceKey.ToString() -> string +override RentADeveloper.ResXLocalization.LookupFailure.GetHashCode() -> int +override RentADeveloper.ResXLocalization.MessageArgument.GetHashCode() -> int +override RentADeveloper.ResXLocalization.ResourceKey.GetHashCode() -> int RentADeveloper.ResXLocalization.CultureChangedEventArgs RentADeveloper.ResXLocalization.CultureChangedEventArgs.CultureChangedEventArgs(System.Globalization.CultureInfo! oldCulture, System.Globalization.CultureInfo! newCulture) -> void RentADeveloper.ResXLocalization.CultureChangedEventArgs.NewCulture.get -> System.Globalization.CultureInfo! @@ -8,45 +17,86 @@ RentADeveloper.ResXLocalization.ILocalizer.ClearResourceManagers() -> void RentADeveloper.ResXLocalization.ILocalizer.CultureChanged -> System.EventHandler! RentADeveloper.ResXLocalization.ILocalizer.CurrentCulture.get -> System.Globalization.CultureInfo! RentADeveloper.ResXLocalization.ILocalizer.CurrentCulture.set -> void -RentADeveloper.ResXLocalization.ILocalizer.Get(RentADeveloper.ResXLocalization.ResourceKey key) -> string! -RentADeveloper.ResXLocalization.ILocalizer.Get(RentADeveloper.ResXLocalization.ResourceKey key, params object?[]! arguments) -> string! -RentADeveloper.ResXLocalization.ILocalizer.Get(System.Enum! value, System.Resources.ResourceManager! resourceManager, string! keyPrefix = "Enum_") -> string! -RentADeveloper.ResXLocalization.ILocalizer.Get(System.Enum! value, string! keyPrefix = "Enum_") -> string! -RentADeveloper.ResXLocalization.ILocalizer.Get(string! key) -> string! -RentADeveloper.ResXLocalization.ILocalizer.Get(string! key, System.Resources.ResourceManager! resourceManager) -> string! -RentADeveloper.ResXLocalization.ILocalizer.Get(string! key, System.Resources.ResourceManager! resourceManager, params object?[]! arguments) -> string! -RentADeveloper.ResXLocalization.ILocalizer.Get(string! key, params object?[]! arguments) -> 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.GetAvailableCultures() -> System.Collections.Generic.IReadOnlyList! RentADeveloper.ResXLocalization.ILocalizer.GetAvailableCultures(System.Resources.ResourceManager! resourceManager) -> System.Collections.Generic.IReadOnlyList! -RentADeveloper.ResXLocalization.ILocalizer.MissingTranslationFormat.get -> string! -RentADeveloper.ResXLocalization.ILocalizer.MissingTranslationFormat.set -> void +RentADeveloper.ResXLocalization.ILocalizer.LookupFailed -> System.EventHandler! +RentADeveloper.ResXLocalization.ILocalizer.LookupFailureTextBuilder.get -> System.Func! +RentADeveloper.ResXLocalization.ILocalizer.LookupFailureTextBuilder.set -> void +RentADeveloper.ResXLocalization.ILocalizer.MessageFormatter.get -> RentADeveloper.ResXLocalization.IMessageFormatter! +RentADeveloper.ResXLocalization.ILocalizer.MessageFormatter.set -> void +RentADeveloper.ResXLocalization.ILocalizer.RefreshTriggeringRevision.get -> int RentADeveloper.ResXLocalization.ILocalizer.RegisterResourceManager(System.Resources.ResourceManager! resourceManager) -> void -RentADeveloper.ResXLocalization.ILocalizer.TranslationNotFound -> System.EventHandler! -RentADeveloper.ResXLocalization.ILocalizer.UnregisterResourceManager(System.Resources.ResourceManager! resourceManager) -> bool RentADeveloper.ResXLocalization.ILocalizer.this[string! key].get -> string! +RentADeveloper.ResXLocalization.ILocalizer.UnregisterResourceManager(System.Resources.ResourceManager! resourceManager) -> bool +RentADeveloper.ResXLocalization.IMessageFormatter +RentADeveloper.ResXLocalization.IMessageFormatter.Format(string! pattern, RentADeveloper.ResXLocalization.MessageArguments! arguments, System.Globalization.CultureInfo! culture) -> string! RentADeveloper.ResXLocalization.Localizer RentADeveloper.ResXLocalization.Localizer.ClearResourceManagers() -> void RentADeveloper.ResXLocalization.Localizer.CultureChanged -> System.EventHandler? RentADeveloper.ResXLocalization.Localizer.CurrentCulture.get -> System.Globalization.CultureInfo! RentADeveloper.ResXLocalization.Localizer.CurrentCulture.set -> void -RentADeveloper.ResXLocalization.Localizer.Get(RentADeveloper.ResXLocalization.ResourceKey key) -> string! -RentADeveloper.ResXLocalization.Localizer.Get(RentADeveloper.ResXLocalization.ResourceKey key, params object?[]! arguments) -> string! -RentADeveloper.ResXLocalization.Localizer.Get(System.Enum! value, System.Resources.ResourceManager! resourceManager, string! keyPrefix = "Enum_") -> string! -RentADeveloper.ResXLocalization.Localizer.Get(System.Enum! value, string! keyPrefix = "Enum_") -> string! -RentADeveloper.ResXLocalization.Localizer.Get(string! key) -> string! -RentADeveloper.ResXLocalization.Localizer.Get(string! key, System.Resources.ResourceManager! resourceManager) -> string! -RentADeveloper.ResXLocalization.Localizer.Get(string! key, System.Resources.ResourceManager! resourceManager, params object?[]! arguments) -> string! -RentADeveloper.ResXLocalization.Localizer.Get(string! key, params object?[]! arguments) -> 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.GetAvailableCultures() -> System.Collections.Generic.IReadOnlyList! RentADeveloper.ResXLocalization.Localizer.GetAvailableCultures(System.Resources.ResourceManager! resourceManager) -> System.Collections.Generic.IReadOnlyList! RentADeveloper.ResXLocalization.Localizer.Localizer() -> void -RentADeveloper.ResXLocalization.Localizer.MissingTranslationFormat.get -> string! -RentADeveloper.ResXLocalization.Localizer.MissingTranslationFormat.set -> void +RentADeveloper.ResXLocalization.Localizer.LookupFailed -> System.EventHandler? +RentADeveloper.ResXLocalization.Localizer.LookupFailureTextBuilder.get -> System.Func! +RentADeveloper.ResXLocalization.Localizer.LookupFailureTextBuilder.set -> void +RentADeveloper.ResXLocalization.Localizer.MessageFormatter.get -> RentADeveloper.ResXLocalization.IMessageFormatter! +RentADeveloper.ResXLocalization.Localizer.MessageFormatter.set -> void RentADeveloper.ResXLocalization.Localizer.PropertyChanged -> System.ComponentModel.PropertyChangedEventHandler? +RentADeveloper.ResXLocalization.Localizer.RefreshTriggeringRevision.get -> int RentADeveloper.ResXLocalization.Localizer.RegisterResourceManager(System.Resources.ResourceManager! resourceManager) -> void -RentADeveloper.ResXLocalization.Localizer.TranslationNotFound -> System.EventHandler? -RentADeveloper.ResXLocalization.Localizer.UnregisterResourceManager(System.Resources.ResourceManager! resourceManager) -> bool RentADeveloper.ResXLocalization.Localizer.this[string! key].get -> string! +RentADeveloper.ResXLocalization.Localizer.UnregisterResourceManager(System.Resources.ResourceManager! resourceManager) -> bool +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.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.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.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 +RentADeveloper.ResXLocalization.LookupFailure.Reason.init -> void +RentADeveloper.ResXLocalization.LookupFailure.ResourceManager.get -> System.Resources.ResourceManager? +RentADeveloper.ResXLocalization.LookupFailure.ResourceManager.init -> void +RentADeveloper.ResXLocalization.LookupFailureReason +RentADeveloper.ResXLocalization.LookupFailureReason.MessageFormatFailed = 1 -> RentADeveloper.ResXLocalization.LookupFailureReason +RentADeveloper.ResXLocalization.LookupFailureReason.TranslationNotFound = 0 -> RentADeveloper.ResXLocalization.LookupFailureReason +RentADeveloper.ResXLocalization.MessageArgument +RentADeveloper.ResXLocalization.MessageArgument.Deconstruct(out int Position, out string? Name, out object? Value) -> void +RentADeveloper.ResXLocalization.MessageArgument.Equals(RentADeveloper.ResXLocalization.MessageArgument other) -> bool +RentADeveloper.ResXLocalization.MessageArgument.MessageArgument() -> void +RentADeveloper.ResXLocalization.MessageArgument.MessageArgument(int Position, string? Name, object? Value) -> void +RentADeveloper.ResXLocalization.MessageArgument.Name.get -> string? +RentADeveloper.ResXLocalization.MessageArgument.Name.init -> void +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.ResourceKey RentADeveloper.ResXLocalization.ResourceKey.Deconstruct(out string! Name, out System.Resources.ResourceManager! Manager) -> void RentADeveloper.ResXLocalization.ResourceKey.Equals(RentADeveloper.ResXLocalization.ResourceKey other) -> bool @@ -56,15 +106,20 @@ RentADeveloper.ResXLocalization.ResourceKey.Name.get -> string! RentADeveloper.ResXLocalization.ResourceKey.Name.init -> void RentADeveloper.ResXLocalization.ResourceKey.ResourceKey() -> void RentADeveloper.ResXLocalization.ResourceKey.ResourceKey(string! Name, System.Resources.ResourceManager! Manager) -> void -RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs -RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs.Culture.get -> System.Globalization.CultureInfo! -RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs.Key.get -> string! -RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs.ResourceManager.get -> System.Resources.ResourceManager? -RentADeveloper.ResXLocalization.TranslationNotFoundEventArgs.TranslationNotFoundEventArgs(string! key, System.Globalization.CultureInfo! culture, System.Resources.ResourceManager? resourceManager) -> void -override RentADeveloper.ResXLocalization.ResourceKey.GetHashCode() -> int +RentADeveloper.ResXLocalization.StringFormatMessageFormatter +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.Localizer.Current.get -> RentADeveloper.ResXLocalization.ILocalizer! static RentADeveloper.ResXLocalization.Localizer.Current.set -> void +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! static RentADeveloper.ResXLocalization.ResourceKey.operator !=(RentADeveloper.ResXLocalization.ResourceKey left, RentADeveloper.ResXLocalization.ResourceKey right) -> bool static RentADeveloper.ResXLocalization.ResourceKey.operator ==(RentADeveloper.ResXLocalization.ResourceKey left, RentADeveloper.ResXLocalization.ResourceKey right) -> bool -~override RentADeveloper.ResXLocalization.ResourceKey.Equals(object obj) -> bool -~override RentADeveloper.ResXLocalization.ResourceKey.ToString() -> string 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 new file mode 100644 index 0000000..1d78e24 --- /dev/null +++ b/src/ResXLocalization.Core/StringFormatMessageFormatter.cs @@ -0,0 +1,37 @@ +namespace RentADeveloper.ResXLocalization; + +/// +/// The default : returns a value without arguments unchanged, and +/// formats a value with arguments with +/// in the lookup's culture. Argument +/// names are ignored; only positions count. +/// +public sealed class StringFormatMessageFormatter : IMessageFormatter +{ + /// The shared instance, and the default of . + public static readonly StringFormatMessageFormatter Instance = new(); + + /// + /// 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 . + /// The culture to format in. + /// The formatted string, or when there are no arguments. + /// + /// is not a valid composite format string for + /// . The localizer catches it and returns the failure text. + /// + public string Format(string pattern, MessageArguments arguments, CultureInfo culture) => + arguments.Count == 0 + ? pattern + : string.Format( + culture, + pattern, + arguments.OrderBy(argument => argument.Position).Select(argument => argument.Value).ToArray() + ); +} diff --git a/src/ResXLocalization.Core/TranslationNotFoundEventArgs.cs b/src/ResXLocalization.Core/TranslationNotFoundEventArgs.cs deleted file mode 100644 index 28d2bc1..0000000 --- a/src/ResXLocalization.Core/TranslationNotFoundEventArgs.cs +++ /dev/null @@ -1,42 +0,0 @@ -namespace RentADeveloper.ResXLocalization; - -/// -/// Provides the details of a lookup that no resource file could satisfy, raised through -/// . Subscribe to log missing translations during -/// development. Normal .NET parent/neutral resource fallback runs first, so this event means the -/// key was unresolved across the complete fallback chain; the lookup itself still returns the -/// missing-translation sentinel (see ). -/// -public sealed class TranslationNotFoundEventArgs : EventArgs -{ - /// - /// Initializes a new instance of the class. - /// - /// The resource key that could not be resolved. - /// The culture the lookup ran against. - /// - /// The single resource manager of a scoped or typed lookup, or for a - /// search-all lookup. - /// - public TranslationNotFoundEventArgs(string key, CultureInfo culture, ResourceManager? resourceManager) - { - ArgumentNullException.ThrowIfNull(key); - ArgumentNullException.ThrowIfNull(culture); - - this.Key = key; - this.Culture = culture; - this.ResourceManager = resourceManager; - } - - /// Gets the culture the lookup ran against. - public CultureInfo Culture { get; } - - /// Gets the resource key that could not be resolved. - public string Key { get; } - - /// - /// Gets the single resource manager of a scoped or typed lookup, or when - /// the miss came from a search-all lookup across every registered resource manager. - /// - public ResourceManager? ResourceManager { get; } -} diff --git a/src/ResXLocalization.MessageFormat/GlobalUsings.cs b/src/ResXLocalization.MessageFormat/GlobalUsings.cs new file mode 100644 index 0000000..8b812c5 --- /dev/null +++ b/src/ResXLocalization.MessageFormat/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using System.Globalization; +// An alias rather than the namespace: Jeffijoe.MessageFormat declares an IMessageFormatter of its own, which +// would make RentADeveloper.ResXLocalization.IMessageFormatter ambiguous. +global using JeffijoeMessageFormatter = Jeffijoe.MessageFormat.MessageFormatter; diff --git a/src/ResXLocalization.MessageFormat/IcuMessageFormatter.cs b/src/ResXLocalization.MessageFormat/IcuMessageFormatter.cs new file mode 100644 index 0000000..bfabfa0 --- /dev/null +++ b/src/ResXLocalization.MessageFormat/IcuMessageFormatter.cs @@ -0,0 +1,85 @@ +namespace RentADeveloper.ResXLocalization.MessageFormat; + +/// +/// The ICU MessageFormat that +/// installs. It formats every resolved value +/// with one , also a value without arguments. +/// +/// +/// The formatter of the MessageFormat library, used as given for every culture and every thread. +/// +internal sealed class IcuMessageFormatter(JeffijoeMessageFormatter messageFormatter) : IMessageFormatter +{ + /// + /// The culture to format in when the lookup culture is the invariant culture: en, or the invariant + /// culture itself where en cannot be created (globalization-invariant mode). + /// + private readonly CultureInfo englishCulture = ResolveEnglishCulture(); + + /// The formatter of the MessageFormat library. + private readonly JeffijoeMessageFormatter messageFormatter = messageFormatter; + + /// + /// Formats as an ICU message, also when is empty. + /// Every argument is passed under its ("0", "1", and + /// so on) and, if it has a , under that name as well; a name overwrites + /// a position with the same text. + /// + /// The resolved resource value. + /// The arguments of the lookup; never . + /// + /// The culture to format in. The invariant culture formats as en, because the plural rules of the + /// MessageFormat library for the invariant culture know only the category other. + /// + /// The formatted message. + /// + /// is not a valid ICU message, references a variable + /// does not supply, or uses an unsupported style. The localizer catches it, + /// like any other exception of a formatter, and returns the failure text. + /// + /// A plural argument is a string that doesn't represent a number. + /// + /// A plural argument is neither a number nor a string. + /// + public string Format(string pattern, MessageArguments arguments, CultureInfo culture) + { + var values = new Dictionary(StringComparer.Ordinal); + + foreach (var argument in arguments) + { + values[argument.Position.ToString(CultureInfo.InvariantCulture)] = argument.Value; + } + + // A second pass, so that a name overwrites a position whatever the order of the arguments. + foreach (var argument in arguments) + { + if (argument.Name is { } name) + { + values[name] = argument.Value; + } + } + + return this.messageFormatter.FormatMessage( + pattern, + values, + culture.Name.Length == 0 ? this.englishCulture : culture + ); + } + + /// + /// Returns en, or the invariant culture when en cannot be created, as in + /// globalization-invariant mode with only predefined cultures allowed. + /// + /// The culture that replaces the invariant culture when formatting. + private static CultureInfo ResolveEnglishCulture() + { + try + { + return CultureInfo.GetCultureInfo("en"); + } + catch (CultureNotFoundException) + { + return CultureInfo.InvariantCulture; + } + } +} diff --git a/src/ResXLocalization.MessageFormat/MessageFormatLocalizerExtensions.cs b/src/ResXLocalization.MessageFormat/MessageFormatLocalizerExtensions.cs new file mode 100644 index 0000000..6b9cb84 --- /dev/null +++ b/src/ResXLocalization.MessageFormat/MessageFormatLocalizerExtensions.cs @@ -0,0 +1,57 @@ +namespace RentADeveloper.ResXLocalization.MessageFormat; + +/// +/// Installs ICU MessageFormat as the formatter of an , for messages whose shape +/// depends on their arguments, such as {Count, plural, one {# file deleted} other {# files deleted}}. +/// +public static class MessageFormatLocalizerExtensions +{ + /// + /// Replaces the of with the ICU + /// MessageFormat formatter, for every lookup in code and in XAML. Call it once, at startup: + /// Localizer.Current.UseMessageFormat(); + /// + /// + /// + /// After the call, every resolved resource value is parsed as an ICU message, also for a lookup without + /// arguments: Localizer.Current.Get("Key"), the indexer, and + /// {l:Localize Key} of the ResXLocalization.Avalonia and ResXLocalization.WPF packages. Every + /// resource value must therefore follow ICU syntax, including texts without arguments: It''s ready + /// renders It's ready, '{literal}' renders {literal}, and a value that references a + /// variable the lookup does not supply returns the failure text and raises + /// . + /// + /// + /// Named arguments are built with , for example + /// Localizer.Current.Get(AppStringsKeys.FilesDeleted, MessageArguments.FromTuples(("Count", 5))), and + /// declared in XAML as <l:Argument Name="Count" Value="{Binding DeletedCount}" />. Every + /// argument is also reachable under its position: {0}, {1} and so on. + /// + /// + /// The method works with any implementation, because it only assigns + /// . An exception thrown by the setter of a custom implementation, + /// or by a handler during the + /// assignment, propagates. + /// + /// + /// See ICU MessageFormat. + /// + /// + /// + /// The localizer whose formatter to replace, usually . + /// + /// + /// The of the MessageFormat library that formats every + /// message, or (the default) for a new one with the library's defaults. A non-null + /// instance is used as given, with its custom formatters, pluralizers and CustomValueFormatter, and + /// is shared by every culture and every thread: configure it completely before the call, and do not modify + /// it afterwards. Its Culture property has no effect, because every lookup passes its own culture. + /// + /// is . + public static void UseMessageFormat(this ILocalizer localizer, JeffijoeMessageFormatter? messageFormatter = null) + { + ArgumentNullException.ThrowIfNull(localizer); + + localizer.MessageFormatter = new IcuMessageFormatter(messageFormatter ?? new JeffijoeMessageFormatter()); + } +} diff --git a/src/ResXLocalization.MessageFormat/PublicAPI.Shipped.txt b/src/ResXLocalization.MessageFormat/PublicAPI.Shipped.txt new file mode 100644 index 0000000..0d945e0 --- /dev/null +++ b/src/ResXLocalization.MessageFormat/PublicAPI.Shipped.txt @@ -0,0 +1,3 @@ +#nullable enable +RentADeveloper.ResXLocalization.MessageFormat.MessageFormatLocalizerExtensions +static RentADeveloper.ResXLocalization.MessageFormat.MessageFormatLocalizerExtensions.UseMessageFormat(this RentADeveloper.ResXLocalization.ILocalizer! localizer, Jeffijoe.MessageFormat.MessageFormatter? messageFormatter = null) -> void diff --git a/src/ResXLocalization.MessageFormat/PublicAPI.Unshipped.txt b/src/ResXLocalization.MessageFormat/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/src/ResXLocalization.MessageFormat/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/ResXLocalization.MessageFormat/ResXLocalization.MessageFormat.csproj b/src/ResXLocalization.MessageFormat/ResXLocalization.MessageFormat.csproj new file mode 100644 index 0000000..e4d3288 --- /dev/null +++ b/src/ResXLocalization.MessageFormat/ResXLocalization.MessageFormat.csproj @@ -0,0 +1,22 @@ + + + + true + + $(LibraryTargetFrameworks) + + + + The opt-in ICU MessageFormat formatter for ResXLocalization: plural and select messages for every localized text, in code and in XAML. Activated with Localizer.Current.UseMessageFormat(). + localization;resx;i18n;l10n;icu;messageformat;plural;aot + + + + + + + + + + + diff --git a/src/ResXLocalization.SourceGenerators/AnalyzerReleases.Unshipped.md b/src/ResXLocalization.SourceGenerators/AnalyzerReleases.Unshipped.md index c00802b..b785b20 100644 --- a/src/ResXLocalization.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/src/ResXLocalization.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -1,2 +1,12 @@ ### New Rules +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +RXLGEN003 | ResXLocalization.SourceGenerator | Warning | Reports a .resx whose MSBuild resource name did not reach the generator +RXLGEN004 | ResXLocalization.SourceGenerator | Warning | Reports two neutral .resx files that would produce the same keys class + +### Removed Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +RXLGEN002 | ResXLocalization.SourceGenerator | Warning | Reports a missing or unrecognized classic resource accessor diff --git a/src/ResXLocalization.SourceGenerators/ResXLocalization.SourceGenerators.csproj b/src/ResXLocalization.SourceGenerators/ResXLocalization.SourceGenerators.csproj index 2d3b0f3..daf0e6f 100644 --- a/src/ResXLocalization.SourceGenerators/ResXLocalization.SourceGenerators.csproj +++ b/src/ResXLocalization.SourceGenerators/ResXLocalization.SourceGenerators.csproj @@ -14,10 +14,9 @@ - - - - + + + + CP0001 + T:RentADeveloper.ResXLocalization.WPF.LocalizeArgs + lib/net10.0-windows7.0/ResXLocalization.WPF.dll + lib/net10.0-windows7.0/ResXLocalization.WPF.dll + true + + + CP0001 + T:RentADeveloper.ResXLocalization.WPF.LocalizeArgs + lib/net8.0-windows7.0/ResXLocalization.WPF.dll + lib/net8.0-windows7.0/ResXLocalization.WPF.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.WPF.LocalizeExtension.get_Key + lib/net10.0-windows7.0/ResXLocalization.WPF.dll + lib/net10.0-windows7.0/ResXLocalization.WPF.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.WPF.LocalizeExtension.get_ResourceKey + lib/net10.0-windows7.0/ResXLocalization.WPF.dll + lib/net10.0-windows7.0/ResXLocalization.WPF.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.WPF.LocalizeExtension.set_ResourceKey(System.Nullable{RentADeveloper.ResXLocalization.ResourceKey}) + lib/net10.0-windows7.0/ResXLocalization.WPF.dll + lib/net10.0-windows7.0/ResXLocalization.WPF.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.WPF.LocalizeExtension.get_Key + lib/net8.0-windows7.0/ResXLocalization.WPF.dll + lib/net8.0-windows7.0/ResXLocalization.WPF.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.WPF.LocalizeExtension.get_ResourceKey + lib/net8.0-windows7.0/ResXLocalization.WPF.dll + lib/net8.0-windows7.0/ResXLocalization.WPF.dll + true + + + CP0002 + M:RentADeveloper.ResXLocalization.WPF.LocalizeExtension.set_ResourceKey(System.Nullable{RentADeveloper.ResXLocalization.ResourceKey}) + lib/net8.0-windows7.0/ResXLocalization.WPF.dll + lib/net8.0-windows7.0/ResXLocalization.WPF.dll + true + + \ No newline at end of file diff --git a/src/ResXLocalization.WPF/DesignMode.cs b/src/ResXLocalization.WPF/DesignMode.cs new file mode 100644 index 0000000..2bbeb25 --- /dev/null +++ b/src/ResXLocalization.WPF/DesignMode.cs @@ -0,0 +1,85 @@ +using System.ComponentModel; + +namespace RentADeveloper.ResXLocalization.WPF; + +/// +/// Detects whether the code runs inside a XAML designer/previewer - the Visual Studio WPF designer or Rider's WPF +/// previewer - and decides when the markup extensions return a design-time string instead of a binding. +/// +internal static class DesignMode +{ + /// + /// The detected or forced value that determines whether the code runs inside a XAML designer/previewer; + /// until the first read. + /// + private static bool? isInDesignMode; + + /// + /// Gets or sets a value indicating whether the code runs inside a XAML designer/previewer. Detected on the first + /// read; tests set it to force design mode on or off. + /// + internal static bool IsInDesignMode + { + get => isInDesignMode ??= IsRunningInsideXamlDesigner(); + set => isInDesignMode = value; + } + + /// + /// Determines whether and return a + /// design-time string instead of a binding: inside a XAML designer, when the target property is not a + /// and the target object is not a , a + /// or a . + /// + /// + /// Rider's WPF previewer supplies a dependency property as a CLR PropertyInfo (JetBrains + /// RIDER-143207), and WPF throws a XamlParseException when a binding is provided for such a + /// target. Every clause is needed. Outside a designer, a PropertyInfo target is legitimate in + /// , and , which handle + /// markup extensions themselves, and a string there would stop live culture updates inside styles. The + /// Visual Studio designer supplies those three with a PropertyInfo target too, and a real + /// everywhere else, so it keeps live bindings. + /// + /// The service provider supplied by the XAML loader. + /// + /// if the markup extension returns a design-time string; otherwise + /// . + /// + internal static bool ShouldReturnDesignTimeText(IServiceProvider? serviceProvider) + { + var target = serviceProvider?.GetService(typeof(IProvideValueTarget)) as IProvideValueTarget; + + return IsInDesignMode + && target?.TargetProperty is not DependencyProperty + && target?.TargetObject is not (Setter or DataTrigger or Condition); + } + + /// + /// Determines whether the current process is one in which an IDE renders XAML for a preview - the process + /// of the Visual Studio WPF designer or of Rider's WPF previewer - rather than a running application or a + /// test run. calls it once, on its first read. + /// + /// + /// Two checks are combined, because each of them recognizes only one of the two IDEs: + /// + /// + /// The Visual Studio WPF designer changes the default value of the dependency property + /// DesignerProperties.IsInDesignModeProperty to for the whole process. Rider + /// does not. + /// + /// + /// Rider sets the environment variable XAML_PREVIEW_HOST_PROCESS for the process in which its WPF + /// previewer runs. Visual Studio does not. + /// + /// + /// The per-element method DesignerProperties.GetIsInDesignMode(element) is not used: Rider replaces + /// a root with a wrapper object and reports for it + /// (JetBrains RIDER-143208). + /// + /// + /// in the process of the Visual Studio WPF designer or of Rider's WPF previewer; + /// otherwise . + /// + private static bool IsRunningInsideXamlDesigner() => + DesignerProperties.IsInDesignModeProperty.GetMetadata(typeof(DependencyObject)).DefaultValue is true + || Environment.GetEnvironmentVariable("XAML_PREVIEW_HOST_PROCESS") is not null; +} diff --git a/src/ResXLocalization.WPF/DesignTime.cs b/src/ResXLocalization.WPF/DesignTime.cs new file mode 100644 index 0000000..c672ab5 --- /dev/null +++ b/src/ResXLocalization.WPF/DesignTime.cs @@ -0,0 +1,75 @@ +namespace RentADeveloper.ResXLocalization.WPF; + +/// +/// Hosts the DesignTime.Culture attached property, which selects the language a XAML designer +/// previews. Set it on the root element: <Window ... l:DesignTime.Culture="de">. +/// +/// +/// +/// The property is for design time only and has no effect at run time. Inside the Visual Studio WPF +/// designer or Rider's WPF previewer it assigns of the process-wide +/// , so strings that view models look up in code agree with the XAML. It +/// overrides a culture selected by initialization that the designer executes, such as a +/// [ModuleInitializer] that selects English. +/// +/// +/// Without it, the designer uses the machine's UI culture, unless initialization that the designer executes +/// selects another culture. The designer does not call Application.OnStartup, so a culture selected +/// there does not apply at design time. +/// +/// +/// An empty name and a name that rejects, such as a half-typed one, leave the +/// culture unchanged. +/// +/// +public static class DesignTime +{ + /// + /// Identifies the DesignTime.Culture attached property: the name of the culture a XAML designer + /// previews, such as de. + /// + public static readonly DependencyProperty CultureProperty = DependencyProperty.RegisterAttached( + "Culture", + typeof(string), + typeof(DesignTime), + new PropertyMetadata(null, OnCultureChanged) + ); + + /// Gets the culture name set on for the XAML designer. + /// The element to read the value from. + /// The culture name, or if none is set. + public static string? GetCulture(DependencyObject element) => (string?)element.GetValue(CultureProperty); + + /// + /// Sets the culture name a XAML designer previews. Inside a designer this assigns + /// of ; at run time it only stores + /// the value. + /// + /// The element to set the value on, usually the root element of the view. + /// The culture name, such as de. + public static void SetCulture(DependencyObject element, string? value) => element.SetValue(CultureProperty, value); + + /// + /// Assigns the new culture to of + /// inside a XAML designer; does nothing at run time, for an empty name and for a name that + /// rejects. + /// + /// The element the value was set on. + /// The old and the new culture name. + private static void OnCultureChanged(DependencyObject element, DependencyPropertyChangedEventArgs e) + { + if (!DesignMode.IsInDesignMode || e.NewValue is not string { Length: > 0 } name) + { + return; + } + + try + { + Localizer.Current.CurrentCulture = new(name); + } + catch (CultureNotFoundException) + { + // A half-typed culture name must not take the preview down. + } + } +} diff --git a/src/ResXLocalization.WPF/GlobalUsings.cs b/src/ResXLocalization.WPF/GlobalUsings.cs index dc38cb6..a6987b5 100644 --- a/src/ResXLocalization.WPF/GlobalUsings.cs +++ b/src/ResXLocalization.WPF/GlobalUsings.cs @@ -1,4 +1,4 @@ -global using System.ComponentModel; +global using System.Collections.ObjectModel; global using System.Globalization; global using System.Resources; global using System.Windows; diff --git a/src/ResXLocalization.WPF/LocalizeArgs.cs b/src/ResXLocalization.WPF/LocalizeArgs.cs deleted file mode 100644 index d102c3b..0000000 --- a/src/ResXLocalization.WPF/LocalizeArgs.cs +++ /dev/null @@ -1,247 +0,0 @@ -namespace RentADeveloper.ResXLocalization.WPF; - -/// -/// Hosts the Arg0…Arg8 attached properties that supply dynamic composite-format -/// arguments to . Bind them on the element that carries the -/// localized property, for example -/// <TextBlock l:LocalizeArgs.Arg0="{Binding PeopleCount}" Text="{l:Localize {x:Static res:StringsKeys.PeopleInvited}}" /> -/// with a resource value such as {0} people invited; the rendered text re-formats live -/// whenever a bound argument or the culture of the ambient changes. -/// -/// -/// While no argument is set on the element, the resource resolves exactly as without this class - -/// no composite formatting runs, so resource values containing literal braces keep working. As soon -/// as at least one argument is set, the resolved value is treated as a composite format string: -/// the arguments Arg0 up to the highest set slot are passed to the formatting -/// ILocalizer.Get overloads, with unset slots below the highest formatting as -/// . The arguments are per element - two localized properties on the same -/// element share the one argument set. -/// -public static class LocalizeArgs -{ - /// Identifies the LocalizeArgs.Arg0 attached property: format argument {0}. - public static readonly DependencyProperty Arg0Property = RegisterArgument("Arg0"); - - /// Identifies the LocalizeArgs.Arg1 attached property: format argument {1}. - public static readonly DependencyProperty Arg1Property = RegisterArgument("Arg1"); - - /// Identifies the LocalizeArgs.Arg2 attached property: format argument {2}. - public static readonly DependencyProperty Arg2Property = RegisterArgument("Arg2"); - - /// Identifies the LocalizeArgs.Arg3 attached property: format argument {3}. - public static readonly DependencyProperty Arg3Property = RegisterArgument("Arg3"); - - /// Identifies the LocalizeArgs.Arg4 attached property: format argument {4}. - public static readonly DependencyProperty Arg4Property = RegisterArgument("Arg4"); - - /// Identifies the LocalizeArgs.Arg5 attached property: format argument {5}. - public static readonly DependencyProperty Arg5Property = RegisterArgument("Arg5"); - - /// Identifies the LocalizeArgs.Arg6 attached property: format argument {6}. - public static readonly DependencyProperty Arg6Property = RegisterArgument("Arg6"); - - /// Identifies the LocalizeArgs.Arg7 attached property: format argument {7}. - public static readonly DependencyProperty Arg7Property = RegisterArgument("Arg7"); - - /// Identifies the LocalizeArgs.Arg8 attached property: format argument {8}. - public static readonly DependencyProperty Arg8Property = RegisterArgument("Arg8"); - - /// - /// The single "arguments changed" signal subscribes to: every - /// ArgN change increments this version on the element, so one binding per localized - /// property suffices instead of nine. - /// - internal static readonly DependencyProperty ArgsVersionProperty = DependencyProperty.RegisterAttached( - "ArgsVersion", - typeof(int), - typeof(LocalizeArgs), - new PropertyMetadata(0) - ); - - /// The nine argument slots in slot order, for . - private static readonly DependencyProperty[] ArgumentProperties = - [ - Arg0Property, - Arg1Property, - Arg2Property, - Arg3Property, - Arg4Property, - Arg5Property, - Arg6Property, - Arg7Property, - Arg8Property, - ]; - - /// Gets the value of the attached property. - /// The element holding the argument. - /// The argument value, or when the argument is not set. - public static object? GetArg0(DependencyObject element) => GetArgument(element, Arg0Property); - - /// Gets the value of the attached property. - /// The element holding the argument. - /// The argument value, or when the argument is not set. - public static object? GetArg1(DependencyObject element) => GetArgument(element, Arg1Property); - - /// Gets the value of the attached property. - /// The element holding the argument. - /// The argument value, or when the argument is not set. - public static object? GetArg2(DependencyObject element) => GetArgument(element, Arg2Property); - - /// Gets the value of the attached property. - /// The element holding the argument. - /// The argument value, or when the argument is not set. - public static object? GetArg3(DependencyObject element) => GetArgument(element, Arg3Property); - - /// Gets the value of the attached property. - /// The element holding the argument. - /// The argument value, or when the argument is not set. - public static object? GetArg4(DependencyObject element) => GetArgument(element, Arg4Property); - - /// Gets the value of the attached property. - /// The element holding the argument. - /// The argument value, or when the argument is not set. - public static object? GetArg5(DependencyObject element) => GetArgument(element, Arg5Property); - - /// Gets the value of the attached property. - /// The element holding the argument. - /// The argument value, or when the argument is not set. - public static object? GetArg6(DependencyObject element) => GetArgument(element, Arg6Property); - - /// Gets the value of the attached property. - /// The element holding the argument. - /// The argument value, or when the argument is not set. - public static object? GetArg7(DependencyObject element) => GetArgument(element, Arg7Property); - - /// Gets the value of the attached property. - /// The element holding the argument. - /// The argument value, or when the argument is not set. - public static object? GetArg8(DependencyObject element) => GetArgument(element, Arg8Property); - - /// Sets the value of the attached property. - /// The element to hold the argument. - /// The argument value; is a valid, set value. - public static void SetArg0(DependencyObject element, object? value) => element.SetValue(Arg0Property, value); - - /// Sets the value of the attached property. - /// The element to hold the argument. - /// The argument value; is a valid, set value. - public static void SetArg1(DependencyObject element, object? value) => element.SetValue(Arg1Property, value); - - /// Sets the value of the attached property. - /// The element to hold the argument. - /// The argument value; is a valid, set value. - public static void SetArg2(DependencyObject element, object? value) => element.SetValue(Arg2Property, value); - - /// Sets the value of the attached property. - /// The element to hold the argument. - /// The argument value; is a valid, set value. - public static void SetArg3(DependencyObject element, object? value) => element.SetValue(Arg3Property, value); - - /// Sets the value of the attached property. - /// The element to hold the argument. - /// The argument value; is a valid, set value. - public static void SetArg4(DependencyObject element, object? value) => element.SetValue(Arg4Property, value); - - /// Sets the value of the attached property. - /// The element to hold the argument. - /// The argument value; is a valid, set value. - public static void SetArg5(DependencyObject element, object? value) => element.SetValue(Arg5Property, value); - - /// Sets the value of the attached property. - /// The element to hold the argument. - /// The argument value; is a valid, set value. - public static void SetArg6(DependencyObject element, object? value) => element.SetValue(Arg6Property, value); - - /// Sets the value of the attached property. - /// The element to hold the argument. - /// The argument value; is a valid, set value. - public static void SetArg7(DependencyObject element, object? value) => element.SetValue(Arg7Property, value); - - /// Sets the value of the attached property. - /// The element to hold the argument. - /// The argument value; is a valid, set value. - public static void SetArg8(DependencyObject element, object? value) => element.SetValue(Arg8Property, value); - - /// - /// Reads the format arguments currently set on , applying the - /// trimming rules: arguments above the highest set slot are dropped, unset slots below it - /// become . - /// - /// The element to read the arguments from. - /// - /// The arguments to format with, or when no argument is set on the - /// element - the caller must then resolve without composite formatting. - /// - internal static object?[]? GetArguments(DependencyObject element) - { - var lastSetIndex = -1; - - for (var index = ArgumentProperties.Length - 1; index >= 0; index--) - { - if (!ReferenceEquals(element.GetValue(ArgumentProperties[index]), UnsetSentinel.Value)) - { - lastSetIndex = index; - break; - } - } - - if (lastSetIndex < 0) - { - return null; - } - - var arguments = new object?[lastSetIndex + 1]; - - for (var index = 0; index <= lastSetIndex; index++) - { - var value = element.GetValue(ArgumentProperties[index]); - arguments[index] = ReferenceEquals(value, UnsetSentinel.Value) ? null : value; - } - - return arguments; - } - - /// Reads one argument slot, mapping the unset sentinel to . - /// The element holding the argument. - /// The argument slot to read. - /// The argument value, or when the argument is not set. - private static object? GetArgument(DependencyObject element, DependencyProperty property) - { - var value = element.GetValue(property); - return ReferenceEquals(value, UnsetSentinel.Value) ? null : value; - } - - /// - /// Increments on the element whose argument changed, which is - /// what re-triggers the multi-binding produced by . - /// - /// The element whose argument changed. - /// The change data; not used. - private static void OnArgumentChanged(DependencyObject element, DependencyPropertyChangedEventArgs e) => - element.SetValue(ArgsVersionProperty, (int)element.GetValue(ArgsVersionProperty) + 1); - - /// - /// Registers one argument slot. The default is a private sentinel (not ), - /// so "argument never set" and "argument bound to null" stay distinguishable, and every change - /// bumps on the element. - /// - /// The attached property name, Arg0…Arg8. - /// The registered attached property. - private static DependencyProperty RegisterArgument(string name) => - DependencyProperty.RegisterAttached( - name, - typeof(object), - typeof(LocalizeArgs), - new PropertyMetadata(UnsetSentinel.Value, OnArgumentChanged) - ); - - /// - /// Holds the "argument never set" default of the ArgN properties. A nested type keeps the - /// sentinel initialized before the attached-property fields above it, regardless of textual order. - /// - private static class UnsetSentinel - { - /// The sentinel instance; compared by reference. - internal static readonly object Value = new(); - } -} diff --git a/src/ResXLocalization.WPF/LocalizeEnumConverter.cs b/src/ResXLocalization.WPF/LocalizeEnumConverter.cs index 6f1d8c3..da1c2b4 100644 --- a/src/ResXLocalization.WPF/LocalizeEnumConverter.cs +++ b/src/ResXLocalization.WPF/LocalizeEnumConverter.cs @@ -1,17 +1,26 @@ 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}. +/// Multi-value converter that localizes an enumeration value in a hand-written MultiBinding, for places +/// where a markup extension such as is not accepted, for example a +/// Binding-typed property. The children of the MultiBinding are: 0, the refresh trigger +/// <Binding Path="RefreshTriggeringRevision" Source="{x:Static core:Localizer.Current}" />, which +/// re-runs the conversion on every change of ; 1, the +/// enumeration value; 2 and up, the format arguments, in order. The value is mapped to a resource key by +/// , by default {KeyPrefix}{EnumTypeName}_{Value}. /// +/// +/// A MultiBinding whose first child is the enumeration value, followed by CurrentCulture, renders +/// an empty string; the enumeration value must be child 1, after the refresh trigger. +/// public sealed class LocalizeEnumConverter : IMultiValueConverter { /// Indicates whether this is the read-only shared instance. private readonly bool isSharedInstance; + /// The argument names parsed from , one per argument, in order. + private string?[] argumentNames = []; + /// Initializes a new instance of the class. public LocalizeEnumConverter() { } @@ -28,27 +37,75 @@ public LocalizeEnumConverter() { } /// Gets the shared, search-all converter instance with default settings. Reference it from XAML /// as {x:Static l:LocalizeEnumConverter.Default} when no file scoping is required. The /// shared instance is read-only - create your own converter to customize - /// or . + /// , or . /// 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 names of the format arguments, as one comma-separated string: entry i names the + /// argument at child 2 + i. For example, ArgumentNames="Count,Total" names child 2 + /// Count and child 3 Total. Entries are trimmed; an empty entry leaves that argument unnamed + /// (",Total" names only the second). (the default) or an empty string means + /// no names. Every argument keeps its position whether it is named or not, and names matter only to a + /// formatter that understands them, such as ICU MessageFormat; the default formatter ignores them. Extra + /// names are ignored, and arguments without an entry are unnamed. /// - /// The supplied value is . + /// Two entries have the same name, compared ordinally. /// /// The converter is the shared instance, which is read-only. /// - public string KeyPrefix + public string? ArgumentNames { get; set { - ArgumentNullException.ThrowIfNull(value); this.ThrowIfShared(); + + string?[] names = string.IsNullOrEmpty(value) + ? [] + : + [ + .. value + .Split(',') + .Select(static name => name.Trim()) + .Select(static name => name.Length == 0 ? null : name), + ]; + + var duplicate = names + .OfType() + .GroupBy(static name => name, StringComparer.Ordinal) + .FirstOrDefault(static group => group.Skip(1).Any()) + ?.Key; + if (duplicate is not null) + { + throw new ArgumentException( + $"ArgumentNames contains the name '{duplicate}' twice. Argument names must be unique.", + nameof(value) + ); + } + + this.argumentNames = names; field = value; } - } = EnumKeyConvention.DefaultEnumKeyPrefix; + } + + /// + /// 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 converter is the shared instance, which is read-only. + /// + public string? KeyPrefix + { + get; + set + { + this.ThrowIfShared(); + field = value; + } + } /// /// Gets or sets the resource manager that scopes the lookup to a single .resx file. When @@ -67,13 +124,10 @@ public ResourceManager? ResourceManager } } - /// - /// Converts the bound enumeration value to its localized string. - /// + /// Converts the enumeration value to its localized, formatted string. /// - /// The bound values. The first element is expected to be the value to - /// localize; any further bindings (such as the current culture) exist only to trigger - /// re-conversion. + /// The bound values: 0, the refresh trigger, whose value is ignored; 1, the value to + /// localize; 2 and up, the format arguments, named by . /// /// The type of the binding target property. /// An optional converter parameter; not used. @@ -82,21 +136,37 @@ public ResourceManager? ResourceManager /// taken from ). /// /// - /// The localized string for the enumeration value, or when no value - /// is supplied or the first value is not an . + /// The localized string for the enumeration value, or when there are fewer than + /// two values or value 1 is not an . /// public object Convert(object?[] values, Type targetType, object? parameter, CultureInfo culture) { - if (values.Length == 0 || values[0] is not Enum enumValue) + if (values.Length < 2 || values[1] is not Enum enumValue) { return string.Empty; } - var key = EnumKeyConvention.BuildEnumKey(enumValue, this.KeyPrefix); + // Only two values means there are no arguments; every further value becomes (Name, Value), positioned from child 2 on. + var arguments = + values.Length == 2 + ? null + : MessageArguments.FromTuples([ + .. values + .Skip(2) + .Select( + (value, index) => + ( + index < this.argumentNames.Length ? this.argumentNames[index] : null, + value == DependencyProperty.UnsetValue ? null : value + ) + ), + ]); - return this.ResourceManager is null - ? Localizer.Current.Get(key) - : Localizer.Current.Get(key, this.ResourceManager); + return Localizer.Current.Get( + Localizer.Current.BuildEnumKey(enumValue, this.KeyPrefix), + arguments, + this.ResourceManager + ); } /// Not supported; this converter is one-way. @@ -119,7 +189,7 @@ private void ThrowIfShared() { throw new InvalidOperationException( "The shared LocalizeEnumConverter.Default instance is read-only; " - + "create your own LocalizeEnumConverter to customize KeyPrefix or ResourceManager." + + "create your own LocalizeEnumConverter to customize KeyPrefix, ResourceManager or ArgumentNames." ); } } diff --git a/src/ResXLocalization.WPF/LocalizeEnumExtension.cs b/src/ResXLocalization.WPF/LocalizeEnumExtension.cs index c79656e..06195e0 100644 --- a/src/ResXLocalization.WPF/LocalizeEnumExtension.cs +++ b/src/ResXLocalization.WPF/LocalizeEnumExtension.cs @@ -1,19 +1,45 @@ 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}. +/// XAML markup extension that localizes an enumeration value, re-resolving automatically on every change of +/// the value and of of . +/// The value is mapped to a resource key by , by default +/// {KeyPrefix}{EnumTypeName}_{Value}. Usage in XAML: {l:LocalizeEnum Value={Binding SortOrder}} +/// (or the shorthand {l:LocalizeEnum {Binding SortOrder}}) for a bound value, +/// {l:LocalizeEnum Value={Binding}} in an item template whose item is the enumeration value, or +/// {l:LocalizeEnum Value={x:Static m:FileSortOrder.Ascending}} for a fixed member. When the resource +/// value is a composite format string, supply its arguments as child elements: +/// <l:LocalizeEnum Value="{Binding SortOrder}" KeyPrefix="Count_"><l:Argument Value="{Binding FileCount}" /></l:LocalizeEnum>. /// +[ContentProperty(nameof(Arguments))] [MarkupExtensionReturnType(typeof(string))] +[XamlSetMarkupExtension(nameof(ReceiveMarkupExtension))] public sealed class LocalizeEnumExtension : MarkupExtension { + /// Initializes a new instance of the class. + public LocalizeEnumExtension() { } + + /// + /// Initializes a new instance of the class and sets + /// : {l:LocalizeEnum {Binding SortOrder}} is + /// {l:LocalizeEnum Value={Binding SortOrder}}. + /// + /// The enumeration value: a binding or a constant member. + public LocalizeEnumExtension(object value) => this.Value = value; + + /// + /// Gets the format arguments, in declaration order: the child elements. The first + /// argument is {0}, the second {1}, and so on, whether it has a name or not. The enumeration + /// value itself is not an argument; bind it as an when the text needs it. + /// + public Collection Arguments { get; } = []; + /// - /// 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 @@ -22,27 +48,113 @@ public sealed class LocalizeEnumExtension : MarkupExtension public ResourceManager? ResourceManager { get; set; } /// - /// Provides the value supplied to the target property: a multi-binding that yields the localized - /// text for the control's current DataContext enumeration value, updated live on every - /// culture change. + /// Gets or sets the enumeration value to localize: a plain binding, such as {Binding SortOrder} or + /// {Binding} in an item template, or a constant member written as + /// {x:Static m:FileSortOrder.Ascending}. A bound value that is not an , such as + /// , renders an empty string. + /// + public object? Value { get; set; } + + /// + /// Provides the value supplied to the target property: a binding whose value is the localized string for + /// , formatted with , updated live on every change of the + /// value, of the arguments and of . /// /// The service provider supplied by the XAML loader. - /// A WPF multi-binding that yields the localized enumeration text. + /// + /// The binding expression of a WPF multi-binding that yields the localized string, or the multi-binding + /// itself when the target is not a dependency property, such as a value. Inside a + /// XAML designer that supplies the target property as a CLR property instead of a dependency property, + /// such as Rider's WPF previewer, a string instead, without validation: the lookup of a constant + /// without arguments, or an empty string for any other + /// . + /// + /// + /// is , a constant that is not an , or a + /// binding that is not a plain ; or an has no + /// , an empty , the same name as another one, or a + /// binding that is not a plain . + /// public override object ProvideValue(IServiceProvider serviceProvider) { - var converter = new LocalizeEnumConverter + // Before the validation: Rider's previewer does not deliver bound values, and an exception takes the + // preview down. + if (DesignMode.ShouldReturnDesignTimeText(serviceProvider)) + { + return this.GetDesignTimeText(); + } + + this.ValidateValue(); + LocalizedMultiBindingBuilder.ValidateArguments(this.Arguments); + + return LocalizedMultiBindingBuilder + .Build(this.Value!, this.Arguments, this.ResourceManager, isEnumKey: true, enumKeyPrefix: this.KeyPrefix) + .ProvideValue(serviceProvider); + } + + /// + /// Called by the XAML reader for every markup extension written for a property of a + /// . Assigns a binding written for instead of + /// applying it; every other markup extension, such as {x:Static} or {x:Null}, stays unhandled + /// and provides its value as usual. + /// + /// The object whose property is being set. + /// The property and the markup extension written for it. + private static void ReceiveMarkupExtension(object targetObject, XamlSetMarkupExtensionEventArgs eventArgs) + { + if ( + targetObject is LocalizeEnumExtension extension + && eventArgs.Member.Name == nameof(Value) + && eventArgs.MarkupExtension is BindingBase binding + ) + { + extension.Value = binding; + eventArgs.Handled = true; + } + } + + /// + /// Gets the design-time text: the lookup of a constant without + /// arguments, or an empty string for any other , such as a binding, because there is no + /// enumeration value to look up. + /// + /// The design-time text. + private string GetDesignTimeText() => + this.Value is Enum value + ? Localizer.Current.Get(value, resourceManager: this.ResourceManager, keyPrefix: this.KeyPrefix) + : string.Empty; + + /// Checks that is a plain binding or an member. + /// + /// is , a constant that is not an , or a + /// binding that is not a plain . + /// + private void ValidateValue() + { + switch (this.Value) { - KeyPrefix = this.KeyPrefix, - ResourceManager = this.ResourceManager, - }; - - // The empty-path binding reads the target control's DataContext (the enum value); the second - // binding to the ambient culture re-triggers the conversion on every language switch. Because that - // source is the process-wide singleton, WPF keeps the binding target only weakly. - var multiBinding = new MultiBinding { Converter = converter, Mode = BindingMode.OneWay }; - multiBinding.Bindings.Add(new Binding()); - multiBinding.Bindings.Add(new Binding(nameof(ILocalizer.CurrentCulture)) { Source = Localizer.Current }); - - return multiBinding.ProvideValue(serviceProvider); + case null: + throw new InvalidOperationException( + "{l:LocalizeEnum} has no Value. Set it to a binding, as Value={Binding ...}, " + + "or to a fixed member, as Value={x:Static ...}." + ); + + case Binding: + case Enum: + return; + + // WPF's MultiBinding accepts only plain Binding children. + case BindingBase: + throw new InvalidOperationException( + $"Value of {{l:LocalizeEnum}} is a {this.Value.GetType().FullName}. " + + "WPF supports only a plain {Binding} here." + ); + + default: + throw new InvalidOperationException( + $"Value of {{l:LocalizeEnum}} must be a binding or an Enum, but is a {this.Value.GetType().FullName}. " + + "A fixed member is written Value={x:Static ...}." + ); + } } } diff --git a/src/ResXLocalization.WPF/LocalizeExtension.cs b/src/ResXLocalization.WPF/LocalizeExtension.cs index b372195..0481538 100644 --- a/src/ResXLocalization.WPF/LocalizeExtension.cs +++ b/src/ResXLocalization.WPF/LocalizeExtension.cs @@ -1,118 +1,188 @@ namespace RentADeveloper.ResXLocalization.WPF; /// -/// XAML markup extension that produces a binding to a localized string, re-resolving automatically -/// whenever changes culture. Usage in XAML: -/// {l:Localize {x:Static res:StringsKeys.Greeting}} for a typed key, -/// {l:Localize SomeKey} for a key-only lookup across all registered resource managers, or -/// {l:Localize Key=SomeKey, ResourceManager={x:Static res:Strings.ResourceManager}} to scope -/// the lookup to one file. When the resource value is a composite format string, supply its -/// arguments by binding the attached properties on the target element. +/// XAML markup extension that produces a binding to a localized string, re-resolving automatically on every +/// change of of - a +/// language switch, a new formatter, a new lookup-failure text builder or a change of the registered resource +/// managers. Usage in XAML: {l:Localize {x:Static res:StringsKeys.Greeting}} for a typed key, +/// {l:Localize Greeting} for a key-only lookup across all registered resource managers, or +/// {l:Localize Key=Greeting, ResourceManager={x:Static res:StringsKeys.ResourceManager}} to scope the +/// lookup to one file. A key chosen by the view model is bound: {l:Localize {Binding StatusKey}} or +/// Key="{Binding StatusKey}"; bind a to keep +/// the key compile-checked. When the resource value is a composite format string, supply its arguments as +/// child elements: +/// <l:Localize Key="{x:Static res:StringsKeys.PeopleInvited}"><l:Argument Value="{Binding PeopleCount}" /></l:Localize>. /// +[ContentProperty(nameof(Arguments))] [MarkupExtensionReturnType(typeof(string))] +[XamlSetMarkupExtension(nameof(ReceiveMarkupExtension))] public sealed class LocalizeExtension : MarkupExtension { /// Initializes a new instance of the class. public LocalizeExtension() { } /// - /// Initializes a new instance of the class from a single positional - /// argument. A typed (for example from - /// {x:Static res:StringsKeys.Greeting}) becomes ; a - /// becomes ; any other value leaves empty. - /// A single object-typed constructor is used instead of overloads so WPF's positional-argument - /// resolution is never ambiguous. + /// Initializes a new instance of the class and sets : + /// {l:Localize Greeting} is {l:Localize Key=Greeting}, and {l:Localize {Binding StatusKey}} + /// is {l:Localize Key={Binding StatusKey}}. A single object-typed constructor is used instead of + /// overloads so WPF's positional-argument resolution is never ambiguous. /// - /// A or a key string. - public LocalizeExtension(object key) - { - if (key is ResourceKey resourceKey) - { - this.ResourceKey = resourceKey; - } - else - { - this.Key = key as string ?? string.Empty; - } - } + /// + /// The key: a , a , or a + /// plain that yields one of them. + /// + public LocalizeExtension(object key) => this.Key = key; /// - /// Gets or sets the resource key to resolve. Used when is not set. If - /// is also set, the lookup is scoped to that file; otherwise every - /// registered resource manager is searched. + /// Gets the format arguments, in declaration order: the child elements. The first + /// argument is {0}, the second {1}, and so on, whether it has a name or not. /// - public string Key { get; set; } = string.Empty; + public Collection Arguments { get; } = []; /// - /// Gets or sets a typed, file-scoped key. When set, it takes precedence over - /// and and resolves through its own resource manager. + /// Gets or sets the key to resolve: a , scoped to if + /// that is set and otherwise searched across all registered resource managers, or a + /// , which resolves through its own resource + /// manager; or a plain , such as Key="{Binding StatusKey}", whose value is + /// resolved by the same rules on every update. Prefer binding a + /// chosen by the view model, which keeps the key + /// compile-checked; a bound key is an escape hatch where a typo silently becomes the + /// failure text. A bound value that gives no key, such as , an empty string or a + /// value of another type, renders an empty string. /// - public ResourceKey? ResourceKey { get; set; } + public object? Key { get; set; } /// - /// Gets or sets the resource manager that scopes a lookup to a single - /// .resx file. Ignored when is set; when both this and - /// are unset, the key is searched across all registered resource managers. + /// Gets or sets the resource manager that scopes a to a single + /// .resx file. Ignored when is a + /// ; when unset, a key is + /// searched across all registered resource managers. /// public ResourceManager? ResourceManager { get; set; } /// - /// Provides the value supplied to the target property: a multi-binding whose value is the - /// localized string for the configured key, updated live on every culture change and on every - /// change of the target element's arguments. + /// Provides the value supplied to the target property: a multi-binding whose value is the localized + /// string for , formatted with , updated live on every change of + /// the arguments and of . It needs no target element, + /// so it also works in a style setter, in a resource and in a template. /// /// The service provider supplied by the XAML loader. - /// A WPF multi-binding that yields the localized string. + /// + /// The value of the WPF multi-binding for the target: the binding expression for a dependency property, + /// the multi-binding itself for a Setter. Inside a XAML designer that supplies the target property + /// as a CLR property instead of a dependency property, such as Rider's WPF previewer, a string instead, + /// without validation: the lookup of a constant without arguments, or an empty string + /// when is or a binding. + /// + /// + /// is , a constant that is neither a nor a + /// , or a binding that is not a plain + /// ; or an has no , an empty + /// , the same name as another one, or a binding that is not a plain + /// . + /// + /// + /// is a without a resource + /// manager. + /// public override object ProvideValue(IServiceProvider serviceProvider) { - Func resolve; - Func resolveFormatted; - - if (this.ResourceKey.HasValue) + // Before the validation: Rider's previewer often delivers Key as null, and an exception takes the + // preview down. + if (DesignMode.ShouldReturnDesignTimeText(serviceProvider)) { - var resourceKey = this.ResourceKey.Value; - resolve = () => Localizer.Current.Get(resourceKey); - resolveFormatted = arguments => Localizer.Current.Get(resourceKey, arguments); + return this.GetDesignTimeText(); } - 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); - } - else + + this.ValidateKey(); + LocalizedMultiBindingBuilder.ValidateArguments(this.Arguments); + + return LocalizedMultiBindingBuilder + .Build(this.Key!, this.Arguments, this.ResourceManager) + .ProvideValue(serviceProvider); + } + + /// + /// Called by the XAML reader for every markup extension written for a property of a + /// . Assigns a binding written for instead of applying + /// it; every other markup extension, such as {x:Static} or {x:Null}, stays unhandled and + /// provides its value as usual. Also called for the constructor argument, with the member name + /// _PositionalParameters; that case stays unhandled, because a binding passed to the constructor + /// already arrives as a . + /// + /// The object whose property is being set. + /// The property and the markup extension written for it. + private static void ReceiveMarkupExtension(object targetObject, XamlSetMarkupExtensionEventArgs eventArgs) + { + if ( + targetObject is LocalizeExtension extension + && eventArgs.Member.Name == nameof(Key) + && eventArgs.MarkupExtension is BindingBase binding + ) { - var key = this.Key; - resolve = () => Localizer.Current.Get(key); - resolveFormatted = arguments => Localizer.Current.Get(key, arguments); + extension.Key = binding; + eventArgs.Handled = true; } + } - // Three constant children, regardless of how many arguments are used: - // 1. The ambient localizer's CurrentCulture re-triggers the conversion on every language switch. - // Because that source is the process-wide singleton (an INotifyPropertyChanged), WPF keeps the - // binding target only weakly. - // 2. The element's LocalizeArgs args-version re-triggers the conversion whenever any argument - // changes - crucial because ProvideValue runs during XAML instantiation, before argument - // bindings deliver values, so the arguments must be re-read later, not captured here. - // 3. The target element itself, from which the converter reads the current arguments. - // RelativeSource.Self resolves per applied instance, so the extension also works inside - // deferred item and data templates. - var multiBinding = new MultiBinding + /// + /// Gets the design-time text: the lookup of a constant without arguments - under the + /// default formatter the resource value as written, such as {0} people invited - or an empty string + /// for any other , because there is no key to look up. + /// + /// The design-time text. + private string GetDesignTimeText() => + this.Key switch { - Converter = new LocalizedFormattedStringConverter(resolve, resolveFormatted), - Mode = BindingMode.OneWay, + ResourceKey { Manager: not null } resourceKey => Localizer.Current.Get(resourceKey), + string { Length: > 0 } key => Localizer.Current.Get(key, resourceManager: this.ResourceManager), + _ => string.Empty, }; - multiBinding.Bindings.Add(new Binding(nameof(ILocalizer.CurrentCulture)) { Source = Localizer.Current }); - multiBinding.Bindings.Add( - new Binding - { - RelativeSource = RelativeSource.Self, - Path = new PropertyPath(LocalizeArgs.ArgsVersionProperty), - } - ); - multiBinding.Bindings.Add(new Binding { RelativeSource = RelativeSource.Self }); - - return multiBinding.ProvideValue(serviceProvider); + + /// + /// Checks that is a , a usable resource key or a plain binding. + /// + /// + /// is , a constant that is neither a nor a + /// , or a binding that is not a plain + /// . + /// + /// + /// is a without a resource + /// manager. + /// + private void ValidateKey() + { + switch (this.Key) + { + case null: + throw new InvalidOperationException( + "{l:Localize} has no Key. Set the key as {l:Localize Greeting} or as Key=\"...\"." + ); + + case string: + return; + + case ResourceKey { Manager: null }: + throw new ArgumentNullException( + message: "The ResourceKey assigned to Key of {l:Localize} has no ResourceManager.", + innerException: null + ); + + case ResourceKey: + case Binding: + return; + + // WPF's MultiBinding accepts only plain Binding children. + case BindingBase: + throw new InvalidOperationException( + $"Key of {{l:Localize}} is a {this.Key.GetType().FullName}. WPF supports only a plain {{Binding}} here." + ); + + default: + throw new InvalidOperationException( + $"Key of {{l:Localize}} must be a string, a ResourceKey or a binding, but is a {this.Key.GetType().FullName}." + ); + } } } diff --git a/src/ResXLocalization.WPF/LocalizedFormattedStringConverter.cs b/src/ResXLocalization.WPF/LocalizedFormattedStringConverter.cs deleted file mode 100644 index 64e06e1..0000000 --- a/src/ResXLocalization.WPF/LocalizedFormattedStringConverter.cs +++ /dev/null @@ -1,68 +0,0 @@ -namespace RentADeveloper.ResXLocalization.WPF; - -/// -/// Internal multi-value converter used by . The first two bound -/// values - the ambient and the element's -/// args-version - are only re-conversion triggers; the third is the -/// target element itself, whose arguments are read freshly on every -/// conversion. Binding to the ambient - an -/// source - keeps the subscription leak-safe (WPF holds the -/// binding target through a weak reference). -/// -/// Resolves the localized string without composite formatting. -/// Resolves the localized string formatted with the supplied arguments. -internal sealed class LocalizedFormattedStringConverter(Func resolve, Func resolveFormatted) - : IMultiValueConverter -{ - /// - /// Returns the freshly resolved localized string: formatted with the target element's current - /// arguments when at least one is set, otherwise resolved plainly so - /// the no-argument behavior stays byte-for-byte unchanged. - /// - /// - /// The bound values: culture trigger, args-version trigger, and the target element. When the - /// third value is missing or not a (for example - /// ), the string resolves without arguments. - /// - /// The type of the binding target property. - /// An optional converter parameter; not used. - /// - /// The culture supplied by the binding; not used (the active culture is taken from - /// ). - /// - /// - /// The localized, possibly formatted string, or when - /// the format string does not (yet) match the supplied arguments - routine while multi-argument - /// bindings still deliver their values one by one - so the mismatch never throws into the - /// property system mid-delivery and the text resolves cleanly on the next argument or culture - /// change. - /// - public object Convert(object?[] values, Type targetType, object? parameter, CultureInfo culture) - { - if (values.Length < 3 || values[2] is not DependencyObject element) - { - return resolve(); - } - - try - { - var arguments = LocalizeArgs.GetArguments(element); - - return arguments is null ? resolve() : resolveFormatted(arguments); - } - catch (FormatException) - { - return DependencyProperty.UnsetValue; - } - } - - /// Not supported; this converter is one-way. - /// The value produced by the binding target. - /// The types to convert back to. - /// An optional converter parameter; not used. - /// The culture supplied by the binding; not used. - /// Never returns; always throws. - /// Always thrown; the converter is one-way. - public object[] ConvertBack(object? value, Type[] targetTypes, object? parameter, CultureInfo culture) => - throw new NotSupportedException(); -} diff --git a/src/ResXLocalization.WPF/LocalizedMessageConverter.cs b/src/ResXLocalization.WPF/LocalizedMessageConverter.cs new file mode 100644 index 0000000..e1b4d49 --- /dev/null +++ b/src/ResXLocalization.WPF/LocalizedMessageConverter.cs @@ -0,0 +1,102 @@ +namespace RentADeveloper.ResXLocalization.WPF; + +/// +/// The multi-value converter of the that +/// builds: turns its values into one call of ILocalizer.Get(string, MessageArguments?, ResourceManager?) +/// on . Value 0 is the refresh trigger and is ignored; value 1 is the key, or +/// the enumeration value whose key builds; values 2 and up are the +/// argument values. +/// +/// The argument names, one per argument value, in order. +/// +/// The resource manager that scopes a key, or to search all +/// registered resource managers. +/// +/// +/// when value 1 is an enumeration value whose key +/// builds. +/// +/// +/// The key prefix passed to , or to let the +/// builder decide. +/// +internal sealed class LocalizedMessageConverter( + string?[] argumentNames, + ResourceManager? resourceManager, + bool isEnumKey, + string? enumKeyPrefix +) : IMultiValueConverter +{ + /// The argument names, one per argument value, in order. + private readonly string?[] argumentNames = argumentNames; + + /// The key prefix passed to . + private readonly string? enumKeyPrefix = enumKeyPrefix; + + /// Whether value 1 is an enumeration value rather than a key. + private readonly bool isEnumKey = isEnumKey; + + /// The resource manager that scopes a key. + private readonly ResourceManager? resourceManager = resourceManager; + + /// Returns the localized, formatted text for the current values. + /// The refresh trigger, the key or the enumeration value, and the argument values. + /// The type of the binding target property; not used. + /// An optional converter parameter; not used. + /// + /// The culture supplied by the binding; not used (the lookup uses the culture of + /// ). + /// + /// + /// + /// The localized text. + /// + /// The failure text (see ) when the lookup fails. + /// + /// + /// An empty string when value 1 gives no key to look up. When value 1 is a key (isEnumKey is + /// ), that is the case when it is neither a non-empty nor a + /// that has a resource manager. When value 1 is an enumeration value + /// (isEnumKey is ), that is the case when it is not an . + /// Examples are and a binding that has no value yet. + /// + /// + /// + public object Convert(object?[] values, Type targetType, object? parameter, CultureInfo culture) + { + // Never throws for a key value: a converter exception reaches the dispatcher and ends the application. + var (key, keyResourceManager) = (this.isEnumKey, values[1]) switch + { + (true, Enum value) => (Localizer.Current.BuildEnumKey(value, this.enumKeyPrefix), this.resourceManager), + (false, string { Length: > 0 } name) => (name, this.resourceManager), + (false, ResourceKey { Manager: not null } resourceKey) => (resourceKey.Name, resourceKey.Manager), + _ => ((string?)null, (ResourceManager?)null), + }; + + if (key is null) + { + return string.Empty; + } + + // Every argument value becomes (Name, Value); FromTuples gives each its index as position. + var arguments = MessageArguments.FromTuples([ + .. values + .Skip(2) + .Select( + (value, index) => (this.argumentNames[index], value == DependencyProperty.UnsetValue ? null : value) + ), + ]); + + return Localizer.Current.Get(key, arguments, keyResourceManager); + } + + /// Not supported; this converter is one-way. + /// The value produced by the binding target. + /// The types to convert back to. + /// An optional converter parameter; not used. + /// The culture supplied by the binding; not used. + /// Never returns; always throws. + /// Always thrown; the converter is one-way. + public object[] ConvertBack(object? value, Type[] targetTypes, object? parameter, CultureInfo culture) => + throw new NotSupportedException(); +} diff --git a/src/ResXLocalization.WPF/LocalizedMultiBindingBuilder.cs b/src/ResXLocalization.WPF/LocalizedMultiBindingBuilder.cs new file mode 100644 index 0000000..3b45257 --- /dev/null +++ b/src/ResXLocalization.WPF/LocalizedMultiBindingBuilder.cs @@ -0,0 +1,131 @@ +namespace RentADeveloper.ResXLocalization.WPF; + +/// +/// Builds the behind and +/// and validates its entries. The children of the +/// multi-binding are: 0, the refresh trigger, bound to of +/// ; 1, the key or the enumeration value; 2 and up, the argument values in +/// declaration order. Every child is a binding: a constant is wrapped in a +/// binding whose source is the constant, so reads every value the +/// same way. +/// +internal static class LocalizedMultiBindingBuilder +{ + /// Builds the multi-binding that yields the localized, formatted text. + /// + /// The key: a or a ; with , the + /// enumeration value. A binding or a constant. + /// + /// The arguments, already validated by . + /// + /// The resource manager that scopes a key, or to search all + /// registered resource managers. + /// + /// + /// when is an enumeration value whose key + /// builds. + /// + /// + /// The key prefix passed to , or to let the + /// builder decide. Used only with . + /// + /// The multi-binding. + internal static MultiBinding Build( + object key, + Collection arguments, + ResourceManager? resourceManager, + bool isEnumKey = false, + string? enumKeyPrefix = null + ) + { + var multiBinding = new MultiBinding + { + Converter = new LocalizedMessageConverter( + [.. arguments.Select(argument => argument.Name)], + resourceManager, + isEnumKey, + enumKeyPrefix + ), + Mode = BindingMode.OneWay, + }; + + // 0 = refresh trigger, 1 = key or enumeration value, 2.. = argument values. The trigger's source is the process-wide + // localizer, an INotifyPropertyChanged, which WPF observes through the weak event listener PropertyChangedEventManager. + multiBinding.Bindings.Add( + new Binding(nameof(ILocalizer.RefreshTriggeringRevision)) { Source = Localizer.Current } + ); + multiBinding.Bindings.Add(CreateBinding(key)); + + foreach (var argument in arguments) + { + multiBinding.Bindings.Add(CreateBinding(argument.Value)); + } + + return multiBinding; + } + + /// + /// Checks the arguments: every entry has a , no + /// is an empty string, no two entries have the same name (compared + /// ordinally), and a binding is a plain . + /// + /// The arguments to check. + /// An argument breaks one of the rules. + internal static void ValidateArguments(Collection arguments) + { + var names = new HashSet(StringComparer.Ordinal); + + for (var position = 0; position < arguments.Count; position++) + { + var argument = arguments[position]; + + if (!argument.IsValueSet) + { + var name = argument.Name is null ? string.Empty : $" (Name '{argument.Name}')"; + throw new InvalidOperationException( + $"The at position {position}{name} has no Value. Set Value to a constant or a " + + "binding; to pass null, write Value=\"{x:Null}\"." + ); + } + + // WPF's MultiBinding accepts only plain Binding children. + if (argument.Value is BindingBase and not Binding) + { + throw new InvalidOperationException( + $"The at position {position} has a Value of type " + + $"{argument.Value.GetType().FullName}. WPF supports only a plain {{Binding}} here." + ); + } + + if (argument.Name is null) + { + continue; + } + + if (argument.Name.Length == 0) + { + throw new InvalidOperationException( + $"The at position {position} has an empty Name. An empty name is not allowed; " + + "leave Name unset for a positional argument." + ); + } + + if (!names.Add(argument.Name)) + { + throw new InvalidOperationException( + $"Two entries have the Name '{argument.Name}'. Argument names must be unique." + ); + } + } + } + + /// Returns a binding unchanged and wraps a constant in a binding whose source is the constant. + /// A binding or a constant; a source yields . + /// The binding. + private static BindingBase CreateBinding(object? value) => + value switch + { + BindingBase binding => binding, + _ => new Binding { Source = value }, + }; +} diff --git a/src/ResXLocalization.WPF/PublicAPI.Shipped.txt b/src/ResXLocalization.WPF/PublicAPI.Shipped.txt index 18a9eea..58b1733 100644 --- a/src/ResXLocalization.WPF/PublicAPI.Shipped.txt +++ b/src/ResXLocalization.WPF/PublicAPI.Shipped.txt @@ -1,55 +1,42 @@ #nullable enable +override RentADeveloper.ResXLocalization.WPF.LocalizeEnumExtension.ProvideValue(System.IServiceProvider! serviceProvider) -> object! +override RentADeveloper.ResXLocalization.WPF.LocalizeExtension.ProvideValue(System.IServiceProvider! serviceProvider) -> object! +RentADeveloper.ResXLocalization.WPF.Argument +RentADeveloper.ResXLocalization.WPF.Argument.Argument() -> void +RentADeveloper.ResXLocalization.WPF.Argument.Name.get -> string? +RentADeveloper.ResXLocalization.WPF.Argument.Name.set -> void +RentADeveloper.ResXLocalization.WPF.Argument.Value.get -> object? +RentADeveloper.ResXLocalization.WPF.Argument.Value.set -> void +RentADeveloper.ResXLocalization.WPF.DesignTime RentADeveloper.ResXLocalization.WPF.LocalizeEnumConverter +RentADeveloper.ResXLocalization.WPF.LocalizeEnumConverter.ArgumentNames.get -> string? +RentADeveloper.ResXLocalization.WPF.LocalizeEnumConverter.ArgumentNames.set -> void RentADeveloper.ResXLocalization.WPF.LocalizeEnumConverter.Convert(object?[]! values, System.Type! targetType, object? parameter, System.Globalization.CultureInfo! culture) -> object! RentADeveloper.ResXLocalization.WPF.LocalizeEnumConverter.ConvertBack(object? value, System.Type![]! targetTypes, object? parameter, System.Globalization.CultureInfo! culture) -> object![]! -RentADeveloper.ResXLocalization.WPF.LocalizeEnumConverter.KeyPrefix.get -> string! +RentADeveloper.ResXLocalization.WPF.LocalizeEnumConverter.KeyPrefix.get -> string? RentADeveloper.ResXLocalization.WPF.LocalizeEnumConverter.KeyPrefix.set -> void RentADeveloper.ResXLocalization.WPF.LocalizeEnumConverter.LocalizeEnumConverter() -> void RentADeveloper.ResXLocalization.WPF.LocalizeEnumConverter.ResourceManager.get -> System.Resources.ResourceManager? RentADeveloper.ResXLocalization.WPF.LocalizeEnumConverter.ResourceManager.set -> void RentADeveloper.ResXLocalization.WPF.LocalizeEnumExtension -RentADeveloper.ResXLocalization.WPF.LocalizeEnumExtension.KeyPrefix.get -> string! +RentADeveloper.ResXLocalization.WPF.LocalizeEnumExtension.Arguments.get -> System.Collections.ObjectModel.Collection! +RentADeveloper.ResXLocalization.WPF.LocalizeEnumExtension.KeyPrefix.get -> string? RentADeveloper.ResXLocalization.WPF.LocalizeEnumExtension.KeyPrefix.set -> void RentADeveloper.ResXLocalization.WPF.LocalizeEnumExtension.LocalizeEnumExtension() -> void +RentADeveloper.ResXLocalization.WPF.LocalizeEnumExtension.LocalizeEnumExtension(object! value) -> void RentADeveloper.ResXLocalization.WPF.LocalizeEnumExtension.ResourceManager.get -> System.Resources.ResourceManager? RentADeveloper.ResXLocalization.WPF.LocalizeEnumExtension.ResourceManager.set -> void +RentADeveloper.ResXLocalization.WPF.LocalizeEnumExtension.Value.get -> object? +RentADeveloper.ResXLocalization.WPF.LocalizeEnumExtension.Value.set -> void RentADeveloper.ResXLocalization.WPF.LocalizeExtension -RentADeveloper.ResXLocalization.WPF.LocalizeExtension.Key.get -> string! +RentADeveloper.ResXLocalization.WPF.LocalizeExtension.Arguments.get -> System.Collections.ObjectModel.Collection! +RentADeveloper.ResXLocalization.WPF.LocalizeExtension.Key.get -> object? RentADeveloper.ResXLocalization.WPF.LocalizeExtension.Key.set -> void RentADeveloper.ResXLocalization.WPF.LocalizeExtension.LocalizeExtension() -> void RentADeveloper.ResXLocalization.WPF.LocalizeExtension.LocalizeExtension(object! key) -> void -RentADeveloper.ResXLocalization.WPF.LocalizeExtension.ResourceKey.get -> RentADeveloper.ResXLocalization.ResourceKey? -RentADeveloper.ResXLocalization.WPF.LocalizeExtension.ResourceKey.set -> void RentADeveloper.ResXLocalization.WPF.LocalizeExtension.ResourceManager.get -> System.Resources.ResourceManager? RentADeveloper.ResXLocalization.WPF.LocalizeExtension.ResourceManager.set -> void -override RentADeveloper.ResXLocalization.WPF.LocalizeEnumExtension.ProvideValue(System.IServiceProvider! serviceProvider) -> object! -override RentADeveloper.ResXLocalization.WPF.LocalizeExtension.ProvideValue(System.IServiceProvider! serviceProvider) -> object! +static readonly RentADeveloper.ResXLocalization.WPF.DesignTime.CultureProperty -> System.Windows.DependencyProperty! +static RentADeveloper.ResXLocalization.WPF.DesignTime.GetCulture(System.Windows.DependencyObject! element) -> string? +static RentADeveloper.ResXLocalization.WPF.DesignTime.SetCulture(System.Windows.DependencyObject! element, string? value) -> void static RentADeveloper.ResXLocalization.WPF.LocalizeEnumConverter.Default.get -> RentADeveloper.ResXLocalization.WPF.LocalizeEnumConverter! -RentADeveloper.ResXLocalization.WPF.LocalizeArgs -static readonly RentADeveloper.ResXLocalization.WPF.LocalizeArgs.Arg0Property -> System.Windows.DependencyProperty! -static readonly RentADeveloper.ResXLocalization.WPF.LocalizeArgs.Arg1Property -> System.Windows.DependencyProperty! -static readonly RentADeveloper.ResXLocalization.WPF.LocalizeArgs.Arg2Property -> System.Windows.DependencyProperty! -static readonly RentADeveloper.ResXLocalization.WPF.LocalizeArgs.Arg3Property -> System.Windows.DependencyProperty! -static readonly RentADeveloper.ResXLocalization.WPF.LocalizeArgs.Arg4Property -> System.Windows.DependencyProperty! -static readonly RentADeveloper.ResXLocalization.WPF.LocalizeArgs.Arg5Property -> System.Windows.DependencyProperty! -static readonly RentADeveloper.ResXLocalization.WPF.LocalizeArgs.Arg6Property -> System.Windows.DependencyProperty! -static readonly RentADeveloper.ResXLocalization.WPF.LocalizeArgs.Arg7Property -> System.Windows.DependencyProperty! -static readonly RentADeveloper.ResXLocalization.WPF.LocalizeArgs.Arg8Property -> System.Windows.DependencyProperty! -static RentADeveloper.ResXLocalization.WPF.LocalizeArgs.GetArg0(System.Windows.DependencyObject! element) -> object? -static RentADeveloper.ResXLocalization.WPF.LocalizeArgs.GetArg1(System.Windows.DependencyObject! element) -> object? -static RentADeveloper.ResXLocalization.WPF.LocalizeArgs.GetArg2(System.Windows.DependencyObject! element) -> object? -static RentADeveloper.ResXLocalization.WPF.LocalizeArgs.GetArg3(System.Windows.DependencyObject! element) -> object? -static RentADeveloper.ResXLocalization.WPF.LocalizeArgs.GetArg4(System.Windows.DependencyObject! element) -> object? -static RentADeveloper.ResXLocalization.WPF.LocalizeArgs.GetArg5(System.Windows.DependencyObject! element) -> object? -static RentADeveloper.ResXLocalization.WPF.LocalizeArgs.GetArg6(System.Windows.DependencyObject! element) -> object? -static RentADeveloper.ResXLocalization.WPF.LocalizeArgs.GetArg7(System.Windows.DependencyObject! element) -> object? -static RentADeveloper.ResXLocalization.WPF.LocalizeArgs.GetArg8(System.Windows.DependencyObject! element) -> object? -static RentADeveloper.ResXLocalization.WPF.LocalizeArgs.SetArg0(System.Windows.DependencyObject! element, object? value) -> void -static RentADeveloper.ResXLocalization.WPF.LocalizeArgs.SetArg1(System.Windows.DependencyObject! element, object? value) -> void -static RentADeveloper.ResXLocalization.WPF.LocalizeArgs.SetArg2(System.Windows.DependencyObject! element, object? value) -> void -static RentADeveloper.ResXLocalization.WPF.LocalizeArgs.SetArg3(System.Windows.DependencyObject! element, object? value) -> void -static RentADeveloper.ResXLocalization.WPF.LocalizeArgs.SetArg4(System.Windows.DependencyObject! element, object? value) -> void -static RentADeveloper.ResXLocalization.WPF.LocalizeArgs.SetArg5(System.Windows.DependencyObject! element, object? value) -> void -static RentADeveloper.ResXLocalization.WPF.LocalizeArgs.SetArg6(System.Windows.DependencyObject! element, object? value) -> void -static RentADeveloper.ResXLocalization.WPF.LocalizeArgs.SetArg7(System.Windows.DependencyObject! element, object? value) -> void -static RentADeveloper.ResXLocalization.WPF.LocalizeArgs.SetArg8(System.Windows.DependencyObject! element, object? value) -> void diff --git a/src/ResXLocalization.WPF/ResXLocalization.WPF.csproj b/src/ResXLocalization.WPF/ResXLocalization.WPF.csproj index 480e62e..069c2e1 100644 --- a/src/ResXLocalization.WPF/ResXLocalization.WPF.csproj +++ b/src/ResXLocalization.WPF/ResXLocalization.WPF.csproj @@ -25,6 +25,11 @@ + + + + + diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/ArgumentTests.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/ArgumentTests.cs new file mode 100644 index 0000000..def3d8b --- /dev/null +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/ArgumentTests.cs @@ -0,0 +1,562 @@ +using System.Reflection; +using Avalonia.Controls.Templates; +using Avalonia.Markup.Xaml.MarkupExtensions; +using Avalonia.Styling; +using RentADeveloper.ResXLocalization.Core.Tests; + +namespace RentADeveloper.ResXLocalization.Avalonia.Sample.Tests; + +/// +/// Covers the format arguments of {l:Localize}, matching the sample's card 6: +/// entries supply constant or bound values, optionally named; the rendered text re-formats live on every +/// argument change and on every refresh of the localizer; and a localized text without arguments is not +/// formatted. +/// +public class ArgumentTests +{ + [AvaloniaFact] + public void ArgumentChange_UpdatesTheText_Live() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var viewModel = new TestViewModel { Count = 1 }; + + var textBlock = TestSupport.BindLocalizedText( + new(ApplicationStringsKeys.PeopleInvited) { Arguments = { Arg(new Binding("Count")) } }, + viewModel + ); + textBlock.Text.Should().Be("1 people invited"); + + viewModel.Count = 2; + TestSupport.PumpUi(); + textBlock.Text.Should().Be("2 people invited"); + } + + [AvaloniaFact] + public void BoundArgument_UpdatesLive_OnValueAndCultureChange() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var viewModel = new TestViewModel { Count = 5 }; + + var textBlock = TestSupport.BindLocalizedText( + new(ApplicationStringsKeys.PeopleInvited) { Arguments = { Arg(new Binding("Count")) } }, + viewModel + ); + textBlock.Text.Should().Be("5 people invited"); + + viewModel.Count = 6; + TestSupport.PumpUi(); + textBlock.Text.Should().Be("6 people invited"); + + Localizer.Current.CurrentCulture = TestSupport.German; + TestSupport.PumpUi(); + textBlock.Text.Should().Be("6 Personen eingeladen"); + } + + [AvaloniaFact] + public void ConstantArguments_Format() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var textBlock = TestSupport.BindLocalizedText(new("FormatPair") { Arguments = { Arg(3), Arg("10") } }); + + textBlock.Text.Should().Be("File 3 of 10"); + } + + [AvaloniaFact] + public void CultureChange_ReFormats_Live() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var textBlock = TestSupport.BindLocalizedText( + new(ApplicationStringsKeys.PeopleInvited) { Arguments = { Arg(new Binding("Count")) } }, + new TestViewModel { Count = 3 } + ); + textBlock.Text.Should().Be("3 people invited"); + + Localizer.Current.CurrentCulture = TestSupport.German; + TestSupport.PumpUi(); + textBlock.Text.Should().Be("3 Personen eingeladen"); + } + + [AvaloniaFact] + public void DuplicateOrEmptyNames_Throw() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var duplicate = () => + new LocalizeExtension("FormatPair") { Arguments = { Arg("Count", 1), Arg("Count", 2) } }.ProvideValue( + null! + ); + var differentCase = () => + new LocalizeExtension("FormatPair") { Arguments = { Arg("A", 1), Arg("a", 2) } }.ProvideValue(null!); + var empty = () => + new LocalizeExtension("FormatPair") { Arguments = { Arg(string.Empty, 1) } }.ProvideValue(null!); + + duplicate.Should().Throw().WithMessage("*Count*"); + differentCase.Should().NotThrow(); + empty.Should().Throw().WithMessage("*Name*"); + } + + [AvaloniaFact] + public void FormattingFailure_RendersTheFailureText_AndRaisesLookupFailed() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + using var monitor = Localizer.Current.Monitor(); + + var textBlock = TestSupport.BindLocalizedText( + new("FormatBroken") { Arguments = { Arg(new Binding("Count")) } }, + new TestViewModel { Count = 1 } + ); + + textBlock.Text.Should().Be("!FormatBroken!"); + monitor + .LookupFailures() + .Should() + .NotBeEmpty() + .And.OnlyContain(failure => + failure.Reason == LookupFailureReason.MessageFormatFailed + && failure.Key == "FormatBroken" + && failure.Exception is FormatException + ); + } + + [AvaloniaFact] + public void InsideItemTemplate_EachItemFormatsItsOwnArguments() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + // Mirrors real item-template usage: the argument value (the item, via the DataContext) arrives only + // after ProvideValue ran. + var itemsControl = new ItemsControl + { + ItemsSource = new[] { 3, 7 }, + ItemTemplate = new FuncDataTemplate( + (_, _) => + { + var textBlock = new TextBlock(); + textBlock.Bind( + TextBlock.TextProperty, + (BindingBase) + new LocalizeExtension(ApplicationStringsKeys.PeopleInvited) + { + Arguments = { Arg(new Binding()) }, + }.ProvideValue(null!) + ); + return textBlock; + } + ), + }; + + var window = new Window { Content = itemsControl }; + window.Show(); + TestSupport.PumpUi(); + + var texts = TestSupport.AllVisibleText(window); + texts.Should().Contain("3 people invited"); + texts.Should().Contain("7 people invited"); + + window.Close(); + } + + [AvaloniaFact] + public void InvalidKeyOrMissingValue_Throws() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var noKey = () => new LocalizeExtension().ProvideValue(null!); + var numberKey = () => new LocalizeExtension(42).ProvideValue(null!); + var defaultResourceKey = () => new LocalizeExtension(default(ResourceKey)).ProvideValue(null!); + var noValue = () => new LocalizeExtension("FormatPair") { Arguments = { new Argument() } }.ProvideValue(null!); + + noKey.Should().Throw().WithMessage("*Key*"); + numberKey.Should().Throw().WithMessage("*System.Int32*"); + defaultResourceKey.Should().Throw(); + noValue.Should().Throw().WithMessage("*Value*"); + } + + [AvaloniaFact] + public void MissingKey_RendersTheFailureText() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + using var monitor = Localizer.Current.Monitor(); + + var textBlock = TestSupport.BindLocalizedText(new("ThisKeyDoesNotExist") { Arguments = { Arg(1) } }); + + textBlock.Text.Should().Be("!ThisKeyDoesNotExist!"); + monitor + .LookupFailures() + .Should() + .NotBeEmpty() + .And.OnlyContain(failure => failure.Reason == LookupFailureReason.TranslationNotFound); + } + + [AvaloniaFact] + public void MixedNamedAndUnnamedArguments_ReachTheFormatterWithPositionAndName() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var previous = Localizer.Current.MessageFormatter; + var formatter = new CapturingFormatter(); + Localizer.Current.MessageFormatter = formatter; + + try + { + var textBlock = TestSupport.BindLocalizedText( + new("FormatPair") { Arguments = { Arg(new Binding("A")), Arg("Total", new Binding("B")) } }, + new TestViewModel { A = 3, B = 10 } + ); + + textBlock.Text.Should().Be("File 3 of 10"); + formatter + .Calls[^1] + .Arguments.Should() + .Equal(new MessageArgument(0, null, 3), new MessageArgument(1, "Total", 10)); + } + finally + { + Localizer.Current.MessageFormatter = previous; + } + } + + [AvaloniaFact] + public void MultiBindingAndDynamicResourceValues_AreAccepted() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var textBlock = new TextBlock(); + textBlock.Resources["Total"] = "10"; + var multiBinding = new MultiBinding + { + Bindings = { new Binding("A"), new Binding("B") }, + StringFormat = "{0}+{1}", + }; + var dynamicResource = new DynamicResourceExtension("Total").ProvideValue(new EmptyServiceProvider()); + + textBlock.Bind( + TextBlock.TextProperty, + (BindingBase) + new LocalizeExtension("FormatPair") + { + Arguments = { Arg(multiBinding), Arg(dynamicResource) }, + }.ProvideValue(null!) + ); + textBlock.DataContext = new TestViewModel { A = 1, B = 2 }; + TestSupport.PumpUi(); + textBlock.Text.Should().Be("File 1+2 of 10"); + + textBlock.Resources["Total"] = "11"; + TestSupport.PumpUi(); + textBlock.Text.Should().Be("File 1+2 of 11"); + } + + [AvaloniaFact] + public void MultipleArguments_FormatAllPlaceholders() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var textBlock = TestSupport.BindLocalizedText( + new("FormatPair") { Arguments = { Arg(new Binding("A")), Arg(new Binding("B")) } }, + new TestViewModel { A = 2, B = 10 } + ); + + textBlock.Text.Should().Be("File 2 of 10"); + } + + [AvaloniaFact] + public void NamedArguments_FormatByPosition() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var textBlock = TestSupport.BindLocalizedText( + new("PartitionDisplay") + { + Arguments = + { + Arg("Label", new Binding("Label")), + Arg("Letter", new Binding("Letter")), + Arg("Drive", new Binding("Drive")), + Arg("Partition", new Binding("Partition")), + }, + }, + new TestViewModel + { + Label = "My USB", + Letter = "F", + Drive = 2, + Partition = 1, + } + ); + + textBlock.Text.Should().Be("My USB (F:) - drive 2, partition 1"); + } + + [AvaloniaFact] + public void NoArguments_ResolvesWithoutFormatting_KeepingLiteralBraces() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + // Without arguments the value must come back verbatim - composite formatting would throw on (or + // mangle) the un-escaped literal braces. + var textBlock = TestSupport.BindLocalizedText(new("CurlyNoArgs")); + + textBlock.Text.Should().Be("Literal {braces} stay"); + } + + [AvaloniaFact] + public void NullArgument_IsSet_AndFormatsAsEmpty() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + // Null is a set argument (distinct from "never set"), and string.Format renders it as empty. + var textBlock = TestSupport.BindLocalizedText( + new(ApplicationStringsKeys.PeopleInvited) { Arguments = { Arg(null) } } + ); + + textBlock.Text.Should().Be(" people invited"); + } + + [AvaloniaFact] + public void OneArgument_FormatsTheResolvedString() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var textBlock = TestSupport.BindLocalizedText( + new(ApplicationStringsKeys.PeopleInvited) { Arguments = { Arg(3) } } + ); + + textBlock.Text.Should().Be("3 people invited"); + } + + [AvaloniaFact] + public void PublicSurface_HasTheDeclaredShape() + { + var value = typeof(Argument).GetProperty(nameof(Argument.Value))!; + var key = typeof(LocalizeExtension).GetProperty(nameof(LocalizeExtension.Key))!; + var arguments = typeof(LocalizeExtension).GetProperty(nameof(LocalizeExtension.Arguments))!; + + value.PropertyType.Should().Be(); + key.PropertyType.Should().Be(); + arguments.PropertyType.GetGenericArguments().Should().Equal(typeof(Argument)); + typeof(LocalizeExtension).GetProperty("ResourceKey").Should().BeNull(); + typeof(LocalizeExtension).GetProperty("Argument").Should().BeNull(); + value.GetCustomAttribute().Should().NotBeNull(); + key.GetCustomAttribute().Should().NotBeNull(); + arguments.GetCustomAttribute().Should().NotBeNull(); + typeof(Argument).IsAssignableTo(typeof(AvaloniaObject)).Should().BeFalse(); + } + + [AvaloniaFact] + public void RefreshTrigger_ReResolves_OnFormatterTextBuilderAndRegistrationChange() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var previousFormatter = Localizer.Current.MessageFormatter; + var previousTextBuilder = Localizer.Current.LookupFailureTextBuilder; + + try + { + var invited = TestSupport.BindLocalizedText( + new(ApplicationStringsKeys.PeopleInvited) { Arguments = { Arg(new Binding("Count")) } }, + new TestViewModel { Count = 5 } + ); + var missing = TestSupport.BindLocalizedText(new("ThisKeyDoesNotExist")); + Localizer.Current.UnregisterResourceManager(TestSupport.CatalogResources); + var catalogOnly = TestSupport.BindLocalizedText(new("CatalogOnly")); + + invited.Text.Should().Be("5 people invited"); + missing.Text.Should().Be("!ThisKeyDoesNotExist!"); + catalogOnly.Text.Should().Be("!CatalogOnly!"); + + Localizer.Current.MessageFormatter = new UpperCaseFormatter(); + TestSupport.PumpUi(); + invited.Text.Should().Be("{0} PEOPLE INVITED"); + missing.Text.Should().Be("!ThisKeyDoesNotExist!"); + catalogOnly.Text.Should().Be("!CatalogOnly!"); + + // The failure text bypasses the formatter, so it is not upper-cased. + Localizer.Current.LookupFailureTextBuilder = failure => $"[{failure.Key}]"; + TestSupport.PumpUi(); + missing.Text.Should().Be("[ThisKeyDoesNotExist]"); + catalogOnly.Text.Should().Be("[CatalogOnly]"); + + Localizer.Current.RegisterResourceManager(TestSupport.CatalogResources); + TestSupport.PumpUi(); + catalogOnly.Text.Should().Be("CATALOGONLYVALUE"); + } + finally + { + Localizer.Current.MessageFormatter = previousFormatter; + Localizer.Current.LookupFailureTextBuilder = previousTextBuilder; + + // Registering again appended Catalog after Fallback; restore the registration order. + Localizer.Current.ClearResourceManagers(); + TestSupport.ResetToEnglishWithTestCatalogs(); + } + } + + [AvaloniaFact] + public void ScopedLookup_FormatsWithArguments() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var textBlock = TestSupport.BindLocalizedText( + new() + { + Key = "PeopleInvited", + ResourceManager = ApplicationStringsKeys.ResourceManager, + Arguments = { Arg(4) }, + } + ); + + textBlock.Text.Should().Be("4 people invited"); + } + + [AvaloniaFact] + public void StyleSetter_FormatsWithArguments_WithoutATargetElement() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var style = new Style(selector => selector.OfType()) + { + Setters = + { + new Setter( + TextBlock.TextProperty, + (BindingBase) + new LocalizeExtension("FormatPair") + { + Arguments = { Arg(new Binding("A")), Arg("7") }, + }.ProvideValue(null!) + ), + }, + }; + var host = new StackPanel + { + DataContext = new TestViewModel { A = 3 }, + Children = { new TextBlock(), new TextBlock() }, + }; + host.Styles.Add(style); + var window = new Window { Content = host }; + window.Show(); + TestSupport.PumpUi(); + + host.Children.OfType() + .Select(textBlock => textBlock.Text) + .Should() + .Equal("File 3 of 7", "File 3 of 7"); + + Localizer.Current.CurrentCulture = TestSupport.German; + TestSupport.PumpUi(); + host.Children.OfType() + .Select(textBlock => textBlock.Text) + .Should() + .Equal("Datei 3 von 7", "Datei 3 von 7"); + + window.Close(); + } + + [AvaloniaFact] + public void TextAndToolTip_OnOneElement_HaveTheirOwnArguments() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var textBlock = new TextBlock(); + + textBlock.Bind( + TextBlock.TextProperty, + (BindingBase) + new LocalizeExtension("FormatPair") + { + Arguments = { Arg(new Binding("A")), Arg(new Binding("B")) }, + }.ProvideValue(null!) + ); + textBlock.Bind( + ToolTip.TipProperty, + (BindingBase) + new LocalizeExtension("FormatPair") + { + Arguments = { Arg(new Binding("B")), Arg(new Binding("A")) }, + }.ProvideValue(null!) + ); + textBlock.DataContext = new TestViewModel { A = 1, B = 2 }; + TestSupport.PumpUi(); + + textBlock.Text.Should().Be("File 1 of 2"); + ToolTip.GetTip(textBlock).Should().Be("File 2 of 1"); + } + + [AvaloniaFact] + public void TwelveArguments_HaveNoUpperLimit() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var extension = new LocalizeExtension("Twelve"); + + foreach (var letter in "abcdefghijkl") + { + extension.Arguments.Add(Arg(letter.ToString())); + } + + var textBlock = TestSupport.BindLocalizedText(extension); + + textBlock.Text.Should().Be("abcdefghijkl"); + } + + [AvaloniaFact] + public void UnresolvedAndNullValues_RenderEmpty_AndReachTheFormatterAsNull() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var previous = Localizer.Current.MessageFormatter; + var formatter = new CapturingFormatter(); + Localizer.Current.MessageFormatter = formatter; + + // Installing the formatter re-resolves every localized binding still alive; count only this test's. + TestSupport.PumpUi(); + var firstCall = formatter.Calls.Count; + + try + { + var unresolved = TestSupport.BindLocalizedText( + new("FormatPair") { Arguments = { Arg(new Binding("A")), Arg(new Binding("B")) } } + ); + var nulls = TestSupport.BindLocalizedText( + new("FormatPair") { Arguments = { Arg(null), Arg(new Binding("Nothing")) } }, + new TestViewModel() + ); + + unresolved.Text.Should().Be("File of "); + nulls.Text.Should().Be("File of "); + formatter + .Calls.Skip(firstCall) + .SelectMany(call => call.Arguments) + .Should() + .NotBeEmpty() + .And.OnlyContain(argument => argument.Value == null); + } + finally + { + Localizer.Current.MessageFormatter = previous; + } + } + + /// Creates an unnamed argument. + /// The constant or the binding. + /// The argument. + private static Argument Arg(object? value) => new() { Value = value }; + + /// Creates a named argument. + /// The name. + /// The constant or the binding. + /// The argument. + private static Argument Arg(string name, object? value) => new() { Name = name, Value = value }; + + /// A formatter that ignores the arguments and upper-cases the resolved value. + internal sealed class UpperCaseFormatter : IMessageFormatter + { + /// + public string Format(string pattern, MessageArguments arguments, CultureInfo culture) => + pattern.ToUpperInvariant(); + } + + /// A service provider without services, for a markup extension created in code. + private sealed class EmptyServiceProvider : IServiceProvider + { + /// + public object? GetService(Type serviceType) => null; + } +} diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/BoundKeyTests.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/BoundKeyTests.cs new file mode 100644 index 0000000..d60a71b --- /dev/null +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/BoundKeyTests.cs @@ -0,0 +1,127 @@ +using RentADeveloper.ResXLocalization.Core.Tests; + +namespace RentADeveloper.ResXLocalization.Avalonia.Sample.Tests; + +/// +/// Covers a bound key of {l:Localize}: the key comes from the view model, as a +/// or a , is re-read on every conversion, works with arguments +/// and a scoping resource manager, and renders an empty string instead of throwing when the bound value gives +/// no key. +/// +public class BoundKeyTests +{ + [AvaloniaFact] + public void BoundKeyProperty_Resolves() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var textBlock = TestSupport.BindLocalizedText( + new() { Key = new Binding("Key") }, + new TestViewModel { Key = "Greeting" } + ); + + textBlock.Text.Should().Be("Hello and welcome!"); + } + + [AvaloniaFact] + public void BoundKey_ReResolvesOnCultureChange() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var viewModel = new TestViewModel { Key = ApplicationStringsKeys.Greeting }; + + var textBlock = TestSupport.BindLocalizedText(new(new Binding("Key")), viewModel); + textBlock.Text.Should().Be("Hello and welcome!"); + + Localizer.Current.CurrentCulture = TestSupport.German; + TestSupport.PumpUi(); + textBlock.Text.Should().Be("Hallo und willkommen!"); + } + + [AvaloniaFact] + public void BoundKey_ResolvesResourceKeyStringAndEmptyKey() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var viewModel = new TestViewModel { Key = ApplicationStringsKeys.Greeting }; + + var textBlock = TestSupport.BindLocalizedText(new(new Binding("Key")), viewModel); + textBlock.Text.Should().Be("Hello and welcome!"); + + viewModel.Key = "CatalogOnly"; + TestSupport.PumpUi(); + textBlock.Text.Should().Be("CatalogOnlyValue"); + + viewModel.Key = string.Empty; + TestSupport.PumpUi(); + textBlock.Text.Should().BeEmpty(); + } + + [AvaloniaFact] + public void BoundKey_WithArguments_Formats() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var textBlock = TestSupport.BindLocalizedText( + new(new Binding("Key")) { Arguments = { Arg(new Binding("A")), Arg(new Binding("B")) } }, + new TestViewModel + { + Key = "FormatPair", + A = 3, + B = 10, + } + ); + + textBlock.Text.Should().Be("File 3 of 10"); + } + + [AvaloniaFact] + public void BoundKey_WithoutDataContext_RendersEmpty() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var bind = () => TestSupport.BindLocalizedText(new(new Binding("Key"))); + + bind.Should().NotThrow().Which.Text.Should().BeEmpty(); + } + + [AvaloniaFact] + public void BoundKey_WithoutUsableValue_RendersEmpty_WithoutEvent() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + using var monitor = Localizer.Current.Monitor(); + var viewModel = new TestViewModel { Key = null }; + + var textBlock = TestSupport.BindLocalizedText(new(new Binding("Key")), viewModel); + textBlock.Text.Should().BeEmpty(); + + viewModel.Key = 42; + TestSupport.PumpUi(); + textBlock.Text.Should().BeEmpty(); + + viewModel.Key = default(ResourceKey); + TestSupport.PumpUi(); + textBlock.Text.Should().BeEmpty(); + monitor.LookupFailures().Should().BeEmpty(); + + viewModel.Key = ApplicationStringsKeys.Greeting; + TestSupport.PumpUi(); + textBlock.Text.Should().Be("Hello and welcome!"); + } + + [AvaloniaFact] + public void BoundStringKey_WithResourceManager_IsScoped() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var textBlock = TestSupport.BindLocalizedText( + new(new Binding("Key")) { ResourceManager = ApplicationStringsKeys.ResourceManager }, + new TestViewModel { Key = "Greeting" } + ); + + textBlock.Text.Should().Be("Hello and welcome!"); + } + + /// Creates a positional argument. + /// The value: a binding or a constant. + /// The argument. + private static Argument Arg(object? value) => new() { Value = value }; +} diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/DesignTimeTests.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/DesignTimeTests.cs new file mode 100644 index 0000000..2cea1a9 --- /dev/null +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/DesignTimeTests.cs @@ -0,0 +1,27 @@ +namespace RentADeveloper.ResXLocalization.Avalonia.Sample.Tests; + +/// +/// Covers .Culture at run time. Its design-time effect cannot be tested here, because +/// Design.IsDesignMode has no public setter; the maintainer checks it in the previewer. +/// +public class DesignTimeTests +{ + [AvaloniaFact] + public void DesignTime_DoesNothing_AtRunTime() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var textBlock = new TextBlock(); + + try + { + DesignTime.SetCulture(textBlock, "de"); + + Localizer.Current.CurrentCulture.Name.Should().Be("en"); + DesignTime.GetCulture(textBlock).Should().Be("de"); + } + finally + { + Localizer.Current.CurrentCulture = TestSupport.English; + } + } +} diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/GeneratedKeysTests.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/GeneratedKeysTests.cs new file mode 100644 index 0000000..c9aedd1 --- /dev/null +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/GeneratedKeysTests.cs @@ -0,0 +1,39 @@ +namespace RentADeveloper.ResXLocalization.Avalonia.Sample.Tests; + +/// +/// Covers the generated …Keys classes of the sample: their keys use the class's own generated +/// resource manager, not the designer's. +/// +public class GeneratedKeysTests +{ + [AvaloniaFact] + public void SearchAll_FindsTheSampleFiles_ThroughTheGeneratedManagers() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + Localizer.Current.Get("Greeting").Should().Be("Hello and welcome!"); + Localizer.Current.Get("PoweredBy").Should().Be("Powered by rent-a-developer"); + TestSupport.BindLocalizedText(new("Greeting")).Text.Should().Be("Hello and welcome!"); + + try + { + // Proves that the generated instance is the registered one. + Localizer.Current.UnregisterResourceManager(ApplicationStringsKeys.ResourceManager).Should().BeTrue(); + } + finally + { + Localizer.Current.RegisterResourceManager(ApplicationStringsKeys.ResourceManager); + } + } + + [AvaloniaFact] + public void TypedKeys_UseTheGeneratedResourceManager() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + ReferenceEquals(ApplicationStringsKeys.Greeting.Manager, ApplicationStringsKeys.ResourceManager) + .Should() + .BeTrue(); + Localizer.Current.Get(ApplicationStringsKeys.Greeting).Should().Be("Hello and welcome!"); + } +} diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/GlobalUsings.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/GlobalUsings.cs index f638457..d95b24e 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/GlobalUsings.cs +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/GlobalUsings.cs @@ -5,7 +5,6 @@ global using Avalonia.Data; global using Avalonia.Headless; global using Avalonia.Headless.XUnit; -global using Avalonia.Markup.Xaml; global using Avalonia.Threading; global using Avalonia.VisualTree; global using AwesomeAssertions; diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeArgsTests.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeArgsTests.cs deleted file mode 100644 index efdedb5..0000000 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeArgsTests.cs +++ /dev/null @@ -1,190 +0,0 @@ -using Avalonia.Controls.Templates; - -namespace RentADeveloper.ResXLocalization.Avalonia.Sample.Tests; - -/// -/// Covers the dynamic composite-format arguments of {l:Localize}, matching the sample's card 6: -/// the attached properties supply the arguments, the rendered text -/// re-formats live on every argument or culture change, and the no-argument behavior stays unchanged. -/// -public class LocalizeArgsTests -{ - [AvaloniaFact] - public void ArgumentChange_UpdatesTheText_Live() - { - TestSupport.ResetToEnglishWithTestCatalogs(); - - var textBlock = TestSupport.BindLocalizedTextWithTarget(new(ApplicationStringsKeys.PeopleInvited)); - LocalizeArgs.SetArg0(textBlock, 1); - TestSupport.PumpUi(); - textBlock.Text.Should().Be("1 people invited"); - - LocalizeArgs.SetArg0(textBlock, 2); - TestSupport.PumpUi(); - textBlock.Text.Should().Be("2 people invited"); - } - - [AvaloniaFact] - public void BoundControlsWithArguments_AreCollected_WhenDiscarded() - { - TestSupport.ResetToEnglishWithTestCatalogs(); - - var references = CreateAndAbandonBoundControls(200); - - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); - - references.Should().NotContain(reference => reference.IsAlive); - } - - [AvaloniaFact] - public void CultureChange_ReFormats_Live() - { - TestSupport.ResetToEnglishWithTestCatalogs(); - - var textBlock = TestSupport.BindLocalizedTextWithTarget(new(ApplicationStringsKeys.PeopleInvited)); - LocalizeArgs.SetArg0(textBlock, 3); - TestSupport.PumpUi(); - textBlock.Text.Should().Be("3 people invited"); - - Localizer.Current.CurrentCulture = TestSupport.German; - TestSupport.PumpUi(); - textBlock.Text.Should().Be("3 Personen eingeladen"); - } - - [AvaloniaFact] - public void HigherSlotAlone_FillsInteriorGapsWithNull() - { - TestSupport.ResetToEnglishWithTestCatalogs(); - - var textBlock = TestSupport.BindLocalizedTextWithTarget(new("FormatGap")); - LocalizeArgs.SetArg2(textBlock, "X"); - TestSupport.PumpUi(); - - // Only Arg2 is set: Arg0 and Arg1 become null, so {0} renders empty and {2} renders "X". - textBlock.Text.Should().Be("[|X]"); - } - - [AvaloniaFact] - public void InsideItemTemplate_EachItemFormatsItsOwnArguments() - { - TestSupport.ResetToEnglishWithTestCatalogs(); - - // Mirrors real item-template usage: the extension's target is created per item, and the - // argument value (the item, via the DataContext) arrives only after ProvideValue ran. - var itemsControl = new ItemsControl - { - ItemsSource = new[] { 3, 7 }, - ItemTemplate = new FuncDataTemplate( - (_, _) => - { - var textBlock = TestSupport.BindLocalizedTextWithTarget(new(ApplicationStringsKeys.PeopleInvited)); - textBlock.Bind(LocalizeArgs.Arg0Property, new Binding()); - return textBlock; - } - ), - }; - - var window = new Window { Content = itemsControl }; - window.Show(); - TestSupport.PumpUi(); - - var texts = TestSupport.AllVisibleText(window); - texts.Should().Contain("3 people invited"); - texts.Should().Contain("7 people invited"); - - window.Close(); - } - - [AvaloniaFact] - public void MissingKey_ReturnsSentinelUnformatted() - { - TestSupport.ResetToEnglishWithTestCatalogs(); - - var textBlock = TestSupport.BindLocalizedTextWithTarget(new("NoSuchKeyEver")); - LocalizeArgs.SetArg0(textBlock, 1); - TestSupport.PumpUi(); - - textBlock.Text.Should().Be("!NoSuchKeyEver!"); - } - - [AvaloniaFact] - public void MultipleArguments_FormatAllPlaceholders() - { - TestSupport.ResetToEnglishWithTestCatalogs(); - - var textBlock = TestSupport.BindLocalizedTextWithTarget(new("FormatPair")); - LocalizeArgs.SetArg0(textBlock, 2); - LocalizeArgs.SetArg1(textBlock, 10); - TestSupport.PumpUi(); - - textBlock.Text.Should().Be("File 2 of 10"); - } - - [AvaloniaFact] - public void NoArguments_ResolvesWithoutFormatting_KeepingLiteralBraces() - { - TestSupport.ResetToEnglishWithTestCatalogs(); - - // No argument is set, so the value must come back verbatim - composite formatting would - // throw on (or mangle) the un-escaped literal braces. - var textBlock = TestSupport.BindLocalizedTextWithTarget(new("CurlyNoArgs")); - - textBlock.Text.Should().Be("Literal {braces} stay"); - } - - [AvaloniaFact] - public void NullArgument_IsSet_AndFormatsAsEmpty() - { - TestSupport.ResetToEnglishWithTestCatalogs(); - - var textBlock = TestSupport.BindLocalizedTextWithTarget(new(ApplicationStringsKeys.PeopleInvited)); - LocalizeArgs.SetArg0(textBlock, null); - TestSupport.PumpUi(); - - // Null is a set argument (distinct from "never set"), and String.Format renders it as empty. - textBlock.Text.Should().Be(" people invited"); - } - - [AvaloniaFact] - public void OneArgument_FormatsTheResolvedString() - { - TestSupport.ResetToEnglishWithTestCatalogs(); - - var textBlock = TestSupport.BindLocalizedTextWithTarget(new(ApplicationStringsKeys.PeopleInvited)); - LocalizeArgs.SetArg0(textBlock, 3); - TestSupport.PumpUi(); - - textBlock.Text.Should().Be("3 people invited"); - } - - [AvaloniaFact] - public void ScopedLookup_FormatsWithArguments() - { - TestSupport.ResetToEnglishWithTestCatalogs(); - - var textBlock = TestSupport.BindLocalizedTextWithTarget( - new() { Key = "PeopleInvited", ResourceManager = ApplicationStrings.ResourceManager } - ); - LocalizeArgs.SetArg0(textBlock, 4); - TestSupport.PumpUi(); - - textBlock.Text.Should().Be("4 people invited"); - } - - private static List CreateAndAbandonBoundControls(int count) - { - var references = new List(count); - for (var index = 0; index < count; index++) - { - var textBlock = TestSupport.BindLocalizedTextWithTarget(new(ApplicationStringsKeys.PeopleInvited)); - LocalizeArgs.SetArg0(textBlock, index); - references.Add(new(textBlock)); - } - - TestSupport.PumpUi(); - - return references; - } -} diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeEnumConverterTests.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeEnumConverterTests.cs index e386036..2d69acd 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeEnumConverterTests.cs +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeEnumConverterTests.cs @@ -1,11 +1,83 @@ +using RentADeveloper.ResXLocalization.Core.Tests; + namespace RentADeveloper.ResXLocalization.Avalonia.Sample.Tests; /// -/// Covers the four configurations of (the "enum bound as a value" -/// path, card 5), both by calling Convert directly and through a live MultiBinding. +/// Covers (the hand-written MultiBinding path, card 5), both by +/// calling Convert directly and through a live MultiBinding: value 0 is the refresh trigger, +/// value 1 the enumeration value, and values 2 and up the format arguments, named by +/// . /// public class LocalizeEnumConverterTests { + [AvaloniaFact] + public void ArgumentNames_DuplicateName_OrSharedInstance_Throws() + { + var duplicate = () => new LocalizeEnumConverter { ArgumentNames = "A,A" }; + var differentCase = () => new LocalizeEnumConverter { ArgumentNames = "A,a" }; + var shared = () => LocalizeEnumConverter.Default.ArgumentNames = "A"; + + duplicate.Should().Throw().WithMessage("*A*"); + differentCase.Should().NotThrow(); + shared.Should().Throw(); + } + + [AvaloniaFact] + public void ArgumentNames_NameTheArgumentsByPosition() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var previous = Localizer.Current.MessageFormatter; + var formatter = new CapturingFormatter(); + Localizer.Current.MessageFormatter = formatter; + + try + { + var converter = new LocalizeEnumConverter { KeyPrefix = "Count_", ArgumentNames = " Count , Total" }; + + Convert(converter, 0, FileSortOrder.Ascending, 3, 10).Should().Be("3 files, ascending"); + converter.ArgumentNames.Should().Be(" Count , Total"); + formatter + .Calls[^1] + .Arguments.Should() + .Equal(new MessageArgument(0, "Count", 3), new MessageArgument(1, "Total", 10)); + + converter.ArgumentNames = ",Total"; + Convert(converter, 0, FileSortOrder.Ascending, 3, 10).Should().Be("3 files, ascending"); + converter.ArgumentNames.Should().Be(",Total"); + formatter + .Calls[^1] + .Arguments.Should() + .Equal(new MessageArgument(0, null, 3), new MessageArgument(1, "Total", 10)); + + converter.ArgumentNames = "Count,Total,Extra"; + Convert(converter, 0, FileSortOrder.Ascending, 3).Should().Be("3 files, ascending"); + converter.ArgumentNames.Should().Be("Count,Total,Extra"); + formatter.Calls[^1].Arguments.Should().Equal(new MessageArgument(0, "Count", 3)); + } + finally + { + Localizer.Current.MessageFormatter = previous; + } + } + + [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(), 0, FileSortOrder.Ascending).Should().Be("Custom ascending"); + } + finally + { + Localizer.Current.EnumKeyBuilder = original; + } + } + [AvaloniaFact] public void CustomPrefix_NoManager_ResolvesViaSearchAll() { @@ -13,7 +85,7 @@ public void CustomPrefix_NoManager_ResolvesViaSearchAll() var converter = new LocalizeEnumConverter { KeyPrefix = "Display_" }; - Convert(converter, FileSortOrder.Ascending).Should().Be("A to Z"); + Convert(converter, 0, FileSortOrder.Ascending).Should().Be("A to Z"); } [AvaloniaFact] @@ -24,10 +96,10 @@ public void CustomPrefix_WithManager_ResolvesScoped() var converter = new LocalizeEnumConverter { KeyPrefix = "Display_", - ResourceManager = SortingStrings.ResourceManager, + ResourceManager = SortingStringsKeys.ResourceManager, }; - Convert(converter, FileSortOrder.Ascending).Should().Be("A to Z"); + Convert(converter, 0, FileSortOrder.Ascending).Should().Be("A to Z"); } [AvaloniaFact] @@ -35,9 +107,9 @@ public void DefaultPrefix_WithManager_ResolvesScoped() { TestSupport.ResetToEnglishWithTestCatalogs(); - var converter = new LocalizeEnumConverter { ResourceManager = ApplicationStrings.ResourceManager }; + var converter = new LocalizeEnumConverter { ResourceManager = ApplicationStringsKeys.ResourceManager }; - Convert(converter, FileSortOrder.Ascending).Should().Be("Ascending"); + Convert(converter, 0, FileSortOrder.Ascending).Should().Be("Ascending"); } [AvaloniaFact] @@ -45,7 +117,20 @@ public void Default_NoPrefix_NoManager_ResolvesViaSearchAll() { TestSupport.ResetToEnglishWithTestCatalogs(); - Convert(LocalizeEnumConverter.Default, FileSortOrder.Ascending).Should().Be("Ascending"); + Convert(LocalizeEnumConverter.Default, 0, 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] @@ -54,8 +139,10 @@ public void MultiBinding_SwitchesLive_OnCultureChange() TestSupport.ResetToEnglishWithTestCatalogs(); var multiBinding = new MultiBinding { Converter = LocalizeEnumConverter.Default }; + multiBinding.Bindings.Add( + new Binding(nameof(ILocalizer.RefreshTriggeringRevision)) { Source = Localizer.Current } + ); multiBinding.Bindings.Add(new Binding(nameof(Marker.Value)) { Source = new Marker(FileSortOrder.Ascending) }); - multiBinding.Bindings.Add(new Binding(nameof(ILocalizer.CurrentCulture)) { Source = Localizer.Current }); var textBlock = new TextBlock(); textBlock.Bind(TextBlock.TextProperty, multiBinding); @@ -72,11 +159,26 @@ public void NonEnumOrEmptyInput_ReturnsEmptyString() { TestSupport.ResetToEnglishWithTestCatalogs(); - Convert(LocalizeEnumConverter.Default).Should().Be(string.Empty); - LocalizeEnumConverter - .Default.Convert(["not an enum"], typeof(string), null, CultureInfo.InvariantCulture) - .Should() - .Be(string.Empty); + Convert(LocalizeEnumConverter.Default, 0, "not an enum").Should().Be(string.Empty); + Convert(LocalizeEnumConverter.Default, 0, null).Should().Be(string.Empty); + } + + [AvaloniaFact] + public void OldChildOrderOrTooFewValues_ReturnEmptyString() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + Convert(LocalizeEnumConverter.Default, FileSortOrder.Ascending, TestSupport.English).Should().BeEmpty(); + Convert(LocalizeEnumConverter.Default, 0).Should().BeEmpty(); + Convert(LocalizeEnumConverter.Default).Should().BeEmpty(); + } + + [AvaloniaFact] + public void OneArgument_FormatsTheEnumText() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + Convert(new() { KeyPrefix = "Count_" }, 0, FileSortOrder.Ascending, 3).Should().Be("3 files, ascending"); } [AvaloniaFact] @@ -87,7 +189,7 @@ public void SharedDefaultInstance_IsReadOnly() ((Action)(() => LocalizeEnumConverter.Default.KeyPrefix = "Display_")) .Should() .Throw(); - ((Action)(() => LocalizeEnumConverter.Default.ResourceManager = ApplicationStrings.ResourceManager)) + ((Action)(() => LocalizeEnumConverter.Default.ResourceManager = ApplicationStringsKeys.ResourceManager)) .Should() .Throw(); @@ -95,6 +197,37 @@ public void SharedDefaultInstance_IsReadOnly() own.KeyPrefix.Should().Be("Display_"); } + [AvaloniaFact] + public void TriggerSlot_IsNeverAnArgument() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + Convert(new() { KeyPrefix = "Count_" }, "not-an-argument", FileSortOrder.Ascending) + .Should() + .Be("{0} files, ascending"); + } + + [AvaloniaFact] + public void TwoValues_DoNotFormat_KeepingLiteralBraces() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + using var monitor = Localizer.Current.Monitor(); + + Convert(new() { KeyPrefix = "Braces_" }, 0, FileSortOrder.Ascending).Should().Be("{Ascending}"); + + monitor.EventLog().Should().BeEmpty(); + } + + [AvaloniaFact] + public void UnsetArgument_FormatsAsEmpty() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + Convert(new() { KeyPrefix = "Count_" }, 0, FileSortOrder.Ascending, AvaloniaProperty.UnsetValue) + .Should() + .Be(" files, ascending"); + } + private static string Convert(LocalizeEnumConverter converter, params object?[] values) => (string)converter.Convert(values, typeof(string), null, CultureInfo.InvariantCulture); diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeEnumExtensionTests.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeEnumExtensionTests.cs index ccaac9b..df8eec4 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeEnumExtensionTests.cs +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeEnumExtensionTests.cs @@ -1,18 +1,63 @@ +using System.Reflection; + namespace RentADeveloper.ResXLocalization.Avalonia.Sample.Tests; /// -/// Covers the four KeyPrefix × ResourceManager combinations of the {l:LocalizeEnum} markup -/// extension, matching the sample's card 4. The extension reads the control's DataContext, so each -/// test binds it to a text block whose data context is the enumeration value. +/// Covers the {l:LocalizeEnum} markup extension, matching the sample's card 4: the enumeration value +/// comes from - a binding, the item-template form +/// Value={Binding}, or a fixed member - the four KeyPrefix × ResourceManager combinations, and format +/// arguments as entries. /// public class LocalizeEnumExtensionTests { + [AvaloniaFact] + public void BoundArgument_FormatsTheEnumText_AndFollowsCultureChange() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var textBlock = TestSupport.BindLocalizedEnum( + new() + { + Value = new Binding(nameof(TestViewModel.SortOrder)), + KeyPrefix = "Count_", + Arguments = { Arg(new Binding(nameof(TestViewModel.Count))) }, + }, + new TestViewModel { SortOrder = FileSortOrder.Ascending, Count = 3 } + ); + textBlock.Text.Should().Be("3 files, ascending"); + + Localizer.Current.CurrentCulture = TestSupport.German; + TestSupport.PumpUi(); + textBlock.Text.Should().Be("3 Dateien, aufsteigend"); + } + + [AvaloniaFact] + public void BoundValue_FollowsValueAndCultureChanges() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var viewModel = new TestViewModel { SortOrder = FileSortOrder.Ascending }; + + var textBlock = TestSupport.BindLocalizedEnum( + new() { Value = new Binding(nameof(TestViewModel.SortOrder)) }, + viewModel + ); + textBlock.Text.Should().Be("Ascending"); + + viewModel.SortOrder = FileSortOrder.Descending; + TestSupport.PumpUi(); + textBlock.Text.Should().Be("Descending"); + + Localizer.Current.CurrentCulture = TestSupport.German; + TestSupport.PumpUi(); + textBlock.Text.Should().Be("Absteigend"); + } + [AvaloniaFact] public void BoundValue_SwitchesLive_OnCultureChange() { TestSupport.ResetToEnglishWithTestCatalogs(); - var textBlock = TestSupport.BindLocalizedEnum(new(), FileSortOrder.Descending); + var textBlock = TestSupport.BindLocalizedEnum(new() { Value = new Binding() }, FileSortOrder.Descending); textBlock.Text.Should().Be("Descending"); Localizer.Current.CurrentCulture = TestSupport.German; @@ -20,12 +65,73 @@ public void BoundValue_SwitchesLive_OnCultureChange() textBlock.Text.Should().Be("Absteigend"); } + [AvaloniaFact] + public void ConstantValueAndArgument_Format() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var textBlock = TestSupport.BindLocalizedEnum( + new() + { + Value = FileSortOrder.Ascending, + KeyPrefix = "Count_", + Arguments = { Arg(3) }, + }, + null + ); + textBlock.Text.Should().Be("3 files, ascending"); + + Localizer.Current.CurrentCulture = TestSupport.German; + TestSupport.PumpUi(); + textBlock.Text.Should().Be("3 Dateien, aufsteigend"); + } + + [AvaloniaFact] + public void ConstructorArgument_SetsValue() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var bound = TestSupport.BindLocalizedEnum( + new(new Binding(nameof(TestViewModel.SortOrder))), + new TestViewModel { SortOrder = FileSortOrder.Ascending } + ); + var constant = TestSupport.BindLocalizedEnum(new(FileSortOrder.Ascending), null); + var text = () => new LocalizeEnumExtension("Ascending").ProvideValue(null!); + + bound.Text.Should().Be("Ascending"); + constant.Text.Should().Be("Ascending"); + text.Should().Throw().WithMessage("*System.String*"); + } + + [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() { Value = new Binding() }, FileSortOrder.Ascending); + + textBlock.Text.Should().Be("Custom ascending"); + } + finally + { + Localizer.Current.EnumKeyBuilder = original; + } + } + [AvaloniaFact] public void CustomPrefix_NoManager_ResolvesViaSearchAll() { TestSupport.ResetToEnglishWithTestCatalogs(); - var textBlock = TestSupport.BindLocalizedEnum(new() { KeyPrefix = "Display_" }, FileSortOrder.Ascending); + var textBlock = TestSupport.BindLocalizedEnum( + new() { Value = new Binding(), KeyPrefix = "Display_" }, + FileSortOrder.Ascending + ); textBlock.Text.Should().Be("A to Z"); } @@ -36,7 +142,12 @@ public void CustomPrefix_WithManager_ResolvesScoped() TestSupport.ResetToEnglishWithTestCatalogs(); var textBlock = TestSupport.BindLocalizedEnum( - new() { KeyPrefix = "Display_", ResourceManager = SortingStrings.ResourceManager }, + new() + { + Value = new Binding(), + KeyPrefix = "Display_", + ResourceManager = SortingStringsKeys.ResourceManager, + }, FileSortOrder.Ascending ); @@ -48,7 +159,7 @@ public void DefaultPrefix_NoManager_ResolvesViaSearchAll() { TestSupport.ResetToEnglishWithTestCatalogs(); - var textBlock = TestSupport.BindLocalizedEnum(new(), FileSortOrder.Ascending); + var textBlock = TestSupport.BindLocalizedEnum(new() { Value = new Binding() }, FileSortOrder.Ascending); textBlock.Text.Should().Be("Ascending"); } @@ -59,10 +170,89 @@ public void DefaultPrefix_WithManager_ResolvesScoped() TestSupport.ResetToEnglishWithTestCatalogs(); var textBlock = TestSupport.BindLocalizedEnum( - new() { ResourceManager = ApplicationStrings.ResourceManager }, + new() { Value = new Binding(), ResourceManager = ApplicationStringsKeys.ResourceManager }, FileSortOrder.Ascending ); textBlock.Text.Should().Be("Ascending"); } + + [AvaloniaFact] + public void FormatterChange_ReResolvesTheExtensionAndTheConverter_WithoutALanguageSwitch() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var previous = Localizer.Current.MessageFormatter; + var viewModel = new TestViewModel { SortOrder = FileSortOrder.Ascending }; + + try + { + var extension = TestSupport.BindLocalizedEnum( + new() { Value = new Binding(nameof(TestViewModel.SortOrder)) }, + viewModel + ); + + var multiBinding = new MultiBinding { Converter = LocalizeEnumConverter.Default }; + multiBinding.Bindings.Add( + new Binding(nameof(ILocalizer.RefreshTriggeringRevision)) { Source = Localizer.Current } + ); + multiBinding.Bindings.Add(new Binding(nameof(TestViewModel.SortOrder))); + var converter = new TextBlock { DataContext = viewModel }; + converter.Bind(TextBlock.TextProperty, multiBinding); + TestSupport.PumpUi(); + + extension.Text.Should().Be("Ascending"); + converter.Text.Should().Be("Ascending"); + + Localizer.Current.MessageFormatter = new ArgumentTests.UpperCaseFormatter(); + TestSupport.PumpUi(); + + extension.Text.Should().Be("ASCENDING"); + converter.Text.Should().Be("ASCENDING"); + } + finally + { + Localizer.Current.MessageFormatter = previous; + } + } + + [AvaloniaFact] + public void MissingOrNonEnumValue_Throws() + { + var missing = () => new LocalizeEnumExtension().ProvideValue(null!); + var text = () => new LocalizeEnumExtension { Value = "Ascending" }.ProvideValue(null!); + + missing.Should().Throw().WithMessage("*Value*"); + text.Should().Throw().WithMessage("*System.String*"); + } + + [AvaloniaFact] + public void NullBoundValue_RendersEmpty() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var textBlock = TestSupport.BindLocalizedEnum( + new() { Value = new Binding(nameof(TestViewModel.SortOrder)) }, + new TestViewModel { SortOrder = null } + ); + + textBlock.Text.Should().BeEmpty(); + } + + [AvaloniaFact] + public void PublicSurface_HasTheDeclaredShape() + { + var value = typeof(LocalizeEnumExtension).GetProperty(nameof(LocalizeEnumExtension.Value))!; + var arguments = typeof(LocalizeEnumExtension).GetProperty(nameof(LocalizeEnumExtension.Arguments))!; + + value.PropertyType.Should().Be(); + arguments.PropertyType.GetGenericArguments().Should().Equal(typeof(Argument)); + typeof(LocalizeEnumExtension).GetProperty("Argument").Should().BeNull(); + value.GetCustomAttribute().Should().NotBeNull(); + arguments.GetCustomAttribute().Should().NotBeNull(); + } + + /// Creates an unnamed argument. + /// The constant or the binding. + /// The argument. + private static Argument Arg(object? value) => new() { Value = value }; } diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeExtensionTests.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeExtensionTests.cs index 7e7a8f3..085cf51 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeExtensionTests.cs +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeExtensionTests.cs @@ -1,3 +1,5 @@ +using RentADeveloper.ResXLocalization.Core.Tests; + namespace RentADeveloper.ResXLocalization.Avalonia.Sample.Tests; /// @@ -29,51 +31,85 @@ public void KeyProperty_ResolvesViaSearchAll() } [AvaloniaFact] - public void KeyProperty_WithResourceManager_ResolvesScoped() + public void KeyProperty_ResourceManagerScopesAStringKey_AndIsIgnoredForAResourceKey() { TestSupport.ResetToEnglishWithTestCatalogs(); - var textBlock = TestSupport.BindLocalizedText( - new() { Key = "Greeting", ResourceManager = ApplicationStrings.ResourceManager } + var scoped = TestSupport.BindLocalizedText( + new() { Key = "Greeting", ResourceManager = ApplicationStringsKeys.ResourceManager } ); - textBlock.Text.Should().Be("Hello and welcome!"); + // The typed key resolves through its own manager; the Catalog manager does not contain Greeting. + var typed = TestSupport.BindLocalizedText( + new() { Key = ApplicationStringsKeys.Greeting, ResourceManager = TestSupport.CatalogResources } + ); + + scoped.Text.Should().Be("Hello and welcome!"); + typed.Text.Should().Be("Hello and welcome!"); } [AvaloniaFact] - public void ResourceKeyConstructor_ResolvesViaTypedKey() + public void KeyProperty_WithResourceKey_ResolvesViaTypedKey() { TestSupport.ResetToEnglishWithTestCatalogs(); - var textBlock = TestSupport.BindLocalizedText(new(ApplicationStringsKeys.Greeting)); + var textBlock = TestSupport.BindLocalizedText(new() { Key = ApplicationStringsKeys.Greeting }); textBlock.Text.Should().Be("Hello and welcome!"); } [AvaloniaFact] - public void ResourceKeyProperty_ResolvesViaTypedKey() + public void KeyProperty_WithResourceManager_ResolvesScoped() { TestSupport.ResetToEnglishWithTestCatalogs(); - var textBlock = TestSupport.BindLocalizedText(new() { ResourceKey = ApplicationStringsKeys.Greeting }); + var textBlock = TestSupport.BindLocalizedText( + new() { Key = "Greeting", ResourceManager = ApplicationStringsKeys.ResourceManager } + ); textBlock.Text.Should().Be("Hello and welcome!"); } [AvaloniaFact] - public void ResourceKey_TakesPrecedenceOver_KeyAndResourceManager() + public void NoArguments_PassesTheValueThroughTheFormatter() { TestSupport.ResetToEnglishWithTestCatalogs(); + var previous = Localizer.Current.MessageFormatter; + var formatter = new CapturingFormatter(); + Localizer.Current.MessageFormatter = formatter; + + // Installing the formatter re-resolves every localized binding still alive; count only this test's. + TestSupport.PumpUi(); + var firstCall = formatter.Calls.Count; + + try + { + var textBlock = TestSupport.BindLocalizedText(new("CurlyNoArgs")); + TestSupport.PumpUi(); + + textBlock.Text.Should().Be("Literal {braces} stay"); + formatter.Calls.Skip(firstCall).Should().NotBeEmpty(); + formatter + .Calls.Skip(firstCall) + .Should() + .AllSatisfy(call => + { + call.Pattern.Should().Be("Literal {braces} stay"); + call.Arguments.Should().BeEmpty(); + }); + } + finally + { + Localizer.Current.MessageFormatter = previous; + } + } - // ResourceKey is set, so Key and ResourceManager are ignored and the typed key wins. - var textBlock = TestSupport.BindLocalizedText( - new() - { - ResourceKey = ApplicationStringsKeys.Greeting, - Key = "SortingHint", - ResourceManager = SortingStrings.ResourceManager, - } - ); + [AvaloniaFact] + public void ResourceKeyConstructor_ResolvesViaTypedKey() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var textBlock = TestSupport.BindLocalizedText(new(ApplicationStringsKeys.Greeting)); textBlock.Text.Should().Be("Hello and welcome!"); } @@ -94,7 +130,7 @@ public void StringConstructor_WithResourceManager_ResolvesScoped() TestSupport.ResetToEnglishWithTestCatalogs(); var textBlock = TestSupport.BindLocalizedText( - new("SortingHint") { ResourceManager = SortingStrings.ResourceManager } + new("SortingHint") { ResourceManager = SortingStringsKeys.ResourceManager } ); textBlock.Text.Should().Be("Choose how documents are ordered."); diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/MainWindowLiveSwitchTests.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/MainWindowLiveSwitchTests.cs index 535a45b..f6302e4 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/MainWindowLiveSwitchTests.cs +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/MainWindowLiveSwitchTests.cs @@ -6,6 +6,23 @@ namespace RentADeveloper.ResXLocalization.Avalonia.Sample.Tests; /// public class MainWindowLiveSwitchTests { + [AvaloniaFact] + public void BoundKey_FollowsTheViewModel_AndSwitchesLive() + { + var (window, viewModel) = CreateWindow(); + + // Both bound-key rows of card 7, the ResourceKey one and the string one, show the status. + TestSupport.AllVisibleText(window).Count(text => text == "Connecting...").Should().Be(2); + + viewModel.IsConnected = true; + TestSupport.PumpUi(); + TestSupport.AllVisibleText(window).Count(text => text == "Connected").Should().Be(2); + + viewModel.SelectedLanguage = TestSupport.German; + TestSupport.PumpUi(); + TestSupport.AllVisibleText(window).Count(text => text == "Verbunden").Should().Be(2); + } + [AvaloniaFact] public void PlainStrings_SwitchLive() { diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/MainWindowViewModelTests.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/MainWindowViewModelTests.cs index e227959..70f7364 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/MainWindowViewModelTests.cs +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/MainWindowViewModelTests.cs @@ -33,8 +33,7 @@ public void SelectingLanguage_RecomputesAndNotifies_ReadOutProperties() TestSupport.ResetToEnglishWithTestCatalogs(); using var viewModel = new MainWindowViewModel(Localizer.Current); - var changed = new List(); - viewModel.PropertyChanged += (_, args) => changed.Add(args.PropertyName ?? string.Empty); + using var monitor = viewModel.Monitor(); viewModel.SelectedLanguage = TestSupport.German; @@ -42,9 +41,9 @@ public void SelectingLanguage_RecomputesAndNotifies_ReadOutProperties() viewModel.SortOrderViaSearchAllDefaultPrefix.Should().Be("Aufsteigend"); viewModel.PoweredByViaTypedKey.Should().Be("Bereitgestellt von rent-a-developer"); - changed.Should().Contain(nameof(MainWindowViewModel.GreetingViaSearchAll)); - changed.Should().Contain(nameof(MainWindowViewModel.SortOrderViaSearchAllDefaultPrefix)); - changed.Should().Contain(nameof(MainWindowViewModel.PoweredByViaTypedKey)); + monitor.Should().RaisePropertyChangeFor(model => model.GreetingViaSearchAll); + monitor.Should().RaisePropertyChangeFor(model => model.SortOrderViaSearchAllDefaultPrefix); + monitor.Should().RaisePropertyChangeFor(model => model.PoweredByViaTypedKey); } [AvaloniaFact] @@ -64,15 +63,14 @@ public void SelectingSortOrder_RecomputesAndNotifies_EnumReadOuts() TestSupport.ResetToEnglishWithTestCatalogs(); using var viewModel = new MainWindowViewModel(Localizer.Current); - var changed = new List(); - viewModel.PropertyChanged += (_, args) => changed.Add(args.PropertyName ?? string.Empty); + using var monitor = viewModel.Monitor(); viewModel.SelectedFileSortOrder = FileSortOrder.Descending; viewModel.SortOrderViaSearchAllDefaultPrefix.Should().Be("Descending"); viewModel.SortOrderViaSearchAllCustomPrefix.Should().Be("Z to A"); - changed.Should().Contain(nameof(MainWindowViewModel.SortOrderViaSearchAllDefaultPrefix)); - changed.Should().Contain(nameof(MainWindowViewModel.SortOrderViaScopedCustomPrefix)); + monitor.Should().RaisePropertyChangeFor(model => model.SortOrderViaSearchAllDefaultPrefix); + monitor.Should().RaisePropertyChangeFor(model => model.SortOrderViaScopedCustomPrefix); } [AvaloniaFact] diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/MemoryLeakTests.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/MemoryLeakTests.cs index dbef9b3..bdf252a 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/MemoryLeakTests.cs +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/MemoryLeakTests.cs @@ -1,12 +1,30 @@ namespace RentADeveloper.ResXLocalization.Avalonia.Sample.Tests; /// -/// Guards the leak-safety contract: because the engine subscribes to CultureChanged weakly, the +/// Guards the leak-safety contract: because the engine subscribes to the localizer weakly, the /// process-lifetime singleton must not keep bound-then-discarded controls /// alive. If this regresses, every localized control in a long-running app would leak. /// public class MemoryLeakTests { + [AvaloniaFact] + public void BoundControlsWithArguments_AreCollected_WhenDiscarded() + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + // Sanity: the binding really subscribes and resolves, so the leak check below is not vacuous. + var probe = TestSupport.BindLocalizedText(new(ApplicationStringsKeys.Greeting)); + probe.Text.Should().Be("Hello and welcome!"); + + var references = CreateAndAbandonBoundControlsWithArguments(200); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + references.Should().NotContain(reference => reference.IsAlive); + } + [AvaloniaFact] public void BoundControls_AreCollected_WhenDiscarded() { @@ -55,6 +73,20 @@ private static List CreateAndAbandonBoundControls(int count) return references; } + private static List CreateAndAbandonBoundControlsWithArguments(int count) + { + var references = new List(count); + for (var index = 0; index < count; index++) + { + var textBlock = TestSupport.BindLocalizedText( + new(ApplicationStringsKeys.PeopleInvited) { Arguments = { new Argument { Value = 1 } } } + ); + references.Add(new(textBlock)); + } + + return references; + } + private static WeakReference CreateAndDisposeViewModel() { var viewModel = new MainWindowViewModel(Localizer.Current); diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/ResXLocalization.Avalonia.Sample.Tests.csproj b/tests/ResXLocalization.Avalonia.Sample.Tests/ResXLocalization.Avalonia.Sample.Tests.csproj index 305b90c..6d82861 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/ResXLocalization.Avalonia.Sample.Tests.csproj +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/ResXLocalization.Avalonia.Sample.Tests.csproj @@ -14,4 +14,10 @@ + + + + + + diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/Resources/Catalog.de.resx b/tests/ResXLocalization.Avalonia.Sample.Tests/Resources/Catalog.de.resx index d608723..8f366d2 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/Resources/Catalog.de.resx +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/Resources/Catalog.de.resx @@ -22,4 +22,13 @@ CatalogAufsteigend + + Datei {0} von {1} + + + {0} ({1}:) - Laufwerk {2}, Partition {3} + + + {0} Dateien, aufsteigend + diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/Resources/Catalog.resx b/tests/ResXLocalization.Avalonia.Sample.Tests/Resources/Catalog.resx index 7d9ac5c..7e60028 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/Resources/Catalog.resx +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/Resources/Catalog.resx @@ -1,14 +1,18 @@ text/microsoft-resx @@ -34,10 +38,25 @@ File {0} of {1} - - [{0}|{2}] + + {0} ({1}:) - drive {2}, partition {3} + + + {0}{1}{2}{3}{4}{5}{6}{7}{8}{9}{10}{11} Literal {braces} stay + + Broken {0 + + + Custom ascending + + + {0} files, ascending + + + {Ascending} + diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/TestAppBuilder.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/TestAppBuilder.cs index 98eac87..f789100 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/TestAppBuilder.cs +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/TestAppBuilder.cs @@ -17,9 +17,9 @@ private TestAppBuilder() { } public static AppBuilder BuildAvaloniaApp() { - Localizer.Current.RegisterResourceManager(ApplicationStrings.ResourceManager); - Localizer.Current.RegisterResourceManager(SortingStrings.ResourceManager); - Localizer.Current.RegisterResourceManager(BrandingStrings.ResourceManager); + Localizer.Current.RegisterResourceManager(ApplicationStringsKeys.ResourceManager); + Localizer.Current.RegisterResourceManager(SortingStringsKeys.ResourceManager); + Localizer.Current.RegisterResourceManager(BrandingStringsKeys.ResourceManager); return AppBuilder .Configure() diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/TestSupport.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/TestSupport.cs index a6afe23..ab66d82 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/TestSupport.cs +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/TestSupport.cs @@ -1,23 +1,23 @@ namespace RentADeveloper.ResXLocalization.Avalonia.Sample.Tests; /// -/// Shared helpers for the sample tests: access to the test-only resource files, a few binding utilities, -/// and the minimal service-provider stand-ins a markup extension needs outside of the XAML loader. +/// Shared helpers for the sample tests: access to the test-only resource files and a few binding utilities +/// for markup extensions created in code. /// internal static class TestSupport { + /// The test-only Catalog file; registered first so it wins search-all ties for "Shared". + public static readonly ResourceManager CatalogResources = new( + "RentADeveloper.ResXLocalization.Avalonia.Sample.Tests.Resources.Catalog", + typeof(TestSupport).Assembly + ); + /// English culture, used to reset the ambient localizer at the start of each test. public static readonly CultureInfo English = new("en"); /// German culture. Used to assert that values switch live. public static readonly CultureInfo German = new("de"); - /// The test-only Catalog file; registered first so it wins search-all ties for "Shared". - private static readonly ResourceManager CatalogResources = new( - "RentADeveloper.ResXLocalization.Avalonia.Sample.Tests.Resources.Catalog", - typeof(TestSupport).Assembly - ); - /// The test-only Fallback file; registered after . private static readonly ResourceManager FallbackResources = new( "RentADeveloper.ResXLocalization.Avalonia.Sample.Tests.Resources.Fallback", @@ -35,46 +35,36 @@ public static string[] AllVisibleText(Visual root) => .ToArray(); /// - /// Binds a to a fresh text block whose DataContext is the - /// supplied enumeration value, mirroring how the extension is used inside an item template. + /// Binds a to a fresh text block's Text, then sets the text block's + /// DataContext and pumps pending UI work so that bindings settle. /// /// The markup extension that creates the localization binding. - /// The enumeration value to expose as the text block's data context. - /// The bound text block after pending UI work has been processed. - public static TextBlock BindLocalizedEnum(LocalizeEnumExtension extension, Enum value) - { - var textBlock = new TextBlock(); - var binding = (BindingBase) - extension.ProvideValue(new SimpleServiceProvider(new SimpleProvideValueTarget(textBlock))); - textBlock.Bind(TextBlock.TextProperty, binding); - textBlock.DataContext = value; - Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); - return textBlock; - } - - /// Binds a to a fresh text block's Text and returns it. - /// The markup extension that creates the localization binding. + /// + /// The data context: the enumeration value itself for Value = new Binding(), or a view model. + /// /// The bound text block. - public static TextBlock BindLocalizedText(LocalizeExtension extension) + public static TextBlock BindLocalizedEnum(LocalizeEnumExtension extension, object? dataContext) { var textBlock = new TextBlock(); textBlock.Bind(TextBlock.TextProperty, (BindingBase)extension.ProvideValue(null!)); + textBlock.DataContext = dataContext; + PumpUi(); return textBlock; } /// - /// Binds a to a fresh text block's Text, supplying the text block - /// as the extension's target - mirroring real XAML usage - so the produced binding can read the - /// arguments set on it. + /// Binds a to a fresh text block's Text, then sets the text block's + /// DataContext and pumps pending UI work so that bindings settle. /// /// The markup extension that creates the localization binding. + /// The data context for the argument bindings, if any. /// The bound text block. - public static TextBlock BindLocalizedTextWithTarget(LocalizeExtension extension) + public static TextBlock BindLocalizedText(LocalizeExtension extension, object? dataContext = null) { var textBlock = new TextBlock(); - var binding = (BindingBase) - extension.ProvideValue(new SimpleServiceProvider(new SimpleProvideValueTarget(textBlock))); - textBlock.Bind(TextBlock.TextProperty, binding); + textBlock.Bind(TextBlock.TextProperty, (BindingBase)extension.ProvideValue(null!)); + textBlock.DataContext = dataContext; + PumpUi(); return textBlock; } @@ -82,25 +72,16 @@ public static TextBlock BindLocalizedTextWithTarget(LocalizeExtension extension) public static void PumpUi() => Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); /// - /// Resets the ambient localizer to English and registers the two test-only files. Registration dedups - /// by reference, so calling this from every test is safe. + /// Resets the ambient localizer to English and registers the sample and test-only files in the same + /// order the running app uses. Registration dedups by reference, so calling this from every test is safe. /// public static void ResetToEnglishWithTestCatalogs() { Localizer.Current.CurrentCulture = English; + Localizer.Current.RegisterResourceManager(ApplicationStringsKeys.ResourceManager); + Localizer.Current.RegisterResourceManager(SortingStringsKeys.ResourceManager); + Localizer.Current.RegisterResourceManager(BrandingStringsKeys.ResourceManager); Localizer.Current.RegisterResourceManager(CatalogResources); Localizer.Current.RegisterResourceManager(FallbackResources); } - - private sealed class SimpleProvideValueTarget(object targetObject) : IProvideValueTarget - { - public object TargetObject { get; } = targetObject; - - public object TargetProperty => null!; - } - - private sealed class SimpleServiceProvider(IProvideValueTarget target) : IServiceProvider - { - public object? GetService(Type serviceType) => serviceType == typeof(IProvideValueTarget) ? target : null; - } } diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/TestViewModel.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/TestViewModel.cs new file mode 100644 index 0000000..6cb1dd4 --- /dev/null +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/TestViewModel.cs @@ -0,0 +1,88 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace RentADeveloper.ResXLocalization.Avalonia.Sample.Tests; + +/// The data context of the argument tests: one property per bound argument value. +public sealed class TestViewModel : INotifyPropertyChanged +{ + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// Gets a value that is always . + public string? Nothing { get; } + + /// Gets or sets the first value of FormatPair. + public int A + { + get; + set => this.Set(ref field, value); + } + + /// Gets or sets the second value of FormatPair. + public int B + { + get; + set => this.Set(ref field, value); + } + + /// Gets or sets the people count of PeopleInvited. + public int Count + { + get; + set => this.Set(ref field, value); + } + + /// Gets or sets the drive number of PartitionDisplay. + public int Drive + { + get; + set => this.Set(ref field, value); + } + + /// Gets or sets the bound key of the bound-key tests: a key, or any other value. + public object? Key + { + get; + set => this.Set(ref field, value); + } + + /// Gets or sets the volume label of PartitionDisplay. + public string? Label + { + get; + set => this.Set(ref field, value); + } + + /// Gets or sets the drive letter of PartitionDisplay. + public string? Letter + { + get; + set => this.Set(ref field, value); + } + + /// Gets or sets the partition number of PartitionDisplay. + public int Partition + { + get; + set => this.Set(ref field, value); + } + + /// Gets or sets the enumeration value of the enum tests. + public FileSortOrder? SortOrder + { + get; + set => this.Set(ref field, value); + } + + /// Assigns a property's backing field and raises . + /// The property type. + /// The backing field. + /// The new value. + /// The property name, supplied by the compiler. + private void Set(ref T field, T value, [CallerMemberName] string propertyName = "") + { + field = value; + this.PropertyChanged?.Invoke(this, new(propertyName)); + } +} diff --git a/tests/ResXLocalization.Core.Tests/CapturingFormatter.cs b/tests/ResXLocalization.Core.Tests/CapturingFormatter.cs new file mode 100644 index 0000000..dd6f212 --- /dev/null +++ b/tests/ResXLocalization.Core.Tests/CapturingFormatter.cs @@ -0,0 +1,21 @@ +namespace RentADeveloper.ResXLocalization.Core.Tests; + +/// +/// An that records every call in order and otherwise behaves like the +/// default formatter: it returns the pattern unchanged without arguments and formats it with +/// otherwise, so installing it changes no +/// output. Compiled into the UI test suites too. +/// +internal sealed class CapturingFormatter : IMessageFormatter +{ + /// Gets the recorded calls, in order. + public List<(string Pattern, MessageArguments Arguments, CultureInfo Culture)> Calls { get; } = []; + + /// + public string Format(string pattern, MessageArguments arguments, CultureInfo culture) + { + this.Calls.Add((pattern, arguments, culture)); + + return StringFormatMessageFormatter.Instance.Format(pattern, arguments, culture); + } +} diff --git a/tests/ResXLocalization.Core.Tests/IsolatedInstanceTests.cs b/tests/ResXLocalization.Core.Tests/IsolatedInstanceTests.cs index aa101b2..abb2fa4 100644 --- a/tests/ResXLocalization.Core.Tests/IsolatedInstanceTests.cs +++ b/tests/ResXLocalization.Core.Tests/IsolatedInstanceTests.cs @@ -26,15 +26,14 @@ public void CultureChanges_DoNotLeakIntoOtherInstances() { var first = this.resources.CreateLocalizer(); var second = this.resources.CreateLocalizer(); - var secondNotified = false; - second.CultureChanged += (_, _) => secondNotified = true; + using var secondMonitor = second.Monitor(); first.CurrentCulture = TestResources.German; first.Get("Greeting").Should().Be("Hallo und willkommen!"); second.Get("Greeting").Should().Be("Hello and welcome!"); second.CurrentCulture.Should().Be(TestResources.English); - secondNotified.Should().BeFalse(); + secondMonitor.Should().NotRaise(nameof(ILocalizer.CultureChanged)); } [Fact] diff --git a/tests/ResXLocalization.Core.Tests/LocalizerContractTests.cs b/tests/ResXLocalization.Core.Tests/LocalizerContractTests.cs index 340905b..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; /// @@ -29,16 +32,14 @@ public void AssigningNullCulture_Throws() public void AssigningTheSameCulture_RaisesNoNotifications() { var localizer = this.resources.CreateLocalizer(); - var eventCount = 0; - localizer.PropertyChanged += (_, _) => eventCount++; - localizer.CultureChanged += (_, _) => eventCount++; + using var monitor = localizer.Monitor(); // Both the very same instance and a distinct-but-equal culture must be treated as "no change". var sameInstance = localizer.CurrentCulture; localizer.CurrentCulture = sameInstance; localizer.CurrentCulture = new("en"); - eventCount.Should().Be(0); + monitor.EventLog().Should().BeEmpty(); localizer.CurrentCulture.Should().BeSameAs(sameInstance); } @@ -46,27 +47,35 @@ public void AssigningTheSameCulture_RaisesNoNotifications() public void CultureChange_RaisesCurrentCulture_ThenIndexer_ThenCultureChanged() { var localizer = this.resources.CreateLocalizer(); - var events = new List(); - localizer.PropertyChanged += (_, args) => events.Add($"PropertyChanged({args.PropertyName})"); - localizer.CultureChanged += (_, _) => events.Add("CultureChanged"); + using var monitor = localizer.Monitor(); localizer.CurrentCulture = TestResources.German; - events.Should().Equal("PropertyChanged(CurrentCulture)", "PropertyChanged(Item[])", "CultureChanged"); + monitor + .EventLog() + .Should() + .Equal( + "PropertyChanged(CurrentCulture)", + "PropertyChanged(RefreshTriggeringRevision)", + "PropertyChanged(Item[])", + "CultureChanged" + ); } [Fact] public void CultureChanged_CarriesOldAndNewCulture() { var localizer = this.resources.CreateLocalizer(); - CultureChangedEventArgs? captured = null; - localizer.CultureChanged += (_, args) => captured = args; + using var monitor = localizer.Monitor(); localizer.CurrentCulture = TestResources.German; - captured.Should().NotBeNull(); - captured.OldCulture.Should().Be(TestResources.English); - captured.NewCulture.Should().Be(TestResources.German); + monitor + .Should() + .Raise(nameof(ILocalizer.CultureChanged)) + .WithArgs(args => + args.OldCulture.Equals(TestResources.English) && args.NewCulture.Equals(TestResources.German) + ); } [Fact] @@ -80,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 65515ba..6bf077b 100644 --- a/tests/ResXLocalization.Core.Tests/LocalizerFormatTests.cs +++ b/tests/ResXLocalization.Core.Tests/LocalizerFormatTests.cs @@ -1,51 +1,320 @@ namespace RentADeveloper.ResXLocalization.Core.Tests; /// -/// Exercises the format-argument overloads: the resolved resource value is treated as a composite -/// format string and formatted with the localizer's current culture, so numbers and dates inside a -/// translation follow the selected language. +/// Exercises the format-argument overloads and the formatting pipeline: every resolved value passes +/// through , which by default formats it as a composite format +/// string in the localizer's current culture when the lookup has arguments, and a formatting failure +/// returns the failure text and raises . /// public class LocalizerFormatTests { private readonly TestResources resources = new(); + [Fact] + public void EmptyArgumentsArray_ReturnsTheValueUnformatted() + { + var localizer = this.resources.CreateLocalizer(); + using var monitor = localizer.Monitor(); + + localizer.Get("EscapedBraces", MessageArguments.FromValues()).Should().Be("Literal {{braces}} for {0}"); + monitor.Should().NotRaise(nameof(ILocalizer.LookupFailed)); + + localizer.Get("EscapedBraces", MessageArguments.FromValues("x")).Should().Be("Literal {braces} for x"); + } + [Fact] 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] + public void FormatterException_OfAnyType_ReturnsTheFailureText_WithThatException() + { + var localizer = this.resources.CreateLocalizer(); + var exception = new InvalidOperationException("boom"); + localizer.MessageFormatter = new ThrowingFormatter(exception); + using var monitor = localizer.Monitor(); + + localizer.Get("Greeting", MessageArguments.FromValues(1)).Should().Be("!Greeting!"); + + monitor.LookupFailures().Should().ContainSingle().Which.Exception.Should().BeSameAs(exception); } [Fact] - public void InvalidCompositeFormat_FailsLoudly() + public void FormatterException_WithoutArguments_ReturnsTheFailureText_AndRaisesLookupFailed() { var localizer = this.resources.CreateLocalizer(); + var exception = new InvalidOperationException("boom"); + localizer.MessageFormatter = new ThrowingFormatter(exception); + using var monitor = localizer.Monitor(); - // The template needs two arguments; supplying none is a resource defect, not a missing - // translation, so it must throw rather than degrade silently. - var act = () => localizer.Get("ItemsFound", arguments: []); + localizer.Get("Greeting").Should().Be("!Greeting!"); + localizer.Get("Greeting", resourceManager: this.resources.Catalog).Should().Be("!Greeting!"); - act.Should().Throw(); + var raised = monitor.LookupFailures(); + raised.Should().HaveCount(2); + raised + .Should() + .AllSatisfy(failure => + { + failure.Reason.Should().Be(LookupFailureReason.MessageFormatFailed); + failure.Arguments.Should().BeEmpty(); + failure.Exception.Should().BeSameAs(exception); + }); + raised[0].ResourceManager.Should().BeNull(); + raised[1].ResourceManager.Should().BeSameAs(this.resources.Catalog); } [Fact] - public void MissingKey_ReturnsTheSentinelUnformatted() + public void Formatter_ReceivesThePatternTheArgumentsAndTheCulture() { var localizer = this.resources.CreateLocalizer(); + var formatter = new CapturingFormatter(); + localizer.MessageFormatter = formatter; + + localizer.Get("ItemsFound", MessageArguments.FromValues(1, "x")); - localizer.Get("ThisKeyDoesNotExist", 1, 2).Should().Be("!ThisKeyDoesNotExist!"); - localizer.Get("ThisKeyDoesNotExist", this.resources.Catalog, 1, 2).Should().Be("!ThisKeyDoesNotExist!"); + var call = formatter.Calls.Should().ContainSingle().Subject; + call.Pattern.Should().Be("Found {0:N1} items in {1}."); + call.Arguments.Should().Equal(new MessageArgument(0, null, 1), new MessageArgument(1, null, "x")); + call.Culture.Should().Be(TestResources.English); } [Fact] - public void NullArgumentsArray_Throws() + public void FormattingFailure_InTheCurrentCulture_ReportsThatCultureAndPattern() { var localizer = this.resources.CreateLocalizer(); + localizer.CurrentCulture = TestResources.German; + using var monitor = localizer.Monitor(); - var act = () => localizer.Get("Greeting", (object?[])null!); + localizer.Get("Malformed", MessageArguments.FromValues(1)).Should().Be("!Malformed!"); + + var failure = monitor.LookupFailures().Should().ContainSingle().Subject; + failure.Culture.Should().Be(TestResources.German); + failure.Pattern.Should().Be("Kaputt {0"); + } + + [Fact] + public void FormattingFailure_ReturnsTheFailureText_AndRaisesLookupFailed() + { + var localizer = this.resources.CreateLocalizer(); + using var monitor = localizer.Monitor(); + + // The template needs two arguments; one is a resource defect. It is reported, not thrown. + localizer.Get("ItemsFound", MessageArguments.FromValues("only-one")).Should().Be("!ItemsFound!"); + + var failure = monitor.LookupFailures().Should().ContainSingle().Subject; + failure.Reason.Should().Be(LookupFailureReason.MessageFormatFailed); + failure.Key.Should().Be("ItemsFound"); + failure.Culture.Should().Be(TestResources.English); + failure.ResourceManager.Should().BeNull(); + failure.Pattern.Should().Be("Found {0:N1} items in {1}."); + failure.Arguments.Should().Equal(new MessageArgument(0, null, "only-one")); + failure.Exception.Should().BeOfType(); + } + + [Fact] + public void FormattingFailure_ScopedAndTyped_ReportsTheManager() + { + var localizer = this.resources.CreateLocalizer(); + using var monitor = localizer.Monitor(); + + 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() + .Should() + .HaveCount(2) + .And.AllSatisfy(failure => failure.ResourceManager.Should().BeSameAs(this.resources.Catalog)); + } + + [Fact] + public void MessageFormatter_DefaultsToAFormatter_AndRejectsNull() + { + var localizer = new Localizer(); + var formatter = localizer.MessageFormatter; + + formatter.Should().NotBeNull(); + + var act = () => localizer.MessageFormatter = null!; act.Should().Throw(); + localizer.MessageFormatter.Should().BeSameAs(formatter); + } + + [Fact] + public void MissingKey_ReportsTranslationNotFound_NotMessageFormatFailed() + { + var localizer = this.resources.CreateLocalizer(); + using var monitor = localizer.Monitor(); + + 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() + .Should() + .HaveCount(2) + .And.AllSatisfy(failure => failure.Reason.Should().Be(LookupFailureReason.TranslationNotFound)); + } + + [Fact] + public void MissingKey_ReturnsTheFailureTextUnformatted() + { + var localizer = this.resources.CreateLocalizer(); + + localizer.Get("ThisKeyDoesNotExist", MessageArguments.FromValues(1, 2)).Should().Be("!ThisKeyDoesNotExist!"); + localizer + .Get("ThisKeyDoesNotExist", MessageArguments.FromValues(1, 2), this.resources.Catalog) + .Should() + .Be("!ThisKeyDoesNotExist!"); + } + + [Fact] + public void MissingKey_TheFailureText_NeverReachesTheFormatter() + { + var localizer = this.resources.CreateLocalizer(); + localizer.LookupFailureTextBuilder = failure => $"{{{failure.Key}}}"; + var formatter = new CapturingFormatter(); + localizer.MessageFormatter = formatter; + using var monitor = localizer.Monitor(); + + localizer.Get("ThisKeyDoesNotExist", MessageArguments.FromValues(1)).Should().Be("{ThisKeyDoesNotExist}"); + + formatter.Calls.Should().BeEmpty(); + monitor + .LookupFailures() + .Should() + .ContainSingle() + .Which.Reason.Should() + .Be(LookupFailureReason.TranslationNotFound); + } + + [Fact] + public void NoArgumentMembers_PassTheValueThroughTheFormatter_WithAnEmptyArray() + { + var localizer = this.resources.CreateLocalizer(); + var formatter = new CapturingFormatter(); + localizer.MessageFormatter = formatter; + var catalog = this.resources.Catalog; + + string[] results = + [ + localizer.Get("Greeting"), + localizer["Greeting"], + localizer.Get("Greeting", resourceManager: catalog), + localizer.Get(new ResourceKey("Greeting", catalog)), + localizer.Get(TestSortOrder.Ascending), + localizer.Get(TestSortOrder.Ascending, resourceManager: catalog), + ]; + localizer.Get("ThisKeyDoesNotExist"); + localizer.Get(string.Empty); + + formatter + .Calls.Select(call => call.Pattern) + .Should() + .Equal( + "Hello and welcome!", + "Hello and welcome!", + "Hello and welcome!", + "Hello and welcome!", + "Ascending", + "Ascending" + ); + formatter + .Calls.Should() + .AllSatisfy(call => + { + call.Arguments.Should().BeEmpty(); + call.Culture.Should().Be(TestResources.English); + }); + results.Should().Equal(formatter.Calls.Select(call => call.Pattern)); + } + + [Fact] + public void NoArguments_InEveryForm_PassEmptyArgumentsToTheFormatter() + { + var localizer = this.resources.CreateLocalizer(); + var formatter = new CapturingFormatter(); + localizer.MessageFormatter = formatter; + + 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"], + ]; + + results.Should().AllBe("Hello and welcome!"); + formatter.Calls.Should().HaveCount(6).And.AllSatisfy(call => call.Arguments.Should().NotBeNull().And.BeEmpty()); + } + + [Fact] + public void NullManager_MeansSearchAll_ButADefaultResourceKeyThrows() + { + 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 typed = () => localizer.Get(default(ResourceKey)); + var typedWithArguments = () => localizer.Get(default(ResourceKey), MessageArguments.FromValues(1)); + + typed.Should().Throw(); + typedWithArguments.Should().Throw(); + } + + [Fact] + 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", 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 Positions_NotTheEnumerationIndex_DecideWhichPlaceholderAValueFills() + { + var localizer = this.resources.CreateLocalizer(); + + localizer + .Get("FilePosition", MessageArguments.FromArguments(new(1, null, 10), new(0, null, 3))) + .Should() + .Be("File 3 of 10"); + } + + [Fact] + public void Primitive_TreatsNullArgumentsAsNone_AndReturnsEmptyForAnEmptyKey() + { + var localizer = this.resources.CreateLocalizer(); + + 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] @@ -53,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] @@ -62,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] @@ -75,6 +353,15 @@ 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. + /// The exception to throw. + private sealed class ThrowingFormatter(Exception exception) : IMessageFormatter + { + private readonly Exception exception = 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..bf0fc28 100644 --- a/tests/ResXLocalization.Core.Tests/LocalizerLookupTests.cs +++ b/tests/ResXLocalization.Core.Tests/LocalizerLookupTests.cs @@ -18,12 +18,15 @@ public void Indexer_IsShorthandForSearchAllGet() } [Fact] - public void MissingKey_ReturnsTheBangSentinel() + public void MissingKey_ReturnsTheFailureText() { 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] @@ -96,7 +97,7 @@ public void SearchAll_SwitchesLive_OnCultureChange() } [Fact] - public void SearchAll_WithNothingRegistered_ReturnsTheSentinel() + public void SearchAll_WithNothingRegistered_ReturnsTheFailureText() { var localizer = new Localizer { CurrentCulture = TestResources.English }; diff --git a/tests/ResXLocalization.Core.Tests/LocalizerMissTests.cs b/tests/ResXLocalization.Core.Tests/LocalizerMissTests.cs index 82b0b9e..9fda742 100644 --- a/tests/ResXLocalization.Core.Tests/LocalizerMissTests.cs +++ b/tests/ResXLocalization.Core.Tests/LocalizerMissTests.cs @@ -1,107 +1,243 @@ namespace RentADeveloper.ResXLocalization.Core.Tests; /// -/// Exercises the miss diagnostics: the event and the -/// configurable sentinel. +/// Exercises the lookup-failure diagnostics: the event and +/// the failure text produced by the configurable , for a +/// missing translation and for a formatting failure. /// public class LocalizerMissTests { private readonly TestResources resources = new(); [Fact] - public void EnumMiss_RaisesTranslationNotFound_WithTheConventionKey() + public void EnumMiss_RaisesLookupFailed_WithTheConventionKey() { var localizer = this.resources.CreateLocalizer(); - var raised = new List(); - localizer.TranslationNotFound += (_, args) => raised.Add(args); + using var monitor = localizer.Monitor(); localizer.Get(TestSortOrder.Descending); - raised.Should().ContainSingle().Which.Key.Should().Be("Enum_TestSortOrder_Descending"); + monitor.LookupFailures().Should().ContainSingle().Which.Key.Should().Be("Enum_TestSortOrder_Descending"); } [Fact] - public void MissingSatelliteEntry_FallsBackToNeutral_WithoutMissEvent() + public void LookupFailureTextBuilder_AcceptsAFunctionWithoutTheKey() + { + var localizer = this.resources.CreateLocalizer(); + localizer.LookupFailureTextBuilder = _ => "missing"; + + localizer.Get("ThisKeyDoesNotExist").Should().Be("missing"); + } + + [Fact] + public void LookupFailureTextBuilder_CustomizesTheFailureText() + { + var localizer = this.resources.CreateLocalizer(); + localizer.LookupFailureTextBuilder = failure => $"[missing: {failure.Key}]"; + + localizer.Get("ThisKeyDoesNotExist").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(); + } + + [Fact] + public void LookupFailureTextBuilder_DefaultsToBangKeyBang() + { + var first = new Localizer().LookupFailureTextBuilder; + + first.Should().BeSameAs(new Localizer().LookupFailureTextBuilder); + first( + new( + LookupFailureReason.TranslationNotFound, + "Key", + CultureInfo.InvariantCulture, + null, + MessageArguments.Empty, + null, + null + ) + ) + .Should() + .Be("!Key!"); + } + + [Fact] + public void LookupFailureTextBuilder_FormattingFailure_ReceivesTheFailure_AfterLookupFailed() { var localizer = this.resources.CreateLocalizer(); localizer.CurrentCulture = TestResources.German; - var raised = 0; - localizer.TranslationNotFound += (_, _) => raised++; + using var monitor = localizer.Monitor(); + var calls = new List(); + var eventRaisedBeforeCall = false; + localizer.LookupFailureTextBuilder = failure => + { + calls.Add(failure); + eventRaisedBeforeCall = monitor.LookupFailures().Length == 1; + return "[broken]"; + }; + + localizer.Get("Malformed", MessageArguments.FromValues(1), this.resources.Catalog).Should().Be("[broken]"); - localizer.Get("NeutralOnly", this.resources.Catalog).Should().Be("Neutral fallback value"); - raised.Should().Be(0); + eventRaisedBeforeCall.Should().BeTrue(); + var call = calls.Should().ContainSingle().Subject; + call.Should().Be(monitor.LookupFailures().Single()); + call.Reason.Should().Be(LookupFailureReason.MessageFormatFailed); + call.Key.Should().Be("Malformed"); + call.Culture.Should().Be(TestResources.German); + call.ResourceManager.Should().BeSameAs(this.resources.Catalog); + call.Arguments.Should().Equal(new MessageArgument(0, null, 1)); + call.Pattern.Should().Be("Kaputt {0"); + call.Exception.Should().BeOfType(); } [Fact] - public void MissingTranslationFormat_AcceptsAFormatWithoutAPlaceholder() + public void LookupFailureTextBuilder_FormattingFailure_UsesTheDefault() { var localizer = this.resources.CreateLocalizer(); - localizer.MissingTranslationFormat = "missing"; - localizer.Get("ThisKeyDoesNotExist").Should().Be("missing"); + localizer.Get("Malformed", MessageArguments.FromValues(1)).Should().Be("!Malformed!"); } [Fact] - public void MissingTranslationFormat_CustomizesTheSentinel() + public void LookupFailureTextBuilder_IsNotCalled_ForAResolvedValue() { var localizer = this.resources.CreateLocalizer(); - localizer.MissingTranslationFormat = "[missing: {0}]"; + var calls = 0; + localizer.LookupFailureTextBuilder = failure => + { + calls++; + return failure.Key; + }; - localizer.Get("ThisKeyDoesNotExist").Should().Be("[missing: ThisKeyDoesNotExist]"); - localizer.Get("ThisKeyDoesNotExist", this.resources.Catalog).Should().Be("[missing: ThisKeyDoesNotExist]"); + localizer.Get("Greeting").Should().Be("Hello and welcome!"); + calls.Should().Be(0); } [Fact] - public void MissingTranslationFormat_DefaultsToBangKeyBang() => - new Localizer().MissingTranslationFormat.Should().Be("!{0}!"); + public void LookupFailureTextBuilder_NullResult_Throws() + { + var localizer = this.resources.CreateLocalizer(); + localizer.LookupFailureTextBuilder = _ => null!; + + var miss = () => localizer.Get("ThisKeyDoesNotExist"); + var formatFailure = () => localizer.Get("Malformed", MessageArguments.FromValues(1)); + + miss.Should() + .Throw() + .Which.Message.Should() + .ContainAll("LookupFailureTextBuilder", "TranslationNotFound", "ThisKeyDoesNotExist"); + formatFailure + .Should() + .Throw() + .Which.Message.Should() + .ContainAll("LookupFailureTextBuilder", "MessageFormatFailed", "Malformed"); + } [Fact] - public void MissingTranslationFormat_RejectsAnInvalidFormat_Eagerly() + public void LookupFailureTextBuilder_ReceivesTheDetailsOfTheMiss() { var localizer = this.resources.CreateLocalizer(); + localizer.CurrentCulture = TestResources.German; + using var monitor = localizer.Monitor(); + var calls = new List(); + localizer.LookupFailureTextBuilder = failure => + { + calls.Add(failure); + return string.Empty; + }; + var catalog = this.resources.Catalog; - // "{1}" references a second argument that the sentinel never supplies: reject it at assignment, - // not later at the first miss. - var act = () => localizer.MissingTranslationFormat = "{1}"; + localizer.Get("ThisKeyDoesNotExist"); + localizer.Get("ThisKeyDoesNotExist", resourceManager: catalog); + localizer.Get(new ResourceKey("ThisKeyDoesNotExist", catalog)); + localizer.Get(TestSortOrder.Descending, resourceManager: catalog); - act.Should().Throw().WithInnerException(); - localizer.MissingTranslationFormat.Should().Be("!{0}!"); + calls + .Select(call => (call.Key, call.Culture, call.ResourceManager)) + .Should() + .Equal( + ("ThisKeyDoesNotExist", TestResources.German, (ResourceManager?)null), + ("ThisKeyDoesNotExist", TestResources.German, catalog), + ("ThisKeyDoesNotExist", TestResources.German, catalog), + ("Enum_TestSortOrder_Descending", TestResources.German, catalog) + ); + calls + .Should() + .AllSatisfy(call => + { + call.Reason.Should().Be(LookupFailureReason.TranslationNotFound); + call.Arguments.Should().BeEmpty(); + call.Pattern.Should().BeNull(); + call.Exception.Should().BeNull(); + }); + calls.Should().Equal(monitor.LookupFailures()); } [Fact] - public void MissingTranslationFormat_RejectsNull() + public void LookupFailureTextBuilder_RejectsNull() { var localizer = this.resources.CreateLocalizer(); + var builder = localizer.LookupFailureTextBuilder; - var act = () => localizer.MissingTranslationFormat = null!; + var act = () => localizer.LookupFailureTextBuilder = null!; act.Should().Throw(); - localizer.MissingTranslationFormat.Should().Be("!{0}!"); + localizer.LookupFailureTextBuilder.Should().BeSameAs(builder); + } + + [Fact] + public void LookupFailureTextBuilder_ThrowingFunction_Propagates_AfterTheEvent() + { + var localizer = this.resources.CreateLocalizer(); + var exception = new InvalidOperationException("boom"); + localizer.LookupFailureTextBuilder = _ => throw exception; + using var monitor = localizer.Monitor(); + + var miss = () => localizer.Get("ThisKeyDoesNotExist"); + var formatFailure = () => localizer.Get("Malformed", MessageArguments.FromValues(1)); + + miss.Should().Throw().Which.Should().BeSameAs(exception); + monitor.LookupFailures().Should().ContainSingle(); + formatFailure.Should().Throw().Which.Should().BeSameAs(exception); + monitor.LookupFailures().Should().HaveCount(2); + } + + [Fact] + public void MissingSatelliteEntry_FallsBackToNeutral_WithoutMissEvent() + { + var localizer = this.resources.CreateLocalizer(); + localizer.CurrentCulture = TestResources.German; + using var monitor = localizer.Monitor(); + + localizer.Get("NeutralOnly", resourceManager: this.resources.Catalog).Should().Be("Neutral fallback value"); + monitor.Should().NotRaise(nameof(ILocalizer.LookupFailed)); } [Fact] - public void ScopedMiss_RaisesTranslationNotFound_WithTheManager() + public void ScopedMiss_RaisesLookupFailed_WithTheManager() { var localizer = this.resources.CreateLocalizer(); - var raised = new List(); - localizer.TranslationNotFound += (_, args) => raised.Add(args); + using var monitor = localizer.Monitor(); - localizer.Get("ThisKeyDoesNotExist", this.resources.Catalog); + localizer.Get("ThisKeyDoesNotExist", resourceManager: this.resources.Catalog); - var miss = raised.Should().ContainSingle().Subject; + var miss = monitor.LookupFailures().Should().ContainSingle().Subject; miss.ResourceManager.Should().BeSameAs(this.resources.Catalog); } [Fact] - public void SearchAllMiss_RaisesTranslationNotFound_WithoutAManager() + public void SearchAllMiss_RaisesLookupFailed_WithoutAManager() { var localizer = this.resources.CreateLocalizer(); - var raised = new List(); - localizer.TranslationNotFound += (_, args) => raised.Add(args); + using var monitor = localizer.Monitor(); localizer.Get("ThisKeyDoesNotExist"); - var miss = raised.Should().ContainSingle().Subject; + var miss = monitor.LookupFailures().Should().ContainSingle().Subject; miss.Key.Should().Be("ThisKeyDoesNotExist"); miss.Culture.Should().Be(TestResources.English); miss.ResourceManager.Should().BeNull(); @@ -112,25 +248,23 @@ public void SpecificCulture_FallsBackToParentCulture_WithoutMissEvent() { var localizer = this.resources.CreateLocalizer(); localizer.CurrentCulture = new("de-DE"); - var raised = 0; - localizer.TranslationNotFound += (_, _) => raised++; + using var monitor = localizer.Monitor(); - localizer.Get("Greeting", this.resources.Catalog).Should().Be("Hallo und willkommen!"); - raised.Should().Be(0); + localizer.Get("Greeting", resourceManager: this.resources.Catalog).Should().Be("Hallo und willkommen!"); + monitor.Should().NotRaise(nameof(ILocalizer.LookupFailed)); } [Fact] - public void SuccessfulAndEmptyLookups_DoNotRaiseTranslationNotFound() + public void SuccessfulAndEmptyLookups_DoNotRaiseLookupFailed() { var localizer = this.resources.CreateLocalizer(); - var raised = 0; - localizer.TranslationNotFound += (_, _) => raised++; + 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"]; - raised.Should().Be(0); + monitor.Should().NotRaise(nameof(ILocalizer.LookupFailed)); } } diff --git a/tests/ResXLocalization.Core.Tests/LocalizerMonitorExtensions.cs b/tests/ResXLocalization.Core.Tests/LocalizerMonitorExtensions.cs new file mode 100644 index 0000000..b777d8f --- /dev/null +++ b/tests/ResXLocalization.Core.Tests/LocalizerMonitorExtensions.cs @@ -0,0 +1,42 @@ +using System.ComponentModel; +using AwesomeAssertions.Events; + +namespace RentADeveloper.ResXLocalization.Core.Tests; + +/// +/// Reads the failed lookups an AwesomeAssertions event monitor recorded on a localizer. Compiled into the +/// UI test suites too. +/// +internal static class LocalizerMonitorExtensions +{ + /// + /// Gets every event recorded so far, in the order it was raised: PropertyChanged(Name) for a + /// property change, otherwise the event name. + /// + /// The monitored localizer type. + /// The monitor of the localizer under test. + /// The event log. + public static string[] EventLog(this IMonitor monitor) + where T : ILocalizer => + [ + .. monitor + .OccurredEvents.OrderBy(occurred => occurred.Sequence) + .Select(occurred => + occurred.Parameters[1] is PropertyChangedEventArgs args + ? $"PropertyChanged({args.PropertyName})" + : occurred.EventName + ), + ]; + + /// Gets the failure of every recorded so far. + /// The monitored localizer type. + /// The monitor of the localizer under test. + /// The failed lookups, in order. + public static LookupFailure[] LookupFailures(this IMonitor monitor) + where T : ILocalizer => + [ + .. monitor + .OccurredEvents.Where(occurred => occurred.EventName == nameof(ILocalizer.LookupFailed)) + .Select(occurred => ((LookupFailedEventArgs)occurred.Parameters[1]).Failure), + ]; +} diff --git a/tests/ResXLocalization.Core.Tests/LocalizerRegistrationTests.cs b/tests/ResXLocalization.Core.Tests/LocalizerRegistrationTests.cs index 034fdf1..cfc3c5a 100644 --- a/tests/ResXLocalization.Core.Tests/LocalizerRegistrationTests.cs +++ b/tests/ResXLocalization.Core.Tests/LocalizerRegistrationTests.cs @@ -1,8 +1,11 @@ +using System.ComponentModel; +using System.Reflection; + namespace RentADeveloper.ResXLocalization.Core.Tests; /// -/// Exercises search-set maintenance: unregistering a single resource manager and clearing the set, -/// for example when a plugin that owns its strings is unloaded. +/// Exercises search-set maintenance: the registration probe, unregistering a single resource manager and +/// clearing the set, for example when a plugin that owns its strings is unloaded. /// public class LocalizerRegistrationTests { @@ -18,10 +21,41 @@ 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!"); } + [Fact] + public void Register_AcceptsASubclass_WithoutProbingIt() + { + var localizer = new Localizer { CurrentCulture = TestResources.English }; + + // The base class's probe would throw ArgumentNullException for this manager: it has no base name. + localizer.RegisterResourceManager(new GreetingOnlyResourceManager()); + + localizer.Get("Greeting").Should().Be("Hi"); + } + + [Fact] + public void Register_RejectsAPlainManager_WhoseResourcesDoNotExist() + { + const string baseName = "RentADeveloper.ResXLocalization.Core.Tests.Resources.DoesNotExist"; + var localizer = new Localizer { CurrentCulture = TestResources.English }; + var revision = localizer.RefreshTriggeringRevision; + + var act = () => + localizer.RegisterResourceManager(new ResourceManager(baseName, typeof(TestResources).Assembly)); + + act.Should() + .Throw() + .Where(exception => exception.ParamName == "resourceManager" && exception.Message.Contains(baseName)) + .WithInnerException(); + + // Nothing was registered, so the search-all lookup misses instead of throwing. + localizer.Get("Greeting").Should().Be("!Greeting!"); + localizer.RefreshTriggeringRevision.Should().Be(revision); + } + [Fact] public void RegisteringTwice_KeepsASingleEntry_SoOneUnregisterRemovesIt() { @@ -36,6 +70,27 @@ public void RegisteringTwice_KeepsASingleEntry_SoOneUnregisterRemovesIt() localizer.Get("CatalogOnly").Should().Be("!CatalogOnly!"); } + [Fact] + public void RegistrationMembers_AreNotHiddenFromIntelliSense() + { + string[] names = + [ + nameof(ILocalizer.ClearResourceManagers), + nameof(ILocalizer.RegisterResourceManager), + nameof(ILocalizer.UnregisterResourceManager), + ]; + + foreach (var type in new[] { typeof(ILocalizer), typeof(Localizer) }) + { + var methods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(method => names.Contains(method.Name)) + .ToArray(); + + methods.Should().HaveCount(names.Length); + methods.Should().NotContain(method => method.IsDefined(typeof(EditorBrowsableAttribute))); + } + } + [Fact] public void Unregister_RejectsNull() { @@ -66,4 +121,13 @@ public void Unregister_ReturnsFalse_ForAManagerThatWasNeverRegistered() localizer.UnregisterResourceManager(this.resources.Catalog).Should().BeFalse(); } + + /// + /// A resource manager that resolves strings without manifest resources: it overrides only + /// . + /// + private sealed class GreetingOnlyResourceManager : ResourceManager + { + public override string? GetString(string name, CultureInfo? culture) => name == "Greeting" ? "Hi" : null; + } } 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 new file mode 100644 index 0000000..856b854 --- /dev/null +++ b/tests/ResXLocalization.Core.Tests/RefreshTriggeringRevisionTests.cs @@ -0,0 +1,132 @@ +namespace RentADeveloper.ResXLocalization.Core.Tests; + +/// +/// Exercises : it advances, with one +/// PropertyChanged for itself and one for Item[], after every real change that can change a +/// lookup's result, and after nothing else. +/// +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() + { + var localizer = this.resources.CreateLocalizer(); + var culture = localizer.CurrentCulture; + var formatter = localizer.MessageFormatter; + var builder = localizer.LookupFailureTextBuilder; + + AssertDoesNotAdvance(localizer, () => localizer.CurrentCulture = culture); + AssertDoesNotAdvance(localizer, () => localizer.MessageFormatter = formatter); + AssertDoesNotAdvance(localizer, () => localizer.LookupFailureTextBuilder = builder); + AssertDoesNotAdvance(localizer, () => localizer.RegisterResourceManager(this.resources.Catalog)); + AssertDoesNotAdvance(localizer, () => localizer.UnregisterResourceManager(CreateUnregisteredManager())); + + localizer.ClearResourceManagers(); + AssertDoesNotAdvance(localizer, localizer.ClearResourceManagers); + + AssertDoesNotAdvance( + localizer, + () => + FluentActions.Invoking(() => localizer.MessageFormatter = null!).Should().Throw() + ); + AssertDoesNotAdvance( + localizer, + () => + FluentActions + .Invoking(() => localizer.LookupFailureTextBuilder = null!) + .Should() + .Throw() + ); + } + + [Fact] + public void EveryRealChange_Advances() + { + var localizer = this.resources.CreateLocalizer(); + + AssertAdvances( + localizer, + () => localizer.CurrentCulture = TestResources.German, + "PropertyChanged(CurrentCulture)", + "PropertyChanged(RefreshTriggeringRevision)", + "PropertyChanged(Item[])", + "CultureChanged" + ); + AssertAdvances(localizer, () => localizer.MessageFormatter = new CapturingFormatter()); + AssertAdvances(localizer, () => localizer.LookupFailureTextBuilder = failure => $"[{failure.Key}]"); + AssertAdvances(localizer, () => localizer.RegisterResourceManager(CreateUnregisteredManager())); + AssertAdvances(localizer, () => localizer.UnregisterResourceManager(this.resources.Catalog)); + AssertAdvances(localizer, localizer.ClearResourceManagers); + } + + [Fact] + public void StartsAtZero_AndWrapsAround() + { + var localizer = new Localizer(); + + localizer.RefreshTriggeringRevision.Should().Be(0); + + // Start from English, so the switch to German below is a real change whatever the host culture. + localizer.CurrentCulture = TestResources.English; + typeof(Localizer).GetProperty(nameof(Localizer.RefreshTriggeringRevision))!.SetValue(localizer, int.MaxValue); + localizer.CurrentCulture = TestResources.German; + + localizer.RefreshTriggeringRevision.Should().Be(int.MinValue); + } + + /// + /// Asserts that changes the revision and raises exactly + /// (by default one revision and one indexer notification, in that order). + /// + /// The localizer under test. + /// The change to apply. + /// The expected event log; empty for the default. + private static void AssertAdvances(Localizer localizer, Action action, params string[] expectedLog) + { + var before = localizer.RefreshTriggeringRevision; + using var monitor = localizer.Monitor(); + + action(); + + localizer.RefreshTriggeringRevision.Should().NotBe(before); + monitor + .EventLog() + .Should() + .Equal( + expectedLog.Length == 0 + ? ["PropertyChanged(RefreshTriggeringRevision)", "PropertyChanged(Item[])"] + : expectedLog + ); + } + + /// Asserts that leaves the revision unchanged and raises nothing. + /// The localizer under test. + /// The no-op to apply. + private static void AssertDoesNotAdvance(Localizer localizer, Action action) + { + var before = localizer.RefreshTriggeringRevision; + using var monitor = localizer.Monitor(); + + action(); + + localizer.RefreshTriggeringRevision.Should().Be(before); + monitor.EventLog().Should().BeEmpty(); + } + + /// Creates a manager over the Catalog resources that is a different, unregistered instance. + /// The new manager. + private static ResourceManager CreateUnregisteredManager() => + new("RentADeveloper.ResXLocalization.Core.Tests.Resources.Catalog", typeof(TestResources).Assembly); +} diff --git a/tests/ResXLocalization.Core.Tests/Resources/Catalog.de.resx b/tests/ResXLocalization.Core.Tests/Resources/Catalog.de.resx index 725c378..6880770 100644 --- a/tests/ResXLocalization.Core.Tests/Resources/Catalog.de.resx +++ b/tests/ResXLocalization.Core.Tests/Resources/Catalog.de.resx @@ -31,4 +31,13 @@ {0:N1} Elemente in {1} gefunden. + + Kaputt {0 + + + 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 4530513..f2b55f4 100644 --- a/tests/ResXLocalization.Core.Tests/Resources/Catalog.resx +++ b/tests/ResXLocalization.Core.Tests/Resources/Catalog.resx @@ -3,7 +3,8 @@ text/microsoft-resx @@ -38,4 +39,19 @@ Neutral fallback value + + Broken {0 + + + Literal {{braces}} for {0} + + + {0} items, ascending + + + Custom ascending + + + File {0} of {1} + diff --git a/tests/ResXLocalization.MessageFormat.Tests/DictionaryResourceManager.cs b/tests/ResXLocalization.MessageFormat.Tests/DictionaryResourceManager.cs new file mode 100644 index 0000000..515f2a6 --- /dev/null +++ b/tests/ResXLocalization.MessageFormat.Tests/DictionaryResourceManager.cs @@ -0,0 +1,19 @@ +namespace RentADeveloper.ResXLocalization.MessageFormat.Tests; + +/// +/// A over in-memory values, so that ICU patterns need no .resx file. +/// Every value is a neutral value: it is returned for every culture. +/// +/// The resource keys and their values. +internal sealed class DictionaryResourceManager(params (string Key, string Value)[] values) : ResourceManager +{ + /// The values, keyed by resource key. + private readonly Dictionary values = values.ToDictionary( + entry => entry.Key, + entry => entry.Value, + StringComparer.Ordinal + ); + + /// + public override string? GetString(string name, CultureInfo? culture) => this.values.GetValueOrDefault(name); +} diff --git a/tests/ResXLocalization.MessageFormat.Tests/GlobalUsings.cs b/tests/ResXLocalization.MessageFormat.Tests/GlobalUsings.cs new file mode 100644 index 0000000..0cd6a01 --- /dev/null +++ b/tests/ResXLocalization.MessageFormat.Tests/GlobalUsings.cs @@ -0,0 +1,8 @@ +global using System.Globalization; +global using System.Resources; +global using AwesomeAssertions; +global using RentADeveloper.ResXLocalization.Core.Tests; +global using Xunit; +// An alias rather than the namespace: Jeffijoe.MessageFormat declares an IMessageFormatter of its own, which +// would make RentADeveloper.ResXLocalization.IMessageFormatter ambiguous. +global using JeffijoeMessageFormatter = Jeffijoe.MessageFormat.MessageFormatter; diff --git a/tests/ResXLocalization.MessageFormat.Tests/IcuMessageFormatTests.cs b/tests/ResXLocalization.MessageFormat.Tests/IcuMessageFormatTests.cs new file mode 100644 index 0000000..2781b0f --- /dev/null +++ b/tests/ResXLocalization.MessageFormat.Tests/IcuMessageFormatTests.cs @@ -0,0 +1,454 @@ +using Jeffijoe.MessageFormat.Formatting; + +namespace RentADeveloper.ResXLocalization.MessageFormat.Tests; + +/// +/// Exercises and the ICU MessageFormat +/// formatter it installs, through the lookups of a : ICU syntax for every resolved +/// value, with or without arguments, the mapping of positional and named arguments, the format culture, and +/// the formatting failures that return the failure text and raise . +/// +public class IcuMessageFormatTests +{ + private const string FailureText = "!Key!"; + private const string Key = "Key"; + + [Theory] + [InlineData("{0} saved", "3 saved")] + [InlineData("Press to {0}", "Press to 3")] + public void AngleBrackets_AreText(string pattern, string expected) => + FormatShouldReturn(CreateLocalizer(), pattern, MessageArguments.FromValues(3), expected); + + [Theory] + [InlineData("l'utilisateur {0}", "l'utilisateur 3", "en")] + [InlineData("Supprimer l'élément « {0} » ?", "Supprimer l'élément « 3 » ?", "en")] + [InlineData("Don't delete {0}", "Don't delete 3", "en")] + [InlineData("It's {0}'s turn", "It's 3's turn", "en")] + [InlineData("Rock 'n' roll {0}", "Rock 'n' roll 3", "en")] + [InlineData("trailing quote {0}'", "trailing quote 3'", "en")] + [InlineData("{0, plural, one {Don't delete # file} other {Don't delete # files}}", "Don't delete 3 files", "en")] + [InlineData("{0, plural, one {l'élément} other {les # éléments}}", "les 3 éléments", "fr")] + [InlineData("'{0}' was not found", "{0} was not found", "en")] + [InlineData("Datei '{0}' wurde gelöscht", "Datei {0} wurde gelöscht", "en")] + [InlineData("Can't find '{Name}'", "Can't find {Name}", "en")] + [InlineData("'{0}'s file", "{0}s file", "en")] + [InlineData("''{0}''", "'3'", "en")] + [InlineData("It''s {0}", "It's 3", "en")] + [InlineData("'{'literal'}' {0}", "{literal} 3", "en")] + [InlineData("'#' in text {0}", "# in text 3", "en")] + [InlineData("Press to {0}", "Press to 3", "en")] + [InlineData("List of {0}", "List of 3", "en")] + [InlineData("{0} saved", "3 saved", "en")] + [InlineData("a < b is {0}", "a < b is 3", "en")] + public void ApostrophesAndAngleBrackets_RenderAsIcuDefinesThem(string pattern, string expected, string cultureName) + { + var localizer = CreateLocalizer(); + localizer.CurrentCulture = new CultureInfo(cultureName); + + FormatShouldReturn(localizer, pattern, MessageArguments.FromTuples((null, 3), ("Name", "Bob")), expected); + } + + [Fact] + public void ArgumentWithoutStyle_IsFormattedWithoutGrouping() => + FormatShouldReturn(CreateLocalizer(), "{0} people", MessageArguments.FromValues(1234567), "1234567 people"); + + [Fact] + public void CompositeFormatSyntax_ReturnsTheFailureText() + { + var localizer = CreateLocalizer(); + + FormatShouldFail(localizer, "{0:N0} people", MessageArguments.FromValues(1234567)) + .Exception.Should() + .BeAssignableTo(); + FormatShouldFail( + localizer, + "{1:d}", + MessageArguments.FromValues(1, new DateTime(2026, 1, 31, 0, 0, 0, DateTimeKind.Unspecified)) + ); + FormatShouldFail(localizer, "{{literal}}", MessageArguments.FromValues(1)); + FormatShouldFail(localizer, "a { b } c", MessageArguments.FromValues(1)) + .Exception.Should() + .BeOfType() + .Which.MissingVariable.Should() + .Be("b"); + } + + [Fact] + public void Formatter_IsSafeForConcurrentUse() + { + const string pattern = "{Count, plural, one {# file} other {# files}} {Size, number}"; + var formatter = CreateLocalizer().MessageFormatter; + CultureInfo[] cultures = [CultureInfo.GetCultureInfo("en"), CultureInfo.GetCultureInfo("de-DE")]; + + // Input 0 to 3: the culture alternates with every input, the count changes every second input. + string FormatInput(int input) => + formatter.Format( + pattern, + MessageArguments.FromTuples(("Count", input < 2 ? 1 : 5), ("Size", 1234.5)), + cultures[input % 2] + ); + + var expected = Enumerable.Range(0, 4).Select(FormatInput).ToArray(); + expected.Should().Equal("1 file 1,234.5", "1 file 1.234,5", "5 files 1,234.5", "5 files 1.234,5"); + + var mismatches = 0; + var threads = Enumerable + .Range(0, 8) + .Select(thread => new Thread(() => + { + for (var call = 0; call < 1000; call++) + { + var input = (thread + call) % 4; + + if (FormatInput(input) != expected[input]) + { + Interlocked.Increment(ref mismatches); + } + } + })) + .ToList(); + + threads.ForEach(thread => thread.Start()); + threads.ForEach(thread => thread.Join()); + + mismatches.Should().Be(0); + } + + [Fact] + public void MissingVariable_ReturnsTheFailureText_WithVariableNotFoundException() => + FormatShouldFail(CreateLocalizer(), "{Nope} x", MessageArguments.FromValues(1)) + .Exception.Should() + .BeOfType() + .Which.Message.Should() + .Contain("Nope"); + + [Fact] + public void MixedArguments_AreReachableUnderTheirPositionAndTheirName() => + FormatShouldReturn( + CreateLocalizer(), + "{0} ({Letter}:)", + MessageArguments.FromTuples((null, "Data"), ("Letter", "D")), + "Data (D:)" + ); + + [Fact] + public void NameThatIsAPosition_OverwritesThatPosition() => + FormatShouldReturn(CreateLocalizer(), "{0}|{1}", MessageArguments.FromTuples((null, "a"), ("0", "b")), "b|b"); + + [Fact] + public void NamedArguments_WorkThroughEveryGetOverload() + { + const string pattern = "{Count, plural, one {# file} other {# files}}"; + var resourceManager = new DictionaryResourceManager( + ("Files", pattern), + ("Enum_TestKind_A", pattern), + ("Prefix_TestKind_A", pattern) + ); + var localizer = CreateLocalizer(); + localizer.RegisterResourceManager(resourceManager); + ILocalizer interfaceLocalizer = localizer; + var count = MessageArguments.FromTuples(("Count", 1)); + using var monitor = localizer.Monitor(); + + string[] results = + [ + localizer.Get("Files", MessageArguments.FromTuples(("Count", 1))), + interfaceLocalizer.Get( + "Files", + MessageArguments.FromDictionary(new Dictionary { ["Count"] = 1 }) + ), + localizer.Get("Files", MessageArguments.FromObject(new { Count = 1 }), resourceManager), + localizer.Get(new ResourceKey("Files", resourceManager), count), + interfaceLocalizer.Get(TestKind.A, count, resourceManager), + localizer.Get(TestKind.A, count, resourceManager, "Prefix_"), + ]; + + results.Should().AllBe("1 file"); + monitor.Should().NotRaise(nameof(ILocalizer.LookupFailed)); + } + + [Theory] + [InlineData("{0, plural, one {# file} other {# files}}", "0 files")] + [InlineData("{0, number}", "")] + [InlineData("{0, select, a {A} other {O}}", "O")] + [InlineData("{0}", "")] + public void NullArgument_RendersWithoutAFailure(string pattern, string expected) => + FormatShouldReturn(CreateLocalizer(), pattern, MessageArguments.FromValues((object?)null), expected); + + [Fact] + public void NumberStyle_FollowsTheLookupCulture() + { + var localizer = CreateLocalizer(); + + FormatShouldReturn(localizer, "{0, number}", MessageArguments.FromValues(1234.5), "1,234.5"); + + localizer.CurrentCulture = new CultureInfo("de-DE"); + + FormatShouldReturn(localizer, "{0, number}", MessageArguments.FromValues(1234.5), "1.234,5"); + } + + [Fact] + public void NumberStyle_GroupsDigits_AndTheIntegerStyleDoesNot() + { + var localizer = CreateLocalizer(); + + FormatShouldReturn(localizer, "{0, number}", MessageArguments.FromValues(1234567), "1,234,567"); + FormatShouldReturn(localizer, "{0, number, integer}", MessageArguments.FromValues(1234567), "1234567"); + } + + [Fact] + public void Plural_AcceptsNumericStrings_AndFailsForOtherStrings() + { + const string pattern = "{0, plural, one {# file} other {# files}}"; + var localizer = CreateLocalizer(); + + FormatShouldReturn(localizer, pattern, MessageArguments.FromValues("5"), "5 files"); + FormatShouldReturn(localizer, pattern, MessageArguments.FromValues("1"), "1 file"); + FormatShouldFail(localizer, pattern, MessageArguments.FromValues("abc")); + } + + [Theory] + [InlineData("", "1 item")] + [InlineData("en", "1 item")] + [InlineData("de-CH", "1 item")] + [InlineData("iu-Latn-CA", "1 item")] + [InlineData("zh-Hant-TW", "1 items")] + public void Plural_FollowsThePluralRulesOfTheLookupCulture(string cultureName, string expected) + { + var localizer = CreateLocalizer(); + + // An empty culture name is the invariant culture, which formats as en. + localizer.CurrentCulture = CultureInfo.GetCultureInfo(cultureName); + + FormatShouldReturn( + localizer, + "{0, plural, one {# item} other {# items}}", + MessageArguments.FromValues(1), + expected + ); + } + + [Fact] + public void Plural_SelectsTheBranchOfTheCount() + { + const string pattern = "{0, plural, one {# person invited} other {# people invited}}"; + var localizer = CreateLocalizer(); + + FormatShouldReturn(localizer, pattern, MessageArguments.FromValues(1), "1 person invited"); + FormatShouldReturn(localizer, pattern, MessageArguments.FromValues(5), "5 people invited"); + } + + [Fact] + public void ShortDateStyle_FormatsLikeTheEngineInTheLookupCulture() + { + var date = new DateTime(2026, 1, 31, 0, 0, 0, DateTimeKind.Unspecified); + var expected = new JeffijoeMessageFormatter().FormatMessage( + "{1, date, short}", + new Dictionary { ["0"] = 0, ["1"] = date }, + CultureInfo.GetCultureInfo("en") + ); + + expected.Should().NotBe(FailureText); + FormatShouldReturn(CreateLocalizer(), "{1, date, short}", MessageArguments.FromValues(0, date), expected); + } + + [Theory] + [InlineData("")] + [InlineData("iu-Latn-CA")] + public void SimpleArgument_FormatsInEveryCulture(string cultureName) + { + var localizer = CreateLocalizer(); + + // An empty culture name is the invariant culture. + localizer.CurrentCulture = CultureInfo.GetCultureInfo(cultureName); + + FormatShouldReturn(localizer, "{0} items", MessageArguments.FromValues(5), "5 items"); + } + + [Fact] + public void UseMessageFormat_NullLocalizer_ThrowsArgumentNullException() + { + var act = () => ((ILocalizer)null!).UseMessageFormat(); + + act.Should().Throw().WithParameterName("localizer"); + } + + [Fact] + public void UseMessageFormat_TheLookupCultureWinsOverTheCultureOfTheGivenFormatter() + { + var localizer = new Localizer { CurrentCulture = new CultureInfo("en") }; + localizer.UseMessageFormat(new JeffijoeMessageFormatter(culture: CultureInfo.GetCultureInfo("de-DE"))); + + FormatShouldReturn(localizer, "{0, number}", MessageArguments.FromValues(1234.5), "1,234.5"); + } + + [Fact] + public void UseMessageFormat_UsesTheGivenFormatterAsGiven() + { + const string pattern = "{0, date, medium}"; + var arguments = MessageArguments.FromValues(new DateTime(2026, 1, 31, 0, 0, 0, DateTimeKind.Unspecified)); + var localizer = CreateLocalizer(); + + FormatShouldFail(localizer, pattern, arguments).Exception.Should().BeOfType(); + + localizer.UseMessageFormat( + new JeffijoeMessageFormatter( + customValueFormatter: new Jeffijoe.MessageFormat.CustomValueFormatters + { + Date = (CultureInfo culture, object? value, string? style, out string? formatted) => + { + if (style == "medium" && value is DateTime date) + { + formatted = date.ToString("d MMM yyyy", culture); + return true; + } + + formatted = string.Empty; + return false; + }, + } + ) + ); + + FormatShouldReturn(localizer, pattern, arguments, "31 Jan 2026"); + } + + [Fact] + public void UseMessageFormat_WithNullOrWithoutAFormatter_UsesANewFormatter() + { + var withNull = new Localizer { CurrentCulture = new CultureInfo("en") }; + withNull.UseMessageFormat(null); + + foreach (var localizer in new[] { withNull, CreateLocalizer() }) + { + FormatShouldReturn( + localizer, + "{0, plural, one {# file} other {# files}}", + MessageArguments.FromValues(1), + "1 file" + ); + FormatShouldFail(localizer, "{Nope} x", MessageArguments.FromValues(1)); + } + } + + [Theory] + [InlineData("Hello", "Hello")] + [InlineData("It''s ready", "It's ready")] + [InlineData("'{literal}'", "{literal}")] + public void ValueWithoutArguments_IsParsedAsIcu(string pattern, string expected) + { + var localizer = CreateLocalizer(); + using var monitor = localizer.Monitor(); + + LookUpWithoutArguments(localizer, pattern).Should().AllBe(expected); + monitor.Should().NotRaise(nameof(ILocalizer.LookupFailed)); + } + + [Theory] + [InlineData("{Count} a { b } c")] + [InlineData("It''s {0}")] + public void ValueWithoutArguments_ReferencingAVariable_ReturnsTheFailureText(string pattern) + { + var localizer = CreateLocalizer(); + using var monitor = localizer.Monitor(); + + LookUpWithoutArguments(localizer, pattern).Should().AllBe(FailureText); + monitor + .LookupFailures() + .Should() + .HaveCount(5) + .And.AllSatisfy(failure => + { + failure.Reason.Should().Be(LookupFailureReason.MessageFormatFailed); + failure.Exception.Should().BeOfType(); + }); + } + + [Fact] + public void WithoutUseMessageFormat_TheDefaultFormatterFormats() => + FormatShouldReturn( + new Localizer { CurrentCulture = new CultureInfo("en") }, + "{0:N0} people", + MessageArguments.FromValues(1234567), + "1,234,567 people" + ); + + /// Creates a localizer in the culture en with the ICU formatter installed. + /// The localizer. + private static Localizer CreateLocalizer() + { + var localizer = new Localizer { CurrentCulture = new CultureInfo("en") }; + localizer.UseMessageFormat(); + return localizer; + } + + /// Looks up under with a scoped lookup. + /// The localizer. + /// The resource value. + /// The arguments of the lookup. + /// The result of the lookup. + private static string Format(Localizer localizer, string pattern, MessageArguments arguments) => + localizer.Get(Key, arguments, new DictionaryResourceManager((Key, pattern))); + + /// + /// Asserts that looking up returns the failure text and raises exactly one + /// with the reason . + /// + /// The localizer. + /// The resource value. + /// The arguments of the lookup. + /// The failure the event reported. + private static LookupFailure FormatShouldFail(Localizer localizer, string pattern, MessageArguments arguments) + { + using var monitor = localizer.Monitor(); + + Format(localizer, pattern, arguments).Should().Be(FailureText); + + var failure = monitor.LookupFailures().Should().ContainSingle().Which; + failure.Reason.Should().Be(LookupFailureReason.MessageFormatFailed); + return failure; + } + + /// + /// Asserts that looking up returns and raises no + /// . + /// + /// The localizer. + /// The resource value. + /// The arguments of the lookup. + /// The expected result. + private static void FormatShouldReturn( + Localizer localizer, + string pattern, + MessageArguments arguments, + string expected + ) + { + using var monitor = localizer.Monitor(); + + Format(localizer, pattern, arguments).Should().Be(expected); + + monitor.Should().NotRaise(nameof(ILocalizer.LookupFailed)); + } + + /// + /// Looks up with every form of a lookup without arguments: no arguments, + /// , , and empty positional and named arguments. + /// + /// The localizer. + /// The resource value. + /// The five results. + private static string[] LookUpWithoutArguments(Localizer localizer, string pattern) + { + var resourceManager = new DictionaryResourceManager((Key, pattern)); + + return + [ + localizer.Get(Key, resourceManager: resourceManager), + localizer.Get(Key, arguments: null, resourceManager: resourceManager), + localizer.Get(Key, MessageArguments.Empty, resourceManager), + localizer.Get(Key, MessageArguments.FromValues(), resourceManager), + localizer.Get(Key, MessageArguments.FromDictionary(new Dictionary()), resourceManager), + ]; + } +} diff --git a/tests/ResXLocalization.MessageFormat.Tests/ResXLocalization.MessageFormat.Tests.csproj b/tests/ResXLocalization.MessageFormat.Tests/ResXLocalization.MessageFormat.Tests.csproj new file mode 100644 index 0000000..076d053 --- /dev/null +++ b/tests/ResXLocalization.MessageFormat.Tests/ResXLocalization.MessageFormat.Tests.csproj @@ -0,0 +1,17 @@ + + + + + $(LibraryTargetFrameworks) + + + + + + + + + + + + diff --git a/tests/ResXLocalization.MessageFormat.Tests/TestKind.cs b/tests/ResXLocalization.MessageFormat.Tests/TestKind.cs new file mode 100644 index 0000000..566f7e2 --- /dev/null +++ b/tests/ResXLocalization.MessageFormat.Tests/TestKind.cs @@ -0,0 +1,8 @@ +namespace RentADeveloper.ResXLocalization.MessageFormat.Tests; + +/// Test-only enumeration for the enum lookups with named arguments. +public enum TestKind +{ + /// Localized as Enum_TestKind_A and Prefix_TestKind_A. + A = 0, +} diff --git a/tests/ResXLocalization.SourceGenerators.Tests/ResxKeysGeneratorTests.cs b/tests/ResXLocalization.SourceGenerators.Tests/ResxKeysGeneratorTests.cs index 7bacb9d..eee2f58 100644 --- a/tests/ResXLocalization.SourceGenerators.Tests/ResxKeysGeneratorTests.cs +++ b/tests/ResXLocalization.SourceGenerators.Tests/ResxKeysGeneratorTests.cs @@ -1,12 +1,55 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + namespace RentADeveloper.ResXLocalization.SourceGenerators.Tests; /// /// Unit tests for . They drive the incremental generator in-memory /// with against fabricated .resx / .Designer.cs -/// additional files and assert on the generated source - no file system, no MSBuild. +/// additional files and assert on the generated source - no file system, no MSBuild. The MSBuild +/// resource metadata a real build passes through the analyzer config file comes from +/// , which derives it from each path the way a build would. /// public class ResxKeysGeneratorTests { + [Fact] + public void BlankCustomToolNamespace_FallsBackToTheResourceNameNamespace() + { + var result = RunGenerator( + new TestMetadata().With("project/Resources/Strings.resx", ("CustomToolNamespace", " ")), + ("project/Resources/Strings.resx", Resx(("Greeting", "Hello"))) + ); + + SingleGeneratedSource(result).SourceText.ToString().Should().Contain("namespace N.Resources;"); + } + + [Fact] + public void ChangingOnlyCustomToolNamespace_RegeneratesTheKeysClassInTheNewNamespace() + { + var compilation = CSharpCompilation.Create("Tests"); + GeneratorDriver driver = CreateTrackingDriver(new TestMetadata()); + + driver = driver + .AddAdditionalTexts([new InMemoryAdditionalText("project/Strings.resx", Resx(("Greeting", "Hello")))]) + .RunGenerators(compilation) + // Then change only the metadata, as editing the project file does, and run again: the new + // namespace must not be served from the incremental cache. + .WithUpdatedAnalyzerConfigOptions( + new TestMetadata().With("project/Strings.resx", ("CustomToolNamespace", "Texts")) + ) + .RunGenerators(compilation); + + driver + .GetRunResult() + .Results.Single() + .GeneratedSources.Should() + .ContainSingle() + .Which.SourceText.ToString() + .Should() + .Contain("namespace Texts;"); + OutputReasons(driver).Should().Contain(reason => reason != IncrementalStepRunReason.Cached); + } + [Fact] public void CollidingSanitizedNames_GetNumericSuffixes() { @@ -40,6 +83,31 @@ public void ControlCharactersInKeyNames_AreEscapedInTheGeneratedLiteral() code.Should().Contain("""Line_Break = new("Line\nBreak", """); } + [Fact] + public void CustomToolNamespaceSegments_AreSanitized() + { + var result = RunGenerator( + new TestMetadata().With("project/Strings.resx", ("CustomToolNamespace", "My-App.event")), + ("project/Strings.resx", Resx(("Greeting", "Hello"))) + ); + + SingleGeneratedSource(result).SourceText.ToString().Should().Contain("namespace My_App.@event;"); + } + + [Fact] + public void CustomToolNamespace_NamesTheNamespace_OfAResxWithoutADesigner() + { + var result = RunGenerator( + new TestMetadata().With("project/Resources/Strings.resx", ("CustomToolNamespace", " Texts.Custom ")), + ("project/Resources/Strings.resx", Resx(("Greeting", "Hello"))) + ); + + var code = SingleGeneratedSource(result).SourceText.ToString(); + + code.Should().Contain("namespace Texts.Custom;"); + code.Should().Contain("""new("N.Resources.Strings", typeof(StringsKeys).Assembly);"""); + } + [Fact] public void DesignerInADifferentFolder_DoesNotPair() { @@ -48,8 +116,41 @@ public void DesignerInADifferentFolder_DoesNotPair() ("project/Other/Strings.Designer.cs", Designer("My.App")) ); - result.Diagnostics.Should().ContainSingle().Which.Id.Should().Be("RXLGEN002"); - result.Results.Single().GeneratedSources.Should().BeEmpty(); + var code = SingleGeneratedSource(result).SourceText.ToString(); + + code.Should().Contain("namespace N.Resources;"); + code.Should().NotContain("My.App"); + } + + [Fact] + public void DesignerNamespace_IsUsed_ButNotTheDesignersResourceManager() + { + var result = RunGenerator( + ("project/Resources/Strings.resx", Resx(("Greeting", "Hello"))), + ("project/Resources/Strings.Designer.cs", Designer("Custom.Ns")) + ); + + var code = SingleGeneratedSource(result).SourceText.ToString(); + + code.Should().Contain("namespace Custom.Ns;"); + code.Should().Contain("""new("N.Resources.Strings", typeof(StringsKeys).Assembly);"""); + code.Should().NotContain("Custom.Ns.Strings"); + code.Should().NotContain("global::Custom"); + } + + [Fact] + public void DesignerNamespace_WinsOverCustomToolNamespace() + { + var result = RunGenerator( + new TestMetadata().With("project/Resources/Strings.resx", ("CustomToolNamespace", "Texts.Custom")), + ("project/Resources/Strings.resx", Resx(("Greeting", "Hello"))), + ("project/Resources/Strings.Designer.cs", Designer("Designer.Ns")) + ); + + var code = SingleGeneratedSource(result).SourceText.ToString(); + + code.Should().Contain("namespace Designer.Ns;"); + code.Should().NotContain("Texts.Custom"); } [Fact] @@ -70,19 +171,64 @@ private void InitializeComponent() { } ("project/Strings.Designer.cs", formDesigner) ); - result.Diagnostics.Should().ContainSingle().Which.Id.Should().Be("RXLGEN002"); - result.Results.Single().GeneratedSources.Should().BeEmpty(); + var code = SingleGeneratedSource(result).SourceText.ToString(); + + code.Should().Contain("namespace N;"); + code.Should().NotContain("My.App"); } [Fact] - public void DottedResxBaseName_IsTreatedAsSatelliteAndSkipped() + public void DottedNeutralResxBaseName_EmitsASanitizedClassInTheResourceNameNamespace() { var result = RunGenerator( - ("project/My.Strings.resx", Resx(("Greeting", "Hello"))), - ("project/My.Strings.Designer.cs", Designer("My.App")) + new TestMetadata().With("project/My.Strings.resx", ("WithCulture", "false")), + ("project/My.Strings.resx", Resx(("Greeting", "Hello"))) ); - result.Results.Single().GeneratedSources.Should().BeEmpty(); + var code = SingleGeneratedSource(result).SourceText.ToString(); + + code.Should().Contain("namespace N.My;"); + code.Should().Contain("public static partial class My_StringsKeys"); + code.Should().Contain("""new("N.My.Strings", typeof(My_StringsKeys).Assembly);"""); + } + + [Fact] + public void EditingAnUnrelatedResx_KeepsTheCollisionAndTheCollidingFilesCached() + { + var compilation = CSharpCompilation.Create("Tests"); + GeneratorDriver driver = CreateTrackingDriver(new TestMetadata()); + + var otherResx = new InMemoryAdditionalText("project/Other.resx", Resx(("Alpha", "a"))); + + driver = driver + .AddAdditionalTexts([ + new InMemoryAdditionalText("project/A/Strings.resx", Resx(("Beta", "b"))), + new InMemoryAdditionalText("project/A/Strings.Designer.cs", Designer("N")), + new InMemoryAdditionalText("project/B/Strings.resx", Resx(("Gamma", "c"))), + new InMemoryAdditionalText("project/B/Strings.Designer.cs", Designer("N")), + otherResx, + ]) + .RunGenerators(compilation) + // Then edit only Other.resx's keys and run again. + .ReplaceAdditionalText( + otherResx, + new InMemoryAdditionalText(otherResx.Path, Resx(("Alpha", "a"), ("Delta", "d"))) + ) + .RunGenerators(compilation); + + var result = driver.GetRunResult(); + result.Diagnostics.Select(diagnostic => diagnostic.Id).Should().Equal("RXLGEN004", "RXLGEN004"); + result + .Results.Single() + .GeneratedSources.Should() + .ContainSingle() + .Which.HintName.Should() + .StartWith("N.OtherKeys."); + + var reasons = OutputReasons(driver).ToList(); + reasons.Should().HaveCount(3); + reasons.Count(reason => reason == IncrementalStepRunReason.Cached).Should().Be(2); + reasons.Should().ContainSingle(reason => reason == IncrementalStepRunReason.Modified); } [Fact] @@ -110,8 +256,8 @@ public void GlobalNamespaceAndInvalidFilename_EmitValidSanitizedClass() var code = SingleGeneratedSource(result).SourceText.ToString(); code.Should().Contain("class Order_StatusKeys"); - code.Should().Contain("global::OrderStatus.ResourceManager"); - code.Should().NotContain("namespace ;"); + code.Should().Contain("""new("N.Order-Status", typeof(Order_StatusKeys).Assembly);"""); + code.Should().NotContain("namespace"); } [Fact] @@ -140,7 +286,18 @@ public void KeyEqualToTheGeneratedClassName_GetsANumericSuffix() var code = SingleGeneratedSource(result).SourceText.ToString(); - code.Should().Contain("""StringsKeys2 = new("StringsKeys", """); + code.Should().Contain("""StringsKeys2 = new("StringsKeys", ResourceManager);"""); + } + + [Fact] + public void KeyNamedResourceManager_GetsANumericSuffix() + { + var result = RunGenerator(("project/Strings.resx", Resx(("ResourceManager", "a")))); + + var code = SingleGeneratedSource(result).SourceText.ToString(); + + code.Should().Contain("public static global::System.Resources.ResourceManager ResourceManager { get; }"); + code.Should().Contain("""ResourceManager2 = new("ResourceManager", ResourceManager);"""); } [Fact] @@ -158,11 +315,32 @@ public void KeyThatIsACSharpKeyword_IsEscapedWithAnAtPrefix() } [Fact] - public void MalformedEligibleResx_ReportsAnActionableDiagnostic() + public void LogicalName_ReplacesTheManifestResourceName() { var result = RunGenerator( - ("project/Strings.resx", " diagnostic.Id).Should().Equal("RXLGEN004", "RXLGEN004"); + result.Results.Single().GeneratedSources.Should().BeEmpty(); + } + [Fact] public void Members_AreSortedByKeyName() { @@ -204,9 +396,15 @@ public void NeutralResxWithSiblingDesigner_EmitsOneTypedKeyPerStringEntry() var code = source.SourceText.ToString(); code.Should().Contain("namespace My.App.Resources;"); code.Should().Contain("public static partial class StringsKeys"); - code.Should().Contain("""Greeting = new("Greeting", global::My.App.Resources.Strings.ResourceManager);"""); code.Should() - .Contain("""WindowTitle = new("WindowTitle", global::My.App.Resources.Strings.ResourceManager);"""); + .Contain( + """ + public static global::System.Resources.ResourceManager ResourceManager { get; } = + new("N.Resources.Strings", typeof(StringsKeys).Assembly); + """ + ); + code.Should().Contain("""Greeting = new("Greeting", ResourceManager);"""); + code.Should().Contain("""WindowTitle = new("WindowTitle", ResourceManager);"""); } [Fact] @@ -243,6 +441,21 @@ public void QuotesInKeyNames_AreEscapedInTheGeneratedLiteral() code.Should().Contain("""Say__Hi__now = new("Say \"Hi\" now", """); } + [Fact] + public void ResourceManagerDeclaration_PrecedesTheFirstKey() + { + var result = RunGenerator( + ("project/Resources/Strings.resx", Resx(("WindowTitle", "My App"), ("Greeting", "Hello"))) + ); + + var code = SingleGeneratedSource(result).SourceText.ToString(); + + code.IndexOf("ResourceManager ResourceManager", StringComparison.Ordinal) + .Should() + .BePositive() + .And.BeLessThan(code.IndexOf("ResourceKey", StringComparison.Ordinal)); + } + [Theory] [InlineData("global::System.Resources.ResourceManager", "")] [InlineData("System.Resources.ResourceManager", "")] @@ -263,34 +476,80 @@ internal class ActualAccessor ("project/Strings.Designer.cs", designer) ); - SingleGeneratedSource(result) - .SourceText.ToString() - .Should() - .Contain("global::My.App.ActualAccessor.ResourceManager"); + // Pairing is what puts the class into the designer's namespace instead of the resource name's. + SingleGeneratedSource(result).SourceText.ToString().Should().Contain("namespace My.App;"); } [Fact] - public void ResxWithoutSiblingDesigner_ProducesNoOutput() + public void ResourceNameNamespaceSegments_AreSanitized() { - var result = RunGenerator(("project/Strings.resx", Resx(("Greeting", "Hello")))); + var result = RunGenerator( + new TestMetadata().With("project/Strings.resx", ("LogicalName", "My-App.event.Strings.resources")), + ("project/Strings.resx", Resx(("Greeting", "Hello"))) + ); + + var code = SingleGeneratedSource(result).SourceText.ToString(); + + code.Should().Contain("namespace My_App.@event;"); + code.Should().Contain("""new("My-App.event.Strings", typeof(StringsKeys).Assembly);"""); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ResxWithoutResourceName_ReportsRXLGEN003(bool wellFormed) + { + // A file without its resource name is not parsed at all, so malformed XML does not surface. + var result = RunGenerator( + new TestMetadata().Without("project/Strings.resx"), + ("project/Strings.resx", wellFormed ? Resx(("Greeting", "Hello")) : " + /// The resource manager of Strings.resx - the instance every key of this class uses. Register this + /// instance for search-all lookups, and pass it for scoped lookups. + /// + public static global::System.Resources.ResourceManager ResourceManager { get; } = + new("N.Resources.Strings", typeof(StringsKeys).Assembly); + """ + ); + code.Should().Contain("""Greeting = new("Greeting", ResourceManager);"""); + code.Should().Contain("""WindowTitle = new("WindowTitle", ResourceManager);"""); + } + [Fact] public void SatelliteResx_ProducesNoOutput() { var result = RunGenerator( + new TestMetadata().With("project/Strings.de.resx", ("WithCulture", "true")), ("project/Strings.de.resx", Resx(("Greeting", "Hallo"))), ("project/Strings.Designer.cs", Designer("My.App")) ); - // Only the neutral file (with no resx present here) could pair with the designer; the - // satellite must not. + result.Diagnostics.Should().BeEmpty(); result.Results.Single().GeneratedSources.Should().BeEmpty(); } @@ -298,7 +557,7 @@ public void SatelliteResx_ProducesNoOutput() public void SecondRun_WithEquivalentInputs_CachesTheOutput() { var compilation = CSharpCompilation.Create("Tests"); - GeneratorDriver driver = CreateTrackingDriver(); + GeneratorDriver driver = CreateTrackingDriver(new TestMetadata()); var resx = new InMemoryAdditionalText("project/Strings.resx", Resx(("Greeting", "Hello"), ("Bye", "Goodbye"))); var designer = new InMemoryAdditionalText("project/Strings.Designer.cs", Designer("My.App")); @@ -323,7 +582,7 @@ public void SecondRun_WithEquivalentInputs_CachesTheOutput() public void TouchingOneResx_ReRunsOnlyThatFilesOutput() { var compilation = CSharpCompilation.Create("Tests"); - GeneratorDriver driver = CreateTrackingDriver(); + GeneratorDriver driver = CreateTrackingDriver(new TestMetadata()); var oneResx = new InMemoryAdditionalText("project/One/Strings.resx", Resx(("Alpha", "a"))); @@ -354,33 +613,72 @@ public void TouchingOneResx_ReRunsOnlyThatFilesOutput() } [Fact] - public void TwoResxWithTheSameBaseNameAndTheSameNamespace_GetDistinctHintNames_AndMergeAsPartials() + public void TwoResxProducingTheSameKeysClassThroughCustomToolNamespace_ReportRXLGEN004() { - // Customized accessors can legitimately have different type names while two same-named resx - // files generate partial My.App.StringsKeys declarations. Both the input and output compile. var result = RunGenerator( - ("project/One/Strings.resx", Resx(("Alpha", "a"))), - ( - "project/One/Strings.Designer.cs", - Designer("My.App").Replace("class Strings", "class StringsOne", StringComparison.Ordinal) - ), - ("project/Two/Strings.resx", Resx(("Beta", "b"))), - ( - "project/Two/Strings.Designer.cs", - Designer("My.App").Replace("class Strings", "class StringsTwo", StringComparison.Ordinal) - ) + new TestMetadata() + .With("project/A/Strings.resx", ("CustomToolNamespace", "Shared")) + .With("project/B/Strings.resx", ("CustomToolNamespace", "Shared")), + ("project/A/Strings.resx", Resx(("Alpha", "a"))), + ("project/B/Strings.resx", Resx(("Beta", "b"))) ); - result.Diagnostics.Should().BeEmpty(); + result.Diagnostics.Select(diagnostic => diagnostic.Id).Should().Equal("RXLGEN004", "RXLGEN004"); + result + .Diagnostics.Should() + .AllSatisfy(diagnostic => + diagnostic.GetMessage(CultureInfo.InvariantCulture).Should().Contain("'Shared.StringsKeys'") + ); + result.Results.Single().GeneratedSources.Should().BeEmpty(); + } - var sources = result.Results.Single().GeneratedSources; + [Fact] + public void TwoResxProducingTheSameKeysClass_InNamespacesThatDifferOnlyByAVerbatimAt_ReportRXLGEN004() + { + // Foo.@Bar (from the designer) and Foo.Bar (from the resource name) are the same C# namespace. + var result = RunGenerator( + new TestMetadata().With("project/B/Strings.resx", ("LogicalName", "Foo.Bar.Strings.resources")), + ("project/A/Strings.resx", Resx(("Alpha", "a"))), + ("project/A/Strings.Designer.cs", Designer("Foo.@Bar")), + ("project/B/Strings.resx", Resx(("Beta", "b"))) + ); - sources.Should().HaveCount(2); - sources.Select(s => s.HintName).Should().OnlyHaveUniqueItems(); - sources - .Select(s => s.SourceText.ToString()) + result.Diagnostics.Select(diagnostic => diagnostic.Id).Should().Equal("RXLGEN004", "RXLGEN004"); + result.Results.Single().GeneratedSources.Should().BeEmpty(); + } + + [Fact] + public void TwoResxProducingTheSameKeysClass_ReportRXLGEN004_AndGenerateNeither() + { + // Distinct accessor type names keep the input compilation free of a duplicate type; both + // designers still declare namespace N, so both files would produce N.StringsKeys. + var result = RunGenerator( + ("project/A/Strings.resx", Resx(("Alpha", "a"))), + ("project/A/Strings.Designer.cs", Designer("N", "StringsA")), + ("project/B/Strings.resx", Resx(("Beta", "b"))), + ("project/B/Strings.Designer.cs", Designer("N", "StringsB")), + ("project/Other.resx", Resx(("Gamma", "c"))) + ); + + result.Diagnostics.Should().HaveCount(2); + result + .Diagnostics.Select(diagnostic => diagnostic.Location.GetLineSpan().Path) .Should() - .AllSatisfy(code => code.Should().Contain("public static partial class StringsKeys")); + .BeEquivalentTo("project/A/Strings.resx", "project/B/Strings.resx"); + result + .Diagnostics.Should() + .AllSatisfy(diagnostic => + { + diagnostic.Id.Should().Be("RXLGEN004"); + diagnostic.Severity.Should().Be(DiagnosticSeverity.Warning); + var message = diagnostic.GetMessage(CultureInfo.InvariantCulture); + message.Should().Contain("project/A/Strings.resx"); + message.Should().Contain("project/B/Strings.resx"); + message.Should().Contain("'N.StringsKeys'"); + }); + + var source = result.Results.Single().GeneratedSources.Should().ContainSingle().Subject; + source.SourceText.ToString().Should().Contain("public static partial class OtherKeys"); } [Fact] @@ -420,21 +718,21 @@ public void WinFormsDesignerMetadataEntries_AreSkipped() var code = SingleGeneratedSource(result).SourceText.ToString(); code.Should().Contain("""Greeting = new("Greeting", """); - code.Should().NotContain("this"); + code.Should().NotContain("$this"); } - private static CSharpGeneratorDriver CreateTrackingDriver() => + private static CSharpGeneratorDriver CreateTrackingDriver(TestMetadata metadata) => CSharpGeneratorDriver.Create( [new ResxKeysGenerator().AsSourceGenerator()], parseOptions: null, - optionsProvider: null, + optionsProvider: metadata, driverOptions: new(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true) ); - private static string Designer(string ns) => + private static string Designer(string ns, string accessorType = "Strings") => $$""" namespace {{ns}} { - internal class Strings { + internal class {{accessorType}} { public static global::System.Resources.ResourceManager ResourceManager { get { return null; } } @@ -469,7 +767,13 @@ private static string Resx(params (string Name, string Value)[] entries) return builder.ToString(); } - private static GeneratorDriverRunResult RunGenerator(params (string Path, string Content)[] files) + private static GeneratorDriverRunResult RunGenerator(params (string Path, string Content)[] files) => + RunGenerator(new TestMetadata(), files); + + private static GeneratorDriverRunResult RunGenerator( + TestMetadata metadata, + params (string Path, string Content)[] files + ) { var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) .Split(Path.PathSeparator) @@ -493,7 +797,12 @@ private static GeneratorDriverRunResult RunGenerator(params (string Path, string .Select(AdditionalText (file) => new InMemoryAdditionalText(file.Path, file.Content)) .ToImmutableArray(); - var driver = CSharpGeneratorDriver.Create(new ResxKeysGenerator()).AddAdditionalTexts(additionalTexts); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new ResxKeysGenerator().AsSourceGenerator()], + additionalTexts, + parseOptions: null, + optionsProvider: metadata + ); driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out _); outputCompilation .GetDiagnostics() @@ -517,4 +826,84 @@ private sealed class InMemoryAdditionalText(string path, string content) : Addit public override SourceText GetText(CancellationToken cancellationToken = default) => SourceText.From(content, Encoding.UTF8); } + + /// + /// The analyzer config options a real build writes for each .resx. Paths are relative to a + /// project folder project/ whose root namespace is N: project/Resources/Strings.de.resx + /// gets ManifestResourceName N.Resources.Strings and WithCulture true, as + /// MSBuild computes them. A test overrides single values with or withholds them + /// all with . + /// + private sealed class TestMetadata : AnalyzerConfigOptionsProvider + { + private const string Prefix = "build_metadata.AdditionalFiles."; + + private static readonly HashSet CultureNames = CultureInfo + .GetCultures(CultureTypes.AllCultures) + .Select(static culture => culture.Name) + .Where(static name => name.Length > 0) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + private readonly Dictionary> overrides = new( + StringComparer.Ordinal + ); + + public override AnalyzerConfigOptions GlobalOptions => + new TestOptions(ImmutableDictionary.Empty); + + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => + new TestOptions(ImmutableDictionary.Empty); + + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => + new TestOptions(this.MetadataOf(textFile.Path)); + + public TestMetadata With(string path, params (string Name, string Value)[] metadata) + { + this.overrides[path] = this.MetadataOf(path) + .SetItems( + metadata.Select(static item => new KeyValuePair(Prefix + item.Name, item.Value)) + ); + + return this; + } + + public TestMetadata Without(string path) + { + this.overrides[path] = ImmutableDictionary.Empty; + + return this; + } + + private static ImmutableDictionary Derive(string path) + { + if (!path.EndsWith(".resx", StringComparison.OrdinalIgnoreCase)) + { + return ImmutableDictionary.Empty; + } + + var segments = path.Substring(0, path.Length - ".resx".Length).Split('/').Skip(1).ToList(); + var fileName = segments[^1]; + var lastDot = fileName.LastIndexOf('.'); + var withCulture = lastDot >= 0 && CultureNames.Contains(fileName.Substring(lastDot + 1)); + + if (withCulture) + { + segments[^1] = fileName.Substring(0, lastDot); + } + + return ImmutableDictionary + .Empty.Add(Prefix + "ManifestResourceName", "N." + string.Join(".", segments)) + .Add(Prefix + "LogicalName", string.Empty) + .Add(Prefix + "WithCulture", withCulture ? "true" : "false"); + } + + private ImmutableDictionary MetadataOf(string path) => + this.overrides.TryGetValue(path, out var metadata) ? metadata : Derive(path); + } + + private sealed class TestOptions(ImmutableDictionary values) : AnalyzerConfigOptions + { + public override bool TryGetValue(string key, [NotNullWhen(true)] out string? value) => + values.TryGetValue(key, out value); + } } diff --git a/tests/ResXLocalization.WPF.Sample.Tests/ArgumentTests.cs b/tests/ResXLocalization.WPF.Sample.Tests/ArgumentTests.cs new file mode 100644 index 0000000..4f3f086 --- /dev/null +++ b/tests/ResXLocalization.WPF.Sample.Tests/ArgumentTests.cs @@ -0,0 +1,624 @@ +using System.Reflection; +using System.Windows.Markup; +using System.Windows.Media; +using RentADeveloper.ResXLocalization.Core.Tests; + +namespace RentADeveloper.ResXLocalization.WPF.Sample.Tests; + +/// +/// Covers the format arguments of {l:Localize}, matching the sample's card 6: +/// entries supply constant or bound values, optionally named; the rendered text re-formats live on every +/// argument change and on every refresh of the localizer; and a localized text without arguments is not +/// formatted. +/// +public class ArgumentTests +{ + /// The XML namespace declarations of the parsed XAML snippets. + private const string Namespaces = """ + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:l="clr-namespace:RentADeveloper.ResXLocalization.WPF;assembly=ResXLocalization.WPF" + xmlns:res="clr-namespace:RentADeveloper.ResXLocalization.WPF.Sample.Resources;assembly=ResXLocalization.WPF.Sample" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + """; + + /// The items rendered by the data-template test; each becomes one argument value. + private static readonly int[] TemplateItems = [3, 7]; + + [Fact] + public void ArgumentChange_UpdatesTheText_Live() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var viewModel = new TestViewModel { Count = 1 }; + + var textBlock = TestSupport.BindLocalizedText( + new(ApplicationStringsKeys.PeopleInvited) { Arguments = { Arg(new Binding("Count")) } }, + viewModel + ); + textBlock.Text.Should().Be("1 people invited"); + + viewModel.Count = 2; + TestSupport.Flush(); + textBlock.Text.Should().Be("2 people invited"); + }); + + [Fact] + public void BoundArgument_UpdatesLive_OnValueAndCultureChange() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var viewModel = new TestViewModel { Count = 5 }; + + var textBlock = TestSupport.BindLocalizedText( + new(ApplicationStringsKeys.PeopleInvited) { Arguments = { Arg(new Binding("Count")) } }, + viewModel + ); + textBlock.Text.Should().Be("5 people invited"); + + viewModel.Count = 6; + TestSupport.Flush(); + textBlock.Text.Should().Be("6 people invited"); + + Localizer.Current.CurrentCulture = TestSupport.German; + TestSupport.Flush(); + textBlock.Text.Should().Be("6 Personen eingeladen"); + }); + + [Fact] + public void ConstantArguments_Format() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var textBlock = TestSupport.BindLocalizedText(new("FormatPair") { Arguments = { Arg(3), Arg("10") } }); + + textBlock.Text.Should().Be("File 3 of 10"); + }); + + [Fact] + public void CultureChange_ReFormats_Live() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var textBlock = TestSupport.BindLocalizedText( + new(ApplicationStringsKeys.PeopleInvited) { Arguments = { Arg(new Binding("Count")) } }, + new TestViewModel { Count = 3 } + ); + textBlock.Text.Should().Be("3 people invited"); + + Localizer.Current.CurrentCulture = TestSupport.German; + TestSupport.Flush(); + textBlock.Text.Should().Be("3 Personen eingeladen"); + }); + + [Fact] + public void DuplicateOrEmptyNames_Throw() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var duplicate = () => + new LocalizeExtension("FormatPair") { Arguments = { Arg("Count", 1), Arg("Count", 2) } }.ProvideValue( + null! + ); + var differentCase = () => + new LocalizeExtension("FormatPair") { Arguments = { Arg("A", 1), Arg("a", 2) } }.ProvideValue(null!); + var empty = () => + new LocalizeExtension("FormatPair") { Arguments = { Arg(string.Empty, 1) } }.ProvideValue(null!); + + duplicate.Should().Throw().WithMessage("*Count*"); + differentCase.Should().NotThrow(); + empty.Should().Throw().WithMessage("*Name*"); + }); + + [Fact] + public void FormattingFailure_RendersTheFailureText_AndRaisesLookupFailed() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + using var monitor = Localizer.Current.Monitor(); + + var textBlock = TestSupport.BindLocalizedText( + new("FormatBroken") { Arguments = { Arg(new Binding("Count")) } }, + new TestViewModel { Count = 1 } + ); + + textBlock.Text.Should().Be("!FormatBroken!"); + monitor + .LookupFailures() + .Should() + .NotBeEmpty() + .And.OnlyContain(failure => + failure.Reason == LookupFailureReason.MessageFormatFailed + && failure.Key == "FormatBroken" + && failure.Exception is FormatException + ); + }); + + [Fact] + public void InsideItemTemplate_EachItemFormatsItsOwnArguments() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + // Parsed (not compiled) XAML still exercises the real WPF template deferral: ProvideValue runs + // per applied item, and the argument value (the item, via the DataContext) arrives later. + const string xaml = $$""" + + + + + + + + + + + + + + """; + + var itemsControl = (ItemsControl)XamlReader.Parse(xaml); + itemsControl.ItemsSource = TemplateItems; + + // Realize the item containers and flush binding transfers without an interactive session. + itemsControl.Measure(new(500, 500)); + itemsControl.Arrange(new(0, 0, 500, 500)); + itemsControl.UpdateLayout(); + TestSupport.Flush(); + + var texts = VisualTexts(itemsControl); + texts.Should().Contain("3 people invited"); + texts.Should().Contain("7 people invited"); + }); + + [Fact] + public void InvalidKeyOrMissingValue_Throws() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var noKey = () => new LocalizeExtension().ProvideValue(null!); + var numberKey = () => new LocalizeExtension(42).ProvideValue(null!); + var defaultResourceKey = () => new LocalizeExtension(default(ResourceKey)).ProvideValue(null!); + var noValue = () => + new LocalizeExtension("FormatPair") { Arguments = { new Argument() } }.ProvideValue(null!); + + noKey.Should().Throw().WithMessage("*Key*"); + numberKey.Should().Throw().WithMessage("*System.Int32*"); + defaultResourceKey.Should().Throw(); + noValue.Should().Throw().WithMessage("*Value*"); + }); + + [Fact] + public void MissingKey_RendersTheFailureText() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + using var monitor = Localizer.Current.Monitor(); + + var textBlock = TestSupport.BindLocalizedText(new("ThisKeyDoesNotExist") { Arguments = { Arg(1) } }); + + textBlock.Text.Should().Be("!ThisKeyDoesNotExist!"); + monitor + .LookupFailures() + .Should() + .NotBeEmpty() + .And.OnlyContain(failure => failure.Reason == LookupFailureReason.TranslationNotFound); + }); + + [Fact] + public void MixedNamedAndUnnamedArguments_ReachTheFormatterWithPositionAndName() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var previous = Localizer.Current.MessageFormatter; + var formatter = new CapturingFormatter(); + Localizer.Current.MessageFormatter = formatter; + + try + { + var textBlock = TestSupport.BindLocalizedText( + new("FormatPair") { Arguments = { Arg(new Binding("A")), Arg("Total", new Binding("B")) } }, + new TestViewModel { A = 3, B = 10 } + ); + + textBlock.Text.Should().Be("File 3 of 10"); + formatter + .Calls[^1] + .Arguments.Should() + .Equal(new MessageArgument(0, null, 3), new MessageArgument(1, "Total", 10)); + } + finally + { + Localizer.Current.MessageFormatter = previous; + } + }); + + [Fact] + public void MultiBindingOrPriorityBindingValue_Throws() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var multiBinding = () => + new LocalizeExtension("FormatPair") { Arguments = { Arg(new MultiBinding()) } }.ProvideValue(null!); + var priorityBinding = () => + new LocalizeExtension("FormatPair") { Arguments = { Arg(new PriorityBinding()) } }.ProvideValue(null!); + + multiBinding.Should().Throw().WithMessage("*MultiBinding*"); + priorityBinding.Should().Throw().WithMessage("*PriorityBinding*"); + }); + + [Fact] + public void MultipleArguments_FormatAllPlaceholders() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var textBlock = TestSupport.BindLocalizedText( + new("FormatPair") { Arguments = { Arg(new Binding("A")), Arg(new Binding("B")) } }, + new TestViewModel { A = 2, B = 10 } + ); + + textBlock.Text.Should().Be("File 2 of 10"); + }); + + [Fact] + public void NamedArguments_FormatByPosition() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var textBlock = TestSupport.BindLocalizedText( + new("PartitionDisplay") + { + Arguments = + { + Arg("Label", new Binding("Label")), + Arg("Letter", new Binding("Letter")), + Arg("Drive", new Binding("Drive")), + Arg("Partition", new Binding("Partition")), + }, + }, + new TestViewModel + { + Label = "My USB", + Letter = "F", + Drive = 2, + Partition = 1, + } + ); + + textBlock.Text.Should().Be("My USB (F:) - drive 2, partition 1"); + }); + + [Fact] + public void NoArguments_ResolvesWithoutFormatting_KeepingLiteralBraces() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + // Without arguments the value must come back verbatim - composite formatting would throw on (or + // mangle) the un-escaped literal braces. + var textBlock = TestSupport.BindLocalizedText(new("CurlyNoArgs")); + + textBlock.Text.Should().Be("Literal {braces} stay"); + }); + + [Fact] + public void NullArgument_IsSet_AndFormatsAsEmpty() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + // Null is a set argument (distinct from "never set"), and string.Format renders it as empty. + var textBlock = TestSupport.BindLocalizedText( + new(ApplicationStringsKeys.PeopleInvited) { Arguments = { Arg(null) } } + ); + + textBlock.Text.Should().Be(" people invited"); + }); + + [Fact] + public void OneArgument_FormatsTheResolvedString() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var textBlock = TestSupport.BindLocalizedText( + new(ApplicationStringsKeys.PeopleInvited) { Arguments = { Arg(3) } } + ); + + textBlock.Text.Should().Be("3 people invited"); + }); + + [Fact] + public void ParsedXaml_AssignsTheArgumentBinding_AndPassesXNullAsNull() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + const string xaml = $$""" + + + + + + + + + """; + + var textBlock = (TextBlock)XamlReader.Parse(xaml); + textBlock.DataContext = new TestViewModel { A = 3 }; + TestSupport.Flush(); + + textBlock.Text.Should().Be("File 3 of "); + }); + + [Fact] + public void PublicSurface_HasTheDeclaredShape() => + WpfThread.Invoke(() => + { + var value = typeof(Argument).GetProperty(nameof(Argument.Value))!; + var key = typeof(LocalizeExtension).GetProperty(nameof(LocalizeExtension.Key))!; + var arguments = typeof(LocalizeExtension).GetProperty(nameof(LocalizeExtension.Arguments))!; + + value.PropertyType.Should().Be(); + key.PropertyType.Should().Be(); + arguments.PropertyType.GetGenericArguments().Should().Equal(typeof(Argument)); + typeof(LocalizeExtension).GetProperty("ResourceKey").Should().BeNull(); + typeof(LocalizeExtension).GetProperty("Argument").Should().BeNull(); + typeof(Argument).GetCustomAttribute().Should().NotBeNull(); + typeof(LocalizeExtension) + .GetCustomAttribute()! + .Name.Should() + .Be(nameof(LocalizeExtension.Arguments)); + typeof(Argument).IsAssignableTo(typeof(DependencyObject)).Should().BeFalse(); + }); + + [Fact] + public void RefreshTrigger_ReResolves_OnFormatterTextBuilderAndRegistrationChange() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var previousFormatter = Localizer.Current.MessageFormatter; + var previousTextBuilder = Localizer.Current.LookupFailureTextBuilder; + + try + { + var invited = TestSupport.BindLocalizedText( + new(ApplicationStringsKeys.PeopleInvited) { Arguments = { Arg(new Binding("Count")) } }, + new TestViewModel { Count = 5 } + ); + var missing = TestSupport.BindLocalizedText(new("ThisKeyDoesNotExist")); + Localizer.Current.UnregisterResourceManager(TestSupport.CatalogResources); + var catalogOnly = TestSupport.BindLocalizedText(new("CatalogOnly")); + + invited.Text.Should().Be("5 people invited"); + missing.Text.Should().Be("!ThisKeyDoesNotExist!"); + catalogOnly.Text.Should().Be("!CatalogOnly!"); + + Localizer.Current.MessageFormatter = new UpperCaseFormatter(); + TestSupport.Flush(); + invited.Text.Should().Be("{0} PEOPLE INVITED"); + missing.Text.Should().Be("!ThisKeyDoesNotExist!"); + catalogOnly.Text.Should().Be("!CatalogOnly!"); + + // The failure text bypasses the formatter, so it is not upper-cased. + Localizer.Current.LookupFailureTextBuilder = failure => $"[{failure.Key}]"; + TestSupport.Flush(); + missing.Text.Should().Be("[ThisKeyDoesNotExist]"); + catalogOnly.Text.Should().Be("[CatalogOnly]"); + + Localizer.Current.RegisterResourceManager(TestSupport.CatalogResources); + TestSupport.Flush(); + catalogOnly.Text.Should().Be("CATALOGONLYVALUE"); + } + finally + { + Localizer.Current.MessageFormatter = previousFormatter; + Localizer.Current.LookupFailureTextBuilder = previousTextBuilder; + + // Registering again appended Catalog after Fallback; restore the registration order. + Localizer.Current.ClearResourceManagers(); + TestSupport.ResetToEnglishWithTestCatalogs(); + } + }); + + [Fact] + public void ScopedLookup_FormatsWithArguments() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var textBlock = TestSupport.BindLocalizedText( + new() + { + Key = "PeopleInvited", + ResourceManager = ApplicationStringsKeys.ResourceManager, + Arguments = { Arg(4) }, + } + ); + + textBlock.Text.Should().Be("4 people invited"); + }); + + [Fact] + public void StyleSetter_FormatsWithArguments_WithoutATargetElement() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + // Parsed XAML, because WPF calls ProvideValue with the Setter as its target only from the XAML + // reader; the extension then returns the multi-binding itself, which the style applies per element. + const string xaml = $$""" + + + + + + + + """; + + var host = (StackPanel)XamlReader.Parse(xaml); + host.DataContext = new TestViewModel { A = 3 }; + host.Measure(new(500, 500)); + host.Arrange(new(0, 0, 500, 500)); + host.UpdateLayout(); + TestSupport.Flush(); + + host.Children.OfType() + .Select(textBlock => textBlock.Text) + .Should() + .Equal("File 3 of 7", "File 3 of 7"); + + Localizer.Current.CurrentCulture = TestSupport.German; + TestSupport.Flush(); + host.Children.OfType() + .Select(textBlock => textBlock.Text) + .Should() + .Equal("Datei 3 von 7", "Datei 3 von 7"); + }); + + [Fact] + public void TextAndToolTip_OnOneElement_HaveTheirOwnArguments() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var textBlock = new TextBlock(); + + textBlock.SetBinding( + TextBlock.TextProperty, + (BindingBase) + new LocalizeExtension("FormatPair") + { + Arguments = { Arg(new Binding("A")), Arg(new Binding("B")) }, + }.ProvideValue(null!) + ); + textBlock.SetBinding( + FrameworkElement.ToolTipProperty, + (BindingBase) + new LocalizeExtension("FormatPair") + { + Arguments = { Arg(new Binding("B")), Arg(new Binding("A")) }, + }.ProvideValue(null!) + ); + textBlock.DataContext = new TestViewModel { A = 1, B = 2 }; + TestSupport.Flush(); + + textBlock.Text.Should().Be("File 1 of 2"); + textBlock.ToolTip.Should().Be("File 2 of 1"); + }); + + [Fact] + public void TwelveArguments_HaveNoUpperLimit() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var extension = new LocalizeExtension("Twelve"); + + foreach (var letter in "abcdefghijkl") + { + extension.Arguments.Add(Arg(letter.ToString())); + } + + var textBlock = TestSupport.BindLocalizedText(extension); + + textBlock.Text.Should().Be("abcdefghijkl"); + }); + + [Fact] + public void UnresolvedAndNullValues_RenderEmpty_AndReachTheFormatterAsNull() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var previous = Localizer.Current.MessageFormatter; + var formatter = new CapturingFormatter(); + Localizer.Current.MessageFormatter = formatter; + + // Installing the formatter re-resolves every localized binding still alive; count only this test's. + TestSupport.Flush(); + var firstCall = formatter.Calls.Count; + + try + { + var unresolved = TestSupport.BindLocalizedText( + new("FormatPair") { Arguments = { Arg(new Binding("A")), Arg(new Binding("B")) } } + ); + var nulls = TestSupport.BindLocalizedText( + new("FormatPair") { Arguments = { Arg(null), Arg(new Binding("Nothing")) } }, + new TestViewModel() + ); + + unresolved.Text.Should().Be("File of "); + nulls.Text.Should().Be("File of "); + formatter + .Calls.Skip(firstCall) + .SelectMany(call => call.Arguments) + .Should() + .NotBeEmpty() + .And.OnlyContain(argument => argument.Value == null); + } + finally + { + Localizer.Current.MessageFormatter = previous; + } + }); + + /// Creates an unnamed argument. + /// The constant or the binding. + /// The argument. + private static Argument Arg(object? value) => new() { Value = value }; + + /// Creates a named argument. + /// The name. + /// The constant or the binding. + /// The argument. + private static Argument Arg(string name, object? value) => new() { Name = name, Value = value }; + + private static IEnumerable VisualDescendants(DependencyObject root) + { + for (var index = 0; index < VisualTreeHelper.GetChildrenCount(root); index++) + { + var child = VisualTreeHelper.GetChild(root, index); + yield return child; + + foreach (var descendant in VisualDescendants(child)) + { + yield return descendant; + } + } + } + + /// Collects the non-empty text of every in the visual tree. + /// The root of the visual tree to inspect. + /// The non-empty text values in visual-tree traversal order. + private static string[] VisualTexts(DependencyObject root) => + VisualDescendants(root) + .OfType() + .Select(textBlock => textBlock.Text ?? string.Empty) + .Where(text => text.Length > 0) + .ToArray(); + + /// A formatter that ignores the arguments and upper-cases the resolved value. + internal sealed class UpperCaseFormatter : IMessageFormatter + { + /// + public string Format(string pattern, MessageArguments arguments, CultureInfo culture) => + pattern.ToUpperInvariant(); + } +} diff --git a/tests/ResXLocalization.WPF.Sample.Tests/BoundKeyTests.cs b/tests/ResXLocalization.WPF.Sample.Tests/BoundKeyTests.cs new file mode 100644 index 0000000..ef93d69 --- /dev/null +++ b/tests/ResXLocalization.WPF.Sample.Tests/BoundKeyTests.cs @@ -0,0 +1,179 @@ +using System.Windows.Markup; +using RentADeveloper.ResXLocalization.Core.Tests; + +namespace RentADeveloper.ResXLocalization.WPF.Sample.Tests; + +/// +/// Covers a bound key of {l:Localize}: the key comes from the view model, as a +/// or a , is re-read on every conversion, works with arguments +/// and a scoping resource manager, and renders an empty string instead of throwing when the bound value gives +/// no key. +/// +public class BoundKeyTests +{ + /// The XML namespace declarations of the parsed XAML snippets. + private const string Namespaces = """ + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:l="clr-namespace:RentADeveloper.ResXLocalization.WPF;assembly=ResXLocalization.WPF" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + """; + + [Fact] + public void BoundKeyProperty_Resolves() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var textBlock = TestSupport.BindLocalizedText( + new() { Key = new Binding("Key") }, + new TestViewModel { Key = "Greeting" } + ); + + textBlock.Text.Should().Be("Hello and welcome!"); + }); + + [Fact] + public void BoundKey_ReResolvesOnCultureChange() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var viewModel = new TestViewModel { Key = ApplicationStringsKeys.Greeting }; + + var textBlock = TestSupport.BindLocalizedText(new(new Binding("Key")), viewModel); + textBlock.Text.Should().Be("Hello and welcome!"); + + Localizer.Current.CurrentCulture = TestSupport.German; + TestSupport.Flush(); + textBlock.Text.Should().Be("Hallo und willkommen!"); + }); + + [Fact] + public void BoundKey_ResolvesResourceKeyStringAndEmptyKey() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var viewModel = new TestViewModel { Key = ApplicationStringsKeys.Greeting }; + + var textBlock = TestSupport.BindLocalizedText(new(new Binding("Key")), viewModel); + textBlock.Text.Should().Be("Hello and welcome!"); + + viewModel.Key = "CatalogOnly"; + TestSupport.Flush(); + textBlock.Text.Should().Be("CatalogOnlyValue"); + + viewModel.Key = string.Empty; + TestSupport.Flush(); + textBlock.Text.Should().BeEmpty(); + }); + + [Fact] + public void BoundKey_WithArguments_Formats() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var textBlock = TestSupport.BindLocalizedText( + new(new Binding("Key")) { Arguments = { Arg(new Binding("A")), Arg(new Binding("B")) } }, + new TestViewModel + { + Key = "FormatPair", + A = 3, + B = 10, + } + ); + + textBlock.Text.Should().Be("File 3 of 10"); + }); + + [Fact] + public void BoundKey_WithoutDataContext_RendersEmpty() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var bind = () => TestSupport.BindLocalizedText(new(new Binding("Key"))); + + bind.Should().NotThrow().Which.Text.Should().BeEmpty(); + }); + + [Fact] + public void BoundKey_WithoutUsableValue_RendersEmpty_WithoutEvent() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + using var monitor = Localizer.Current.Monitor(); + var viewModel = new TestViewModel { Key = null }; + + var textBlock = TestSupport.BindLocalizedText(new(new Binding("Key")), viewModel); + textBlock.Text.Should().BeEmpty(); + + viewModel.Key = 42; + TestSupport.Flush(); + textBlock.Text.Should().BeEmpty(); + + viewModel.Key = default(ResourceKey); + TestSupport.Flush(); + textBlock.Text.Should().BeEmpty(); + monitor.LookupFailures().Should().BeEmpty(); + + viewModel.Key = ApplicationStringsKeys.Greeting; + TestSupport.Flush(); + textBlock.Text.Should().Be("Hello and welcome!"); + }); + + [Fact] + public void BoundStringKey_WithResourceManager_IsScoped() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var textBlock = TestSupport.BindLocalizedText( + new(new Binding("Key")) { ResourceManager = ApplicationStringsKeys.ResourceManager }, + new TestViewModel { Key = "Greeting" } + ); + + textBlock.Text.Should().Be("Hello and welcome!"); + }); + + [Fact] + public void MultiBindingKey_Throws() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var provide = () => new LocalizeExtension { Key = new MultiBinding() }.ProvideValue(null!); + + provide.Should().Throw().WithMessage("*MultiBinding*"); + }); + + [Fact] + public void ParsedBoundKey_IsAssignedByTheHandler() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + const string attributeXaml = $$$""""""; + const string elementXaml = $$""" + + + + + + + + """; + + var attribute = (TextBlock)XamlReader.Parse(attributeXaml); + var element = (TextBlock)XamlReader.Parse(elementXaml); + attribute.DataContext = new TestViewModel { Key = "Greeting" }; + element.DataContext = new TestViewModel { Key = "Greeting" }; + TestSupport.Flush(); + + attribute.Text.Should().Be("Hello and welcome!"); + element.Text.Should().Be("Hello and welcome!"); + }); + + /// Creates a positional argument. + /// The value: a binding or a constant. + /// The argument. + private static Argument Arg(object? value) => new() { Value = value }; +} diff --git a/tests/ResXLocalization.WPF.Sample.Tests/DesignTimeTests.cs b/tests/ResXLocalization.WPF.Sample.Tests/DesignTimeTests.cs new file mode 100644 index 0000000..3bf00de --- /dev/null +++ b/tests/ResXLocalization.WPF.Sample.Tests/DesignTimeTests.cs @@ -0,0 +1,188 @@ +using System.Reflection; + +namespace RentADeveloper.ResXLocalization.WPF.Sample.Tests; + +/// +/// Covers .Culture and the design-time fallback of and +/// : inside a XAML designer that supplies the target property as a CLR +/// property, as Rider's WPF previewer does, the extensions return a string instead of a binding. +/// +public class DesignTimeTests +{ + /// Setter.Value, a CLR property in every host. + private static readonly PropertyInfo SetterValue = typeof(Setter).GetProperty(nameof(Setter.Value))!; + + /// TextBlock.Text as a CLR property, the way Rider's WPF previewer supplies it. + private static readonly PropertyInfo TextBlockTextProperty = typeof(TextBlock).GetProperty(nameof(TextBlock.Text))!; + + [Fact] + public void DesignTime_DoesNothing_AtRunTime() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + using var scope = new TestSupport.DesignModeScope(false); + var textBlock = new TextBlock(); + + try + { + DesignTime.SetCulture(textBlock, "de"); + + Localizer.Current.CurrentCulture.Name.Should().Be("en"); + DesignTime.GetCulture(textBlock).Should().Be("de"); + } + finally + { + Localizer.Current.CurrentCulture = TestSupport.English; + } + }); + + [Fact] + public void DesignTime_EmptyOrInvalidName_LeavesTheCultureUnchanged() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + using var scope = new TestSupport.DesignModeScope(true); + var textBlock = new TextBlock(); + + try + { + DesignTime.SetCulture(textBlock, "invalid!culture"); + Localizer.Current.CurrentCulture.Name.Should().Be("en"); + + DesignTime.SetCulture(textBlock, ""); + Localizer.Current.CurrentCulture.Name.Should().Be("en"); + } + finally + { + Localizer.Current.CurrentCulture = TestSupport.English; + } + }); + + [Fact] + public void DesignTime_SetsTheCulture_InDesignMode() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + using var scope = new TestSupport.DesignModeScope(true); + + try + { + DesignTime.SetCulture(new TextBlock(), "de"); + + Localizer.Current.CurrentCulture.Name.Should().Be("de"); + } + finally + { + Localizer.Current.CurrentCulture = TestSupport.English; + } + }); + + [Fact] + public void Fallback_BoundKey_ReturnsAnEmptyString() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + using var scope = new TestSupport.DesignModeScope(true); + + var value = new LocalizeExtension(new Binding("Key")).ProvideValue(ClrTextTarget()); + + value.Should().Be(""); + }); + + [Fact] + public void Fallback_Enum_ReturnsTheConstantMemberText_OrAnEmptyStringForABinding() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + using var scope = new TestSupport.DesignModeScope(true); + + var bound = new LocalizeEnumExtension { Value = new Binding(nameof(TestViewModel.SortOrder)) }.ProvideValue( + ClrTextTarget() + ); + var constant = new LocalizeEnumExtension { Value = FileSortOrder.Ascending }.ProvideValue(ClrTextTarget()); + + bound.Should().Be(""); + constant.Should().Be("Ascending"); + }); + + [Fact] + public void Fallback_MissingKey_ReturnsTheFailureText() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + using var scope = new TestSupport.DesignModeScope(true); + + var value = new LocalizeExtension("ThisKeyDoesNotExist").ProvideValue(ClrTextTarget()); + + value.Should().Be("!ThisKeyDoesNotExist!"); + }); + + [Fact] + public void Fallback_PrecedesValidation() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + using var scope = new TestSupport.DesignModeScope(true); + + var withoutKey = () => new LocalizeExtension().ProvideValue(ClrTextTarget()); + var withUnsetArgument = () => + new LocalizeExtension("Greeting") { Arguments = { new Argument() } }.ProvideValue(ClrTextTarget()); + + withoutKey.Should().NotThrow().Which.Should().Be(""); + withUnsetArgument.Should().NotThrow().Which.Should().Be("Hello and welcome!"); + }); + + [Fact] + public void Fallback_TypedKey_ReturnsTheRawPattern_ForAClrTargetProperty() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + using var scope = new TestSupport.DesignModeScope(true); + + var value = PeopleInvitedWithArgument().ProvideValue(ClrTextTarget()); + + value.Should().Be("{0} people invited"); + }); + + [Fact] + public void NoFallback_ForADependencyPropertyTarget_InDesignMode() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + using var scope = new TestSupport.DesignModeScope(true); + + var value = PeopleInvitedWithArgument().ProvideValue(Target(new TextBlock(), TextBlock.TextProperty)); + + value.Should().BeOfType(); + }); + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void NoFallback_ForASetter(bool isInDesignMode) => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + using var scope = new TestSupport.DesignModeScope(isInDesignMode); + var setter = new Setter(); + + var value = PeopleInvitedWithArgument().ProvideValue(Target(setter, SetterValue)); + + // WPF's Setter handles markup extensions itself: the MultiBinding is returned, or assigned to + // Setter.Value. + (value is MultiBinding || setter.Value is MultiBinding) + .Should() + .BeTrue(); + }); + + private static IServiceProvider ClrTextTarget() => Target(new TextBlock(), TextBlockTextProperty); + + private static LocalizeExtension PeopleInvitedWithArgument() => + new(ApplicationStringsKeys.PeopleInvited) + { + Arguments = { new Argument { Value = new Binding(nameof(TestViewModel.Count)) } }, + }; + + private static IServiceProvider Target(object targetObject, object targetProperty) => + new TestSupport.SimpleServiceProvider(new TestSupport.SimpleProvideValueTarget(targetObject, targetProperty)); +} diff --git a/tests/ResXLocalization.WPF.Sample.Tests/GeneratedKeysTests.cs b/tests/ResXLocalization.WPF.Sample.Tests/GeneratedKeysTests.cs new file mode 100644 index 0000000..58421d5 --- /dev/null +++ b/tests/ResXLocalization.WPF.Sample.Tests/GeneratedKeysTests.cs @@ -0,0 +1,41 @@ +namespace RentADeveloper.ResXLocalization.WPF.Sample.Tests; + +/// +/// Covers the generated …Keys classes of the sample: their keys use the class's own generated +/// resource manager, not the designer's. +/// +public class GeneratedKeysTests +{ + [Fact] + public void SearchAll_FindsTheSampleFiles_ThroughTheGeneratedManagers() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + Localizer.Current.Get("Greeting").Should().Be("Hello and welcome!"); + Localizer.Current.Get("PoweredBy").Should().Be("Powered by rent-a-developer"); + TestSupport.BindLocalizedText(new("Greeting")).Text.Should().Be("Hello and welcome!"); + + try + { + // Proves that the generated instance is the registered one. + Localizer.Current.UnregisterResourceManager(ApplicationStringsKeys.ResourceManager).Should().BeTrue(); + } + finally + { + Localizer.Current.RegisterResourceManager(ApplicationStringsKeys.ResourceManager); + } + }); + + [Fact] + public void TypedKeys_UseTheGeneratedResourceManager() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + ReferenceEquals(ApplicationStringsKeys.Greeting.Manager, ApplicationStringsKeys.ResourceManager) + .Should() + .BeTrue(); + Localizer.Current.Get(ApplicationStringsKeys.Greeting).Should().Be("Hello and welcome!"); + }); +} diff --git a/tests/ResXLocalization.WPF.Sample.Tests/LocalizeArgsTests.cs b/tests/ResXLocalization.WPF.Sample.Tests/LocalizeArgsTests.cs deleted file mode 100644 index be9c281..0000000 --- a/tests/ResXLocalization.WPF.Sample.Tests/LocalizeArgsTests.cs +++ /dev/null @@ -1,234 +0,0 @@ -using System.Windows.Markup; -using System.Windows.Media; - -namespace RentADeveloper.ResXLocalization.WPF.Sample.Tests; - -/// -/// Covers the dynamic composite-format arguments of {l:Localize}, matching the sample's card 6: -/// the attached properties supply the arguments, the rendered text -/// re-formats live on every argument or culture change, and the no-argument behavior stays unchanged. -/// -public class LocalizeArgsTests -{ - /// The items rendered by the data-template test; each becomes one Arg0 value. - private static readonly int[] TemplateItems = [3, 7]; - - [Fact] - public void ArgumentChange_UpdatesTheText_Live() => - WpfThread.Invoke(() => - { - TestSupport.ResetToEnglishWithTestCatalogs(); - - var textBlock = TestSupport.BindLocalizedText(new(ApplicationStringsKeys.PeopleInvited)); - LocalizeArgs.SetArg0(textBlock, 1); - TestSupport.Flush(); - textBlock.Text.Should().Be("1 people invited"); - - LocalizeArgs.SetArg0(textBlock, 2); - TestSupport.Flush(); - textBlock.Text.Should().Be("2 people invited"); - }); - - [Fact] - public void BoundControlsWithArguments_AreCollected_WhenDiscarded() => - WpfThread.Invoke(() => - { - TestSupport.ResetToEnglishWithTestCatalogs(); - - var references = CreateAndAbandonBoundControls(200); - - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); - - references.Should().NotContain(reference => reference.IsAlive); - }); - - [Fact] - public void CultureChange_ReFormats_Live() => - WpfThread.Invoke(() => - { - TestSupport.ResetToEnglishWithTestCatalogs(); - - var textBlock = TestSupport.BindLocalizedText(new(ApplicationStringsKeys.PeopleInvited)); - LocalizeArgs.SetArg0(textBlock, 3); - TestSupport.Flush(); - textBlock.Text.Should().Be("3 people invited"); - - Localizer.Current.CurrentCulture = TestSupport.German; - TestSupport.Flush(); - textBlock.Text.Should().Be("3 Personen eingeladen"); - }); - - [Fact] - public void HigherSlotAlone_FillsInteriorGapsWithNull() => - WpfThread.Invoke(() => - { - TestSupport.ResetToEnglishWithTestCatalogs(); - - var textBlock = TestSupport.BindLocalizedText(new("FormatGap")); - LocalizeArgs.SetArg2(textBlock, "X"); - TestSupport.Flush(); - - // Only Arg2 is set: Arg0 and Arg1 become null, so {0} renders empty and {2} renders "X". - textBlock.Text.Should().Be("[|X]"); - }); - - [Fact] - public void InsideDataTemplate_EachItemFormatsItsOwnArguments() => - WpfThread.Invoke(() => - { - TestSupport.ResetToEnglishWithTestCatalogs(); - - // Parsed (not compiled) XAML still exercises the real WPF template deferral: ProvideValue - // runs once while the template loads, and the RelativeSource.Self bindings must resolve - // per applied item, with the argument (the item, via the DataContext) arriving later. - const string xaml = """ - - - - - - - - """; - - var itemsControl = (ItemsControl)XamlReader.Parse(xaml); - itemsControl.ItemsSource = TemplateItems; - - // Realize the item containers and flush binding transfers without an interactive session. - itemsControl.Measure(new(500, 500)); - itemsControl.Arrange(new(0, 0, 500, 500)); - itemsControl.UpdateLayout(); - TestSupport.Flush(); - - var texts = VisualTexts(itemsControl); - texts.Should().Contain("3 people invited"); - texts.Should().Contain("7 people invited"); - }); - - [Fact] - public void MissingKey_ReturnsSentinelUnformatted() => - WpfThread.Invoke(() => - { - TestSupport.ResetToEnglishWithTestCatalogs(); - - var textBlock = TestSupport.BindLocalizedText(new("NoSuchKeyEver")); - LocalizeArgs.SetArg0(textBlock, 1); - TestSupport.Flush(); - - textBlock.Text.Should().Be("!NoSuchKeyEver!"); - }); - - [Fact] - public void MultipleArguments_FormatAllPlaceholders() => - WpfThread.Invoke(() => - { - TestSupport.ResetToEnglishWithTestCatalogs(); - - var textBlock = TestSupport.BindLocalizedText(new("FormatPair")); - LocalizeArgs.SetArg0(textBlock, 2); - LocalizeArgs.SetArg1(textBlock, 10); - TestSupport.Flush(); - - textBlock.Text.Should().Be("File 2 of 10"); - }); - - [Fact] - public void NoArguments_ResolvesWithoutFormatting_KeepingLiteralBraces() => - WpfThread.Invoke(() => - { - TestSupport.ResetToEnglishWithTestCatalogs(); - - // No argument is set, so the value must come back verbatim - composite formatting would - // throw on (or mangle) the un-escaped literal braces. - var textBlock = TestSupport.BindLocalizedText(new("CurlyNoArgs")); - - textBlock.Text.Should().Be("Literal {braces} stay"); - }); - - [Fact] - public void NullArgument_IsSet_AndFormatsAsEmpty() => - WpfThread.Invoke(() => - { - TestSupport.ResetToEnglishWithTestCatalogs(); - - var textBlock = TestSupport.BindLocalizedText(new(ApplicationStringsKeys.PeopleInvited)); - LocalizeArgs.SetArg0(textBlock, null); - TestSupport.Flush(); - - // Null is a set argument (distinct from "never set"), and String.Format renders it as empty. - textBlock.Text.Should().Be(" people invited"); - }); - - [Fact] - public void OneArgument_FormatsTheResolvedString() => - WpfThread.Invoke(() => - { - TestSupport.ResetToEnglishWithTestCatalogs(); - - var textBlock = TestSupport.BindLocalizedText(new(ApplicationStringsKeys.PeopleInvited)); - LocalizeArgs.SetArg0(textBlock, 3); - TestSupport.Flush(); - - textBlock.Text.Should().Be("3 people invited"); - }); - - [Fact] - public void ScopedLookup_FormatsWithArguments() => - WpfThread.Invoke(() => - { - TestSupport.ResetToEnglishWithTestCatalogs(); - - var textBlock = TestSupport.BindLocalizedText( - new() { Key = "PeopleInvited", ResourceManager = ApplicationStrings.ResourceManager } - ); - LocalizeArgs.SetArg0(textBlock, 4); - TestSupport.Flush(); - - textBlock.Text.Should().Be("4 people invited"); - }); - - private static List CreateAndAbandonBoundControls(int count) - { - var references = new List(count); - for (var index = 0; index < count; index++) - { - var textBlock = TestSupport.BindLocalizedText(new(ApplicationStringsKeys.PeopleInvited)); - LocalizeArgs.SetArg0(textBlock, index); - references.Add(new(textBlock)); - } - - TestSupport.Flush(); - - return references; - } - - private static IEnumerable VisualDescendants(DependencyObject root) - { - for (var index = 0; index < VisualTreeHelper.GetChildrenCount(root); index++) - { - var child = VisualTreeHelper.GetChild(root, index); - yield return child; - - foreach (var descendant in VisualDescendants(child)) - { - yield return descendant; - } - } - } - - /// Collects the non-empty text of every in the visual tree. - /// The root of the visual tree to inspect. - /// The non-empty text values in visual-tree traversal order. - private static string[] VisualTexts(DependencyObject root) => - VisualDescendants(root) - .OfType() - .Select(textBlock => textBlock.Text ?? string.Empty) - .Where(text => text.Length > 0) - .ToArray(); -} diff --git a/tests/ResXLocalization.WPF.Sample.Tests/LocalizeEnumConverterTests.cs b/tests/ResXLocalization.WPF.Sample.Tests/LocalizeEnumConverterTests.cs index 53d10c2..25e01df 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/LocalizeEnumConverterTests.cs +++ b/tests/ResXLocalization.WPF.Sample.Tests/LocalizeEnumConverterTests.cs @@ -1,11 +1,86 @@ +using RentADeveloper.ResXLocalization.Core.Tests; + namespace RentADeveloper.ResXLocalization.WPF.Sample.Tests; /// -/// Covers the four configurations of (the "enum bound as a value" -/// path, card 5), both by calling Convert directly and through a live MultiBinding. +/// Covers (the hand-written MultiBinding path, card 5), both by +/// calling Convert directly and through a live MultiBinding: value 0 is the refresh trigger, +/// value 1 the enumeration value, and values 2 and up the format arguments, named by +/// . /// public class LocalizeEnumConverterTests { + [Fact] + public void ArgumentNames_DuplicateName_OrSharedInstance_Throws() => + WpfThread.Invoke(() => + { + var duplicate = () => new LocalizeEnumConverter { ArgumentNames = "A,A" }; + var differentCase = () => new LocalizeEnumConverter { ArgumentNames = "A,a" }; + var shared = () => LocalizeEnumConverter.Default.ArgumentNames = "A"; + + duplicate.Should().Throw().WithMessage("*A*"); + differentCase.Should().NotThrow(); + shared.Should().Throw(); + }); + + [Fact] + public void ArgumentNames_NameTheArgumentsByPosition() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var previous = Localizer.Current.MessageFormatter; + var formatter = new CapturingFormatter(); + Localizer.Current.MessageFormatter = formatter; + + try + { + var converter = new LocalizeEnumConverter { KeyPrefix = "Count_", ArgumentNames = " Count , Total" }; + + Convert(converter, 0, FileSortOrder.Ascending, 3, 10).Should().Be("3 files, ascending"); + converter.ArgumentNames.Should().Be(" Count , Total"); + formatter + .Calls[^1] + .Arguments.Should() + .Equal(new MessageArgument(0, "Count", 3), new MessageArgument(1, "Total", 10)); + + converter.ArgumentNames = ",Total"; + Convert(converter, 0, FileSortOrder.Ascending, 3, 10).Should().Be("3 files, ascending"); + converter.ArgumentNames.Should().Be(",Total"); + formatter + .Calls[^1] + .Arguments.Should() + .Equal(new MessageArgument(0, null, 3), new MessageArgument(1, "Total", 10)); + + converter.ArgumentNames = "Count,Total,Extra"; + Convert(converter, 0, FileSortOrder.Ascending, 3).Should().Be("3 files, ascending"); + converter.ArgumentNames.Should().Be("Count,Total,Extra"); + formatter.Calls[^1].Arguments.Should().Equal(new MessageArgument(0, "Count", 3)); + } + finally + { + Localizer.Current.MessageFormatter = previous; + } + }); + + [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(), 0, FileSortOrder.Ascending).Should().Be("Custom ascending"); + } + finally + { + Localizer.Current.EnumKeyBuilder = original; + } + }); + [Fact] public void CustomPrefix_NoManager_ResolvesViaSearchAll() => WpfThread.Invoke(() => @@ -14,7 +89,7 @@ public void CustomPrefix_NoManager_ResolvesViaSearchAll() => var converter = new LocalizeEnumConverter { KeyPrefix = "Display_" }; - Convert(converter, FileSortOrder.Ascending).Should().Be("A to Z"); + Convert(converter, 0, FileSortOrder.Ascending).Should().Be("A to Z"); }); [Fact] @@ -26,10 +101,10 @@ public void CustomPrefix_WithManager_ResolvesScoped() => var converter = new LocalizeEnumConverter { KeyPrefix = "Display_", - ResourceManager = SortingStrings.ResourceManager, + ResourceManager = SortingStringsKeys.ResourceManager, }; - Convert(converter, FileSortOrder.Ascending).Should().Be("A to Z"); + Convert(converter, 0, FileSortOrder.Ascending).Should().Be("A to Z"); }); [Fact] @@ -38,9 +113,9 @@ public void DefaultPrefix_WithManager_ResolvesScoped() => { TestSupport.ResetToEnglishWithTestCatalogs(); - var converter = new LocalizeEnumConverter { ResourceManager = ApplicationStrings.ResourceManager }; + var converter = new LocalizeEnumConverter { ResourceManager = ApplicationStringsKeys.ResourceManager }; - Convert(converter, FileSortOrder.Ascending).Should().Be("Ascending"); + Convert(converter, 0, FileSortOrder.Ascending).Should().Be("Ascending"); }); [Fact] @@ -49,7 +124,23 @@ public void Default_NoPrefix_NoManager_ResolvesViaSearchAll() => { TestSupport.ResetToEnglishWithTestCatalogs(); - Convert(LocalizeEnumConverter.Default, FileSortOrder.Ascending).Should().Be("Ascending"); + Convert(LocalizeEnumConverter.Default, 0, 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] @@ -59,10 +150,12 @@ public void MultiBinding_SwitchesLive_OnCultureChange() => TestSupport.ResetToEnglishWithTestCatalogs(); var multiBinding = new MultiBinding { Converter = LocalizeEnumConverter.Default }; + multiBinding.Bindings.Add( + new Binding(nameof(ILocalizer.RefreshTriggeringRevision)) { Source = Localizer.Current } + ); multiBinding.Bindings.Add( new Binding(nameof(Marker.Value)) { Source = new Marker(FileSortOrder.Ascending) } ); - multiBinding.Bindings.Add(new Binding(nameof(ILocalizer.CurrentCulture)) { Source = Localizer.Current }); var textBlock = new TextBlock(); textBlock.SetBinding(TextBlock.TextProperty, multiBinding); @@ -80,11 +173,28 @@ public void NonEnumOrEmptyInput_ReturnsEmptyString() => { TestSupport.ResetToEnglishWithTestCatalogs(); - Convert(LocalizeEnumConverter.Default).Should().Be(string.Empty); - LocalizeEnumConverter - .Default.Convert(["not an enum"], typeof(string), null, CultureInfo.InvariantCulture) - .Should() - .Be(string.Empty); + Convert(LocalizeEnumConverter.Default, 0, "not an enum").Should().Be(string.Empty); + Convert(LocalizeEnumConverter.Default, 0, null).Should().Be(string.Empty); + }); + + [Fact] + public void OldChildOrderOrTooFewValues_ReturnEmptyString() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + Convert(LocalizeEnumConverter.Default, FileSortOrder.Ascending, TestSupport.English).Should().BeEmpty(); + Convert(LocalizeEnumConverter.Default, 0).Should().BeEmpty(); + Convert(LocalizeEnumConverter.Default).Should().BeEmpty(); + }); + + [Fact] + public void OneArgument_FormatsTheEnumText() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + Convert(new() { KeyPrefix = "Count_" }, 0, FileSortOrder.Ascending, 3).Should().Be("3 files, ascending"); }); [Fact] @@ -96,7 +206,7 @@ public void SharedDefaultInstance_IsReadOnly() => ((Action)(() => LocalizeEnumConverter.Default.KeyPrefix = "Display_")) .Should() .Throw(); - ((Action)(() => LocalizeEnumConverter.Default.ResourceManager = ApplicationStrings.ResourceManager)) + ((Action)(() => LocalizeEnumConverter.Default.ResourceManager = ApplicationStringsKeys.ResourceManager)) .Should() .Throw(); @@ -104,6 +214,40 @@ public void SharedDefaultInstance_IsReadOnly() => own.KeyPrefix.Should().Be("Display_"); }); + [Fact] + public void TriggerSlot_IsNeverAnArgument() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + Convert(new() { KeyPrefix = "Count_" }, "not-an-argument", FileSortOrder.Ascending) + .Should() + .Be("{0} files, ascending"); + }); + + [Fact] + public void TwoValues_DoNotFormat_KeepingLiteralBraces() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + using var monitor = Localizer.Current.Monitor(); + + Convert(new() { KeyPrefix = "Braces_" }, 0, FileSortOrder.Ascending).Should().Be("{Ascending}"); + + monitor.EventLog().Should().BeEmpty(); + }); + + [Fact] + public void UnsetArgument_FormatsAsEmpty() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + Convert(new() { KeyPrefix = "Count_" }, 0, FileSortOrder.Ascending, DependencyProperty.UnsetValue) + .Should() + .Be(" files, ascending"); + }); + private static string Convert(LocalizeEnumConverter converter, params object?[] values) => (string)converter.Convert(values, typeof(string), null, CultureInfo.InvariantCulture); diff --git a/tests/ResXLocalization.WPF.Sample.Tests/LocalizeEnumExtensionTests.cs b/tests/ResXLocalization.WPF.Sample.Tests/LocalizeEnumExtensionTests.cs index 221c73b..779c091 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/LocalizeEnumExtensionTests.cs +++ b/tests/ResXLocalization.WPF.Sample.Tests/LocalizeEnumExtensionTests.cs @@ -1,19 +1,67 @@ +using System.Reflection; +using System.Windows.Markup; + namespace RentADeveloper.ResXLocalization.WPF.Sample.Tests; /// -/// Covers the four KeyPrefix × ResourceManager combinations of the {l:LocalizeEnum} markup -/// extension, matching the sample's card 4. The extension reads the control's DataContext, so each -/// test binds it to a text block whose data context is the enumeration value. +/// Covers the {l:LocalizeEnum} markup extension, matching the sample's card 4: the enumeration value +/// comes from - a binding, the item-template form +/// Value={Binding}, or a fixed member - the four KeyPrefix × ResourceManager combinations, and format +/// arguments as entries. /// public class LocalizeEnumExtensionTests { + [Fact] + public void BoundArgument_FormatsTheEnumText_AndFollowsCultureChange() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var textBlock = TestSupport.BindLocalizedEnum( + new() + { + Value = new Binding(nameof(TestViewModel.SortOrder)), + KeyPrefix = "Count_", + Arguments = { Arg(new Binding(nameof(TestViewModel.Count))) }, + }, + new TestViewModel { SortOrder = FileSortOrder.Ascending, Count = 3 } + ); + textBlock.Text.Should().Be("3 files, ascending"); + + Localizer.Current.CurrentCulture = TestSupport.German; + TestSupport.Flush(); + textBlock.Text.Should().Be("3 Dateien, aufsteigend"); + }); + + [Fact] + public void BoundValue_FollowsValueAndCultureChanges() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var viewModel = new TestViewModel { SortOrder = FileSortOrder.Ascending }; + + var textBlock = TestSupport.BindLocalizedEnum( + new() { Value = new Binding(nameof(TestViewModel.SortOrder)) }, + viewModel + ); + textBlock.Text.Should().Be("Ascending"); + + viewModel.SortOrder = FileSortOrder.Descending; + TestSupport.Flush(); + textBlock.Text.Should().Be("Descending"); + + Localizer.Current.CurrentCulture = TestSupport.German; + TestSupport.Flush(); + textBlock.Text.Should().Be("Absteigend"); + }); + [Fact] public void BoundValue_SwitchesLive_OnCultureChange() => WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); - var textBlock = TestSupport.BindLocalizedEnum(new(), FileSortOrder.Descending); + var textBlock = TestSupport.BindLocalizedEnum(new() { Value = new Binding() }, FileSortOrder.Descending); textBlock.Text.Should().Be("Descending"); Localizer.Current.CurrentCulture = TestSupport.German; @@ -21,13 +69,77 @@ public void BoundValue_SwitchesLive_OnCultureChange() => textBlock.Text.Should().Be("Absteigend"); }); + [Fact] + public void ConstantValueAndArgument_Format() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var textBlock = TestSupport.BindLocalizedEnum( + new() + { + Value = FileSortOrder.Ascending, + KeyPrefix = "Count_", + Arguments = { Arg(3) }, + }, + null + ); + textBlock.Text.Should().Be("3 files, ascending"); + + Localizer.Current.CurrentCulture = TestSupport.German; + TestSupport.Flush(); + textBlock.Text.Should().Be("3 Dateien, aufsteigend"); + }); + + [Fact] + public void ConstructorArgument_SetsValue() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var bound = TestSupport.BindLocalizedEnum( + new(new Binding(nameof(TestViewModel.SortOrder))), + new TestViewModel { SortOrder = FileSortOrder.Ascending } + ); + var constant = TestSupport.BindLocalizedEnum(new(FileSortOrder.Ascending), null); + var text = () => new LocalizeEnumExtension("Ascending").ProvideValue(EmptyServiceProvider.Instance); + + bound.Text.Should().Be("Ascending"); + constant.Text.Should().Be("Ascending"); + text.Should().Throw().WithMessage("*System.String*"); + }); + + [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() { Value = new Binding() }, FileSortOrder.Ascending); + + textBlock.Text.Should().Be("Custom ascending"); + } + finally + { + Localizer.Current.EnumKeyBuilder = original; + } + }); + [Fact] public void CustomPrefix_NoManager_ResolvesViaSearchAll() => WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); - var textBlock = TestSupport.BindLocalizedEnum(new() { KeyPrefix = "Display_" }, FileSortOrder.Ascending); + var textBlock = TestSupport.BindLocalizedEnum( + new() { Value = new Binding(), KeyPrefix = "Display_" }, + FileSortOrder.Ascending + ); textBlock.Text.Should().Be("A to Z"); }); @@ -39,7 +151,12 @@ public void CustomPrefix_WithManager_ResolvesScoped() => TestSupport.ResetToEnglishWithTestCatalogs(); var textBlock = TestSupport.BindLocalizedEnum( - new() { KeyPrefix = "Display_", ResourceManager = SortingStrings.ResourceManager }, + new() + { + Value = new Binding(), + KeyPrefix = "Display_", + ResourceManager = SortingStringsKeys.ResourceManager, + }, FileSortOrder.Ascending ); @@ -52,7 +169,7 @@ public void DefaultPrefix_NoManager_ResolvesViaSearchAll() => { TestSupport.ResetToEnglishWithTestCatalogs(); - var textBlock = TestSupport.BindLocalizedEnum(new(), FileSortOrder.Ascending); + var textBlock = TestSupport.BindLocalizedEnum(new() { Value = new Binding() }, FileSortOrder.Ascending); textBlock.Text.Should().Be("Ascending"); }); @@ -64,10 +181,139 @@ public void DefaultPrefix_WithManager_ResolvesScoped() => TestSupport.ResetToEnglishWithTestCatalogs(); var textBlock = TestSupport.BindLocalizedEnum( - new() { ResourceManager = ApplicationStrings.ResourceManager }, + new() { Value = new Binding(), ResourceManager = ApplicationStringsKeys.ResourceManager }, FileSortOrder.Ascending ); textBlock.Text.Should().Be("Ascending"); }); + + [Fact] + public void FormatterChange_ReResolvesTheExtensionAndTheConverter_WithoutALanguageSwitch() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + var previous = Localizer.Current.MessageFormatter; + var viewModel = new TestViewModel { SortOrder = FileSortOrder.Ascending }; + + try + { + var extension = TestSupport.BindLocalizedEnum( + new() { Value = new Binding(nameof(TestViewModel.SortOrder)) }, + viewModel + ); + + var multiBinding = new MultiBinding { Converter = LocalizeEnumConverter.Default }; + multiBinding.Bindings.Add( + new Binding(nameof(ILocalizer.RefreshTriggeringRevision)) { Source = Localizer.Current } + ); + multiBinding.Bindings.Add(new Binding(nameof(TestViewModel.SortOrder))); + var converter = new TextBlock { DataContext = viewModel }; + converter.SetBinding(TextBlock.TextProperty, multiBinding); + TestSupport.Flush(); + + extension.Text.Should().Be("Ascending"); + converter.Text.Should().Be("Ascending"); + + Localizer.Current.MessageFormatter = new ArgumentTests.UpperCaseFormatter(); + TestSupport.Flush(); + + extension.Text.Should().Be("ASCENDING"); + converter.Text.Should().Be("ASCENDING"); + } + finally + { + Localizer.Current.MessageFormatter = previous; + } + }); + + [Fact] + public void MissingOrNonEnumValue_Throws() => + WpfThread.Invoke(() => + { + var missing = () => new LocalizeEnumExtension().ProvideValue(EmptyServiceProvider.Instance); + var text = () => + new LocalizeEnumExtension { Value = "Ascending" }.ProvideValue(EmptyServiceProvider.Instance); + + missing.Should().Throw().WithMessage("*Value*"); + text.Should().Throw().WithMessage("*System.String*"); + }); + + [Fact] + public void MultiBindingOrPriorityBindingValue_Throws() => + WpfThread.Invoke(() => + { + var multiBinding = () => + new LocalizeEnumExtension { Value = new MultiBinding() }.ProvideValue(EmptyServiceProvider.Instance); + var priorityBinding = () => + new LocalizeEnumExtension { Value = new PriorityBinding() }.ProvideValue(EmptyServiceProvider.Instance); + + multiBinding.Should().Throw().WithMessage("*MultiBinding*"); + priorityBinding.Should().Throw().WithMessage("*PriorityBinding*"); + }); + + [Fact] + public void NullBoundValue_RendersEmpty() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var textBlock = TestSupport.BindLocalizedEnum( + new() { Value = new Binding(nameof(TestViewModel.SortOrder)) }, + new TestViewModel { SortOrder = null } + ); + + textBlock.Text.Should().BeEmpty(); + }); + + [Fact] + public void ParsedXaml_ShorthandBinding_SetsValue() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + const string xaml = """ + + """; + + var textBlock = (TextBlock)XamlReader.Parse(xaml); + textBlock.DataContext = new TestViewModel { SortOrder = FileSortOrder.Ascending }; + TestSupport.Flush(); + + textBlock.Text.Should().Be("Ascending"); + }); + + [Fact] + public void PublicSurface_HasTheDeclaredShape() => + WpfThread.Invoke(() => + { + var value = typeof(LocalizeEnumExtension).GetProperty(nameof(LocalizeEnumExtension.Value))!; + var arguments = typeof(LocalizeEnumExtension).GetProperty(nameof(LocalizeEnumExtension.Arguments))!; + + value.PropertyType.Should().Be(); + arguments.PropertyType.GetGenericArguments().Should().Equal(typeof(Argument)); + typeof(LocalizeEnumExtension).GetProperty("Argument").Should().BeNull(); + typeof(LocalizeEnumExtension) + .GetCustomAttribute()! + .Name.Should() + .Be(nameof(LocalizeEnumExtension.Arguments)); + typeof(LocalizeEnumExtension).GetCustomAttribute().Should().NotBeNull(); + }); + + /// Creates an unnamed argument. + /// The constant or the binding. + /// The argument. + private static Argument Arg(object? value) => new() { Value = value }; + + /// A service provider without services, for a markup extension created in code. + private sealed class EmptyServiceProvider : IServiceProvider + { + /// The shared instance. + public static readonly EmptyServiceProvider Instance = new(); + + /// + public object? GetService(Type serviceType) => null; + } } diff --git a/tests/ResXLocalization.WPF.Sample.Tests/LocalizeExtensionTests.cs b/tests/ResXLocalization.WPF.Sample.Tests/LocalizeExtensionTests.cs index 9300dff..5906539 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/LocalizeExtensionTests.cs +++ b/tests/ResXLocalization.WPF.Sample.Tests/LocalizeExtensionTests.cs @@ -1,3 +1,5 @@ +using RentADeveloper.ResXLocalization.Core.Tests; + namespace RentADeveloper.ResXLocalization.WPF.Sample.Tests; /// @@ -32,55 +34,90 @@ public void KeyProperty_ResolvesViaSearchAll() => }); [Fact] - public void KeyProperty_WithResourceManager_ResolvesScoped() => + public void KeyProperty_ResourceManagerScopesAStringKey_AndIsIgnoredForAResourceKey() => WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); - var textBlock = TestSupport.BindLocalizedText( - new() { Key = "Greeting", ResourceManager = ApplicationStrings.ResourceManager } + var scoped = TestSupport.BindLocalizedText( + new() { Key = "Greeting", ResourceManager = ApplicationStringsKeys.ResourceManager } ); - textBlock.Text.Should().Be("Hello and welcome!"); + // The typed key resolves through its own manager; the Catalog manager does not contain Greeting. + var typed = TestSupport.BindLocalizedText( + new() { Key = ApplicationStringsKeys.Greeting, ResourceManager = TestSupport.CatalogResources } + ); + + scoped.Text.Should().Be("Hello and welcome!"); + typed.Text.Should().Be("Hello and welcome!"); }); [Fact] - public void ResourceKeyConstructor_ResolvesViaTypedKey() => + public void KeyProperty_WithResourceKey_ResolvesViaTypedKey() => WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); - var textBlock = TestSupport.BindLocalizedText(new(ApplicationStringsKeys.Greeting)); + var textBlock = TestSupport.BindLocalizedText(new() { Key = ApplicationStringsKeys.Greeting }); textBlock.Text.Should().Be("Hello and welcome!"); }); [Fact] - public void ResourceKeyProperty_ResolvesViaTypedKey() => + public void KeyProperty_WithResourceManager_ResolvesScoped() => WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); - var textBlock = TestSupport.BindLocalizedText(new() { ResourceKey = ApplicationStringsKeys.Greeting }); + var textBlock = TestSupport.BindLocalizedText( + new() { Key = "Greeting", ResourceManager = ApplicationStringsKeys.ResourceManager } + ); textBlock.Text.Should().Be("Hello and welcome!"); }); [Fact] - public void ResourceKey_TakesPrecedenceOver_KeyAndResourceManager() => + public void NoArguments_PassesTheValueThroughTheFormatter() => WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); + var previous = Localizer.Current.MessageFormatter; + var formatter = new CapturingFormatter(); + Localizer.Current.MessageFormatter = formatter; - // ResourceKey is set, so Key and ResourceManager are ignored and the typed key wins. - var textBlock = TestSupport.BindLocalizedText( - new() - { - ResourceKey = ApplicationStringsKeys.Greeting, - Key = "SortingHint", - ResourceManager = SortingStrings.ResourceManager, - } - ); + // Installing the formatter re-resolves every localized binding still alive; count only this test's. + TestSupport.Flush(); + var firstCall = formatter.Calls.Count; + + try + { + var textBlock = TestSupport.BindLocalizedText(new("CurlyNoArgs")); + TestSupport.Flush(); + + textBlock.Text.Should().Be("Literal {braces} stay"); + formatter.Calls.Skip(firstCall).Should().NotBeEmpty(); + formatter + .Calls.Skip(firstCall) + .Should() + .AllSatisfy(call => + { + call.Pattern.Should().Be("Literal {braces} stay"); + call.Arguments.Should().BeEmpty(); + }); + } + finally + { + Localizer.Current.MessageFormatter = previous; + } + }); + + [Fact] + public void ResourceKeyConstructor_ResolvesViaTypedKey() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + var textBlock = TestSupport.BindLocalizedText(new(ApplicationStringsKeys.Greeting)); textBlock.Text.Should().Be("Hello and welcome!"); }); @@ -103,7 +140,7 @@ public void StringConstructor_WithResourceManager_ResolvesScoped() => TestSupport.ResetToEnglishWithTestCatalogs(); var textBlock = TestSupport.BindLocalizedText( - new("SortingHint") { ResourceManager = SortingStrings.ResourceManager } + new("SortingHint") { ResourceManager = SortingStringsKeys.ResourceManager } ); textBlock.Text.Should().Be("Choose how documents are ordered."); diff --git a/tests/ResXLocalization.WPF.Sample.Tests/MainWindowLiveSwitchTests.cs b/tests/ResXLocalization.WPF.Sample.Tests/MainWindowLiveSwitchTests.cs index ae61daa..5ad7d85 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/MainWindowLiveSwitchTests.cs +++ b/tests/ResXLocalization.WPF.Sample.Tests/MainWindowLiveSwitchTests.cs @@ -6,6 +6,24 @@ namespace RentADeveloper.ResXLocalization.WPF.Sample.Tests; /// public class MainWindowLiveSwitchTests { + [Fact] + public void BoundKey_FollowsTheViewModel_AndSwitchesLive() => + WpfThread.Invoke(() => + { + var (window, viewModel) = CreateWindow(); + + // Both bound-key rows of card 7, the ResourceKey one and the string one, show the status. + TestSupport.AllVisibleText(window).Count(text => text == "Connecting...").Should().Be(2); + + viewModel.IsConnected = true; + TestSupport.Flush(); + TestSupport.AllVisibleText(window).Count(text => text == "Connected").Should().Be(2); + + viewModel.SelectedLanguage = TestSupport.German; + TestSupport.Flush(); + TestSupport.AllVisibleText(window).Count(text => text == "Verbunden").Should().Be(2); + }); + [Fact] public void PlainStrings_SwitchLive() => WpfThread.Invoke(() => diff --git a/tests/ResXLocalization.WPF.Sample.Tests/MainWindowViewModelTests.cs b/tests/ResXLocalization.WPF.Sample.Tests/MainWindowViewModelTests.cs index b42bb30..e30e31e 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/MainWindowViewModelTests.cs +++ b/tests/ResXLocalization.WPF.Sample.Tests/MainWindowViewModelTests.cs @@ -35,8 +35,7 @@ public void SelectingLanguage_RecomputesAndNotifies_ReadOutProperties() => TestSupport.ResetToEnglishWithTestCatalogs(); using var viewModel = new MainWindowViewModel(Localizer.Current); - var changed = new List(); - viewModel.PropertyChanged += (_, args) => changed.Add(args.PropertyName ?? string.Empty); + using var monitor = viewModel.Monitor(); viewModel.SelectedLanguage = TestSupport.German; @@ -44,9 +43,9 @@ public void SelectingLanguage_RecomputesAndNotifies_ReadOutProperties() => viewModel.SortOrderViaSearchAllDefaultPrefix.Should().Be("Aufsteigend"); viewModel.PoweredByViaTypedKey.Should().Be("Bereitgestellt von rent-a-developer"); - changed.Should().Contain(nameof(MainWindowViewModel.GreetingViaSearchAll)); - changed.Should().Contain(nameof(MainWindowViewModel.SortOrderViaSearchAllDefaultPrefix)); - changed.Should().Contain(nameof(MainWindowViewModel.PoweredByViaTypedKey)); + monitor.Should().RaisePropertyChangeFor(model => model.GreetingViaSearchAll); + monitor.Should().RaisePropertyChangeFor(model => model.SortOrderViaSearchAllDefaultPrefix); + monitor.Should().RaisePropertyChangeFor(model => model.PoweredByViaTypedKey); }); [Fact] @@ -68,15 +67,14 @@ public void SelectingSortOrder_RecomputesAndNotifies_EnumReadOuts() => TestSupport.ResetToEnglishWithTestCatalogs(); using var viewModel = new MainWindowViewModel(Localizer.Current); - var changed = new List(); - viewModel.PropertyChanged += (_, args) => changed.Add(args.PropertyName ?? string.Empty); + using var monitor = viewModel.Monitor(); viewModel.SelectedFileSortOrder = FileSortOrder.Descending; viewModel.SortOrderViaSearchAllDefaultPrefix.Should().Be("Descending"); viewModel.SortOrderViaSearchAllCustomPrefix.Should().Be("Z to A"); - changed.Should().Contain(nameof(MainWindowViewModel.SortOrderViaSearchAllDefaultPrefix)); - changed.Should().Contain(nameof(MainWindowViewModel.SortOrderViaScopedCustomPrefix)); + monitor.Should().RaisePropertyChangeFor(model => model.SortOrderViaSearchAllDefaultPrefix); + monitor.Should().RaisePropertyChangeFor(model => model.SortOrderViaScopedCustomPrefix); }); [Fact] diff --git a/tests/ResXLocalization.WPF.Sample.Tests/MemoryLeakTests.cs b/tests/ResXLocalization.WPF.Sample.Tests/MemoryLeakTests.cs index b914e1f..140b781 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/MemoryLeakTests.cs +++ b/tests/ResXLocalization.WPF.Sample.Tests/MemoryLeakTests.cs @@ -8,6 +8,25 @@ namespace RentADeveloper.ResXLocalization.WPF.Sample.Tests; /// public class MemoryLeakTests { + [Fact] + public void BoundControlsWithArguments_AreCollected_WhenDiscarded() => + WpfThread.Invoke(static () => + { + TestSupport.ResetToEnglishWithTestCatalogs(); + + // Sanity: the binding really subscribes and resolves, so the leak check below is not vacuous. + var probe = TestSupport.BindLocalizedText(new(ApplicationStringsKeys.Greeting)); + probe.Text.Should().Be("Hello and welcome!"); + + var references = CreateAndAbandonBoundControlsWithArguments(200); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + references.Should().NotContain(static reference => reference.IsAlive); + }); + [Fact] public void BoundControls_AreCollected_WhenDiscarded() => WpfThread.Invoke(static () => @@ -54,6 +73,20 @@ private static List CreateAndAbandonBoundControls(int count) return references; } + private static List CreateAndAbandonBoundControlsWithArguments(int count) + { + var references = new List(count); + for (var index = 0; index < count; index++) + { + var textBlock = TestSupport.BindLocalizedText( + new(ApplicationStringsKeys.PeopleInvited) { Arguments = { new Argument { Value = 1 } } } + ); + references.Add(new(textBlock)); + } + + return references; + } + private static WeakReference CreateAndDisposeViewModel() { var viewModel = new MainWindowViewModel(Localizer.Current); diff --git a/tests/ResXLocalization.WPF.Sample.Tests/ResXLocalization.WPF.Sample.Tests.csproj b/tests/ResXLocalization.WPF.Sample.Tests/ResXLocalization.WPF.Sample.Tests.csproj index 8cb4809..d8b7a8b 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/ResXLocalization.WPF.Sample.Tests.csproj +++ b/tests/ResXLocalization.WPF.Sample.Tests/ResXLocalization.WPF.Sample.Tests.csproj @@ -9,4 +9,10 @@ + + + + + + diff --git a/tests/ResXLocalization.WPF.Sample.Tests/Resources/Catalog.de.resx b/tests/ResXLocalization.WPF.Sample.Tests/Resources/Catalog.de.resx index d608723..8f366d2 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/Resources/Catalog.de.resx +++ b/tests/ResXLocalization.WPF.Sample.Tests/Resources/Catalog.de.resx @@ -22,4 +22,13 @@ CatalogAufsteigend + + Datei {0} von {1} + + + {0} ({1}:) - Laufwerk {2}, Partition {3} + + + {0} Dateien, aufsteigend + diff --git a/tests/ResXLocalization.WPF.Sample.Tests/Resources/Catalog.resx b/tests/ResXLocalization.WPF.Sample.Tests/Resources/Catalog.resx index 7d9ac5c..7e60028 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/Resources/Catalog.resx +++ b/tests/ResXLocalization.WPF.Sample.Tests/Resources/Catalog.resx @@ -1,14 +1,18 @@ text/microsoft-resx @@ -34,10 +38,25 @@ File {0} of {1} - - [{0}|{2}] + + {0} ({1}:) - drive {2}, partition {3} + + + {0}{1}{2}{3}{4}{5}{6}{7}{8}{9}{10}{11} Literal {braces} stay + + Broken {0 + + + Custom ascending + + + {0} files, ascending + + + {Ascending} + diff --git a/tests/ResXLocalization.WPF.Sample.Tests/TestSupport.cs b/tests/ResXLocalization.WPF.Sample.Tests/TestSupport.cs index e1fd0c9..7baa081 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/TestSupport.cs +++ b/tests/ResXLocalization.WPF.Sample.Tests/TestSupport.cs @@ -1,3 +1,6 @@ +using System.Windows.Markup; +using System.Xaml; + namespace RentADeveloper.ResXLocalization.WPF.Sample.Tests; /// @@ -7,18 +10,18 @@ namespace RentADeveloper.ResXLocalization.WPF.Sample.Tests; /// internal static class TestSupport { + /// The test-only Catalog file; registered first so it wins search-all ties for "Shared". + public static readonly ResourceManager CatalogResources = new( + "RentADeveloper.ResXLocalization.WPF.Sample.Tests.Resources.Catalog", + typeof(TestSupport).Assembly + ); + /// English culture, used to reset the ambient localizer at the start of each test. public static readonly CultureInfo English = new("en"); /// German culture. Used to assert that values switch live. public static readonly CultureInfo German = new("de"); - /// The test-only Catalog file; registered first so it wins search-all ties for "Shared". - private static readonly ResourceManager CatalogResources = new( - "RentADeveloper.ResXLocalization.WPF.Sample.Tests.Resources.Catalog", - typeof(TestSupport).Assembly - ); - /// The test-only Fallback file; registered after . private static readonly ResourceManager FallbackResources = new( "RentADeveloper.ResXLocalization.WPF.Sample.Tests.Resources.Fallback", @@ -36,33 +39,41 @@ public static string[] AllVisibleText(DependencyObject root) => .ToArray(); /// - /// Binds a to a fresh text block whose DataContext is the - /// supplied enumeration value, mirroring how the extension is used inside an item template. + /// Binds a to a fresh text block's Text, then sets the text block's + /// DataContext and flushes pending binding transfers. /// /// The markup extension that creates the localization binding. - /// The enumeration value to expose as the text block's data context. + /// + /// The data context: the enumeration value itself for Value = new Binding(), or a view model. + /// /// The bound text block after pending binding transfers have been applied. - public static TextBlock BindLocalizedEnum(LocalizeEnumExtension extension, Enum value) + public static TextBlock BindLocalizedEnum(LocalizeEnumExtension extension, object? dataContext) { - var textBlock = new TextBlock { DataContext = value }; + var textBlock = new TextBlock(); textBlock.SetBinding( TextBlock.TextProperty, (BindingBase)extension.ProvideValue(EmptyServiceProvider.Instance) ); + textBlock.DataContext = dataContext; Flush(); return textBlock; } - /// Binds a to a fresh text block's Text and returns it. + /// + /// Binds a to a fresh text block's Text, then sets the text block's + /// DataContext and flushes pending binding transfers. + /// /// The markup extension that creates the localization binding. + /// The data context for the argument bindings, if any. /// The bound text block after pending binding transfers have been applied. - public static TextBlock BindLocalizedText(LocalizeExtension extension) + public static TextBlock BindLocalizedText(LocalizeExtension extension, object? dataContext = null) { var textBlock = new TextBlock(); textBlock.SetBinding( TextBlock.TextProperty, (BindingBase)extension.ProvideValue(EmptyServiceProvider.Instance) ); + textBlock.DataContext = dataContext; Flush(); return textBlock; } @@ -85,9 +96,9 @@ public static void Flush() public static void ResetToEnglishWithTestCatalogs() { Localizer.Current.CurrentCulture = English; - Localizer.Current.RegisterResourceManager(ApplicationStrings.ResourceManager); - Localizer.Current.RegisterResourceManager(SortingStrings.ResourceManager); - Localizer.Current.RegisterResourceManager(BrandingStrings.ResourceManager); + Localizer.Current.RegisterResourceManager(ApplicationStringsKeys.ResourceManager); + Localizer.Current.RegisterResourceManager(SortingStringsKeys.ResourceManager); + Localizer.Current.RegisterResourceManager(BrandingStringsKeys.ResourceManager); Localizer.Current.RegisterResourceManager(CatalogResources); Localizer.Current.RegisterResourceManager(FallbackResources); } @@ -105,6 +116,61 @@ private static IEnumerable LogicalDescendants(DependencyObject } } + /// + /// Forces DesignMode.IsInDesignMode to a value and restores the previous value on dispose. The suite + /// runs without parallelization, so the process-wide value cannot leak into a concurrent test. + /// + internal sealed class DesignModeScope : IDisposable + { + private readonly bool previousValue; + + /// Initializes a new instance of the class. + /// The value DesignMode.IsInDesignMode has until the scope is disposed. + public DesignModeScope(bool value) + { + this.previousValue = DesignMode.IsInDesignMode; + DesignMode.IsInDesignMode = value; + } + + public void Dispose() => DesignMode.IsInDesignMode = this.previousValue; + } + + /// An with a fixed target object and target property. + /// The object whose property the markup extension provides a value for. + /// + /// The target property: a , or a CLR PropertyInfo as Rider's WPF + /// previewer supplies it. + /// + internal sealed class SimpleProvideValueTarget(object targetObject, object targetProperty) : IProvideValueTarget + { + public object TargetObject { get; } = targetObject; + + public object TargetProperty { get; } = targetProperty; + } + + /// + /// A service provider that supplies an and, as the XAML loader does, an + /// . WPF needs the schema context to let a + /// receive a binding for its Value property; without it, WPF throws for a . + /// + /// The target to supply. + internal sealed class SimpleServiceProvider(IProvideValueTarget target) + : IServiceProvider, + IXamlSchemaContextProvider + { + private static readonly XamlSchemaContext SharedSchemaContext = new(); + + public XamlSchemaContext SchemaContext => SharedSchemaContext; + + public object? GetService(Type serviceType) => + serviceType switch + { + _ when serviceType == typeof(IProvideValueTarget) => target, + _ when serviceType == typeof(IXamlSchemaContextProvider) => this, + _ => null, + }; + } + private sealed class EmptyServiceProvider : IServiceProvider { public static readonly EmptyServiceProvider Instance = new(); diff --git a/tests/ResXLocalization.WPF.Sample.Tests/TestViewModel.cs b/tests/ResXLocalization.WPF.Sample.Tests/TestViewModel.cs new file mode 100644 index 0000000..e70ff08 --- /dev/null +++ b/tests/ResXLocalization.WPF.Sample.Tests/TestViewModel.cs @@ -0,0 +1,88 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace RentADeveloper.ResXLocalization.WPF.Sample.Tests; + +/// The data context of the argument tests: one property per bound argument value. +public sealed class TestViewModel : INotifyPropertyChanged +{ + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// Gets a value that is always . + public string? Nothing { get; } + + /// Gets or sets the first value of FormatPair. + public int A + { + get; + set => this.Set(ref field, value); + } + + /// Gets or sets the second value of FormatPair. + public int B + { + get; + set => this.Set(ref field, value); + } + + /// Gets or sets the people count of PeopleInvited. + public int Count + { + get; + set => this.Set(ref field, value); + } + + /// Gets or sets the drive number of PartitionDisplay. + public int Drive + { + get; + set => this.Set(ref field, value); + } + + /// Gets or sets the bound key of the bound-key tests: a key, or any other value. + public object? Key + { + get; + set => this.Set(ref field, value); + } + + /// Gets or sets the volume label of PartitionDisplay. + public string? Label + { + get; + set => this.Set(ref field, value); + } + + /// Gets or sets the drive letter of PartitionDisplay. + public string? Letter + { + get; + set => this.Set(ref field, value); + } + + /// Gets or sets the partition number of PartitionDisplay. + public int Partition + { + get; + set => this.Set(ref field, value); + } + + /// Gets or sets the enumeration value of the enum tests. + public FileSortOrder? SortOrder + { + get; + set => this.Set(ref field, value); + } + + /// Assigns a property's backing field and raises . + /// The property type. + /// The backing field. + /// The new value. + /// The property name, supplied by the compiler. + private void Set(ref T field, T value, [CallerMemberName] string propertyName = "") + { + field = value; + this.PropertyChanged?.Invoke(this, new(propertyName)); + } +} diff --git a/tests/package-consumption/AvaloniaConsumer/AvaloniaConsumer.csproj b/tests/package-consumption/AvaloniaConsumer/AvaloniaConsumer.csproj index cea5d23..750f463 100644 --- a/tests/package-consumption/AvaloniaConsumer/AvaloniaConsumer.csproj +++ b/tests/package-consumption/AvaloniaConsumer/AvaloniaConsumer.csproj @@ -19,4 +19,16 @@ + + + + + + + + + + + diff --git a/tests/package-consumption/AvaloniaConsumer/LocalizedView.axaml b/tests/package-consumption/AvaloniaConsumer/LocalizedView.axaml index bedea44..fcaad4a 100644 --- a/tests/package-consumption/AvaloniaConsumer/LocalizedView.axaml +++ b/tests/package-consumption/AvaloniaConsumer/LocalizedView.axaml @@ -1,13 +1,20 @@  + xmlns:resources="using:AvaloniaConsumer.Resources" + x:CompileBindings="True" + x:DataType="consumer:LocalizedViewModel"> + + + @@ -18,23 +25,32 @@ --> + ResourceManager={x:Static resources:CatalogKeys.ResourceManager}}" /> + ResourceManager={x:Static resources:CatalogKeys.ResourceManager}}" /> - + + + + + + + + + - + + Text="{l:LocalizeEnum Value={Binding SortOrder}}" /> diff --git a/tests/package-consumption/AvaloniaConsumer/LocalizedView.axaml.cs b/tests/package-consumption/AvaloniaConsumer/LocalizedView.axaml.cs index 73960b1..ed98324 100644 --- a/tests/package-consumption/AvaloniaConsumer/LocalizedView.axaml.cs +++ b/tests/package-consumption/AvaloniaConsumer/LocalizedView.axaml.cs @@ -1,20 +1,20 @@ using Avalonia.Controls; -using RentADeveloper.ResXLocalization.Avalonia; namespace AvaloniaConsumer; public partial class LocalizedView : UserControl { + private readonly LocalizedViewModel viewModel = new(); + public LocalizedView() { this.InitializeComponent(); - // The enum the {l:LocalizeEnum} markup extension localizes: it reads the target control's - // DataContext, exactly as an item template would supply it. - this.SortOrderText.DataContext = ConsumerSortOrder.Ascending; + // The view model the compiled bindings of InvitedText and SortOrderText read. + this.DataContext = this.viewModel; } /// Supplies the composite-format argument for the "{0} people invited" resource. /// The number to format into the resolved value. - public void SetInvitedCount(Int32 count) => LocalizeArgs.SetArg0(this.InvitedText, count); + public void SetInvitedCount(int count) => this.viewModel.InvitedCount = count; } diff --git a/tests/package-consumption/AvaloniaConsumer/LocalizedViewModel.cs b/tests/package-consumption/AvaloniaConsumer/LocalizedViewModel.cs new file mode 100644 index 0000000..da6dffb --- /dev/null +++ b/tests/package-consumption/AvaloniaConsumer/LocalizedViewModel.cs @@ -0,0 +1,28 @@ +using System.ComponentModel; + +namespace AvaloniaConsumer; + +/// +/// The data context of : the values its compiled bindings read. +/// +public sealed class LocalizedViewModel : INotifyPropertyChanged +{ + private int invitedCount; + + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// Gets or sets the number formatted into the "{0} people invited" resource. + public int InvitedCount + { + get => this.invitedCount; + set + { + this.invitedCount = value; + this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.InvitedCount))); + } + } + + /// Gets the enumeration value that {l:LocalizeEnum} localizes. + public ConsumerSortOrder SortOrder { get; } = ConsumerSortOrder.Ascending; +} diff --git a/tests/package-consumption/AvaloniaConsumer/Program.cs b/tests/package-consumption/AvaloniaConsumer/Program.cs index 7ea40f0..b35a58e 100644 --- a/tests/package-consumption/AvaloniaConsumer/Program.cs +++ b/tests/package-consumption/AvaloniaConsumer/Program.cs @@ -1,17 +1,24 @@ // End-to-end consumption check for the ResXLocalization.Avalonia NuGet package. Everything below must // arrive through the single PackageReference: the Core engine, the markup-extension assembly, and - -// proven at compile time by the StringsKeys and CatalogKeys classes - the packaged source generator plus -// the buildTransitive MSBuild wiring that feeds it. Exits non-zero on the first failed check. +// proven at compile time by the StringsKeys, CatalogKeys and DesignerlessKeys classes - the packaged +// source generator plus the buildTransitive MSBuild wiring that feeds it, including the MSBuild resource +// name a .resx without a designer needs (Designerless.resx), also when LogicalName replaces it +// (Logical.resx) and when the DependentUpon convention names it after a class (Conventional.resx). Exits +// non-zero on the first failed check. // // 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 a compiled argument binding, and named +// arguments read by reflection in MessageArguments.FromObject. using System.Globalization; using Avalonia.Controls; using Avalonia.Threading; using AvaloniaConsumer; +using AvaloniaConsumer.Convention; +using AvaloniaConsumer.Custom; using AvaloniaConsumer.Resources; +using AvaloniaConsumer.Texts; using RentADeveloper.ResXLocalization; using RentADeveloper.ResXLocalization.Avalonia; @@ -32,7 +39,7 @@ void Check(string description, string actual, string expected) // Strings is registered; Catalog deliberately is NOT, so a search-all lookup cannot reach it and only // the scoped overloads can. -Localizer.Current.RegisterResourceManager(Strings.ResourceManager); +Localizer.Current.RegisterResourceManager(StringsKeys.ResourceManager); Localizer.Current.CurrentCulture = new CultureInfo("en"); // --- The engine, in English ------------------------------------------------------------------------- @@ -41,11 +48,33 @@ 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: CatalogKeys.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("typed key without a designer", Localizer.Current.Get(DesignerlessKeys.Title), "Designer-less"); +Check( + "scoped lookup into the generated resource manager", + Localizer.Current.Get("Title", resourceManager: DesignerlessKeys.ResourceManager), + "Designer-less" +); +Check("typed key whose resource name is a LogicalName", Localizer.Current.Get(LogicalKeys.Title), "Logical name"); +Check( + "typed key named by the DependentUpon convention", + Localizer.Current.Get(ConventionalKeys.Title), + "By convention" +); +Check( + "typed key in the namespace CustomToolNamespace names", + Localizer.Current.Get(NamedKeys.Title), + "Custom tool namespace" +); +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 ------------------------------------------------------------------ @@ -54,12 +83,20 @@ void Check(string description, string actual, string expected) Check("compiled XAML typed key", localizedView.GreetingText.Text ?? "", "Hello from the package!"); Check("compiled XAML scoped lookup", localizedView.ScopedText.Text ?? "", "Scoped to the catalog"); Check("compiled XAML enum by convention", localizedView.SortOrderText.Text ?? "", "Ascending"); +Check("compiled XAML typed key without a designer", localizedView.DesignerlessText.Text ?? "", "Designer-less"); Check( - "compiled XAML composite format, no argument set", - localizedView.InvitedText.Text ?? "", + "compiled XAML composite format, no argument", + localizedView.InvitedWithoutArgumentsText.Text ?? "", "{0} people invited" ); +// The compiled argument binding delivers the view model's initial value. +Check( + "compiled XAML composite format, initial argument", + localizedView.InvitedText.Text ?? "", + "0 people invited" +); + // An argument change, with the culture unchanged: the rendered text must re-format. localizedView.SetInvitedCount(3); Dispatcher.UIThread.RunJobs(); @@ -76,8 +113,34 @@ void Check(string description, string actual, string expected) Check("live switch to de (satellite)", Localizer.Current.Get(StringsKeys.Greeting), "Hallo aus dem Paket!"); Check("live switch, enum by convention", Localizer.Current.Get(ConsumerSortOrder.Ascending), "Aufsteigend"); +Check("live switch, typed key without a designer", Localizer.Current.Get(DesignerlessKeys.Title), "Ohne Designer"); +Check( + "live switch, scoped lookup into the generated resource manager", + Localizer.Current.Get("Title", resourceManager: DesignerlessKeys.ResourceManager), + "Ohne Designer" +); +Check( + "live switch, typed key whose resource name is a LogicalName", + Localizer.Current.Get(LogicalKeys.Title), + "Logischer Name" +); +Check( + "live switch, typed key named by the DependentUpon convention", + Localizer.Current.Get(ConventionalKeys.Title), + "Nach Konvention" +); +Check( + "live switch, typed key in the namespace CustomToolNamespace names", + Localizer.Current.Get(NamedKeys.Title), + "Eigener Namespace" +); Check("compiled XAML live switch", localizedView.GreetingText.Text ?? "", "Hallo aus dem Paket!"); Check("compiled XAML live switch, enum", localizedView.SortOrderText.Text ?? "", "Aufsteigend"); +Check( + "compiled XAML live switch, typed key without a designer", + localizedView.DesignerlessText.Text ?? "", + "Ohne Designer" +); Check( "compiled XAML live switch, composite format keeps its argument", localizedView.InvitedText.Text ?? "", @@ -86,7 +149,7 @@ void Check(string description, string actual, string expected) // --- Culture fallback, which is what a trimmed satellite would break silently ------------------------ // -// Neither key exists in the German files. The neutral value has to come back - not the sentinel, and not +// Neither key exists in the German files. The neutral value has to come back - not the failure text, and not // an empty string. Check( @@ -96,7 +159,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: CatalogKeys.ResourceManager), "Catalog value with no German translation" ); Check("compiled XAML typed fallback", localizedView.FallbackText.Text ?? "", "Only in the neutral file"); @@ -119,7 +182,7 @@ void Check(string description, string actual, string expected) ); var extension = new LocalizeExtension(StringsKeys.Greeting); -Check("markup extension instantiation", extension.ResourceKey?.Name ?? "", "Greeting"); +Check("markup extension instantiation", extension.Key is ResourceKey key ? key.Name : "", "Greeting"); if (failures > 0) { diff --git a/tests/package-consumption/AvaloniaConsumer/Resources/Conventional.cs b/tests/package-consumption/AvaloniaConsumer/Resources/Conventional.cs new file mode 100644 index 0000000..0173c80 --- /dev/null +++ b/tests/package-consumption/AvaloniaConsumer/Resources/Conventional.cs @@ -0,0 +1,5 @@ +// Not used by the program. Its presence next to Conventional.resx is what makes MSBuild name the +// embedded resources after this class (the DependentUpon convention), not after the folder. +namespace AvaloniaConsumer.Convention; + +internal static class Conventional { } diff --git a/tests/package-consumption/AvaloniaConsumer/Resources/Conventional.de.resx b/tests/package-consumption/AvaloniaConsumer/Resources/Conventional.de.resx new file mode 100644 index 0000000..90c3b39 --- /dev/null +++ b/tests/package-consumption/AvaloniaConsumer/Resources/Conventional.de.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Nach Konvention + + diff --git a/tests/package-consumption/AvaloniaConsumer/Resources/Conventional.resx b/tests/package-consumption/AvaloniaConsumer/Resources/Conventional.resx new file mode 100644 index 0000000..09f5a54 --- /dev/null +++ b/tests/package-consumption/AvaloniaConsumer/Resources/Conventional.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + By convention + + diff --git a/tests/package-consumption/AvaloniaConsumer/Resources/Designerless.de.resx b/tests/package-consumption/AvaloniaConsumer/Resources/Designerless.de.resx new file mode 100644 index 0000000..2123c7f --- /dev/null +++ b/tests/package-consumption/AvaloniaConsumer/Resources/Designerless.de.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Ohne Designer + + diff --git a/tests/package-consumption/AvaloniaConsumer/Resources/Designerless.resx b/tests/package-consumption/AvaloniaConsumer/Resources/Designerless.resx new file mode 100644 index 0000000..6b8cc73 --- /dev/null +++ b/tests/package-consumption/AvaloniaConsumer/Resources/Designerless.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Designer-less + + diff --git a/tests/package-consumption/AvaloniaConsumer/Resources/Logical.de.resx b/tests/package-consumption/AvaloniaConsumer/Resources/Logical.de.resx new file mode 100644 index 0000000..65f59fc --- /dev/null +++ b/tests/package-consumption/AvaloniaConsumer/Resources/Logical.de.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Logischer Name + + diff --git a/tests/package-consumption/AvaloniaConsumer/Resources/Logical.resx b/tests/package-consumption/AvaloniaConsumer/Resources/Logical.resx new file mode 100644 index 0000000..66c21e4 --- /dev/null +++ b/tests/package-consumption/AvaloniaConsumer/Resources/Logical.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Logical name + + diff --git a/tests/package-consumption/AvaloniaConsumer/Resources/Named.de.resx b/tests/package-consumption/AvaloniaConsumer/Resources/Named.de.resx new file mode 100644 index 0000000..278445e --- /dev/null +++ b/tests/package-consumption/AvaloniaConsumer/Resources/Named.de.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Eigener Namespace + + diff --git a/tests/package-consumption/AvaloniaConsumer/Resources/Named.resx b/tests/package-consumption/AvaloniaConsumer/Resources/Named.resx new file mode 100644 index 0000000..f2d965d --- /dev/null +++ b/tests/package-consumption/AvaloniaConsumer/Resources/Named.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Custom tool namespace + + 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} + diff --git a/tests/package-consumption/CombinedConsumer/Program.cs b/tests/package-consumption/CombinedConsumer/Program.cs index 0dc710b..45f9c7f 100644 --- a/tests/package-consumption/CombinedConsumer/Program.cs +++ b/tests/package-consumption/CombinedConsumer/Program.cs @@ -2,6 +2,10 @@ _ = new RentADeveloper.ResXLocalization.Avalonia.LocalizeExtension("Probe"); _ = new RentADeveloper.ResXLocalization.WPF.LocalizeExtension("Probe"); + +// Load Core through its own type: neither constructor above has to touch it. +_ = Localizer.Current; + var coreAssemblies = AppDomain .CurrentDomain.GetAssemblies() .Where(static assembly => assembly.GetName().Name == "ResXLocalization.Core") diff --git a/tests/package-consumption/MessageFormatConsumer/LocalizedView.axaml b/tests/package-consumption/MessageFormatConsumer/LocalizedView.axaml new file mode 100644 index 0000000..f5b635f --- /dev/null +++ b/tests/package-consumption/MessageFormatConsumer/LocalizedView.axaml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + diff --git a/tests/package-consumption/MessageFormatConsumer/LocalizedView.axaml.cs b/tests/package-consumption/MessageFormatConsumer/LocalizedView.axaml.cs new file mode 100644 index 0000000..d162642 --- /dev/null +++ b/tests/package-consumption/MessageFormatConsumer/LocalizedView.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace MessageFormatConsumer; + +public partial class LocalizedView : UserControl +{ + /// + /// Creates the view without a data context: the caller assigns one, so a view can also be observed + /// before its argument binding resolves. + /// + public LocalizedView() => this.InitializeComponent(); +} diff --git a/tests/package-consumption/MessageFormatConsumer/LocalizedViewModel.cs b/tests/package-consumption/MessageFormatConsumer/LocalizedViewModel.cs new file mode 100644 index 0000000..d0563bf --- /dev/null +++ b/tests/package-consumption/MessageFormatConsumer/LocalizedViewModel.cs @@ -0,0 +1,25 @@ +using System.ComponentModel; + +namespace MessageFormatConsumer; + +/// +/// The data context of : the value its compiled argument binding reads. +/// +public sealed class LocalizedViewModel : INotifyPropertyChanged +{ + private int count; + + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// Gets or sets the number the ICU plural pattern of "PeopleInvited" selects its branch by. + public int Count + { + get => this.count; + set + { + this.count = value; + this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.Count))); + } + } +} diff --git a/tests/package-consumption/MessageFormatConsumer/MessageFormatConsumer.csproj b/tests/package-consumption/MessageFormatConsumer/MessageFormatConsumer.csproj new file mode 100644 index 0000000..3401e9c --- /dev/null +++ b/tests/package-consumption/MessageFormatConsumer/MessageFormatConsumer.csproj @@ -0,0 +1,22 @@ + + + + Exe + + net8.0;net10.0 + enable + enable + en;de + + 1.0.0 + + + + + + + + + diff --git a/tests/package-consumption/MessageFormatConsumer/Program.cs b/tests/package-consumption/MessageFormatConsumer/Program.cs new file mode 100644 index 0000000..d70dcaf --- /dev/null +++ b/tests/package-consumption/MessageFormatConsumer/Program.cs @@ -0,0 +1,118 @@ +// End-to-end consumption check for the ResXLocalization.MessageFormat NuGet package, next to +// ResXLocalization.Avalonia. The ICU formatter, its MessageFormat dependency and - proven at compile time +// by the StringsKeys class of a .resx without a designer - the packaged source generator must all arrive +// through the two PackageReferences. Exits non-zero when any check failed. +// +// This program is also the assertion set the Native AOT gate runs for the ICU formatter. Everything a +// trimmer can break and a JIT cannot is here: the plural rules of the MessageFormat library, the named +// argument delivered by a compiled binding, the German satellite, and the format culture of the invariant +// culture. + +using System.Globalization; +using Avalonia.Threading; +using MessageFormatConsumer; +using MessageFormatConsumer.Resources; +using RentADeveloper.ResXLocalization; +using RentADeveloper.ResXLocalization.MessageFormat; + +var failures = 0; + +void Check(string description, string actual, string expected) +{ + if (actual == expected) + { + Console.WriteLine($"PASS {description}"); + } + else + { + Console.Error.WriteLine($"FAIL {description}: expected \"{expected}\", got \"{actual}\""); + failures++; + } +} + +Localizer.Current.UseMessageFormat(); +Localizer.Current.RegisterResourceManager(StringsKeys.ResourceManager); +Localizer.Current.CurrentCulture = new CultureInfo("en"); + +// --- The compiled XAML ------------------------------------------------------------------------------ + +var viewModel = new LocalizedViewModel { Count = 1 }; +var localizedView = new LocalizedView { DataContext = viewModel }; +Dispatcher.UIThread.RunJobs(); + +Check("compiled XAML plural, one", localizedView.PeopleInvitedText.Text ?? "", "1 person invited"); + +viewModel.Count = 5; +Dispatcher.UIThread.RunJobs(); +Check("compiled XAML plural, other", localizedView.PeopleInvitedText.Text ?? "", "5 people invited"); + +Localizer.Current.CurrentCulture = new CultureInfo("de"); +Dispatcher.UIThread.RunJobs(); +Check( + "compiled XAML plural after the live switch to de (satellite)", + localizedView.PeopleInvitedText.Text ?? "", + "5 Personen eingeladen" +); + +// --- Named arguments in code ------------------------------------------------------------------------ + +Localizer.Current.CurrentCulture = new CultureInfo("en"); +Check( + "named argument in code", + Localizer.Current.Get(StringsKeys.PeopleInvited, MessageArguments.FromTuples(("Count", 1))), + "1 person invited" +); + +// The invariant culture has only CLDR's root plural rules; the ICU formatter formats it as en. +Localizer.Current.CurrentCulture = CultureInfo.InvariantCulture; +Check( + "named argument in code, invariant culture formatted as en", + Localizer.Current.Get(StringsKeys.PeopleInvited, MessageArguments.FromTuples(("Count", 1))), + "1 person invited" +); + +// --- An argument binding that has not resolved yet -------------------------------------------------- +// +// A view whose data context arrives after it is created formats with a null argument first, which ICU +// renders without throwing - so no lookup failure is reported. + +Localizer.Current.CurrentCulture = new CultureInfo("en"); + +var lookupFailures = 0; +void OnLookupFailed(object? sender, LookupFailedEventArgs e) => lookupFailures++; + +Localizer.Current.LookupFailed += OnLookupFailed; +try +{ + var lateView = new LocalizedView(); + Dispatcher.UIThread.RunJobs(); + + lateView.DataContext = new LocalizedViewModel { Count = 1 }; + Dispatcher.UIThread.RunJobs(); +} +finally +{ + Localizer.Current.LookupFailed -= OnLookupFailed; +} + +Check( + "data context set after the view was created, lookup failures", + lookupFailures.ToString(CultureInfo.InvariantCulture), + "0" +); + +// --- A value without arguments, which ICU parses as well -------------------------------------------- + +Check("ICU escaping without arguments in code", Localizer.Current.Get(StringsKeys.Ready), "It's ready"); +Check("ICU escaping without arguments in compiled XAML", localizedView.ReadyText.Text ?? "", "It's ready"); + +if (failures > 0) +{ + Console.Error.WriteLine($"{failures} package consumption check(s) FAILED."); + + return 1; +} + +Console.WriteLine("All package consumption checks passed."); + +return 0; diff --git a/tests/package-consumption/MessageFormatConsumer/Resources/Strings.de.resx b/tests/package-consumption/MessageFormatConsumer/Resources/Strings.de.resx new file mode 100644 index 0000000..5897dc5 --- /dev/null +++ b/tests/package-consumption/MessageFormatConsumer/Resources/Strings.de.resx @@ -0,0 +1,21 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + {Count, plural, one {# Person eingeladen} other {# Personen eingeladen}} + + + Bereit + + diff --git a/tests/package-consumption/MessageFormatConsumer/Resources/Strings.resx b/tests/package-consumption/MessageFormatConsumer/Resources/Strings.resx new file mode 100644 index 0000000..5712297 --- /dev/null +++ b/tests/package-consumption/MessageFormatConsumer/Resources/Strings.resx @@ -0,0 +1,21 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + {Count, plural, one {# person invited} other {# people invited}} + + + It''s ready + + diff --git a/tests/package-consumption/README.md b/tests/package-consumption/README.md index 7f96417..93579c1 100644 --- a/tests/package-consumption/README.md +++ b/tests/package-consumption/README.md @@ -4,15 +4,29 @@ These package-only apps consume the **packed NuGet packages** (not project refer only place the most fragile parts of the packaging are exercised end to end: - the `buildTransitive/.targets` auto-import that feeds `.resx` + `.Designer.cs` files - to the source generator, + to the source generator, together with the MSBuild resource name of each `.resx` that every keys + class is created from - for a file without a designer (`Resources/Designerless.resx`), a name + replaced by `LogicalName` (`Logical.resx`) and one the `DependentUpon` convention takes from a class + (`Conventional.resx` next to `Conventional.cs`), in a multi-targeted project (`AvaloniaConsumer`) + and through WPF's temporary markup-compile pass (`WpfConsumer` with the SDK-default `RootNamespace`, + `WpfDerivedNamespaceConsumer` with one derived from its project name and a `LogicalName` and a + `CustomToolNamespace` built from it - see the comment "WPF's temporary markup-compile pass" in `build/ResXLocalization.Resx.targets`), and a `CustomToolNamespace` that names the + namespace of a keys class without a designer (`Named.resx`), - the source generator shipped as `analyzers/dotnet/cs`, - the transitive `ResXLocalization.Core` package, - the `net8.0` target of the multi-targeted packages, -- the satellite-assembly flow of a real consumer. - -The Avalonia and WPF apps compile real framework XAML against a generated `StringsKeys` class, -instantiate the view, and assert rendered live language switching. `CombinedConsumer` references -both UI packages and proves that one Core assembly is resolved. Every app exits non-zero on failure. +- the satellite-assembly flow of a real consumer, +- the opt-in `ResXLocalization.MessageFormat` package with its `MessageFormat` dependency + (`MessageFormatConsumer`): ICU plural patterns with a named argument from a compiled binding, in + code and in compiled XAML, a live switch to German, the invariant culture formatted as `en`, an + argument binding that resolves after the view was created, and ICU escaping in a value without + arguments. + +The Avalonia and WPF apps compile real framework XAML against the generated `StringsKeys` and +`DesignerlessKeys` classes, instantiate the view, and assert rendered live language switching. +`CombinedConsumer` references both UI packages and proves that one Core assembly is resolved. Every +app exits non-zero on failure. `AvaloniaConsumer` and `MessageFormatConsumer` are also the two apps +`scripts/verify-package-aot.ps1` publishes with Native AOT and runs. The folder has its own `Directory.Build.props`/`.targets`/`Directory.Packages.props` boundary so none of the repository's in-repo wiring leaks in, and its own isolated package cache (`.packages`). @@ -23,12 +37,26 @@ none of the repository's in-repo wiring leaks in, and its own isolated package c # From the repository root: pack the packages, then run the consumers. dotnet pack src/ResXLocalization.Core/ResXLocalization.Core.csproj -c Release -o artifacts/packages dotnet pack src/ResXLocalization.Avalonia/ResXLocalization.Avalonia.csproj -c Release -o artifacts/packages +dotnet pack src/ResXLocalization.MessageFormat/ResXLocalization.MessageFormat.csproj -c Release -o artifacts/packages dotnet pack src/ResXLocalization.WPF/ResXLocalization.WPF.csproj -c Release -o artifacts/packages # Windows only -dotnet run --project tests/package-consumption/AvaloniaConsumer/AvaloniaConsumer.csproj -c Release +# A reused MSBuild node keeps evaluating the previously packed targets of the same version; stop it. +dotnet build-server shutdown + +# The two multi-targeted consumers run once per target framework, which `dotnet run` needs named. +dotnet run --project tests/package-consumption/AvaloniaConsumer/AvaloniaConsumer.csproj -c Release --framework net8.0 +dotnet run --project tests/package-consumption/AvaloniaConsumer/AvaloniaConsumer.csproj -c Release --framework net10.0 +dotnet run --project tests/package-consumption/MessageFormatConsumer/MessageFormatConsumer.csproj -c Release --framework net8.0 +dotnet run --project tests/package-consumption/MessageFormatConsumer/MessageFormatConsumer.csproj -c Release --framework net10.0 dotnet run --project tests/package-consumption/WpfConsumer/WpfConsumer.csproj -c Release # Windows only +dotnet run --project tests/package-consumption/WpfDerivedNamespaceConsumer/WpfDerivedNamespaceConsumer.csproj -c Release # Windows only dotnet run --project tests/package-consumption/CombinedConsumer/CombinedConsumer.csproj -c Release # Windows only ``` After re-packing, delete `tests/package-consumption/.packages` so NuGet picks up the new build of the same version number. + +The consumers also have to work when built by the .NET 8 SDK, which differs from the pinned one in more +than its compiler - for example in WPF's temporary markup-compile pass. +`pwsh -File scripts/pre-release-gate.ps1` runs them a second time on an 8.0 SDK, as CI does: it writes a +temporary `global.json` into this folder, which is gitignored, and removes it again. diff --git a/tests/package-consumption/WpfConsumer/LocalizedView.xaml b/tests/package-consumption/WpfConsumer/LocalizedView.xaml index 5f0b1de..c0fed1c 100644 --- a/tests/package-consumption/WpfConsumer/LocalizedView.xaml +++ b/tests/package-consumption/WpfConsumer/LocalizedView.xaml @@ -3,5 +3,15 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:l="clr-namespace:RentADeveloper.ResXLocalization.WPF;assembly=ResXLocalization.WPF" xmlns:resources="clr-namespace:WpfConsumer.Resources"> - + + + + + + diff --git a/tests/package-consumption/WpfConsumer/Program.cs b/tests/package-consumption/WpfConsumer/Program.cs index 73f68d5..5b08d88 100644 --- a/tests/package-consumption/WpfConsumer/Program.cs +++ b/tests/package-consumption/WpfConsumer/Program.cs @@ -1,8 +1,9 @@ using System.Globalization; -using System.Windows.Controls; using System.Windows.Threading; using RentADeveloper.ResXLocalization; using RentADeveloper.ResXLocalization.WPF; +using WpfConsumer.Convention; +using WpfConsumer.Custom; using WpfConsumer.Resources; namespace WpfConsumer; @@ -27,7 +28,7 @@ void Check(String description, String actual, String expected) } } - Localizer.Current.RegisterResourceManager(Strings.ResourceManager); + Localizer.Current.RegisterResourceManager(StringsKeys.ResourceManager); Localizer.Current.CurrentCulture = new CultureInfo("en"); Check( @@ -36,15 +37,46 @@ void Check(String description, String actual, String expected) "Hello from the package!" ); Check("search-all lookup", Localizer.Current.Get("Greeting"), "Hello from the package!"); + Check("typed key without a designer", Localizer.Current.Get(DesignerlessKeys.Title), "Designer-less"); + Check( + "typed key whose resource name is a LogicalName", + Localizer.Current.Get(LogicalKeys.Title), + "Logical name" + ); + Check( + "typed key named by the DependentUpon convention", + Localizer.Current.Get(ConventionalKeys.Title), + "By convention" + ); var localizedView = new LocalizedView(); - var localizedText = (TextBlock)localizedView.Content; - Check("compiled XAML initial value", localizedText.Text, "Hello from the package!"); + Check("compiled XAML initial value", localizedView.GreetingText.Text, "Hello from the package!"); + Check("compiled XAML typed key without a designer", localizedView.DesignerlessText.Text, "Designer-less"); Localizer.Current.CurrentCulture = new CultureInfo("de"); Dispatcher.CurrentDispatcher.Invoke(static () => { }, DispatcherPriority.Background); Check("live switch to de (satellite)", Localizer.Current.Get(StringsKeys.Greeting), "Hallo aus dem Paket!"); - Check("compiled XAML live switch", localizedText.Text, "Hallo aus dem Paket!"); + Check("compiled XAML live switch", localizedView.GreetingText.Text, "Hallo aus dem Paket!"); + Check( + "live switch, typed key without a designer", + Localizer.Current.Get(DesignerlessKeys.Title), + "Ohne Designer" + ); + Check( + "compiled XAML live switch, typed key without a designer", + localizedView.DesignerlessText.Text, + "Ohne Designer" + ); + Check( + "live switch, typed key whose resource name is a LogicalName", + Localizer.Current.Get(LogicalKeys.Title), + "Logischer Name" + ); + Check( + "live switch, typed key named by the DependentUpon convention", + Localizer.Current.Get(ConventionalKeys.Title), + "Nach Konvention" + ); var cultures = Localizer.Current.GetAvailableCultures(); Check( @@ -54,7 +86,7 @@ void Check(String description, String actual, String expected) ); var extension = new LocalizeExtension(StringsKeys.Greeting); - Check("markup extension instantiation", extension.ResourceKey?.Name ?? "", "Greeting"); + Check("markup extension instantiation", extension.Key is ResourceKey key ? key.Name : "", "Greeting"); if (failures > 0) { diff --git a/tests/package-consumption/WpfConsumer/Resources/Conventional.cs b/tests/package-consumption/WpfConsumer/Resources/Conventional.cs new file mode 100644 index 0000000..d460452 --- /dev/null +++ b/tests/package-consumption/WpfConsumer/Resources/Conventional.cs @@ -0,0 +1,5 @@ +// Not used by the program. Its presence next to Conventional.resx is what makes MSBuild name the +// embedded resources after this class (the DependentUpon convention), not after the folder. +namespace WpfConsumer.Convention; + +internal static class Conventional { } diff --git a/tests/package-consumption/WpfConsumer/Resources/Conventional.de.resx b/tests/package-consumption/WpfConsumer/Resources/Conventional.de.resx new file mode 100644 index 0000000..90c3b39 --- /dev/null +++ b/tests/package-consumption/WpfConsumer/Resources/Conventional.de.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Nach Konvention + + diff --git a/tests/package-consumption/WpfConsumer/Resources/Conventional.resx b/tests/package-consumption/WpfConsumer/Resources/Conventional.resx new file mode 100644 index 0000000..09f5a54 --- /dev/null +++ b/tests/package-consumption/WpfConsumer/Resources/Conventional.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + By convention + + diff --git a/tests/package-consumption/WpfConsumer/Resources/Designerless.de.resx b/tests/package-consumption/WpfConsumer/Resources/Designerless.de.resx new file mode 100644 index 0000000..2123c7f --- /dev/null +++ b/tests/package-consumption/WpfConsumer/Resources/Designerless.de.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Ohne Designer + + diff --git a/tests/package-consumption/WpfConsumer/Resources/Designerless.resx b/tests/package-consumption/WpfConsumer/Resources/Designerless.resx new file mode 100644 index 0000000..6b8cc73 --- /dev/null +++ b/tests/package-consumption/WpfConsumer/Resources/Designerless.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Designer-less + + diff --git a/tests/package-consumption/WpfConsumer/Resources/Logical.de.resx b/tests/package-consumption/WpfConsumer/Resources/Logical.de.resx new file mode 100644 index 0000000..65f59fc --- /dev/null +++ b/tests/package-consumption/WpfConsumer/Resources/Logical.de.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Logischer Name + + diff --git a/tests/package-consumption/WpfConsumer/Resources/Logical.resx b/tests/package-consumption/WpfConsumer/Resources/Logical.resx new file mode 100644 index 0000000..66c21e4 --- /dev/null +++ b/tests/package-consumption/WpfConsumer/Resources/Logical.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Logical name + + diff --git a/tests/package-consumption/WpfConsumer/WpfConsumer.csproj b/tests/package-consumption/WpfConsumer/WpfConsumer.csproj index 8000d0a..36285ba 100644 --- a/tests/package-consumption/WpfConsumer/WpfConsumer.csproj +++ b/tests/package-consumption/WpfConsumer/WpfConsumer.csproj @@ -19,4 +19,11 @@ + + + + + + diff --git a/tests/package-consumption/WpfDerivedNamespaceConsumer/LocalizedView.xaml b/tests/package-consumption/WpfDerivedNamespaceConsumer/LocalizedView.xaml new file mode 100644 index 0000000..c9b28ab --- /dev/null +++ b/tests/package-consumption/WpfDerivedNamespaceConsumer/LocalizedView.xaml @@ -0,0 +1,12 @@ + + + + diff --git a/tests/package-consumption/WpfDerivedNamespaceConsumer/LocalizedView.xaml.cs b/tests/package-consumption/WpfDerivedNamespaceConsumer/LocalizedView.xaml.cs new file mode 100644 index 0000000..36c6fc6 --- /dev/null +++ b/tests/package-consumption/WpfDerivedNamespaceConsumer/LocalizedView.xaml.cs @@ -0,0 +1,8 @@ +using System.Windows.Controls; + +namespace Derived.WpfDerivedNamespaceConsumer; + +public partial class LocalizedView : UserControl +{ + public LocalizedView() => this.InitializeComponent(); +} diff --git a/tests/package-consumption/WpfDerivedNamespaceConsumer/Program.cs b/tests/package-consumption/WpfDerivedNamespaceConsumer/Program.cs new file mode 100644 index 0000000..92fc788 --- /dev/null +++ b/tests/package-consumption/WpfDerivedNamespaceConsumer/Program.cs @@ -0,0 +1,83 @@ +// End-to-end check for a WPF consumer whose RootNamespace is derived from its project name +// (Derived.$(MSBuildProjectName)). WPF compiles the XAML in a temporary copy of the project with a +// different name; the packaged build wiring must give the keys class of a .resx without a designer the +// same namespace there as in the real build - also for a LogicalName (Logical.resx) and a +// CustomToolNamespace (Named.resx) built from that RootNamespace - or this program and its XAML do not +// compile. See the comment "WPF's temporary markup-compile pass" in build/ResXLocalization.Resx.targets. +// Exits non-zero on the first failed check. + +using System.Globalization; +using System.Windows.Threading; +using Derived.WpfDerivedNamespaceConsumer.Custom; +using Derived.WpfDerivedNamespaceConsumer.Resources; +using Derived.WpfDerivedNamespaceConsumer.Texts; +using RentADeveloper.ResXLocalization; + +namespace Derived.WpfDerivedNamespaceConsumer; + +internal static class Program +{ + [STAThread] + private static int Main() + { + var failures = 0; + + void Check(string description, string actual, string expected) + { + if (actual == expected) + { + Console.WriteLine($"PASS {description}"); + } + else + { + Console.Error.WriteLine($"FAIL {description}: expected \"{expected}\", got \"{actual}\""); + failures++; + } + } + + Localizer.Current.CurrentCulture = new CultureInfo("en"); + + Check("typed key in the derived namespace", Localizer.Current.Get(DesignerlessKeys.Title), "Designer-less"); + Check( + "typed key whose LogicalName is built from RootNamespace", + Localizer.Current.Get(LogicalKeys.Title), + "Logical name" + ); + Check( + "typed key whose CustomToolNamespace is built from RootNamespace", + Localizer.Current.Get(NamedKeys.Title), + "Custom tool namespace" + ); + + var localizedView = new LocalizedView(); + Check("compiled XAML typed key in the derived namespace", localizedView.DesignerlessText.Text, "Designer-less"); + + Localizer.Current.CurrentCulture = new CultureInfo("de"); + Dispatcher.CurrentDispatcher.Invoke(static () => { }, DispatcherPriority.Background); + Check( + "live switch, typed key in the derived namespace", + Localizer.Current.Get(DesignerlessKeys.Title), + "Ohne Designer" + ); + Check("compiled XAML live switch", localizedView.DesignerlessText.Text, "Ohne Designer"); + Check( + "live switch, typed key whose LogicalName is built from RootNamespace", + Localizer.Current.Get(LogicalKeys.Title), + "Logischer Name" + ); + Check( + "live switch, typed key whose CustomToolNamespace is built from RootNamespace", + Localizer.Current.Get(NamedKeys.Title), + "Eigener Namespace" + ); + + if (failures > 0) + { + Console.Error.WriteLine($"{failures} package consumption check(s) FAILED."); + return 1; + } + + Console.WriteLine("All package consumption checks passed."); + return 0; + } +} diff --git a/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Designerless.de.resx b/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Designerless.de.resx new file mode 100644 index 0000000..2123c7f --- /dev/null +++ b/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Designerless.de.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Ohne Designer + + diff --git a/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Designerless.resx b/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Designerless.resx new file mode 100644 index 0000000..6b8cc73 --- /dev/null +++ b/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Designerless.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Designer-less + + diff --git a/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Logical.de.resx b/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Logical.de.resx new file mode 100644 index 0000000..65f59fc --- /dev/null +++ b/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Logical.de.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Logischer Name + + diff --git a/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Logical.resx b/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Logical.resx new file mode 100644 index 0000000..66c21e4 --- /dev/null +++ b/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Logical.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Logical name + + diff --git a/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Named.de.resx b/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Named.de.resx new file mode 100644 index 0000000..278445e --- /dev/null +++ b/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Named.de.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Eigener Namespace + + diff --git a/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Named.resx b/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Named.resx new file mode 100644 index 0000000..f2d965d --- /dev/null +++ b/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Named.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Custom tool namespace + + diff --git a/tests/package-consumption/WpfDerivedNamespaceConsumer/WpfDerivedNamespaceConsumer.csproj b/tests/package-consumption/WpfDerivedNamespaceConsumer/WpfDerivedNamespaceConsumer.csproj new file mode 100644 index 0000000..48da8d5 --- /dev/null +++ b/tests/package-consumption/WpfDerivedNamespaceConsumer/WpfDerivedNamespaceConsumer.csproj @@ -0,0 +1,31 @@ + + + + Exe + net8.0-windows + true + enable + enable + en;de + + Derived.$(MSBuildProjectName) + + 1.0.0 + + + + + + + + + + + + + + +