From 2e78c0b2aeec62ee829f84e972b80962d0c4909c Mon Sep 17 00:00:00 2001 From: David Liebeherr Date: Mon, 7 Sep 2026 19:12:26 +0200 Subject: [PATCH 1/6] style: apply the C# style, ordering and formatting rules Tool-produced and nothing else: dotnet format style for the Roslyn code-style rules, ReSharper cleanupcode for member ordering, and CSharpier for formatting, in that order. The visible change is the type names: this repository writes the C# keywords now, so String becomes string, Object? becomes object? and so on, everywhere. Run it yourself with: pwsh -File scripts/tidy-code.ps1 -Scope all Co-Authored-By: Claude Opus 5 --- .../App.axaml.cs | 3 +- .../Controls/SyntaxTextBlock.cs | 66 +-- .../Models/FileSortOrder.cs | 2 +- .../Program.cs | 8 +- .../ViewModels/MainWindowViewModel.cs | 124 +++-- .../Views/MainWindow.axaml.cs | 3 +- .../Controls/SyntaxTextBlock.cs | 85 ++-- .../Models/FileSortOrder.cs | 2 +- .../ViewModels/MainWindowViewModel.cs | 124 +++-- src/ResXLocalization.Avalonia/LocalizeArgs.cs | 166 ++++--- .../LocalizeEnumConverter.cs | 56 +-- .../LocalizeEnumExtension.cs | 8 +- .../LocalizeExtension.cs | 18 +- .../LocalizedEnumObservable.cs | 74 ++- .../LocalizedFormattedTextObservable.cs | 51 +- .../LocalizedTextObservable.cs | 23 +- .../LocalizerWeakEvents.cs | 12 +- .../CultureChangedEventArgs.cs | 6 +- .../EnumKeyConvention.cs | 9 +- src/ResXLocalization.Core/ILocalizer.cs | 132 ++--- src/ResXLocalization.Core/Localizer.cs | 209 ++++---- src/ResXLocalization.Core/ResourceKey.cs | 2 +- .../TranslationNotFoundEventArgs.cs | 8 +- .../EquatableArray.cs | 40 +- .../ResxKeysGenerator.cs | 249 +++++----- src/ResXLocalization.WPF/LocalizeArgs.cs | 102 ++-- .../LocalizeEnumConverter.cs | 58 ++- .../LocalizeEnumExtension.cs | 8 +- src/ResXLocalization.WPF/LocalizeExtension.cs | 33 +- .../LocalizedFormattedStringConverter.cs | 9 +- .../LocalizeArgsTests.cs | 165 +++---- .../LocalizeEnumConverterTests.cs | 31 +- .../LocalizeEnumExtensionTests.cs | 5 +- .../LocalizeExtensionTests.cs | 26 +- .../MainWindowViewModelTests.cs | 8 +- .../MemoryLeakTests.cs | 18 +- .../TestAppBuilder.cs | 9 +- .../TestSupport.cs | 61 +-- .../AmbientLocalizerGroup.cs | 2 +- .../AvailableCulturesTests.cs | 60 +-- .../IsolatedInstanceTests.cs | 44 +- .../LocalizerContractTests.cs | 68 ++- .../LocalizerEnumLookupTests.cs | 43 +- .../LocalizerFormatTests.cs | 57 ++- .../LocalizerLookupTests.cs | 76 +-- .../LocalizerMissTests.cs | 119 +++-- .../LocalizerRegistrationTests.cs | 48 +- .../TestSortOrder.cs | 2 +- .../ResxKeysGeneratorTests.cs | 463 +++++++++--------- .../LocalizeArgsTests.cs | 225 +++++---- .../LocalizeEnumConverterTests.cs | 69 +-- .../LocalizeEnumExtensionTests.cs | 35 +- .../LocalizeExtensionTests.cs | 68 ++- .../MainWindowLiveSwitchTests.cs | 18 +- .../MainWindowViewModelTests.cs | 38 +- .../MemoryLeakTests.cs | 30 +- .../TestSupport.cs | 40 +- .../WpfThread.cs | 42 +- .../CombinedConsumer/Program.cs | 3 +- .../WpfConsumer/Program.cs | 6 +- 60 files changed, 1770 insertions(+), 1799 deletions(-) diff --git a/samples/ResXLocalization.Avalonia.Sample/App.axaml.cs b/samples/ResXLocalization.Avalonia.Sample/App.axaml.cs index 87c7f20..4dfb743 100644 --- a/samples/ResXLocalization.Avalonia.Sample/App.axaml.cs +++ b/samples/ResXLocalization.Avalonia.Sample/App.axaml.cs @@ -7,8 +7,7 @@ namespace RentADeveloper.ResXLocalization.Avalonia.Sample; public class App : Application { - public override void Initialize() => - AvaloniaXamlLoader.Load(this); + public override void Initialize() => AvaloniaXamlLoader.Load(this); public override void OnFrameworkInitializationCompleted() { diff --git a/samples/ResXLocalization.Avalonia.Sample/Controls/SyntaxTextBlock.cs b/samples/ResXLocalization.Avalonia.Sample/Controls/SyntaxTextBlock.cs index 3a38250..abbd6cc 100644 --- a/samples/ResXLocalization.Avalonia.Sample/Controls/SyntaxTextBlock.cs +++ b/samples/ResXLocalization.Avalonia.Sample/Controls/SyntaxTextBlock.cs @@ -7,15 +7,25 @@ namespace RentADeveloper.ResXLocalization.Avalonia.Sample.Controls; public sealed class SyntaxTextBlock : TextBlock { - public String Code + public static readonly StyledProperty CodeProperty = AvaloniaProperty.Register( + nameof(Code), + string.Empty + ); + + private static readonly IBrush KeywordBrush = SolidColorBrush.Parse("#0000FF"); + private static readonly IBrush MemberBrush = SolidColorBrush.Parse("#660E7A"); + + private static readonly IBrush PunctuationBrush = SolidColorBrush.Parse("#808080"); + private static readonly IBrush ResourceBrush = SolidColorBrush.Parse("#2B91AF"); + private static readonly IBrush StringBrush = SolidColorBrush.Parse("#008000"); + private static readonly IBrush TextBrush = SolidColorBrush.Parse("#000000"); + + public string Code { get => this.GetValue(CodeProperty); set => this.SetValue(CodeProperty, value); } - public static readonly StyledProperty CodeProperty = - AvaloniaProperty.Register(nameof(Code), String.Empty); - protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); @@ -26,14 +36,29 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang } } - private void AppendRun(String text, IBrush foreground) => + private static bool IsIdentifierPart(char value) => char.IsLetterOrDigit(value) || value is '_' or ':' or '.'; + + private static bool IsPunctuation(char value) => + value is '<' or '>' or '/' or '{' or '}' or '(' or ')' or '[' or ']' or ',' or '='; + + private static IBrush SelectIdentifierBrush(string text) => + text switch + { + _ when text.Contains(':', StringComparison.Ordinal) => KeywordBrush, + _ when text.Contains('.', StringComparison.Ordinal) => ResourceBrush, + "StaticResource" => KeywordBrush, + "Localizer" or "Get" or "ResourceManager" or "Converter" or "Key" or "KeyPrefix" or "Code" => MemberBrush, + _ => TextBrush, + }; + + private void AppendRun(string text, IBrush foreground) => this.Inlines?.Add(new Run(text) { Foreground = foreground }); private void Highlight() { this.Inlines?.Clear(); - if (String.IsNullOrEmpty(this.Code)) + if (string.IsNullOrEmpty(this.Code)) { return; } @@ -58,7 +83,7 @@ private void Highlight() } } - private Boolean TryReadIdentifier(ref Int32 index) + private bool TryReadIdentifier(ref int index) { if (!IsIdentifierPart(this.Code[index])) { @@ -76,7 +101,7 @@ private Boolean TryReadIdentifier(ref Int32 index) return true; } - private Boolean TryReadQuotedString(ref Int32 index) + private bool TryReadQuotedString(ref int index) { if (this.Code[index] != '"') { @@ -96,29 +121,4 @@ private Boolean TryReadQuotedString(ref Int32 index) this.AppendRun(this.Code[start..index], StringBrush); return true; } - - private static Boolean IsIdentifierPart(Char value) => - Char.IsLetterOrDigit(value) || value is '_' or ':' or '.'; - - private static Boolean IsPunctuation(Char value) => - value is '<' or '>' or '/' or '{' or '}' or '(' or ')' or '[' or ']' or ',' or '='; - - private static IBrush SelectIdentifierBrush(String text) => - text switch - { - _ when text.Contains(':', StringComparison.Ordinal) => KeywordBrush, - _ when text.Contains('.', StringComparison.Ordinal) => ResourceBrush, - "StaticResource" => KeywordBrush, - "Localizer" or "Get" or "ResourceManager" or "Converter" or "Key" or "KeyPrefix" or "Code" => - MemberBrush, - _ => TextBrush - }; - - private static readonly IBrush KeywordBrush = SolidColorBrush.Parse("#0000FF"); - private static readonly IBrush MemberBrush = SolidColorBrush.Parse("#660E7A"); - - private static readonly IBrush PunctuationBrush = SolidColorBrush.Parse("#808080"); - private static readonly IBrush ResourceBrush = SolidColorBrush.Parse("#2B91AF"); - private static readonly IBrush StringBrush = SolidColorBrush.Parse("#008000"); - private static readonly IBrush TextBrush = SolidColorBrush.Parse("#000000"); } diff --git a/samples/ResXLocalization.Avalonia.Sample/Models/FileSortOrder.cs b/samples/ResXLocalization.Avalonia.Sample/Models/FileSortOrder.cs index 48711e2..48bf763 100644 --- a/samples/ResXLocalization.Avalonia.Sample/Models/FileSortOrder.cs +++ b/samples/ResXLocalization.Avalonia.Sample/Models/FileSortOrder.cs @@ -23,5 +23,5 @@ public enum FileSortOrder Ascending = 1, /// Files are shown in descending order. - Descending = 2 + Descending = 2, } diff --git a/samples/ResXLocalization.Avalonia.Sample/Program.cs b/samples/ResXLocalization.Avalonia.Sample/Program.cs index de265db..9e6fa42 100644 --- a/samples/ResXLocalization.Avalonia.Sample/Program.cs +++ b/samples/ResXLocalization.Avalonia.Sample/Program.cs @@ -6,8 +6,7 @@ namespace RentADeveloper.ResXLocalization.Avalonia.Sample; internal static class Program { [STAThread] - public static void Main(String[] args) => - BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + public static void Main(string[] args) => BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); /// Referenced by Main and by the Avalonia design-time tooling. /// The configured application builder. @@ -31,9 +30,6 @@ private static AppBuilder BuildAvaloniaApp() // machine's culture. The default would otherwise be CultureInfo.CurrentUICulture. Localizer.Current.CurrentCulture = new("en"); - return AppBuilder.Configure() - .UsePlatformDetect() - .WithInterFont() - .LogToTrace(); + return AppBuilder.Configure().UsePlatformDetect().WithInterFont().LogToTrace(); } } diff --git a/samples/ResXLocalization.Avalonia.Sample/ViewModels/MainWindowViewModel.cs b/samples/ResXLocalization.Avalonia.Sample/ViewModels/MainWindowViewModel.cs index 352bc14..676ab2e 100644 --- a/samples/ResXLocalization.Avalonia.Sample/ViewModels/MainWindowViewModel.cs +++ b/samples/ResXLocalization.Avalonia.Sample/ViewModels/MainWindowViewModel.cs @@ -13,6 +13,26 @@ namespace RentADeveloper.ResXLocalization.Avalonia.Sample.ViewModels; /// public sealed partial class MainWindowViewModel : ObservableObject, IDisposable { + /// + /// The read-out properties have no backing field, so a culture switch must notify them explicitly. + /// + private static readonly string[] CultureSensitiveProperties = + [ + nameof(GreetingViaSearchAll), + nameof(GreetingViaIndexer), + nameof(GreetingViaTypedKey), + nameof(GreetingViaScopedManager), + nameof(PoweredByViaTypedKey), + nameof(PoweredByViaScopedInternalManager), + nameof(MissingKeyExample), + nameof(SortOrderViaSearchAllDefaultPrefix), + nameof(SortOrderViaSearchAllCustomPrefix), + nameof(SortOrderViaScopedDefaultPrefix), + nameof(SortOrderViaScopedCustomPrefix), + ]; + + private readonly ILocalizer localizer; + /// /// Initializes a new instance of the class. /// @@ -32,49 +52,62 @@ public MainWindowViewModel(ILocalizer localizer) localizer.CultureChanged += this.OnLocalizerCultureChanged; } - /// Releases the culture-change subscription when the owning window closes. - public void Dispose() - { - this.localizer.CultureChanged -= this.OnLocalizerCultureChanged; - GC.SuppressFinalize(this); - } - /// Gets all values of , shown in the sort-order combo boxes. public FileSortOrder[] FileSortOrders { get; } = Enum.GetValues(); /// Gets the greeting via the indexer, which is shorthand for the search-all Get(string). - public String GreetingViaIndexer => this.localizer["Greeting"]; + 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); + public string GreetingViaScopedManager => this.localizer.Get("Greeting", ApplicationStrings.ResourceManager); // ---- Code-behind string lookups (each maps to one ILocalizer member) ------------------------------ /// Gets the greeting via the key-only, search-all overload: Get(string). - public String GreetingViaSearchAll => this.localizer.Get("Greeting"); + public string GreetingViaSearchAll => this.localizer.Get("Greeting"); /// Gets the greeting via the typed-key overload: Get(ResourceKey). - public String GreetingViaTypedKey => this.localizer.Get(ApplicationStringsKeys.Greeting); + public string GreetingViaTypedKey => this.localizer.Get(ApplicationStringsKeys.Greeting); /// /// Gets a deliberately missing key, to show the engine's miss marker: a lookup that finds nothing returns /// !key! rather than throwing or returning null. /// - public String MissingKeyExample => this.localizer.Get("ThisKeyDoesNotExist"); + public string MissingKeyExample => this.localizer.Get("ThisKeyDoesNotExist"); + + /// Gets the choices offered for in the format-arguments demo. + 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. /// - public String PoweredByViaScopedInternalManager => - this.localizer.Get("PoweredBy", BrandingStrings.ResourceManager); + public string PoweredByViaScopedInternalManager => this.localizer.Get("PoweredBy", BrandingStrings.ResourceManager); /// /// Gets the branding text via a typed key. This resolves even though BrandingStrings has an internal /// accessor, because the generated BrandingStringsKeys class is always public. /// - public String PoweredByViaTypedKey => this.localizer.Get(BrandingStringsKeys.PoweredBy); + public string PoweredByViaTypedKey => this.localizer.Get(BrandingStringsKeys.PoweredBy); + + /// Gets the selection via Get(Enum, ResourceManager, keyPrefix): scoped, custom prefix. + public string SortOrderViaScopedCustomPrefix => + this.localizer.Get(this.SelectedFileSortOrder, SortingStrings.ResourceManager, "Display_"); + + /// Gets the selection via Get(Enum, ResourceManager): scoped, default prefix. + public string SortOrderViaScopedDefaultPrefix => + this.localizer.Get(this.SelectedFileSortOrder, ApplicationStrings.ResourceManager); + + /// Gets the selection via Get(Enum, keyPrefix): search-all with a custom prefix. + public string SortOrderViaSearchAllCustomPrefix => this.localizer.Get(this.SelectedFileSortOrder, "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 languages offered in the language switcher. + public CultureInfo[] SupportedLanguages { get; } = [new("en"), new("de")]; /// /// The people count formatted into the PeopleInvited resource by the dynamic @@ -82,10 +115,7 @@ public void Dispose() /// re-formats the localized text live. /// [ObservableProperty] - public partial Int32 PeopleCount { get; set; } = 5; - - /// Gets the choices offered for in the format-arguments demo. - public Int32[] PeopleCountOptions { get; } = [0, 1, 2, 5, 10]; + public partial int PeopleCount { get; set; } = 5; /// The selected sort order. Changing it refreshes the four enum read-outs below. [ObservableProperty] @@ -102,31 +132,14 @@ public void Dispose() [ObservableProperty] public partial CultureInfo SelectedLanguage { get; set; } - /// Gets the selection via Get(Enum, ResourceManager, keyPrefix): scoped, custom prefix. - public String SortOrderViaScopedCustomPrefix => - this.localizer.Get(this.SelectedFileSortOrder, SortingStrings.ResourceManager, "Display_"); - - /// Gets the selection via Get(Enum, ResourceManager): scoped, default prefix. - public String SortOrderViaScopedDefaultPrefix => - this.localizer.Get(this.SelectedFileSortOrder, ApplicationStrings.ResourceManager); - - /// Gets the selection via Get(Enum, keyPrefix): search-all with a custom prefix. - public String SortOrderViaSearchAllCustomPrefix => - this.localizer.Get(this.SelectedFileSortOrder, "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 languages offered in the language switcher. - public CultureInfo[] SupportedLanguages { get; } = - [ - new("en"), - new("de") - ]; + /// Releases the culture-change subscription when the owning window closes. + public void Dispose() + { + this.localizer.CultureChanged -= this.OnLocalizerCultureChanged; + GC.SuppressFinalize(this); + } - private void OnLocalizerCultureChanged(Object? sender, CultureChangedEventArgs e) + private void OnLocalizerCultureChanged(object? sender, CultureChangedEventArgs e) { foreach (var propertyName in CultureSensitiveProperties) { @@ -134,26 +147,5 @@ private void OnLocalizerCultureChanged(Object? sender, CultureChangedEventArgs e } } - partial void OnSelectedLanguageChanged(CultureInfo value) => - this.localizer.CurrentCulture = value; - - private readonly ILocalizer localizer; - - /// - /// The read-out properties have no backing field, so a culture switch must notify them explicitly. - /// - private static readonly String[] CultureSensitiveProperties = - [ - nameof(GreetingViaSearchAll), - nameof(GreetingViaIndexer), - nameof(GreetingViaTypedKey), - nameof(GreetingViaScopedManager), - nameof(PoweredByViaTypedKey), - nameof(PoweredByViaScopedInternalManager), - nameof(MissingKeyExample), - nameof(SortOrderViaSearchAllDefaultPrefix), - nameof(SortOrderViaSearchAllCustomPrefix), - nameof(SortOrderViaScopedDefaultPrefix), - nameof(SortOrderViaScopedCustomPrefix) - ]; + partial void OnSelectedLanguageChanged(CultureInfo value) => this.localizer.CurrentCulture = value; } diff --git a/samples/ResXLocalization.Avalonia.Sample/Views/MainWindow.axaml.cs b/samples/ResXLocalization.Avalonia.Sample/Views/MainWindow.axaml.cs index dbfa1c5..c31e94c 100644 --- a/samples/ResXLocalization.Avalonia.Sample/Views/MainWindow.axaml.cs +++ b/samples/ResXLocalization.Avalonia.Sample/Views/MainWindow.axaml.cs @@ -13,6 +13,5 @@ public MainWindow() this.Closed += (_, _) => (this.DataContext as IDisposable)?.Dispose(); } - private void InitializeComponent() => - AvaloniaXamlLoader.Load(this); + private void InitializeComponent() => AvaloniaXamlLoader.Load(this); } diff --git a/samples/ResXLocalization.WPF.Sample/Controls/SyntaxTextBlock.cs b/samples/ResXLocalization.WPF.Sample/Controls/SyntaxTextBlock.cs index 2dea398..4bea36e 100644 --- a/samples/ResXLocalization.WPF.Sample/Controls/SyntaxTextBlock.cs +++ b/samples/ResXLocalization.WPF.Sample/Controls/SyntaxTextBlock.cs @@ -7,28 +7,56 @@ namespace RentADeveloper.ResXLocalization.WPF.Sample.Controls; public sealed class SyntaxTextBlock : TextBlock { - public String Code + public static readonly DependencyProperty CodeProperty = DependencyProperty.Register( + nameof(Code), + typeof(string), + typeof(SyntaxTextBlock), + new(string.Empty, OnCodeChanged) + ); + + private static readonly Brush KeywordBrush = ParseBrush("#0000FF"); + private static readonly Brush MemberBrush = ParseBrush("#660E7A"); + + private static readonly Brush PunctuationBrush = ParseBrush("#808080"); + private static readonly Brush ResourceBrush = ParseBrush("#2B91AF"); + private static readonly Brush StringBrush = ParseBrush("#008000"); + private static readonly Brush TextBrush = ParseBrush("#000000"); + + public string Code { - get => (String)this.GetValue(CodeProperty); + get => (string)this.GetValue(CodeProperty); set => this.SetValue(CodeProperty, value); } - public static readonly DependencyProperty CodeProperty = - DependencyProperty.Register( - nameof(Code), - typeof(String), - typeof(SyntaxTextBlock), - new(String.Empty, OnCodeChanged) - ); + private static bool IsIdentifierPart(char value) => char.IsLetterOrDigit(value) || value is '_' or ':' or '.'; + + private static bool IsPunctuation(char value) => + value is '<' or '>' or '/' or '{' or '}' or '(' or ')' or '[' or ']' or ',' or '='; + + private static void OnCodeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) => + ((SyntaxTextBlock)d).Highlight(); + + private static Brush ParseBrush(string hex) => + (SolidColorBrush)(new BrushConverter().ConvertFromString(hex) ?? Brushes.Black); + + private static Brush SelectIdentifierBrush(string text) => + text switch + { + _ when text.Contains(':', StringComparison.Ordinal) => KeywordBrush, + _ when text.Contains('.', StringComparison.Ordinal) => ResourceBrush, + "StaticResource" => KeywordBrush, + "Localizer" or "Get" or "ResourceManager" or "Converter" or "Key" or "KeyPrefix" or "Code" => MemberBrush, + _ => TextBrush, + }; - private void AppendRun(String text, Brush foreground) => + private void AppendRun(string text, Brush foreground) => this.Inlines.Add(new Run(text) { Foreground = foreground }); private void Highlight() { this.Inlines.Clear(); - if (String.IsNullOrEmpty(this.Code)) + if (string.IsNullOrEmpty(this.Code)) { return; } @@ -53,7 +81,7 @@ private void Highlight() } } - private Boolean TryReadIdentifier(ref Int32 index) + private bool TryReadIdentifier(ref int index) { if (!IsIdentifierPart(this.Code[index])) { @@ -71,7 +99,7 @@ private Boolean TryReadIdentifier(ref Int32 index) return true; } - private Boolean TryReadQuotedString(ref Int32 index) + private bool TryReadQuotedString(ref int index) { if (this.Code[index] != '"') { @@ -91,35 +119,4 @@ private Boolean TryReadQuotedString(ref Int32 index) this.AppendRun(this.Code[start..index], StringBrush); return true; } - - private static Boolean IsIdentifierPart(Char value) => - Char.IsLetterOrDigit(value) || value is '_' or ':' or '.'; - - private static Boolean IsPunctuation(Char value) => - value is '<' or '>' or '/' or '{' or '}' or '(' or ')' or '[' or ']' or ',' or '='; - - private static void OnCodeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) => - ((SyntaxTextBlock)d).Highlight(); - - private static Brush ParseBrush(String hex) => - (SolidColorBrush)(new BrushConverter().ConvertFromString(hex) ?? Brushes.Black); - - private static Brush SelectIdentifierBrush(String text) => - text switch - { - _ when text.Contains(':', StringComparison.Ordinal) => KeywordBrush, - _ when text.Contains('.', StringComparison.Ordinal) => ResourceBrush, - "StaticResource" => KeywordBrush, - "Localizer" or "Get" or "ResourceManager" or "Converter" or "Key" or "KeyPrefix" or "Code" => - MemberBrush, - _ => TextBrush - }; - - private static readonly Brush KeywordBrush = ParseBrush("#0000FF"); - private static readonly Brush MemberBrush = ParseBrush("#660E7A"); - - private static readonly Brush PunctuationBrush = ParseBrush("#808080"); - private static readonly Brush ResourceBrush = ParseBrush("#2B91AF"); - private static readonly Brush StringBrush = ParseBrush("#008000"); - private static readonly Brush TextBrush = ParseBrush("#000000"); } diff --git a/samples/ResXLocalization.WPF.Sample/Models/FileSortOrder.cs b/samples/ResXLocalization.WPF.Sample/Models/FileSortOrder.cs index 309ed86..4a81d0e 100644 --- a/samples/ResXLocalization.WPF.Sample/Models/FileSortOrder.cs +++ b/samples/ResXLocalization.WPF.Sample/Models/FileSortOrder.cs @@ -23,5 +23,5 @@ public enum FileSortOrder Ascending = 1, /// Files are shown in descending order. - Descending = 2 + Descending = 2, } diff --git a/samples/ResXLocalization.WPF.Sample/ViewModels/MainWindowViewModel.cs b/samples/ResXLocalization.WPF.Sample/ViewModels/MainWindowViewModel.cs index fc33111..f006d5a 100644 --- a/samples/ResXLocalization.WPF.Sample/ViewModels/MainWindowViewModel.cs +++ b/samples/ResXLocalization.WPF.Sample/ViewModels/MainWindowViewModel.cs @@ -13,6 +13,26 @@ namespace RentADeveloper.ResXLocalization.WPF.Sample.ViewModels; /// public sealed partial class MainWindowViewModel : ObservableObject, IDisposable { + /// + /// The read-out properties have no backing field, so a culture switch must notify them explicitly. + /// + private static readonly string[] CultureSensitiveProperties = + [ + nameof(GreetingViaSearchAll), + nameof(GreetingViaIndexer), + nameof(GreetingViaTypedKey), + nameof(GreetingViaScopedManager), + nameof(PoweredByViaTypedKey), + nameof(PoweredByViaScopedInternalManager), + nameof(MissingKeyExample), + nameof(SortOrderViaSearchAllDefaultPrefix), + nameof(SortOrderViaSearchAllCustomPrefix), + nameof(SortOrderViaScopedDefaultPrefix), + nameof(SortOrderViaScopedCustomPrefix), + ]; + + private readonly ILocalizer localizer; + /// /// Initializes a new instance of the class. /// @@ -32,49 +52,62 @@ public MainWindowViewModel(ILocalizer localizer) localizer.CultureChanged += this.OnLocalizerCultureChanged; } - /// Releases the culture-change subscription when the owning window closes. - public void Dispose() - { - this.localizer.CultureChanged -= this.OnLocalizerCultureChanged; - GC.SuppressFinalize(this); - } - /// Gets all values of , shown in the sort-order combo boxes. public FileSortOrder[] FileSortOrders { get; } = Enum.GetValues(); /// Gets the greeting via the indexer, which is shorthand for the search-all Get(string). - public String GreetingViaIndexer => this.localizer["Greeting"]; + 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); + public string GreetingViaScopedManager => this.localizer.Get("Greeting", ApplicationStrings.ResourceManager); // ---- Code-behind string lookups (each maps to one ILocalizer member) ------------------------------ /// Gets the greeting via the key-only, search-all overload: Get(string). - public String GreetingViaSearchAll => this.localizer.Get("Greeting"); + public string GreetingViaSearchAll => this.localizer.Get("Greeting"); /// Gets the greeting via the typed-key overload: Get(ResourceKey). - public String GreetingViaTypedKey => this.localizer.Get(ApplicationStringsKeys.Greeting); + public string GreetingViaTypedKey => this.localizer.Get(ApplicationStringsKeys.Greeting); /// /// Gets a deliberately missing key, to show the engine's miss marker: a lookup that finds nothing returns /// !key! rather than throwing or returning null. /// - public String MissingKeyExample => this.localizer.Get("ThisKeyDoesNotExist"); + public string MissingKeyExample => this.localizer.Get("ThisKeyDoesNotExist"); + + /// Gets the choices offered for in the format-arguments demo. + 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. /// - public String PoweredByViaScopedInternalManager => - this.localizer.Get("PoweredBy", BrandingStrings.ResourceManager); + public string PoweredByViaScopedInternalManager => this.localizer.Get("PoweredBy", BrandingStrings.ResourceManager); /// /// Gets the branding text via a typed key. This resolves even though BrandingStrings has an internal /// accessor, because the generated BrandingStringsKeys class is always public. /// - public String PoweredByViaTypedKey => this.localizer.Get(BrandingStringsKeys.PoweredBy); + public string PoweredByViaTypedKey => this.localizer.Get(BrandingStringsKeys.PoweredBy); + + /// Gets the selection via Get(Enum, ResourceManager, keyPrefix): scoped, custom prefix. + public string SortOrderViaScopedCustomPrefix => + this.localizer.Get(this.SelectedFileSortOrder, SortingStrings.ResourceManager, "Display_"); + + /// Gets the selection via Get(Enum, ResourceManager): scoped, default prefix. + public string SortOrderViaScopedDefaultPrefix => + this.localizer.Get(this.SelectedFileSortOrder, ApplicationStrings.ResourceManager); + + /// Gets the selection via Get(Enum, keyPrefix): search-all with a custom prefix. + public string SortOrderViaSearchAllCustomPrefix => this.localizer.Get(this.SelectedFileSortOrder, "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 languages offered in the language switcher. + public CultureInfo[] SupportedLanguages { get; } = [new("en"), new("de")]; /// /// The people count formatted into the PeopleInvited resource by the dynamic @@ -82,10 +115,7 @@ public void Dispose() /// re-formats the localized text live. /// [ObservableProperty] - public partial Int32 PeopleCount { get; set; } = 5; - - /// Gets the choices offered for in the format-arguments demo. - public Int32[] PeopleCountOptions { get; } = [0, 1, 2, 5, 10]; + public partial int PeopleCount { get; set; } = 5; /// The selected sort order. Changing it refreshes the four enum read-outs below. [ObservableProperty] @@ -102,31 +132,14 @@ public void Dispose() [ObservableProperty] public partial CultureInfo SelectedLanguage { get; set; } - /// Gets the selection via Get(Enum, ResourceManager, keyPrefix): scoped, custom prefix. - public String SortOrderViaScopedCustomPrefix => - this.localizer.Get(this.SelectedFileSortOrder, SortingStrings.ResourceManager, "Display_"); - - /// Gets the selection via Get(Enum, ResourceManager): scoped, default prefix. - public String SortOrderViaScopedDefaultPrefix => - this.localizer.Get(this.SelectedFileSortOrder, ApplicationStrings.ResourceManager); - - /// Gets the selection via Get(Enum, keyPrefix): search-all with a custom prefix. - public String SortOrderViaSearchAllCustomPrefix => - this.localizer.Get(this.SelectedFileSortOrder, "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 languages offered in the language switcher. - public CultureInfo[] SupportedLanguages { get; } = - [ - new("en"), - new("de") - ]; + /// Releases the culture-change subscription when the owning window closes. + public void Dispose() + { + this.localizer.CultureChanged -= this.OnLocalizerCultureChanged; + GC.SuppressFinalize(this); + } - private void OnLocalizerCultureChanged(Object? sender, CultureChangedEventArgs e) + private void OnLocalizerCultureChanged(object? sender, CultureChangedEventArgs e) { foreach (var propertyName in CultureSensitiveProperties) { @@ -134,26 +147,5 @@ private void OnLocalizerCultureChanged(Object? sender, CultureChangedEventArgs e } } - partial void OnSelectedLanguageChanged(CultureInfo value) => - this.localizer.CurrentCulture = value; - - private readonly ILocalizer localizer; - - /// - /// The read-out properties have no backing field, so a culture switch must notify them explicitly. - /// - private static readonly String[] CultureSensitiveProperties = - [ - nameof(GreetingViaSearchAll), - nameof(GreetingViaIndexer), - nameof(GreetingViaTypedKey), - nameof(GreetingViaScopedManager), - nameof(PoweredByViaTypedKey), - nameof(PoweredByViaScopedInternalManager), - nameof(MissingKeyExample), - nameof(SortOrderViaSearchAllDefaultPrefix), - nameof(SortOrderViaSearchAllCustomPrefix), - nameof(SortOrderViaScopedDefaultPrefix), - nameof(SortOrderViaScopedCustomPrefix) - ]; + partial void OnSelectedLanguageChanged(CultureInfo value) => this.localizer.CurrentCulture = value; } diff --git a/src/ResXLocalization.Avalonia/LocalizeArgs.cs b/src/ResXLocalization.Avalonia/LocalizeArgs.cs index 307bae3..818038a 100644 --- a/src/ResXLocalization.Avalonia/LocalizeArgs.cs +++ b/src/ResXLocalization.Avalonia/LocalizeArgs.cs @@ -20,138 +20,174 @@ namespace RentADeveloper.ResXLocalization.Avalonia; public sealed class LocalizeArgs : AvaloniaObject { /// Identifies the LocalizeArgs.Arg0 attached property: format argument {0}. - public static readonly AttachedProperty Arg0Property = - AvaloniaProperty.RegisterAttached("Arg0", UnsetSentinel.Value); + 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("Arg1", UnsetSentinel.Value); + 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("Arg2", UnsetSentinel.Value); + 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("Arg3", UnsetSentinel.Value); + 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("Arg4", UnsetSentinel.Value); + 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("Arg5", UnsetSentinel.Value); + 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("Arg6", UnsetSentinel.Value); + 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("Arg7", UnsetSentinel.Value); + 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("Arg8", UnsetSentinel.Value); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); - - /// - /// 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("ArgsVersion"); + public static void SetArg8(AvaloniaObject element, object? value) => element.SetValue(Arg8Property, value); /// /// Reads the format arguments currently set on , applying the @@ -163,7 +199,7 @@ public sealed class LocalizeArgs : AvaloniaObject /// 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) + internal static object?[]? GetArguments(AvaloniaObject element) { var lastSetIndex = -1; @@ -181,7 +217,7 @@ public sealed class LocalizeArgs : AvaloniaObject return null; } - var arguments = new Object?[lastSetIndex + 1]; + var arguments = new object?[lastSetIndex + 1]; for (var index = 0; index <= lastSetIndex; index++) { @@ -192,21 +228,6 @@ public sealed class LocalizeArgs : AvaloniaObject return arguments; } - /// Prevents instantiation: the class only hosts attached properties. - private LocalizeArgs() - { - } - - /// 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; - } - /// /// Collects the nine argument slots and hooks their change notification: every ArgN /// change bumps on the changed element. Invoked from the @@ -214,9 +235,9 @@ private LocalizeArgs() /// attached-property fields stay simple static initializers. /// /// The nine argument slots in slot order. - private static AttachedProperty[] CollectArgumentsAndHookChanges() + private static AttachedProperty[] CollectArgumentsAndHookChanges() { - AttachedProperty[] properties = + AttachedProperty[] properties = [ Arg0Property, Arg1Property, @@ -226,21 +247,28 @@ private LocalizeArgs() Arg5Property, Arg6Property, Arg7Property, - Arg8Property + Arg8Property, ]; foreach (var property in properties) { - _ = property.Changed.AddClassHandler(static (element, _) => - element.SetValue(ArgsVersionProperty, element.GetValue(ArgsVersionProperty) + 1) + _ = property.Changed.AddClassHandler( + static (element, _) => element.SetValue(ArgsVersionProperty, element.GetValue(ArgsVersionProperty) + 1) ); } return properties; } - /// The nine argument slots in slot order, for . - private static readonly AttachedProperty[] ArgumentProperties = CollectArgumentsAndHookChanges(); + /// 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 @@ -249,6 +277,6 @@ private LocalizeArgs() private static class UnsetSentinel { /// The sentinel instance; compared by reference. - internal static readonly Object Value = new(); + internal static readonly object Value = new(); } } diff --git a/src/ResXLocalization.Avalonia/LocalizeEnumConverter.cs b/src/ResXLocalization.Avalonia/LocalizeEnumConverter.cs index 6699bfc..c1b6714 100644 --- a/src/ResXLocalization.Avalonia/LocalizeEnumConverter.cs +++ b/src/ResXLocalization.Avalonia/LocalizeEnumConverter.cs @@ -9,10 +9,28 @@ namespace RentADeveloper.ResXLocalization.Avalonia; /// public sealed class LocalizeEnumConverter : IMultiValueConverter { + /// Indicates whether this is the read-only shared instance. + private readonly bool isSharedInstance; + /// Initializes a new instance of the class. - public LocalizeEnumConverter() - { - } + public LocalizeEnumConverter() { } + + /// + /// Initializes a new instance of the class, optionally + /// marked as the read-only shared instance. Used only to create . + /// + /// + /// to make the instance read-only, rejecting property assignments. + /// + private LocalizeEnumConverter(bool isSharedInstance) => this.isSharedInstance = isSharedInstance; + + /// + /// 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 . + /// + public static LocalizeEnumConverter Default { get; } = new(isSharedInstance: true); /// /// Gets or sets the prefix prepended to the generated resource key. Defaults to Enum_. @@ -21,7 +39,7 @@ public LocalizeEnumConverter() /// /// The converter is the shared instance, which is read-only. /// - public String KeyPrefix + public string KeyPrefix { get; set @@ -64,14 +82,14 @@ public ResourceManager? ResourceManager /// taken from ). /// /// - /// The localized string for the enumeration value, or when no value + /// The localized string for the enumeration value, or when no value /// is supplied or the first value is not an . /// - public Object Convert(IList values, Type targetType, Object? parameter, CultureInfo culture) + public object Convert(IList values, Type targetType, object? parameter, CultureInfo culture) { if (values.Count == 0 || values[0] is not Enum enumValue) { - return String.Empty; + return string.Empty; } var key = EnumKeyConvention.BuildEnumKey(enumValue, this.KeyPrefix); @@ -81,23 +99,6 @@ public Object Convert(IList values, Type targetType, Object? parameter, : Localizer.Current.Get(key, this.ResourceManager); } - /// - /// 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 . - /// - public static LocalizeEnumConverter Default { get; } = new(isSharedInstance: true); - - /// - /// Initializes a new instance of the class, optionally - /// marked as the read-only shared instance. Used only to create . - /// - /// - /// to make the instance read-only, rejecting property assignments. - /// - private LocalizeEnumConverter(Boolean isSharedInstance) => this.isSharedInstance = isSharedInstance; - /// Guards property setters against mutating the shared instance. /// /// This converter is the shared instance, which is read-only. @@ -107,12 +108,9 @@ private void ThrowIfShared() if (this.isSharedInstance) { throw new InvalidOperationException( - "The shared LocalizeEnumConverter.Default instance is read-only; " + - "create your own LocalizeEnumConverter to customize KeyPrefix or ResourceManager." + "The shared LocalizeEnumConverter.Default instance is read-only; " + + "create your own LocalizeEnumConverter to customize KeyPrefix or ResourceManager." ); } } - - /// Indicates whether this is the read-only shared instance. - private readonly Boolean isSharedInstance; } diff --git a/src/ResXLocalization.Avalonia/LocalizeEnumExtension.cs b/src/ResXLocalization.Avalonia/LocalizeEnumExtension.cs index f7a3af9..e0a33d6 100644 --- a/src/ResXLocalization.Avalonia/LocalizeEnumExtension.cs +++ b/src/ResXLocalization.Avalonia/LocalizeEnumExtension.cs @@ -12,7 +12,7 @@ public sealed class LocalizeEnumExtension : MarkupExtension /// /// Gets or sets the prefix prepended to the generated resource key. Defaults to Enum_. /// - public String KeyPrefix { get; set; } = EnumKeyConvention.DefaultEnumKeyPrefix; + public string KeyPrefix { get; set; } = EnumKeyConvention.DefaultEnumKeyPrefix; /// /// Gets or sets the resource manager that scopes the lookup to a single .resx file. When @@ -28,15 +28,15 @@ public sealed class LocalizeEnumExtension : MarkupExtension /// The service provider supplied by the XAML loader. /// /// An Avalonia binding that yields the localized enumeration text, or - /// when the target is not an . + /// when the target is not an . /// - public override Object ProvideValue(IServiceProvider serviceProvider) + 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) { - return String.Empty; + return string.Empty; } // Live stream of this control's DataContext (the enum value). diff --git a/src/ResXLocalization.Avalonia/LocalizeExtension.cs b/src/ResXLocalization.Avalonia/LocalizeExtension.cs index faeb88c..8815e33 100644 --- a/src/ResXLocalization.Avalonia/LocalizeExtension.cs +++ b/src/ResXLocalization.Avalonia/LocalizeExtension.cs @@ -12,20 +12,18 @@ namespace RentADeveloper.ResXLocalization.Avalonia; public sealed class LocalizeExtension : MarkupExtension { /// Initializes a new instance of the class. - public LocalizeExtension() - { - } + 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. + /// 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. /// /// A or a key string. - public LocalizeExtension(Object key) + public LocalizeExtension(object key) { if (key is ResourceKey resourceKey) { @@ -33,7 +31,7 @@ public LocalizeExtension(Object key) } else { - this.Key = key as String ?? String.Empty; + this.Key = key as string ?? string.Empty; } } @@ -42,7 +40,7 @@ public LocalizeExtension(Object key) /// is also set, the lookup is scoped to that file; otherwise every /// registered resource manager is searched. /// - public String Key { get; set; } = String.Empty; + public string Key { get; set; } = string.Empty; /// /// Gets or sets a typed, file-scoped key. When set, it takes precedence over @@ -64,10 +62,10 @@ public LocalizeExtension(Object key) /// /// The service provider supplied by the XAML loader. /// An Avalonia binding that yields the localized string. - public override Object ProvideValue(IServiceProvider serviceProvider) + public override object ProvideValue(IServiceProvider serviceProvider) { - Func resolve; - Func resolveFormatted; + Func resolve; + Func resolveFormatted; if (this.ResourceKey.HasValue) { diff --git a/src/ResXLocalization.Avalonia/LocalizedEnumObservable.cs b/src/ResXLocalization.Avalonia/LocalizedEnumObservable.cs index df1d69e..6658858 100644 --- a/src/ResXLocalization.Avalonia/LocalizedEnumObservable.cs +++ b/src/ResXLocalization.Avalonia/LocalizedEnumObservable.cs @@ -15,11 +15,10 @@ namespace RentADeveloper.ResXLocalization.Avalonia; /// to search all registered resource managers. /// internal sealed class LocalizedEnumObservable( - IObservable dataContextSource, - String keyPrefix, + IObservable dataContextSource, + string keyPrefix, ResourceManager? resourceManager -) - : IObservable +) : IObservable { /// /// Pushes the localized text for the control's current DataContext value to @@ -27,7 +26,7 @@ internal sealed class LocalizedEnumObservable( /// /// The observer receiving the localized enumeration text. /// A subscription that stops the updates when disposed. - public IDisposable Subscribe(IObserver observer) => + public IDisposable Subscribe(IObserver observer) => new Subscription(dataContextSource, keyPrefix, resourceManager, observer); /// @@ -37,6 +36,27 @@ public IDisposable Subscribe(IObserver observer) => /// 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 @@ -50,22 +70,21 @@ private sealed class Subscription : IDisposable, IWeakEventSubscriber /// The observer receiving the localized enumeration text. public Subscription( - IObservable dataContextSource, - String keyPrefix, + IObservable dataContextSource, + string keyPrefix, ResourceManager? resourceManager, - IObserver observer + 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(); - } - ) + new AnonymousObserver(value => + { + this.currentValue = value as Enum; + this.Emit(); + }) ); LocalizerWeakEvents.CultureChanged.Subscribe(Localizer.Current, this); } @@ -81,18 +100,18 @@ public void Dispose() /// 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(); + 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 + /// 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); + this.observer.OnNext(string.Empty); return; } @@ -103,26 +122,5 @@ this.resourceManager is null : Localizer.Current.Get(key, this.resourceManager) ); } - - /// 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; } } diff --git a/src/ResXLocalization.Avalonia/LocalizedFormattedTextObservable.cs b/src/ResXLocalization.Avalonia/LocalizedFormattedTextObservable.cs index 8fe98a0..514210d 100644 --- a/src/ResXLocalization.Avalonia/LocalizedFormattedTextObservable.cs +++ b/src/ResXLocalization.Avalonia/LocalizedFormattedTextObservable.cs @@ -13,10 +13,9 @@ namespace RentADeveloper.ResXLocalization.Avalonia; /// Resolves the localized string formatted with the supplied arguments. internal sealed class LocalizedFormattedTextObservable( AvaloniaObject element, - Func resolve, - Func resolveFormatted -) - : IObservable + Func resolve, + Func resolveFormatted +) : IObservable { /// /// Pushes the currently resolved string to and keeps it updated on @@ -24,7 +23,7 @@ internal sealed class LocalizedFormattedTextObservable( /// /// The observer receiving the localized string. /// A subscription that stops the updates when disposed. - public IDisposable Subscribe(IObserver observer) => + public IDisposable Subscribe(IObserver observer) => new Subscription(element, resolve, resolveFormatted, observer); /// @@ -35,6 +34,21 @@ public IDisposable Subscribe(IObserver observer) => /// 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. @@ -45,9 +59,9 @@ private sealed class Subscription : IDisposable, IWeakEventSubscriberThe observer receiving each freshly resolved string. public Subscription( AvaloniaObject element, - Func resolve, - Func resolveFormatted, - IObserver observer + Func resolve, + Func resolveFormatted, + IObserver observer ) { this.element = element; @@ -56,7 +70,7 @@ public Subscription( this.observer = observer; this.argsVersionSubscription = element .GetObservable(LocalizeArgs.ArgsVersionProperty) - .Subscribe(new AnonymousObserver(_ => this.Emit())); + .Subscribe(new AnonymousObserver(_ => this.Emit())); LocalizerWeakEvents.CultureChanged.Subscribe(Localizer.Current, this); } @@ -71,7 +85,7 @@ public void Dispose() /// 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(); + public void OnEvent(object? sender, WeakEvent ev, CultureChangedEventArgs e) => this.Emit(); /// /// Emits the freshly resolved string: formatted with the element's current @@ -80,7 +94,7 @@ public void Dispose() /// private void Emit() { - Object? value; + object? value; try { @@ -99,20 +113,5 @@ private void Emit() this.observer.OnNext(value); } - - /// 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; } } diff --git a/src/ResXLocalization.Avalonia/LocalizedTextObservable.cs b/src/ResXLocalization.Avalonia/LocalizedTextObservable.cs index a31afd0..c18d581 100644 --- a/src/ResXLocalization.Avalonia/LocalizedTextObservable.cs +++ b/src/ResXLocalization.Avalonia/LocalizedTextObservable.cs @@ -8,7 +8,7 @@ namespace RentADeveloper.ResXLocalization.Avalonia; /// localizer lives on. /// /// Produces the localized string for the current culture on demand. -internal sealed class LocalizedTextObservable(Func valueFactory) : IObservable +internal sealed class LocalizedTextObservable(Func valueFactory) : IObservable { /// /// Pushes the currently resolved string to and keeps it updated on @@ -16,7 +16,7 @@ internal sealed class LocalizedTextObservable(Func valueFactory) : IObse /// /// The observer receiving the localized string. /// A subscription that stops the culture-change updates when disposed. - public IDisposable Subscribe(IObserver observer) + public IDisposable Subscribe(IObserver observer) { observer.OnNext(valueFactory()); return new Subscription(valueFactory, observer); @@ -29,13 +29,19 @@ public IDisposable Subscribe(IObserver observer) /// 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) + public Subscription(Func valueFactory, IObserver observer) { this.valueFactory = valueFactory; this.observer = observer; @@ -43,20 +49,13 @@ public Subscription(Func valueFactory, IObserver observer) } /// Unsubscribes from the culture-change weak event. - public void Dispose() => - LocalizerWeakEvents.CultureChanged.Unsubscribe(Localizer.Current, this); + 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) => + public void OnEvent(object? sender, WeakEvent ev, CultureChangedEventArgs e) => this.observer.OnNext(this.valueFactory()); - - /// 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; } } diff --git a/src/ResXLocalization.Avalonia/LocalizerWeakEvents.cs b/src/ResXLocalization.Avalonia/LocalizerWeakEvents.cs index 523871b..16bfe99 100644 --- a/src/ResXLocalization.Avalonia/LocalizerWeakEvents.cs +++ b/src/ResXLocalization.Avalonia/LocalizerWeakEvents.cs @@ -8,9 +8,11 @@ namespace RentADeveloper.ResXLocalization.Avalonia; internal static class LocalizerWeakEvents { /// The weak-event wrapper around . - internal static readonly WeakEvent CultureChanged = - WeakEvent.Register( - static (localizer, handler) => localizer.CultureChanged += handler, - static (localizer, handler) => localizer.CultureChanged -= handler - ); + 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.Core/CultureChangedEventArgs.cs b/src/ResXLocalization.Core/CultureChangedEventArgs.cs index 5ce1b3e..5d20100 100644 --- a/src/ResXLocalization.Core/CultureChangedEventArgs.cs +++ b/src/ResXLocalization.Core/CultureChangedEventArgs.cs @@ -23,9 +23,9 @@ public CultureChangedEventArgs(CultureInfo oldCulture, CultureInfo newCulture) this.NewCulture = newCulture; } - /// Gets the culture that was in effect before the change. - public CultureInfo OldCulture { get; } - /// Gets the culture that is in effect after the change. public CultureInfo NewCulture { get; } + + /// Gets the culture that was in effect before the change. + public CultureInfo OldCulture { get; } } diff --git a/src/ResXLocalization.Core/EnumKeyConvention.cs b/src/ResXLocalization.Core/EnumKeyConvention.cs index d09e8b3..799556d 100644 --- a/src/ResXLocalization.Core/EnumKeyConvention.cs +++ b/src/ResXLocalization.Core/EnumKeyConvention.cs @@ -8,13 +8,12 @@ namespace RentADeveloper.ResXLocalization; /// 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; - - /// The default prefix (Enum_) prepended to generated enumeration resource keys. - internal const String DefaultEnumKeyPrefix = "Enum_"; + 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 4048a74..ad76309 100644 --- a/src/ResXLocalization.Core/ILocalizer.cs +++ b/src/ResXLocalization.Core/ILocalizer.cs @@ -12,6 +12,20 @@ namespace RentADeveloper.ResXLocalization; /// public interface ILocalizer : INotifyPropertyChanged { + /// + /// Occurs after changes, carrying the previous and current culture. + /// Subscribe to refresh values that are not resolved through a binding. + /// + 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. + /// + event EventHandler TranslationNotFound; + /// /// Gets or sets the culture used to resolve resources. Setting a different value raises /// and , causing @@ -29,19 +43,6 @@ public interface ILocalizer : INotifyPropertyChanged /// The supplied value is . CultureInfo CurrentCulture { get; set; } - /// - /// 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 this[String key] { get; } - /// /// 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 @@ -58,21 +59,26 @@ public interface ILocalizer : INotifyPropertyChanged /// example {1}. It is validated on assignment so the defect surfaces here rather than on the /// first miss. /// - String MissingTranslationFormat { get; set; } + string MissingTranslationFormat { get; set; } /// - /// Occurs after changes, carrying the previous and current culture. - /// Subscribe to refresh values that are not resolved through a binding. + /// Gets the localized string for the specified key by searching every registered + /// in registration order. /// - event EventHandler CultureChanged; + /// 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; } /// - /// 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. + /// Removes every registered from the search-all set. Typed and + /// scoped lookups are unaffected - they never consult the registration set. /// - event EventHandler TranslationNotFound; + void ClearResourceManagers(); /// /// Resolves the localized string for the specified key by searching every registered @@ -81,11 +87,11 @@ public interface ILocalizer : INotifyPropertyChanged /// The resource key to resolve. /// /// The localized string for in ; - /// if is or empty; + /// 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); + string Get(string key); /// /// Resolves the localized string for the specified key by searching every registered @@ -96,7 +102,7 @@ public interface ILocalizer : INotifyPropertyChanged /// The resource key to resolve. /// The values to format into the resolved string. /// - /// The formatted, localized string; if is + /// 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. @@ -107,7 +113,7 @@ public interface ILocalizer : INotifyPropertyChanged /// . 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); + string Get(string key, params object?[] arguments); /// /// Resolves the localized string for an enumeration value by mapping it to a resource key using @@ -124,7 +130,7 @@ public interface ILocalizer : INotifyPropertyChanged /// /// or is . /// - String Get(Enum value, String keyPrefix = EnumKeyConvention.DefaultEnumKeyPrefix); + string Get(Enum value, string keyPrefix = EnumKeyConvention.DefaultEnumKeyPrefix); /// /// Resolves the localized string for the specified key from a single, explicit @@ -134,14 +140,14 @@ public interface ILocalizer : INotifyPropertyChanged /// The resource manager to read the key from. /// /// The localized string for in ; - /// if is or empty; + /// 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); + string Get(string key, ResourceManager resourceManager); /// /// Resolves the localized string for the specified key from a single, explicit @@ -152,7 +158,7 @@ public interface ILocalizer : INotifyPropertyChanged /// The resource manager to read the key from. /// The values to format into the resolved string. /// - /// The formatted, localized string; if is + /// 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. @@ -164,7 +170,7 @@ public interface ILocalizer : INotifyPropertyChanged /// 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, ResourceManager resourceManager, params object?[] arguments); /// /// Resolves the localized string for an enumeration value from a single, explicit @@ -183,7 +189,7 @@ public interface ILocalizer : INotifyPropertyChanged /// , , or /// is . /// - String Get(Enum value, ResourceManager resourceManager, String keyPrefix = EnumKeyConvention.DefaultEnumKeyPrefix); + string Get(Enum value, ResourceManager resourceManager, string keyPrefix = EnumKeyConvention.DefaultEnumKeyPrefix); /// /// Resolves the localized string for a typed , which carries both the @@ -199,7 +205,7 @@ public interface ILocalizer : INotifyPropertyChanged /// is a default or otherwise uninitialized , /// whose is . /// - String Get(ResourceKey key); + string Get(ResourceKey key); /// /// Resolves the localized string for a typed , then formats it as a @@ -221,39 +227,7 @@ public interface ILocalizer : INotifyPropertyChanged /// The resolved resource value is not a valid composite format string for /// . /// - String Get(ResourceKey key, params Object?[] arguments); - - /// - /// 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. - /// - /// The resource manager to add to the search set. - /// - /// is . - /// - void RegisterResourceManager(ResourceManager resourceManager); - - /// - /// 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. - /// - /// The resource manager to remove from the search set. - /// - /// when the manager was registered and has been removed; - /// when it was not registered. - /// - /// - /// is . - /// - Boolean UnregisterResourceManager(ResourceManager resourceManager); - - /// - /// Removes every registered from the search-all set. Typed and - /// scoped lookups are unaffected - they never consult the registration set. - /// - void ClearResourceManagers(); + string Get(ResourceKey key, params object?[] arguments); /// /// Discovers the cultures for which any registered ships its own @@ -292,4 +266,30 @@ public interface ILocalizer : INotifyPropertyChanged /// is . /// 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. + /// + /// The resource manager to add to the search set. + /// + /// is . + /// + void RegisterResourceManager(ResourceManager resourceManager); + + /// + /// 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. + /// + /// The resource manager to remove from the search set. + /// + /// when the manager was registered and has been removed; + /// when it was not registered. + /// + /// + /// is . + /// + bool UnregisterResourceManager(ResourceManager resourceManager); } diff --git a/src/ResXLocalization.Core/Localizer.cs b/src/ResXLocalization.Core/Localizer.cs index b1cdb4a..7b49c06 100644 --- a/src/ResXLocalization.Core/Localizer.cs +++ b/src/ResXLocalization.Core/Localizer.cs @@ -12,15 +12,60 @@ namespace RentADeveloper.ResXLocalization; /// public sealed class Localizer : ILocalizer { + /// Cached event args announcing a change. + private static readonly PropertyChangedEventArgs CurrentCultureChangedArgs = new(nameof(CurrentCulture)); + + /// + /// Cached event args announcing an indexer change (Item[]), which makes bindings through + /// the indexer re-resolve after a culture change. + /// + private static readonly PropertyChangedEventArgs IndexerChangedArgs = new("Item[]"); + + /// Backing field of . + private CultureInfo currentCulture = CultureInfo.CurrentUICulture; + + /// + /// The search set for key-only and enum lookups, in registration order. Mutations swap the whole + /// immutable array (copy-on-write; see ). + /// + private ImmutableArray resourceManagers = []; + /// /// Initializes a new, isolated localizer with no registered resource managers. The XAML markup /// extensions always resolve through the ambient instance - create your own /// instance only where isolation matters, such as unit tests or code-behind consumers that inject /// . /// - public Localizer() + public Localizer() { } + + /// + public event EventHandler? CultureChanged; + + /// + /// Occurs when a property value changes, including and the indexer, + /// so that localized bindings re-resolve after a culture change. + /// + 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 + /// may replace it at startup with their DI-owned implementation; markup extensions always resolve + /// through the current value. + /// + /// The assigned value is . + public static ILocalizer Current { - } + get; + set + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } = new Localizer(); /// public CultureInfo CurrentCulture @@ -45,10 +90,7 @@ public CultureInfo CurrentCulture } /// - public String this[String key] => this.Get(key); - - /// - public String MissingTranslationFormat + public string MissingTranslationFormat { get; set @@ -60,13 +102,13 @@ public String MissingTranslationFormat // inside the first cache miss where the FormatException would be far from its cause. try { - _ = String.Format(CultureInfo.InvariantCulture, value, "probe"); + _ = 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).", + + "placeholder ({0}, the key).", nameof(value), exception ); @@ -77,36 +119,30 @@ public String MissingTranslationFormat } = "!{0}!"; /// - public event EventHandler? CultureChanged; + public string this[string key] => this.Get(key); /// - public event EventHandler? TranslationNotFound; - - /// - /// Occurs when a property value changes, including and the indexer, - /// so that localized bindings re-resolve after a culture change. - /// - public event PropertyChangedEventHandler? PropertyChanged; + public void ClearResourceManagers() => this.resourceManagers = []; /// - public String Get(String key) + public string Get(string key) { - if (String.IsNullOrEmpty(key)) + if (string.IsNullOrEmpty(key)) { - return String.Empty; + return string.Empty; } return this.FindInRegisteredManagers(key) ?? this.Miss(key, null); } /// - public String Get(String key, params Object?[] arguments) + public string Get(string key, params object?[] arguments) { ArgumentNullException.ThrowIfNull(arguments); - if (String.IsNullOrEmpty(key)) + if (string.IsNullOrEmpty(key)) { - return String.Empty; + return string.Empty; } var value = this.FindInRegisteredManagers(key); @@ -115,7 +151,7 @@ public String Get(String key, params Object?[] arguments) } /// - public String Get(Enum value, String keyPrefix = EnumKeyConvention.DefaultEnumKeyPrefix) + public string Get(Enum value, string keyPrefix = EnumKeyConvention.DefaultEnumKeyPrefix) { ArgumentNullException.ThrowIfNull(value); ArgumentNullException.ThrowIfNull(keyPrefix); @@ -124,27 +160,27 @@ public String Get(Enum value, String keyPrefix = EnumKeyConvention.DefaultEnumKe } /// - public String Get(String key, ResourceManager resourceManager) + public string Get(string key, ResourceManager resourceManager) { ArgumentNullException.ThrowIfNull(resourceManager); - if (String.IsNullOrEmpty(key)) + if (string.IsNullOrEmpty(key)) { - return String.Empty; + return string.Empty; } return resourceManager.GetString(key, this.currentCulture) ?? this.Miss(key, resourceManager); } /// - public String Get(String key, ResourceManager resourceManager, params Object?[] arguments) + public string Get(string key, ResourceManager resourceManager, params object?[] arguments) { ArgumentNullException.ThrowIfNull(resourceManager); ArgumentNullException.ThrowIfNull(arguments); - if (String.IsNullOrEmpty(key)) + if (string.IsNullOrEmpty(key)) { - return String.Empty; + return string.Empty; } var value = resourceManager.GetString(key, this.currentCulture); @@ -153,10 +189,10 @@ public String Get(String key, ResourceManager resourceManager, params Object?[] } /// - public String Get( + public string Get( Enum value, ResourceManager resourceManager, - String keyPrefix = EnumKeyConvention.DefaultEnumKeyPrefix + string keyPrefix = EnumKeyConvention.DefaultEnumKeyPrefix ) { ArgumentNullException.ThrowIfNull(value); @@ -167,11 +203,34 @@ public String Get( } /// - public String Get(ResourceKey key) => this.Get(key.Name, key.Manager); + 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() + { + var cultures = new HashSet(); + + foreach (var resourceManager in this.resourceManagers) + { + CollectAvailableCultures(resourceManager, cultures); + } + + return SortCultures(cultures); + } /// - public String Get(ResourceKey key, params Object?[] arguments) => - this.Get(key.Name, key.Manager, arguments); + public IReadOnlyList GetAvailableCultures(ResourceManager resourceManager) + { + ArgumentNullException.ThrowIfNull(resourceManager); + + var cultures = new HashSet(); + CollectAvailableCultures(resourceManager, cultures); + + return SortCultures(cultures); + } /// public void RegisterResourceManager(ResourceManager resourceManager) @@ -191,7 +250,7 @@ public void RegisterResourceManager(ResourceManager resourceManager) } /// - public Boolean UnregisterResourceManager(ResourceManager resourceManager) + public bool UnregisterResourceManager(ResourceManager resourceManager) { ArgumentNullException.ThrowIfNull(resourceManager); @@ -207,50 +266,6 @@ public Boolean UnregisterResourceManager(ResourceManager resourceManager) return true; } - /// - public void ClearResourceManagers() => this.resourceManagers = []; - - /// - public IReadOnlyList GetAvailableCultures() - { - var cultures = new HashSet(); - - foreach (var resourceManager in this.resourceManagers) - { - CollectAvailableCultures(resourceManager, cultures); - } - - return SortCultures(cultures); - } - - /// - public IReadOnlyList GetAvailableCultures(ResourceManager resourceManager) - { - ArgumentNullException.ThrowIfNull(resourceManager); - - var cultures = new HashSet(); - CollectAvailableCultures(resourceManager, cultures); - - return SortCultures(cultures); - } - - /// - /// 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 - /// may replace it at startup with their DI-owned implementation; markup extensions always resolve - /// through the current value. - /// - /// The assigned value is . - public static ILocalizer Current - { - get; - set - { - ArgumentNullException.ThrowIfNull(value); - field = value; - } - } = new Localizer(); - /// /// Probes every known culture for a resource set of and adds /// the hits to . Neutral resources surface as the invariant culture. @@ -274,11 +289,13 @@ private static void CollectAvailableCultures(ResourceManager resourceManager, Ha // to the neutral resources, GetResourceSet("fr") returns that cached (parent) set even // with tryParents: false. A culture therefore only counts when its set is not simply its // parent's set surfacing through the cache. - if (!culture.Equals(CultureInfo.InvariantCulture) + if ( + !culture.Equals(CultureInfo.InvariantCulture) && ReferenceEquals( resourceSet, resourceManager.GetResourceSet(culture.Parent, createIfNotExists: true, tryParents: false) - )) + ) + ) { continue; } @@ -302,7 +319,7 @@ private static IReadOnlyList SortCultures(HashSet cult /// /// The resource key to resolve. /// The first matching value, or . - private String? FindInRegisteredManagers(String key) + private string? FindInRegisteredManagers(string key) { foreach (var resourceManager in this.resourceManagers) { @@ -316,12 +333,12 @@ private static IReadOnlyList SortCultures(HashSet cult return null; } - /// Formats a resolved value with in the current culture. + /// 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); + private string FormatValue(string value, object?[] arguments) => + string.Format(this.currentCulture, value, arguments); /// /// Handles a lookup no resource file could satisfy: raises @@ -332,28 +349,10 @@ private String FormatValue(String value, Object?[] arguments) => /// 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) + private string Miss(string key, ResourceManager? resourceManager) { this.TranslationNotFound?.Invoke(this, new(key, this.currentCulture, resourceManager)); - return String.Format(CultureInfo.InvariantCulture, this.MissingTranslationFormat, key); + return string.Format(CultureInfo.InvariantCulture, this.MissingTranslationFormat, key); } - - /// - /// The search set for key-only and enum lookups, in registration order. Mutations swap the whole - /// immutable array (copy-on-write; see ). - /// - private ImmutableArray resourceManagers = []; - - /// Backing field of . - private CultureInfo currentCulture = CultureInfo.CurrentUICulture; - - /// Cached event args announcing a change. - private static readonly PropertyChangedEventArgs CurrentCultureChangedArgs = new(nameof(CurrentCulture)); - - /// - /// Cached event args announcing an indexer change (Item[]), which makes bindings through - /// the indexer re-resolve after a culture change. - /// - private static readonly PropertyChangedEventArgs IndexerChangedArgs = new("Item[]"); } diff --git a/src/ResXLocalization.Core/ResourceKey.cs b/src/ResXLocalization.Core/ResourceKey.cs index d6a0889..576cb4a 100644 --- a/src/ResXLocalization.Core/ResourceKey.cs +++ b/src/ResXLocalization.Core/ResourceKey.cs @@ -8,4 +8,4 @@ namespace RentADeveloper.ResXLocalization; /// /// The resource key name as it appears in the .resx file. /// The resource manager that resolves . -public readonly record struct ResourceKey(String Name, ResourceManager Manager); +public readonly record struct ResourceKey(string Name, ResourceManager Manager); diff --git a/src/ResXLocalization.Core/TranslationNotFoundEventArgs.cs b/src/ResXLocalization.Core/TranslationNotFoundEventArgs.cs index cc40b32..28d2bc1 100644 --- a/src/ResXLocalization.Core/TranslationNotFoundEventArgs.cs +++ b/src/ResXLocalization.Core/TranslationNotFoundEventArgs.cs @@ -18,7 +18,7 @@ public sealed class TranslationNotFoundEventArgs : EventArgs /// The single resource manager of a scoped or typed lookup, or for a /// search-all lookup. /// - public TranslationNotFoundEventArgs(String key, CultureInfo culture, ResourceManager? resourceManager) + public TranslationNotFoundEventArgs(string key, CultureInfo culture, ResourceManager? resourceManager) { ArgumentNullException.ThrowIfNull(key); ArgumentNullException.ThrowIfNull(culture); @@ -28,12 +28,12 @@ public TranslationNotFoundEventArgs(String key, CultureInfo culture, ResourceMan this.ResourceManager = resourceManager; } - /// Gets the resource key that could not be resolved. - public String Key { get; } - /// 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. diff --git a/src/ResXLocalization.SourceGenerators/EquatableArray.cs b/src/ResXLocalization.SourceGenerators/EquatableArray.cs index 01b76ec..333528d 100644 --- a/src/ResXLocalization.SourceGenerators/EquatableArray.cs +++ b/src/ResXLocalization.SourceGenerators/EquatableArray.cs @@ -14,17 +14,27 @@ namespace RentADeveloper.ResXLocalization.SourceGenerators; internal readonly struct EquatableArray(ImmutableArray array) : IEquatable>, IEnumerable where T : IEquatable { + /// + /// Gets the backing array. Exists because a primary-constructor parameter is only accessible on + /// the current instance - needs to read + /// other's array too. + /// + private ImmutableArray Items => array; + /// Determines whether two arrays are sequence-equal. /// The first array to compare. /// The second array to compare. /// when the arrays contain equal elements in the same order. - public static Boolean operator ==(EquatableArray left, EquatableArray right) => left.Equals(right); + public static bool operator ==(EquatableArray left, EquatableArray right) => left.Equals(right); /// Determines whether two arrays are not sequence-equal. /// The first array to compare. /// The second array to compare. /// when the arrays differ in length or in any element. - public static Boolean operator !=(EquatableArray left, EquatableArray right) => !left.Equals(right); + public static bool operator !=(EquatableArray left, EquatableArray right) => !left.Equals(right); + + /// + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); /// /// Determines whether this array and contain equal elements in the @@ -33,7 +43,7 @@ internal readonly struct EquatableArray(ImmutableArray array) : IEquatable /// /// The array to compare with this one. /// when the arrays are sequence-equal. - public Boolean Equals(EquatableArray other) + public bool Equals(EquatableArray other) { var left = this.Items; var right = other.Items; @@ -65,11 +75,16 @@ public Boolean Equals(EquatableArray other) /// /// The object to compare with this array. /// when is a sequence-equal array. - public override Boolean Equals(Object? obj) => obj is EquatableArray other && this.Equals(other); + public override bool Equals(object? obj) => obj is EquatableArray other && this.Equals(other); + + /// Returns an enumerator over the elements; a default (uninitialized) instance enumerates as empty. + /// The element enumerator. + public IEnumerator GetEnumerator() => + (array.IsDefault ? ImmutableArray.Empty : array).AsEnumerable().GetEnumerator(); /// Computes a hash code aggregated over all elements, so sequence-equal arrays hash alike. /// The aggregated hash code; 0 for a default (uninitialized) instance. - public override Int32 GetHashCode() + public override int GetHashCode() { if (array.IsDefault) { @@ -85,19 +100,4 @@ public override Int32 GetHashCode() return hash; } - - /// Returns an enumerator over the elements; a default (uninitialized) instance enumerates as empty. - /// The element enumerator. - public IEnumerator GetEnumerator() => - (array.IsDefault ? ImmutableArray.Empty : array).AsEnumerable().GetEnumerator(); - - /// - IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); - - /// - /// Gets the backing array. Exists because a primary-constructor parameter is only accessible on - /// the current instance - needs to read - /// other's array too. - /// - private ImmutableArray Items => array; } diff --git a/src/ResXLocalization.SourceGenerators/ResxKeysGenerator.cs b/src/ResXLocalization.SourceGenerators/ResxKeysGenerator.cs index a52ce22..2e72ce7 100644 --- a/src/ResXLocalization.SourceGenerators/ResxKeysGenerator.cs +++ b/src/ResXLocalization.SourceGenerators/ResxKeysGenerator.cs @@ -36,6 +36,33 @@ namespace RentADeveloper.ResXLocalization.SourceGenerators; [Generator] public sealed class ResxKeysGenerator : IIncrementalGenerator { + /// + /// RXLGEN001 (warning): an eligible .resx file - neutral, with a matching designer - + /// contains malformed XML, so no keys were generated for it. + /// + private static readonly DiagnosticDescriptor InvalidResxDescriptor = new( + "RXLGEN001", + "Invalid .resx file", + "Could not parse eligible resource file '{0}': {1}", + "ResXLocalization.SourceGenerator", + DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + /// + /// RXLGEN002 (warning): a neutral .resx file has no same-folder classic + /// .Designer.cs accessor with a static ResourceManager property, so no keys were + /// generated for it. + /// + private static readonly DiagnosticDescriptor MissingDesignerDescriptor = new( + "RXLGEN002", + "Eligible .resx has no recognized classic designer", + "Resource file '{0}' needs a same-folder classic .Designer.cs accessor with a static System.Resources.ResourceManager property", + "ResXLocalization.SourceGenerator", + DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + /// /// Configures the generation pipeline. Called once by the Roslyn host to register the /// .resx-driven source outputs. @@ -43,14 +70,16 @@ public sealed class ResxKeysGenerator : IIncrementalGenerator /// The initialization context used to build the incremental pipeline. public void Initialize(IncrementalGeneratorInitializationContext context) { - var resXFiles = context.AdditionalTextsProvider - .Where(static f => f.Path.EndsWith(".resx", StringComparison.OrdinalIgnoreCase)) + var resXFiles = context + .AdditionalTextsProvider.Where(static f => f.Path.EndsWith(".resx", StringComparison.OrdinalIgnoreCase)) .Select(static (f, ct) => ParseResxFile(f, ct)) .Where(static m => m is not null) .Select(static (m, _) => m!.Value); - var resXDesignerFiles = context.AdditionalTextsProvider - .Where(static f => f.Path.EndsWith(".Designer.cs", StringComparison.OrdinalIgnoreCase)) + var resXDesignerFiles = context + .AdditionalTextsProvider.Where(static f => + f.Path.EndsWith(".Designer.cs", StringComparison.OrdinalIgnoreCase) + ) .Select(static (f, ct) => ParseResXDesignerFile(f, ct)) .Where(static m => m is not null) .Select(static (m, _) => m!.Value) @@ -105,8 +134,9 @@ ImmutableArray resXDesignerFiles var ns = resXDesignerFile.Value.Namespace; var className = SanitizeIdentifier(resXFile.FileBaseName + "Keys"); - var resourceManagerReference = "global::" - + (ns.Length == 0 ? String.Empty : ns + ".") + var resourceManagerReference = + "global::" + + (ns.Length == 0 ? string.Empty : ns + ".") + resXDesignerFile.Value.AccessorType + ".ResourceManager"; @@ -114,7 +144,7 @@ ImmutableArray resXDesignerFiles // Seed with the class name: a C# member must not be named like its enclosing type (CS0542), // so a key equal to the class name falls into the regular numeric-suffix collision handling. - var usedMemberNames = new HashSet(StringComparer.Ordinal) { className }; + var usedMemberNames = new HashSet(StringComparer.Ordinal) { className }; foreach (var key in resXFile.Keys) { @@ -125,27 +155,26 @@ ImmutableArray resXDesignerFiles ); } - var classCode = - $$""" - // - // Generated from a .resx by RentADeveloper.ResXLocalization.SourceGenerators - regenerates on build. - #nullable enable + var classCode = $$""" + // + // Generated from a .resx by RentADeveloper.ResXLocalization.SourceGenerators - regenerates on build. + #nullable enable - {{(ns.Length == 0 ? String.Empty : "namespace " + ns + ";")}} + {{(ns.Length == 0 ? string.Empty : "namespace " + ns + ";")}} - /// Typed, compile-validated keys generated from {{resXFile.FileBaseName}}.resx. - public static partial class {{className}} - { - {{membersBuilder}} - } - """; + /// Typed, compile-validated keys generated from {{resXFile.FileBaseName}}.resx. + public static partial class {{className}} + { + {{membersBuilder}} + } + """; // The namespace-qualified name keeps the hint unique across resx with the same base name in // different namespaces; the directory hash disambiguates the remaining case of two resx with // the same base name whose designers declare the SAME namespace (their partial classes merge, // but each AddSource call still needs its own hint name). context.AddSource( - (ns.Length == 0 ? String.Empty : ns.Replace("@", String.Empty) + ".") + (ns.Length == 0 ? string.Empty : ns.Replace("@", string.Empty) + ".") + className + "." + StableHash(resXFile.DirectoryPath) @@ -157,7 +186,7 @@ public static partial class {{className}} /// Escapes a sanitized member name that happens to be a reserved C# keyword. /// The sanitized member name. /// The name, @-prefixed when it is a reserved keyword. - private static String EscapeIdentifier(String name) => + private static string EscapeIdentifier(string name) => SyntaxFacts.GetKeywordKind(name) == SyntaxKind.None ? name : "@" + name; /// @@ -176,16 +205,14 @@ ImmutableArray resXDesignerFiles // default instance (never null) and a .resx without a designer would emit a broken class. foreach (var resXDesignerFile in resXDesignerFiles) { - if (String.Equals( - resXDesignerFile.FileBaseName, - resxFile.FileBaseName, - StringComparison.OrdinalIgnoreCase - ) && - String.Equals( + if ( + string.Equals(resXDesignerFile.FileBaseName, resxFile.FileBaseName, StringComparison.OrdinalIgnoreCase) + && string.Equals( resXDesignerFile.DirectoryPath, resxFile.DirectoryPath, StringComparison.OrdinalIgnoreCase - )) + ) + ) { return resXDesignerFile; } @@ -194,6 +221,28 @@ ImmutableArray resXDesignerFiles return null; } + /// + /// Determines whether a designer property type refers to + /// - written fully qualified (with or without + /// global::), as the bare name under a using System.Resources; import, or through + /// a using alias. + /// + /// The property type as written in the designer source. + /// The file's using aliases that resolve to the resource manager type. + /// Whether the file imports System.Resources. + /// when the type refers to the resource manager type. + private static bool IsResourceManagerType( + string? typeName, + ImmutableHashSet aliases, + bool importsSystemResources = false + ) + { + var normalized = typeName?.Replace("global::", string.Empty); + return normalized == "System.Resources.ResourceManager" + || (normalized == "ResourceManager" && importsSystemResources) + || (normalized is not null && aliases.Contains(normalized)); + } + /// /// Parses a .Designer.cs file and extracts the classic resource accessor: the type /// declaring a static ResourceManager property of type @@ -226,11 +275,10 @@ ImmutableArray resXDesignerFiles var root = CSharpSyntaxTree.ParseText(source, cancellationToken: ct).GetRoot(ct); var resourceManagerAliases = root.DescendantNodes() .OfType() - .Where(static directive => directive.Alias is not null - && IsResourceManagerType( - directive.Name?.ToString(), - ImmutableHashSet.Empty - )) + .Where(static directive => + directive.Alias is not null + && IsResourceManagerType(directive.Name?.ToString(), ImmutableHashSet.Empty) + ) .Select(static directive => directive.Alias!.Name.Identifier.ValueText) .ToImmutableHashSet(StringComparer.Ordinal); var importsSystemResources = root.DescendantNodes() @@ -239,9 +287,11 @@ ImmutableArray resXDesignerFiles foreach (var property in root.DescendantNodes().OfType()) { - if (property.Identifier.ValueText != "ResourceManager" + if ( + property.Identifier.ValueText != "ResourceManager" || !property.Modifiers.Any(SyntaxKind.StaticKeyword) - || !IsResourceManagerType(property.Type.ToString(), resourceManagerAliases, importsSystemResources)) + || !IsResourceManagerType(property.Type.ToString(), resourceManagerAliases, importsSystemResources) + ) { continue; } @@ -256,7 +306,7 @@ ImmutableArray resXDesignerFiles return new ResXDesignerFile( Path.GetDirectoryName(text.Path)!, fileBaseName, - namespaceNode?.Name.ToString() ?? String.Empty, + namespaceNode?.Name.ToString() ?? string.Empty, EscapeIdentifier(accessor.Identifier.ValueText) ); } @@ -314,7 +364,7 @@ ImmutableArray resXDesignerFiles /// The raw .resx XML. /// The string-resource key names, in document order. /// is not well-formed. - private static IEnumerable ReadResXKeys(String xml) + private static IEnumerable ReadResXKeys(string xml) { var doc = XDocument.Parse(xml); @@ -322,7 +372,7 @@ private static IEnumerable ReadResXKeys(String xml) { var name = data.Attribute("name")?.Value; - if (String.IsNullOrEmpty(name)) + if (string.IsNullOrEmpty(name)) { continue; } @@ -345,6 +395,29 @@ private static IEnumerable ReadResXKeys(String xml) } } + /// + /// Converts arbitrary text into a valid C# identifier: invalid characters become underscores, + /// an invalid first character gets an underscore prefix, and reserved keywords are + /// @-escaped. + /// + /// The text to convert. + /// The valid, escaped identifier. + private static string SanitizeIdentifier(string value) + { + var builder = new StringBuilder(value.Length + 1); + foreach (var character in value) + { + builder.Append(SyntaxFacts.IsIdentifierPartCharacter(character) ? character : '_'); + } + + if (builder.Length == 0 || !SyntaxFacts.IsIdentifierStartCharacter(builder[0])) + { + builder.Insert(0, '_'); + } + + return EscapeIdentifier(builder.ToString()); + } + /// /// Converts a resource key into a unique, valid member name: non-alphanumeric characters become /// underscores, a leading digit gets an underscore prefix, and a name already taken - by another @@ -353,16 +426,16 @@ private static IEnumerable ReadResXKeys(String xml) /// The resource key to convert. /// The names taken so far; the chosen name is added to the set. /// The unique member name, not yet @-escaped (see ). - private static String SanitizeMemberName(String key, HashSet usedMemberNames) + private static string SanitizeMemberName(string key, HashSet usedMemberNames) { var builder = new StringBuilder(key.Length); foreach (var c in key) { - builder.Append(Char.IsLetterOrDigit(c) ? c : '_'); + builder.Append(char.IsLetterOrDigit(c) ? c : '_'); } - if (builder.Length == 0 || Char.IsDigit(builder[0])) + if (builder.Length == 0 || char.IsDigit(builder[0])) { builder.Insert(0, '_'); } @@ -380,58 +453,13 @@ private static String SanitizeMemberName(String key, HashSet usedMemberN return name; } - /// - /// Converts arbitrary text into a valid C# identifier: invalid characters become underscores, - /// an invalid first character gets an underscore prefix, and reserved keywords are - /// @-escaped. - /// - /// The text to convert. - /// The valid, escaped identifier. - private static String SanitizeIdentifier(String value) - { - var builder = new StringBuilder(value.Length + 1); - foreach (var character in value) - { - builder.Append(SyntaxFacts.IsIdentifierPartCharacter(character) ? character : '_'); - } - - if (builder.Length == 0 || !SyntaxFacts.IsIdentifierStartCharacter(builder[0])) - { - builder.Insert(0, '_'); - } - - return EscapeIdentifier(builder.ToString()); - } - - /// - /// Determines whether a designer property type refers to - /// - written fully qualified (with or without - /// global::), as the bare name under a using System.Resources; import, or through - /// a using alias. - /// - /// The property type as written in the designer source. - /// The file's using aliases that resolve to the resource manager type. - /// Whether the file imports System.Resources. - /// when the type refers to the resource manager type. - private static Boolean IsResourceManagerType( - String? typeName, - ImmutableHashSet aliases, - Boolean importsSystemResources = false - ) - { - var normalized = typeName?.Replace("global::", String.Empty); - return normalized == "System.Resources.ResourceManager" - || (normalized == "ResourceManager" && importsSystemResources) - || (normalized is not null && aliases.Contains(normalized)); - } - /// /// Produces a short, deterministic (FNV-1a) hex digest of a path, used to keep hint names unique /// per source directory. Case-normalized to match the OrdinalIgnoreCase path pairing above. /// /// The path to digest. /// An eight-character lowercase hex digest. - private static String StableHash(String value) + private static string StableHash(string value) { var hash = 2166136261u; @@ -450,38 +478,11 @@ private static String StableHash(String value) /// without , or unchanged when the suffix /// is absent. /// - private static String StripSuffix(String name, String suffix) => + private static string StripSuffix(string name, string suffix) => name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) ? name.Substring(0, name.Length - suffix.Length) : name; - /// - /// RXLGEN001 (warning): an eligible .resx file - neutral, with a matching designer - - /// contains malformed XML, so no keys were generated for it. - /// - private static readonly DiagnosticDescriptor InvalidResxDescriptor = new( - "RXLGEN001", - "Invalid .resx file", - "Could not parse eligible resource file '{0}': {1}", - "ResXLocalization.SourceGenerator", - DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - - /// - /// RXLGEN002 (warning): a neutral .resx file has no same-folder classic - /// .Designer.cs accessor with a static ResourceManager property, so no keys were - /// generated for it. - /// - private static readonly DiagnosticDescriptor MissingDesignerDescriptor = new( - "RXLGEN002", - "Eligible .resx has no recognized classic designer", - "Resource file '{0}' needs a same-folder classic .Designer.cs accessor with a static System.Resources.ResourceManager property", - "ResXLocalization.SourceGenerator", - DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - /// The incremental-pipeline model of a parsed neutral .resx file. /// The full path of the .resx file, used in diagnostic locations. /// The containing directory, used to pair the file with its designer. @@ -489,11 +490,11 @@ private static String StripSuffix(String name, String suffix) => /// The string-resource keys, ordinally sorted; empty when parsing failed. /// The XML parse error message, or when parsing succeeded. private readonly record struct ResXFile( - String Path, - String DirectoryPath, - String FileBaseName, - EquatableArray Keys, - String? ParseError + string Path, + string DirectoryPath, + string FileBaseName, + EquatableArray Keys, + string? ParseError ); /// The incremental-pipeline model of a classic .Designer.cs resource accessor. @@ -505,9 +506,9 @@ private readonly record struct ResXFile( /// property. /// private readonly record struct ResXDesignerFile( - String DirectoryPath, - String FileBaseName, - String Namespace, - String AccessorType + string DirectoryPath, + string FileBaseName, + string Namespace, + string AccessorType ); } diff --git a/src/ResXLocalization.WPF/LocalizeArgs.cs b/src/ResXLocalization.WPF/LocalizeArgs.cs index cf7c075..d102c3b 100644 --- a/src/ResXLocalization.WPF/LocalizeArgs.cs +++ b/src/ResXLocalization.WPF/LocalizeArgs.cs @@ -46,107 +46,121 @@ public static class LocalizeArgs /// 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); - - /// - /// 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(Int32), - typeof(LocalizeArgs), - new PropertyMetadata(0) - ); + public static void SetArg8(DependencyObject element, object? value) => element.SetValue(Arg8Property, value); /// /// Reads the format arguments currently set on , applying the @@ -158,7 +172,7 @@ public static class LocalizeArgs /// 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) + internal static object?[]? GetArguments(DependencyObject element) { var lastSetIndex = -1; @@ -176,7 +190,7 @@ public static class LocalizeArgs return null; } - var arguments = new Object?[lastSetIndex + 1]; + var arguments = new object?[lastSetIndex + 1]; for (var index = 0; index <= lastSetIndex; index++) { @@ -191,7 +205,7 @@ public static class LocalizeArgs /// 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) + private static object? GetArgument(DependencyObject element, DependencyProperty property) { var value = element.GetValue(property); return ReferenceEquals(value, UnsetSentinel.Value) ? null : value; @@ -204,7 +218,7 @@ public static class LocalizeArgs /// The element whose argument changed. /// The change data; not used. private static void OnArgumentChanged(DependencyObject element, DependencyPropertyChangedEventArgs e) => - element.SetValue(ArgsVersionProperty, (Int32)element.GetValue(ArgsVersionProperty) + 1); + element.SetValue(ArgsVersionProperty, (int)element.GetValue(ArgsVersionProperty) + 1); /// /// Registers one argument slot. The default is a private sentinel (not ), @@ -213,28 +227,14 @@ private static void OnArgumentChanged(DependencyObject element, DependencyProper /// /// The attached property name, Arg0Arg8. /// The registered attached property. - private static DependencyProperty RegisterArgument(String name) => + private static DependencyProperty RegisterArgument(string name) => DependencyProperty.RegisterAttached( name, - typeof(Object), + typeof(object), typeof(LocalizeArgs), new PropertyMetadata(UnsetSentinel.Value, OnArgumentChanged) ); - /// The nine argument slots in slot order, for . - private static readonly DependencyProperty[] ArgumentProperties = - [ - Arg0Property, - Arg1Property, - Arg2Property, - Arg3Property, - Arg4Property, - Arg5Property, - Arg6Property, - Arg7Property, - Arg8Property - ]; - /// /// 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. @@ -242,6 +242,6 @@ private static DependencyProperty RegisterArgument(String name) => private static class UnsetSentinel { /// The sentinel instance; compared by reference. - internal static readonly Object Value = new(); + internal static readonly object Value = new(); } } diff --git a/src/ResXLocalization.WPF/LocalizeEnumConverter.cs b/src/ResXLocalization.WPF/LocalizeEnumConverter.cs index b615805..6f1d8c3 100644 --- a/src/ResXLocalization.WPF/LocalizeEnumConverter.cs +++ b/src/ResXLocalization.WPF/LocalizeEnumConverter.cs @@ -9,10 +9,28 @@ namespace RentADeveloper.ResXLocalization.WPF; /// public sealed class LocalizeEnumConverter : IMultiValueConverter { + /// Indicates whether this is the read-only shared instance. + private readonly bool isSharedInstance; + /// Initializes a new instance of the class. - public LocalizeEnumConverter() - { - } + public LocalizeEnumConverter() { } + + /// + /// Initializes a new instance of the class, optionally + /// marked as the read-only shared instance. Used only to create . + /// + /// + /// to make the instance read-only, rejecting property assignments. + /// + private LocalizeEnumConverter(bool isSharedInstance) => this.isSharedInstance = isSharedInstance; + + /// + /// 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 . + /// + public static LocalizeEnumConverter Default { get; } = new(isSharedInstance: true); /// /// Gets or sets the prefix prepended to the generated resource key. Defaults to Enum_. @@ -21,7 +39,7 @@ public LocalizeEnumConverter() /// /// The converter is the shared instance, which is read-only. /// - public String KeyPrefix + public string KeyPrefix { get; set @@ -64,14 +82,14 @@ public ResourceManager? ResourceManager /// taken from ). /// /// - /// The localized string for the enumeration value, or when no value + /// The localized string for the enumeration value, or when no value /// is supplied or the first value is not an . /// - public Object Convert(Object?[] values, Type targetType, Object? parameter, CultureInfo culture) + public object Convert(object?[] values, Type targetType, object? parameter, CultureInfo culture) { if (values.Length == 0 || values[0] is not Enum enumValue) { - return String.Empty; + return string.Empty; } var key = EnumKeyConvention.BuildEnumKey(enumValue, this.KeyPrefix); @@ -88,26 +106,9 @@ public Object Convert(Object?[] values, Type targetType, Object? parameter, Cult /// 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) => + public object[] ConvertBack(object? value, Type[] targetTypes, object? parameter, CultureInfo culture) => throw new NotSupportedException(); - /// - /// 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 . - /// - public static LocalizeEnumConverter Default { get; } = new(isSharedInstance: true); - - /// - /// Initializes a new instance of the class, optionally - /// marked as the read-only shared instance. Used only to create . - /// - /// - /// to make the instance read-only, rejecting property assignments. - /// - private LocalizeEnumConverter(Boolean isSharedInstance) => this.isSharedInstance = isSharedInstance; - /// Guards property setters against mutating the shared instance. /// /// This converter is the shared instance, which is read-only. @@ -117,12 +118,9 @@ private void ThrowIfShared() if (this.isSharedInstance) { throw new InvalidOperationException( - "The shared LocalizeEnumConverter.Default instance is read-only; " + - "create your own LocalizeEnumConverter to customize KeyPrefix or ResourceManager." + "The shared LocalizeEnumConverter.Default instance is read-only; " + + "create your own LocalizeEnumConverter to customize KeyPrefix or ResourceManager." ); } } - - /// Indicates whether this is the read-only shared instance. - private readonly Boolean isSharedInstance; } diff --git a/src/ResXLocalization.WPF/LocalizeEnumExtension.cs b/src/ResXLocalization.WPF/LocalizeEnumExtension.cs index f99f1d3..c79656e 100644 --- a/src/ResXLocalization.WPF/LocalizeEnumExtension.cs +++ b/src/ResXLocalization.WPF/LocalizeEnumExtension.cs @@ -7,13 +7,13 @@ namespace RentADeveloper.ResXLocalization.WPF; /// <TextBlock Text="{l:LocalizeEnum}" />. The value is mapped to a resource key using the /// convention {KeyPrefix}{EnumTypeName}_{Value}. /// -[MarkupExtensionReturnType(typeof(String))] +[MarkupExtensionReturnType(typeof(string))] public sealed class LocalizeEnumExtension : MarkupExtension { /// /// Gets or sets the prefix prepended to the generated resource key. Defaults to Enum_. /// - public String KeyPrefix { get; set; } = EnumKeyConvention.DefaultEnumKeyPrefix; + public string KeyPrefix { get; set; } = EnumKeyConvention.DefaultEnumKeyPrefix; /// /// Gets or sets the resource manager that scopes the lookup to a single .resx file. When @@ -28,12 +28,12 @@ public sealed class LocalizeEnumExtension : MarkupExtension /// /// The service provider supplied by the XAML loader. /// A WPF multi-binding that yields the localized enumeration text. - public override Object ProvideValue(IServiceProvider serviceProvider) + public override object ProvideValue(IServiceProvider serviceProvider) { var converter = new LocalizeEnumConverter { KeyPrefix = this.KeyPrefix, - ResourceManager = this.ResourceManager + ResourceManager = this.ResourceManager, }; // The empty-path binding reads the target control's DataContext (the enum value); the second diff --git a/src/ResXLocalization.WPF/LocalizeExtension.cs b/src/ResXLocalization.WPF/LocalizeExtension.cs index dd504eb..b372195 100644 --- a/src/ResXLocalization.WPF/LocalizeExtension.cs +++ b/src/ResXLocalization.WPF/LocalizeExtension.cs @@ -9,24 +9,22 @@ namespace RentADeveloper.ResXLocalization.WPF; /// 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. /// -[MarkupExtensionReturnType(typeof(String))] +[MarkupExtensionReturnType(typeof(string))] public sealed class LocalizeExtension : MarkupExtension { /// Initializes a new instance of the class. - public LocalizeExtension() - { - } + 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. + /// 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. /// /// A or a key string. - public LocalizeExtension(Object key) + public LocalizeExtension(object key) { if (key is ResourceKey resourceKey) { @@ -34,7 +32,7 @@ public LocalizeExtension(Object key) } else { - this.Key = key as String ?? String.Empty; + this.Key = key as string ?? string.Empty; } } @@ -43,7 +41,7 @@ public LocalizeExtension(Object key) /// is also set, the lookup is scoped to that file; otherwise every /// registered resource manager is searched. /// - public String Key { get; set; } = String.Empty; + public string Key { get; set; } = string.Empty; /// /// Gets or sets a typed, file-scoped key. When set, it takes precedence over @@ -65,10 +63,10 @@ public LocalizeExtension(Object key) /// /// The service provider supplied by the XAML loader. /// A WPF multi-binding that yields the localized string. - public override Object ProvideValue(IServiceProvider serviceProvider) + public override object ProvideValue(IServiceProvider serviceProvider) { - Func resolve; - Func resolveFormatted; + Func resolve; + Func resolveFormatted; if (this.ResourceKey.HasValue) { @@ -103,14 +101,15 @@ public override Object ProvideValue(IServiceProvider serviceProvider) var multiBinding = new MultiBinding { Converter = new LocalizedFormattedStringConverter(resolve, resolveFormatted), - Mode = BindingMode.OneWay + Mode = BindingMode.OneWay, }; 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, + Path = new PropertyPath(LocalizeArgs.ArgsVersionProperty), + } ); multiBinding.Bindings.Add(new Binding { RelativeSource = RelativeSource.Self }); diff --git a/src/ResXLocalization.WPF/LocalizedFormattedStringConverter.cs b/src/ResXLocalization.WPF/LocalizedFormattedStringConverter.cs index 7b5fc9e..64e06e1 100644 --- a/src/ResXLocalization.WPF/LocalizedFormattedStringConverter.cs +++ b/src/ResXLocalization.WPF/LocalizedFormattedStringConverter.cs @@ -11,10 +11,7 @@ namespace RentADeveloper.ResXLocalization.WPF; /// /// Resolves the localized string without composite formatting. /// Resolves the localized string formatted with the supplied arguments. -internal sealed class LocalizedFormattedStringConverter( - Func resolve, - Func resolveFormatted -) +internal sealed class LocalizedFormattedStringConverter(Func resolve, Func resolveFormatted) : IMultiValueConverter { /// @@ -40,7 +37,7 @@ internal sealed class LocalizedFormattedStringConverter( /// 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) + public object Convert(object?[] values, Type targetType, object? parameter, CultureInfo culture) { if (values.Length < 3 || values[2] is not DependencyObject element) { @@ -66,6 +63,6 @@ public Object Convert(Object?[] values, Type targetType, Object? parameter, Cult /// 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) => + public object[] ConvertBack(object? value, Type[] targetTypes, object? parameter, CultureInfo culture) => throw new NotSupportedException(); } diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeArgsTests.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeArgsTests.cs index ce57111..efdedb5 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeArgsTests.cs +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeArgsTests.cs @@ -10,178 +10,175 @@ namespace RentADeveloper.ResXLocalization.Avalonia.Sample.Tests; public class LocalizeArgsTests { [AvaloniaFact] - public void OneArgument_FormatsTheResolvedString() + public void ArgumentChange_UpdatesTheText_Live() { TestSupport.ResetToEnglishWithTestCatalogs(); var textBlock = TestSupport.BindLocalizedTextWithTarget(new(ApplicationStringsKeys.PeopleInvited)); - LocalizeArgs.SetArg0(textBlock, 3); + LocalizeArgs.SetArg0(textBlock, 1); TestSupport.PumpUi(); + textBlock.Text.Should().Be("1 people invited"); - textBlock.Text.Should().Be("3 people invited"); + LocalizeArgs.SetArg0(textBlock, 2); + TestSupport.PumpUi(); + textBlock.Text.Should().Be("2 people invited"); } [AvaloniaFact] - public void MultipleArguments_FormatAllPlaceholders() + public void BoundControlsWithArguments_AreCollected_WhenDiscarded() { TestSupport.ResetToEnglishWithTestCatalogs(); - var textBlock = TestSupport.BindLocalizedTextWithTarget(new("FormatPair")); - LocalizeArgs.SetArg0(textBlock, 2); - LocalizeArgs.SetArg1(textBlock, 10); - TestSupport.PumpUi(); + var references = CreateAndAbandonBoundControls(200); - textBlock.Text.Should().Be("File 2 of 10"); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + references.Should().NotContain(reference => reference.IsAlive); } [AvaloniaFact] - public void ScopedLookup_FormatsWithArguments() + public void CultureChange_ReFormats_Live() { TestSupport.ResetToEnglishWithTestCatalogs(); - var textBlock = TestSupport.BindLocalizedTextWithTarget( - new() { Key = "PeopleInvited", ResourceManager = ApplicationStrings.ResourceManager } - ); - LocalizeArgs.SetArg0(textBlock, 4); + var textBlock = TestSupport.BindLocalizedTextWithTarget(new(ApplicationStringsKeys.PeopleInvited)); + LocalizeArgs.SetArg0(textBlock, 3); TestSupport.PumpUi(); + textBlock.Text.Should().Be("3 people invited"); - textBlock.Text.Should().Be("4 people invited"); + Localizer.Current.CurrentCulture = TestSupport.German; + TestSupport.PumpUi(); + textBlock.Text.Should().Be("3 Personen eingeladen"); } [AvaloniaFact] - public void ArgumentChange_UpdatesTheText_Live() + public void HigherSlotAlone_FillsInteriorGapsWithNull() { TestSupport.ResetToEnglishWithTestCatalogs(); - var textBlock = TestSupport.BindLocalizedTextWithTarget(new(ApplicationStringsKeys.PeopleInvited)); - LocalizeArgs.SetArg0(textBlock, 1); + var textBlock = TestSupport.BindLocalizedTextWithTarget(new("FormatGap")); + LocalizeArgs.SetArg2(textBlock, "X"); TestSupport.PumpUi(); - textBlock.Text.Should().Be("1 people invited"); - LocalizeArgs.SetArg0(textBlock, 2); - TestSupport.PumpUi(); - textBlock.Text.Should().Be("2 people invited"); + // 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 CultureChange_ReFormats_Live() + public void InsideItemTemplate_EachItemFormatsItsOwnArguments() { TestSupport.ResetToEnglishWithTestCatalogs(); - var textBlock = TestSupport.BindLocalizedTextWithTarget(new(ApplicationStringsKeys.PeopleInvited)); - LocalizeArgs.SetArg0(textBlock, 3); - TestSupport.PumpUi(); - textBlock.Text.Should().Be("3 people invited"); + // 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; + } + ), + }; - Localizer.Current.CurrentCulture = TestSupport.German; + var window = new Window { Content = itemsControl }; + window.Show(); TestSupport.PumpUi(); - textBlock.Text.Should().Be("3 Personen eingeladen"); + + var texts = TestSupport.AllVisibleText(window); + texts.Should().Contain("3 people invited"); + texts.Should().Contain("7 people invited"); + + window.Close(); } [AvaloniaFact] - public void NoArguments_ResolvesWithoutFormatting_KeepingLiteralBraces() + public void MissingKey_ReturnsSentinelUnformatted() { 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")); + var textBlock = TestSupport.BindLocalizedTextWithTarget(new("NoSuchKeyEver")); + LocalizeArgs.SetArg0(textBlock, 1); + TestSupport.PumpUi(); - textBlock.Text.Should().Be("Literal {braces} stay"); + textBlock.Text.Should().Be("!NoSuchKeyEver!"); } [AvaloniaFact] - public void NullArgument_IsSet_AndFormatsAsEmpty() + public void MultipleArguments_FormatAllPlaceholders() { TestSupport.ResetToEnglishWithTestCatalogs(); - var textBlock = TestSupport.BindLocalizedTextWithTarget(new(ApplicationStringsKeys.PeopleInvited)); - LocalizeArgs.SetArg0(textBlock, null); + var textBlock = TestSupport.BindLocalizedTextWithTarget(new("FormatPair")); + LocalizeArgs.SetArg0(textBlock, 2); + LocalizeArgs.SetArg1(textBlock, 10); TestSupport.PumpUi(); - // Null is a set argument (distinct from "never set"), and String.Format renders it as empty. - textBlock.Text.Should().Be(" people invited"); + textBlock.Text.Should().Be("File 2 of 10"); } [AvaloniaFact] - public void HigherSlotAlone_FillsInteriorGapsWithNull() + public void NoArguments_ResolvesWithoutFormatting_KeepingLiteralBraces() { TestSupport.ResetToEnglishWithTestCatalogs(); - var textBlock = TestSupport.BindLocalizedTextWithTarget(new("FormatGap")); - LocalizeArgs.SetArg2(textBlock, "X"); - TestSupport.PumpUi(); + // 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")); - // Only Arg2 is set: Arg0 and Arg1 become null, so {0} renders empty and {2} renders "X". - textBlock.Text.Should().Be("[|X]"); + textBlock.Text.Should().Be("Literal {braces} stay"); } [AvaloniaFact] - public void MissingKey_ReturnsSentinelUnformatted() + public void NullArgument_IsSet_AndFormatsAsEmpty() { TestSupport.ResetToEnglishWithTestCatalogs(); - var textBlock = TestSupport.BindLocalizedTextWithTarget(new("NoSuchKeyEver")); - LocalizeArgs.SetArg0(textBlock, 1); + var textBlock = TestSupport.BindLocalizedTextWithTarget(new(ApplicationStringsKeys.PeopleInvited)); + LocalizeArgs.SetArg0(textBlock, null); TestSupport.PumpUi(); - textBlock.Text.Should().Be("!NoSuchKeyEver!"); + // 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 InsideItemTemplate_EachItemFormatsItsOwnArguments() + public void OneArgument_FormatsTheResolvedString() { 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(); + var textBlock = TestSupport.BindLocalizedTextWithTarget(new(ApplicationStringsKeys.PeopleInvited)); + LocalizeArgs.SetArg0(textBlock, 3); TestSupport.PumpUi(); - var texts = TestSupport.AllVisibleText(window); - texts.Should().Contain("3 people invited"); - texts.Should().Contain("7 people invited"); - - window.Close(); + textBlock.Text.Should().Be("3 people invited"); } [AvaloniaFact] - public void BoundControlsWithArguments_AreCollected_WhenDiscarded() + public void ScopedLookup_FormatsWithArguments() { TestSupport.ResetToEnglishWithTestCatalogs(); - var references = CreateAndAbandonBoundControls(200); - - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); + var textBlock = TestSupport.BindLocalizedTextWithTarget( + new() { Key = "PeopleInvited", ResourceManager = ApplicationStrings.ResourceManager } + ); + LocalizeArgs.SetArg0(textBlock, 4); + TestSupport.PumpUi(); - references.Should().NotContain(reference => reference.IsAlive); + textBlock.Text.Should().Be("4 people invited"); } - private static List CreateAndAbandonBoundControls(Int32 count) + 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) - ); + var textBlock = TestSupport.BindLocalizedTextWithTarget(new(ApplicationStringsKeys.PeopleInvited)); LocalizeArgs.SetArg0(textBlock, index); references.Add(new(textBlock)); } diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeEnumConverterTests.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeEnumConverterTests.cs index 1ba6ad7..e386036 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeEnumConverterTests.cs +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeEnumConverterTests.cs @@ -24,28 +24,28 @@ public void CustomPrefix_WithManager_ResolvesScoped() var converter = new LocalizeEnumConverter { KeyPrefix = "Display_", - ResourceManager = SortingStrings.ResourceManager + ResourceManager = SortingStrings.ResourceManager, }; Convert(converter, FileSortOrder.Ascending).Should().Be("A to Z"); } [AvaloniaFact] - public void Default_NoPrefix_NoManager_ResolvesViaSearchAll() + public void DefaultPrefix_WithManager_ResolvesScoped() { TestSupport.ResetToEnglishWithTestCatalogs(); - Convert(LocalizeEnumConverter.Default, FileSortOrder.Ascending).Should().Be("Ascending"); + var converter = new LocalizeEnumConverter { ResourceManager = ApplicationStrings.ResourceManager }; + + Convert(converter, FileSortOrder.Ascending).Should().Be("Ascending"); } [AvaloniaFact] - public void DefaultPrefix_WithManager_ResolvesScoped() + public void Default_NoPrefix_NoManager_ResolvesViaSearchAll() { TestSupport.ResetToEnglishWithTestCatalogs(); - var converter = new LocalizeEnumConverter { ResourceManager = ApplicationStrings.ResourceManager }; - - Convert(converter, FileSortOrder.Ascending).Should().Be("Ascending"); + Convert(LocalizeEnumConverter.Default, FileSortOrder.Ascending).Should().Be("Ascending"); } [AvaloniaFact] @@ -72,10 +72,11 @@ public void NonEnumOrEmptyInput_ReturnsEmptyString() { TestSupport.ResetToEnglishWithTestCatalogs(); - Convert(LocalizeEnumConverter.Default).Should().Be(String.Empty); - LocalizeEnumConverter.Default.Convert(["not an enum"], typeof(String), null, CultureInfo.InvariantCulture) + Convert(LocalizeEnumConverter.Default).Should().Be(string.Empty); + LocalizeEnumConverter + .Default.Convert(["not an enum"], typeof(string), null, CultureInfo.InvariantCulture) .Should() - .Be(String.Empty); + .Be(string.Empty); } [AvaloniaFact] @@ -84,16 +85,18 @@ public void SharedDefaultInstance_IsReadOnly() // Mutating the process-wide Default would silently reconfigure every default conversion in // the app, so it must refuse; a private instance stays fully configurable. ((Action)(() => LocalizeEnumConverter.Default.KeyPrefix = "Display_")) - .Should().Throw(); + .Should() + .Throw(); ((Action)(() => LocalizeEnumConverter.Default.ResourceManager = ApplicationStrings.ResourceManager)) - .Should().Throw(); + .Should() + .Throw(); var own = new LocalizeEnumConverter { KeyPrefix = "Display_" }; own.KeyPrefix.Should().Be("Display_"); } - private static String Convert(LocalizeEnumConverter converter, params Object?[] values) => - (String)converter.Convert(values, typeof(String), null, CultureInfo.InvariantCulture); + private static string Convert(LocalizeEnumConverter converter, params object?[] values) => + (string)converter.Convert(values, typeof(string), null, CultureInfo.InvariantCulture); private sealed class Marker(FileSortOrder value) { diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeEnumExtensionTests.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeEnumExtensionTests.cs index dfb2ec4..ccaac9b 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeEnumExtensionTests.cs +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeEnumExtensionTests.cs @@ -25,10 +25,7 @@ public void CustomPrefix_NoManager_ResolvesViaSearchAll() { TestSupport.ResetToEnglishWithTestCatalogs(); - var textBlock = TestSupport.BindLocalizedEnum( - new() { KeyPrefix = "Display_" }, - FileSortOrder.Ascending - ); + var textBlock = TestSupport.BindLocalizedEnum(new() { KeyPrefix = "Display_" }, FileSortOrder.Ascending); textBlock.Text.Should().Be("A to Z"); } diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeExtensionTests.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeExtensionTests.cs index 75cb0e0..7e7a8f3 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeExtensionTests.cs +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/LocalizeExtensionTests.cs @@ -41,40 +41,38 @@ public void KeyProperty_WithResourceManager_ResolvesScoped() } [AvaloniaFact] - public void ResourceKey_TakesPrecedenceOver_KeyAndResourceManager() + public void ResourceKeyConstructor_ResolvesViaTypedKey() { TestSupport.ResetToEnglishWithTestCatalogs(); - // 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 - } - ); + var textBlock = TestSupport.BindLocalizedText(new(ApplicationStringsKeys.Greeting)); textBlock.Text.Should().Be("Hello and welcome!"); } [AvaloniaFact] - public void ResourceKeyConstructor_ResolvesViaTypedKey() + public void ResourceKeyProperty_ResolvesViaTypedKey() { TestSupport.ResetToEnglishWithTestCatalogs(); - var textBlock = TestSupport.BindLocalizedText(new(ApplicationStringsKeys.Greeting)); + var textBlock = TestSupport.BindLocalizedText(new() { ResourceKey = ApplicationStringsKeys.Greeting }); textBlock.Text.Should().Be("Hello and welcome!"); } [AvaloniaFact] - public void ResourceKeyProperty_ResolvesViaTypedKey() + public void ResourceKey_TakesPrecedenceOver_KeyAndResourceManager() { TestSupport.ResetToEnglishWithTestCatalogs(); + // ResourceKey is set, so Key and ResourceManager are ignored and the typed key wins. var textBlock = TestSupport.BindLocalizedText( - new() { ResourceKey = ApplicationStringsKeys.Greeting } + new() + { + ResourceKey = ApplicationStringsKeys.Greeting, + Key = "SortingHint", + ResourceManager = SortingStrings.ResourceManager, + } ); textBlock.Text.Should().Be("Hello and welcome!"); diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/MainWindowViewModelTests.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/MainWindowViewModelTests.cs index 2e56ef9..e227959 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/MainWindowViewModelTests.cs +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/MainWindowViewModelTests.cs @@ -33,8 +33,8 @@ 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); + var changed = new List(); + viewModel.PropertyChanged += (_, args) => changed.Add(args.PropertyName ?? string.Empty); viewModel.SelectedLanguage = TestSupport.German; @@ -64,8 +64,8 @@ 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); + var changed = new List(); + viewModel.PropertyChanged += (_, args) => changed.Add(args.PropertyName ?? string.Empty); viewModel.SelectedFileSortOrder = FileSortOrder.Descending; diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/MemoryLeakTests.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/MemoryLeakTests.cs index 128b36d..dbef9b3 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/MemoryLeakTests.cs +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/MemoryLeakTests.cs @@ -39,15 +39,7 @@ public void DisposedViewModel_IsCollected_WhileAmbientLocalizerLives() Localizer.Current.Should().NotBeNull(); } - private static WeakReference CreateAndDisposeViewModel() - { - var viewModel = new MainWindowViewModel(Localizer.Current); - var reference = new WeakReference(viewModel); - viewModel.Dispose(); - return reference; - } - - private static List CreateAndAbandonBoundControls(Int32 count) + private static List CreateAndAbandonBoundControls(int count) { var references = new List(count); for (var index = 0; index < count; index++) @@ -62,4 +54,12 @@ private static List CreateAndAbandonBoundControls(Int32 count) return references; } + + private static WeakReference CreateAndDisposeViewModel() + { + var viewModel = new MainWindowViewModel(Localizer.Current); + var reference = new WeakReference(viewModel); + viewModel.Dispose(); + return reference; + } } diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/TestAppBuilder.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/TestAppBuilder.cs index 8b9d0f2..9ba3caa 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/TestAppBuilder.cs +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/TestAppBuilder.cs @@ -15,10 +15,9 @@ public static AppBuilder BuildAvaloniaApp() Localizer.Current.RegisterResourceManager(SortingStrings.ResourceManager); Localizer.Current.RegisterResourceManager(BrandingStrings.ResourceManager); - return AppBuilder.Configure().UseSkia().UseHeadless(new() - { - ShouldRenderOnUIThread = true, - UseHeadlessDrawing = false - }); + return AppBuilder + .Configure() + .UseSkia() + .UseHeadless(new() { ShouldRenderOnUIThread = true, UseHeadlessDrawing = false }); } } diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/TestSupport.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/TestSupport.cs index 2f72ef2..a6afe23 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/TestSupport.cs +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/TestSupport.cs @@ -6,12 +6,31 @@ namespace RentADeveloper.ResXLocalization.Avalonia.Sample.Tests; /// internal static class TestSupport { + /// 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", + typeof(TestSupport).Assembly + ); + /// Collects the non-empty text of every beneath a visual. /// The visual whose descendants to inspect. /// The non-empty text values in visual-tree traversal order. - public static String[] AllVisibleText(Visual root) => - root.GetVisualDescendants().OfType() - .Select(static textBlock => textBlock.Text ?? String.Empty) + public static string[] AllVisibleText(Visual root) => + root.GetVisualDescendants() + .OfType() + .Select(static textBlock => textBlock.Text ?? string.Empty) .Where(static text => text.Length > 0) .ToArray(); @@ -25,9 +44,8 @@ public static String[] AllVisibleText(Visual root) => public static TextBlock BindLocalizedEnum(LocalizeEnumExtension extension, Enum value) { var textBlock = new TextBlock(); - var binding = (BindingBase)extension.ProvideValue( - new SimpleServiceProvider(new SimpleProvideValueTarget(textBlock)) - ); + var binding = (BindingBase) + extension.ProvideValue(new SimpleServiceProvider(new SimpleProvideValueTarget(textBlock))); textBlock.Bind(TextBlock.TextProperty, binding); textBlock.DataContext = value; Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); @@ -54,16 +72,14 @@ public static TextBlock BindLocalizedText(LocalizeExtension extension) public static TextBlock BindLocalizedTextWithTarget(LocalizeExtension extension) { var textBlock = new TextBlock(); - var binding = (BindingBase)extension.ProvideValue( - new SimpleServiceProvider(new SimpleProvideValueTarget(textBlock)) - ); + var binding = (BindingBase) + extension.ProvideValue(new SimpleServiceProvider(new SimpleProvideValueTarget(textBlock))); textBlock.Bind(TextBlock.TextProperty, binding); return textBlock; } /// Pumps queued UI work so that bindings settle before assertions. - public static void PumpUi() => - Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + public static void PumpUi() => Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); /// /// Resets the ambient localizer to English and registers the two test-only files. Registration dedups @@ -76,30 +92,15 @@ public static void ResetToEnglishWithTestCatalogs() Localizer.Current.RegisterResourceManager(FallbackResources); } - /// 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", typeof(TestSupport).Assembly); - - private sealed class SimpleProvideValueTarget(Object targetObject) : IProvideValueTarget + private sealed class SimpleProvideValueTarget(object targetObject) : IProvideValueTarget { - public Object TargetObject { get; } = targetObject; + public object TargetObject { get; } = targetObject; - public Object TargetProperty => null!; + public object TargetProperty => null!; } private sealed class SimpleServiceProvider(IProvideValueTarget target) : IServiceProvider { - public Object? GetService(Type serviceType) => - serviceType == typeof(IProvideValueTarget) ? target : null; + public object? GetService(Type serviceType) => serviceType == typeof(IProvideValueTarget) ? target : null; } } diff --git a/tests/ResXLocalization.Core.Tests/AmbientLocalizerGroup.cs b/tests/ResXLocalization.Core.Tests/AmbientLocalizerGroup.cs index 6b8038b..00681b6 100644 --- a/tests/ResXLocalization.Core.Tests/AmbientLocalizerGroup.cs +++ b/tests/ResXLocalization.Core.Tests/AmbientLocalizerGroup.cs @@ -11,5 +11,5 @@ namespace RentADeveloper.ResXLocalization.Core.Tests; public sealed class AmbientLocalizerGroup { /// The collection name shared by the classes that use . - public const String Name = "Ambient Localizer.Current"; + public const string Name = "Ambient Localizer.Current"; } diff --git a/tests/ResXLocalization.Core.Tests/AvailableCulturesTests.cs b/tests/ResXLocalization.Core.Tests/AvailableCulturesTests.cs index 08094fe..e710740 100644 --- a/tests/ResXLocalization.Core.Tests/AvailableCulturesTests.cs +++ b/tests/ResXLocalization.Core.Tests/AvailableCulturesTests.cs @@ -6,27 +6,7 @@ namespace RentADeveloper.ResXLocalization.Core.Tests; /// public class AvailableCulturesTests { - [Fact] - public void PerManager_ReportsNeutralAsInvariant_AndEachSatelliteCulture() - { - var localizer = this.resources.CreateLocalizer(); - - var cultures = localizer.GetAvailableCultures(this.resources.Catalog); - - cultures.Should().Contain(CultureInfo.InvariantCulture); - cultures.Should().Contain(CultureInfo.GetCultureInfo("de")); - cultures.Should().NotContain(CultureInfo.GetCultureInfo("fr")); - } - - [Fact] - public void PerManager_SortsTheInvariantCultureFirst() - { - var localizer = this.resources.CreateLocalizer(); - - var cultures = localizer.GetAvailableCultures(this.resources.Catalog); - - cultures[0].Should().Be(CultureInfo.InvariantCulture); - } + private readonly TestResources resources = new(); [Fact] public void AcrossAllManagers_ReturnsTheUnion() @@ -40,14 +20,6 @@ public void AcrossAllManagers_ReturnsTheUnion() cultures.Should().OnlyHaveUniqueItems(); } - [Fact] - public void WithNothingRegistered_ReturnsAnEmptyList() - { - var localizer = new Localizer(); - - localizer.GetAvailableCultures().Should().BeEmpty(); - } - [Fact] public void FallbackLookups_DoNotPolluteTheResult() { @@ -76,5 +48,33 @@ public void PerManager_RejectsNull() act.Should().Throw(); } - private readonly TestResources resources = new(); + [Fact] + public void PerManager_ReportsNeutralAsInvariant_AndEachSatelliteCulture() + { + var localizer = this.resources.CreateLocalizer(); + + var cultures = localizer.GetAvailableCultures(this.resources.Catalog); + + cultures.Should().Contain(CultureInfo.InvariantCulture); + cultures.Should().Contain(CultureInfo.GetCultureInfo("de")); + cultures.Should().NotContain(CultureInfo.GetCultureInfo("fr")); + } + + [Fact] + public void PerManager_SortsTheInvariantCultureFirst() + { + var localizer = this.resources.CreateLocalizer(); + + var cultures = localizer.GetAvailableCultures(this.resources.Catalog); + + cultures[0].Should().Be(CultureInfo.InvariantCulture); + } + + [Fact] + public void WithNothingRegistered_ReturnsAnEmptyList() + { + var localizer = new Localizer(); + + localizer.GetAvailableCultures().Should().BeEmpty(); + } } diff --git a/tests/ResXLocalization.Core.Tests/IsolatedInstanceTests.cs b/tests/ResXLocalization.Core.Tests/IsolatedInstanceTests.cs index 45ada7d..aa101b2 100644 --- a/tests/ResXLocalization.Core.Tests/IsolatedInstanceTests.cs +++ b/tests/ResXLocalization.Core.Tests/IsolatedInstanceTests.cs @@ -15,6 +15,28 @@ namespace RentADeveloper.ResXLocalization.Core.Tests; [Collection(AmbientLocalizerGroup.Name)] public class IsolatedInstanceTests { + private readonly TestResources resources = new(); + + [Fact] + public void AnIsolatedInstance_SatisfiesTheILocalizerContract() => + this.AssertLocalizerContract(this.resources.CreateLocalizer()); + + [Fact] + public void CultureChanges_DoNotLeakIntoOtherInstances() + { + var first = this.resources.CreateLocalizer(); + var second = this.resources.CreateLocalizer(); + var secondNotified = false; + second.CultureChanged += (_, _) => secondNotified = true; + + 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(); + } + [Fact] public void Current_CanBeReplaced_AndRejectsNull() { @@ -47,28 +69,6 @@ public void Registrations_AreNotSharedBetweenInstances() second.Get("CatalogOnly").Should().Be("!CatalogOnly!"); } - [Fact] - public void CultureChanges_DoNotLeakIntoOtherInstances() - { - var first = this.resources.CreateLocalizer(); - var second = this.resources.CreateLocalizer(); - var secondNotified = false; - second.CultureChanged += (_, _) => secondNotified = true; - - 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(); - } - - [Fact] - public void AnIsolatedInstance_SatisfiesTheILocalizerContract() => - this.AssertLocalizerContract(this.resources.CreateLocalizer()); - - private readonly TestResources resources = new(); - /// The typical DI shape: the consumer sees only the interface. /// The localizer under test, seen through the interface. [SuppressMessage( diff --git a/tests/ResXLocalization.Core.Tests/LocalizerContractTests.cs b/tests/ResXLocalization.Core.Tests/LocalizerContractTests.cs index e479dad..24d2587 100644 --- a/tests/ResXLocalization.Core.Tests/LocalizerContractTests.cs +++ b/tests/ResXLocalization.Core.Tests/LocalizerContractTests.cs @@ -12,35 +12,17 @@ namespace RentADeveloper.ResXLocalization.Core.Tests; [Collection(AmbientLocalizerGroup.Name)] public class LocalizerContractTests { - [Fact] - 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"); - - localizer.CurrentCulture = TestResources.German; - - events.Should().Equal( - "PropertyChanged(CurrentCulture)", - "PropertyChanged(Item[])", - "CultureChanged" - ); - } + private readonly TestResources resources = new(); [Fact] - public void CultureChanged_CarriesOldAndNewCulture() + public void AssigningNullCulture_Throws() { var localizer = this.resources.CreateLocalizer(); - CultureChangedEventArgs? captured = null; - localizer.CultureChanged += (_, args) => captured = args; - localizer.CurrentCulture = TestResources.German; + var act = () => localizer.CurrentCulture = null!; - captured.Should().NotBeNull(); - captured!.OldCulture.Should().Be(TestResources.English); - captured.NewCulture.Should().Be(TestResources.German); + act.Should().Throw(); + localizer.CurrentCulture.Should().Be(TestResources.English); } [Fact] @@ -61,20 +43,43 @@ public void AssigningTheSameCulture_RaisesNoNotifications() } [Fact] - public void AssigningNullCulture_Throws() + 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"); - var act = () => localizer.CurrentCulture = null!; + localizer.CurrentCulture = TestResources.German; - act.Should().Throw(); - localizer.CurrentCulture.Should().Be(TestResources.English); + events.Should().Equal("PropertyChanged(CurrentCulture)", "PropertyChanged(Item[])", "CultureChanged"); + } + + [Fact] + public void CultureChanged_CarriesOldAndNewCulture() + { + var localizer = this.resources.CreateLocalizer(); + CultureChangedEventArgs? captured = null; + localizer.CultureChanged += (_, args) => captured = args; + + localizer.CurrentCulture = TestResources.German; + + captured.Should().NotBeNull(); + captured!.OldCulture.Should().Be(TestResources.English); + captured.NewCulture.Should().Be(TestResources.German); } [Fact] public void CurrentCulture_DefaultsToCurrentUICulture() => new Localizer().CurrentCulture.Should().Be(CultureInfo.CurrentUICulture); + [Fact] + public void Current_IsAProcessWideSingleton() + { + Localizer.Current.Should().BeSameAs(Localizer.Current); + Localizer.Current.Should().BeAssignableTo(); + } + [Fact] public void RegisterResourceManager_RejectsNull() { @@ -96,13 +101,4 @@ public void RegisteringTheSameManagerTwice_IsANoOp() localizer.Get("Shared").Should().Be("CatalogShared"); localizer.Get("FallbackOnly").Should().Be("FallbackOnlyValue"); } - - [Fact] - public void Current_IsAProcessWideSingleton() - { - Localizer.Current.Should().BeSameAs(Localizer.Current); - Localizer.Current.Should().BeAssignableTo(); - } - - private readonly TestResources resources = new(); } diff --git a/tests/ResXLocalization.Core.Tests/LocalizerEnumLookupTests.cs b/tests/ResXLocalization.Core.Tests/LocalizerEnumLookupTests.cs index 69a3b0e..f2cfaed 100644 --- a/tests/ResXLocalization.Core.Tests/LocalizerEnumLookupTests.cs +++ b/tests/ResXLocalization.Core.Tests/LocalizerEnumLookupTests.cs @@ -6,23 +6,29 @@ namespace RentADeveloper.ResXLocalization.Core.Tests; /// public class LocalizerEnumLookupTests { + private readonly TestResources resources = new(); + [Fact] - public void SearchAll_DefaultPrefix_ResolvesAndSwitchesLive() + public void NullArguments_Throw() { var localizer = this.resources.CreateLocalizer(); - localizer.Get(TestSortOrder.Ascending).Should().Be("Ascending"); - - localizer.CurrentCulture = TestResources.German; - localizer.Get(TestSortOrder.Ascending).Should().Be("Aufsteigend"); + ((Action)(() => localizer.Get((Enum)null!))).Should().Throw(); + ((Action)(() => localizer.Get(TestSortOrder.Ascending, (string)null!))).Should().Throw(); + ((Action)(() => localizer.Get(TestSortOrder.Ascending, (ResourceManager)null!))) + .Should() + .Throw(); + ((Action)(() => localizer.Get(TestSortOrder.Ascending, this.resources.Catalog, null!))) + .Should() + .Throw(); } [Fact] - public void SearchAll_CustomPrefix_Resolves() + public void Scoped_CustomPrefix_Resolves() { var localizer = this.resources.CreateLocalizer(); - localizer.Get(TestSortOrder.Ascending, "Display_").Should().Be("A to Z"); + localizer.Get(TestSortOrder.Ascending, this.resources.Catalog, "Display_").Should().Be("A to Z"); } [Fact] @@ -34,34 +40,29 @@ public void Scoped_DefaultPrefix_Resolves() } [Fact] - public void Scoped_CustomPrefix_Resolves() + public void SearchAll_CustomPrefix_Resolves() { var localizer = this.resources.CreateLocalizer(); - localizer.Get(TestSortOrder.Ascending, this.resources.Catalog, "Display_").Should().Be("A to Z"); + localizer.Get(TestSortOrder.Ascending, "Display_").Should().Be("A to Z"); } [Fact] - public void UnlocalizedEnumValue_ReturnsTheConventionKeyAsSentinel() + public void SearchAll_DefaultPrefix_ResolvesAndSwitchesLive() { var localizer = this.resources.CreateLocalizer(); - localizer.Get(TestSortOrder.Descending).Should().Be("!Enum_TestSortOrder_Descending!"); + localizer.Get(TestSortOrder.Ascending).Should().Be("Ascending"); + + localizer.CurrentCulture = TestResources.German; + localizer.Get(TestSortOrder.Ascending).Should().Be("Aufsteigend"); } [Fact] - public void NullArguments_Throw() + public void UnlocalizedEnumValue_ReturnsTheConventionKeyAsSentinel() { 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!))) - .Should().Throw(); - ((Action)(() => localizer.Get(TestSortOrder.Ascending, this.resources.Catalog, null!))) - .Should().Throw(); + localizer.Get(TestSortOrder.Descending).Should().Be("!Enum_TestSortOrder_Descending!"); } - - private readonly TestResources resources = new(); } diff --git a/tests/ResXLocalization.Core.Tests/LocalizerFormatTests.cs b/tests/ResXLocalization.Core.Tests/LocalizerFormatTests.cs index 923e4bb..69a14a7 100644 --- a/tests/ResXLocalization.Core.Tests/LocalizerFormatTests.cs +++ b/tests/ResXLocalization.Core.Tests/LocalizerFormatTests.cs @@ -7,75 +7,74 @@ namespace RentADeveloper.ResXLocalization.Core.Tests; /// public class LocalizerFormatTests { + private readonly TestResources resources = new(); + [Fact] - public void SearchAll_FormatsTheResolvedValue_InTheCurrentCulture() + public void EmptyKey_ReturnsEmptyString() { 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."); - - // ...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(string.Empty, 1, 2).Should().BeEmpty(); } [Fact] - public void Scoped_FormatsTheResolvedValue() + public void InvalidCompositeFormat_FailsLoudly() { var localizer = this.resources.CreateLocalizer(); - localizer.Get("ItemsFound", this.resources.Catalog, 2.0, "Music") - .Should().Be("Found 2.0 items in Music."); + // 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", []); + + act.Should().Throw(); } [Fact] - public void TypedKey_FormatsTheResolvedValue() + public void MissingKey_ReturnsTheSentinelUnformatted() { 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("ThisKeyDoesNotExist", 1, 2).Should().Be("!ThisKeyDoesNotExist!"); + localizer.Get("ThisKeyDoesNotExist", this.resources.Catalog, 1, 2).Should().Be("!ThisKeyDoesNotExist!"); } [Fact] - public void MissingKey_ReturnsTheSentinelUnformatted() + public void NullArgumentsArray_Throws() { var localizer = this.resources.CreateLocalizer(); - localizer.Get("ThisKeyDoesNotExist", 1, 2).Should().Be("!ThisKeyDoesNotExist!"); - localizer.Get("ThisKeyDoesNotExist", this.resources.Catalog, 1, 2).Should().Be("!ThisKeyDoesNotExist!"); + var act = () => localizer.Get("Greeting", (object?[])null!); + + act.Should().Throw(); } [Fact] - public void EmptyKey_ReturnsEmptyString() + public void Scoped_FormatsTheResolvedValue() { var localizer = this.resources.CreateLocalizer(); - localizer.Get(String.Empty, 1, 2).Should().BeEmpty(); + localizer.Get("ItemsFound", this.resources.Catalog, 2.0, "Music").Should().Be("Found 2.0 items in Music."); } [Fact] - public void InvalidCompositeFormat_FailsLoudly() + public void SearchAll_FormatsTheResolvedValue_InTheCurrentCulture() { var localizer = this.resources.CreateLocalizer(); - // 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", []); + // {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."); - act.Should().Throw(); + // ...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."); } [Fact] - public void NullArgumentsArray_Throws() + public void TypedKey_FormatsTheResolvedValue() { var localizer = this.resources.CreateLocalizer(); + var key = new ResourceKey("ItemsFound", this.resources.Catalog); - var act = () => localizer.Get("Greeting", (Object?[])null!); - - act.Should().Throw(); + localizer.Get(key, 2.0, "Music").Should().Be("Found 2.0 items in Music."); } - - private readonly TestResources resources = new(); } diff --git a/tests/ResXLocalization.Core.Tests/LocalizerLookupTests.cs b/tests/ResXLocalization.Core.Tests/LocalizerLookupTests.cs index fd1e049..17a32a6 100644 --- a/tests/ResXLocalization.Core.Tests/LocalizerLookupTests.cs +++ b/tests/ResXLocalization.Core.Tests/LocalizerLookupTests.cs @@ -6,51 +6,53 @@ namespace RentADeveloper.ResXLocalization.Core.Tests; /// public class LocalizerLookupTests { + private readonly TestResources resources = new(); + [Fact] - public void SearchAll_ReturnsTheFirstMatch_InRegistrationOrder() + public void Indexer_IsShorthandForSearchAllGet() { var localizer = this.resources.CreateLocalizer(); - // Catalog is registered before Fallback, so it wins the collision on "Shared". - localizer.Get("Shared").Should().Be("CatalogShared"); + localizer["Greeting"].Should().Be(localizer.Get("Greeting")); + localizer["Greeting"].Should().Be("Hello and welcome!"); } [Fact] - public void SearchAll_ReachesKeysUniqueToEachRegisteredFile() + public void MissingKey_ReturnsTheBangSentinel() { var localizer = this.resources.CreateLocalizer(); - localizer.Get("CatalogOnly").Should().Be("CatalogOnlyValue"); - localizer.Get("FallbackOnly").Should().Be("FallbackOnlyValue"); + localizer.Get("ThisKeyDoesNotExist").Should().Be("!ThisKeyDoesNotExist!"); + localizer.Get("ThisKeyDoesNotExist", this.resources.Catalog).Should().Be("!ThisKeyDoesNotExist!"); } [Fact] - public void SearchAll_SwitchesLive_OnCultureChange() + public void NullOrEmptyKey_ReturnsEmptyString() { var localizer = this.resources.CreateLocalizer(); - localizer.Get("Greeting").Should().Be("Hello and welcome!"); - localizer.CurrentCulture = TestResources.German; - - localizer.Get("Greeting").Should().Be("Hallo und willkommen!"); + localizer.Get(string.Empty).Should().BeEmpty(); + localizer.Get((string)null!).Should().BeEmpty(); + localizer.Get(string.Empty, this.resources.Catalog).Should().BeEmpty(); } [Fact] - public void Indexer_IsShorthandForSearchAllGet() + public void ScopedLookup_ReadsExactlyTheNamedFile_EvenForACollidingKey() { var localizer = this.resources.CreateLocalizer(); - localizer["Greeting"].Should().Be(localizer.Get("Greeting")); - localizer["Greeting"].Should().Be("Hello and welcome!"); + localizer.Get("Shared", this.resources.Catalog).Should().Be("CatalogShared"); + localizer.Get("Shared", this.resources.Fallback).Should().Be("FallbackShared"); } [Fact] - public void ScopedLookup_ReadsExactlyTheNamedFile_EvenForACollidingKey() + public void ScopedLookup_RejectsNullResourceManager() { var localizer = this.resources.CreateLocalizer(); - localizer.Get("Shared", this.resources.Catalog).Should().Be("CatalogShared"); - localizer.Get("Shared", this.resources.Fallback).Should().Be("FallbackShared"); + var act = () => localizer.Get("Greeting", (ResourceManager)null!); + + act.Should().Throw(); } [Fact] @@ -65,42 +67,40 @@ public void ScopedLookup_SwitchesLive_OnCultureChange() } [Fact] - public void TypedResourceKey_ResolvesThroughItsOwnManager() + public void SearchAll_ReachesKeysUniqueToEachRegisteredFile() { var localizer = this.resources.CreateLocalizer(); - var key = new ResourceKey("Shared", this.resources.Fallback); - // The key carries its manager, so it reads Fallback even though Catalog is registered first. - localizer.Get(key).Should().Be("FallbackShared"); + localizer.Get("CatalogOnly").Should().Be("CatalogOnlyValue"); + localizer.Get("FallbackOnly").Should().Be("FallbackOnlyValue"); } [Fact] - public void NullOrEmptyKey_ReturnsEmptyString() + public void SearchAll_ReturnsTheFirstMatch_InRegistrationOrder() { var localizer = this.resources.CreateLocalizer(); - localizer.Get(String.Empty).Should().BeEmpty(); - localizer.Get((String)null!).Should().BeEmpty(); - localizer.Get(String.Empty, this.resources.Catalog).Should().BeEmpty(); + // Catalog is registered before Fallback, so it wins the collision on "Shared". + localizer.Get("Shared").Should().Be("CatalogShared"); } [Fact] - public void MissingKey_ReturnsTheBangSentinel() + public void SearchAll_SwitchesLive_OnCultureChange() { var localizer = this.resources.CreateLocalizer(); + localizer.Get("Greeting").Should().Be("Hello and welcome!"); - localizer.Get("ThisKeyDoesNotExist").Should().Be("!ThisKeyDoesNotExist!"); - localizer.Get("ThisKeyDoesNotExist", this.resources.Catalog).Should().Be("!ThisKeyDoesNotExist!"); + localizer.CurrentCulture = TestResources.German; + + localizer.Get("Greeting").Should().Be("Hallo und willkommen!"); } [Fact] - public void ScopedLookup_RejectsNullResourceManager() + public void SearchAll_WithNothingRegistered_ReturnsTheSentinel() { - var localizer = this.resources.CreateLocalizer(); - - var act = () => localizer.Get("Greeting", (ResourceManager)null!); + var localizer = new Localizer { CurrentCulture = TestResources.English }; - act.Should().Throw(); + localizer.Get("Greeting").Should().Be("!Greeting!"); } [Fact] @@ -116,12 +116,12 @@ public void TypedResourceKey_Default_Throws() } [Fact] - public void SearchAll_WithNothingRegistered_ReturnsTheSentinel() + public void TypedResourceKey_ResolvesThroughItsOwnManager() { - var localizer = new Localizer { CurrentCulture = TestResources.English }; + var localizer = this.resources.CreateLocalizer(); + var key = new ResourceKey("Shared", this.resources.Fallback); - localizer.Get("Greeting").Should().Be("!Greeting!"); + // The key carries its manager, so it reads Fallback even though Catalog is registered first. + localizer.Get(key).Should().Be("FallbackShared"); } - - private readonly TestResources resources = new(); } diff --git a/tests/ResXLocalization.Core.Tests/LocalizerMissTests.cs b/tests/ResXLocalization.Core.Tests/LocalizerMissTests.cs index 439eaca..82b0b9e 100644 --- a/tests/ResXLocalization.Core.Tests/LocalizerMissTests.cs +++ b/tests/ResXLocalization.Core.Tests/LocalizerMissTests.cs @@ -6,33 +6,7 @@ namespace RentADeveloper.ResXLocalization.Core.Tests; /// public class LocalizerMissTests { - [Fact] - public void SearchAllMiss_RaisesTranslationNotFound_WithoutAManager() - { - var localizer = this.resources.CreateLocalizer(); - var raised = new List(); - localizer.TranslationNotFound += (_, args) => raised.Add(args); - - localizer.Get("ThisKeyDoesNotExist"); - - var miss = raised.Should().ContainSingle().Subject; - miss.Key.Should().Be("ThisKeyDoesNotExist"); - miss.Culture.Should().Be(TestResources.English); - miss.ResourceManager.Should().BeNull(); - } - - [Fact] - public void ScopedMiss_RaisesTranslationNotFound_WithTheManager() - { - var localizer = this.resources.CreateLocalizer(); - var raised = new List(); - localizer.TranslationNotFound += (_, args) => raised.Add(args); - - localizer.Get("ThisKeyDoesNotExist", this.resources.Catalog); - - var miss = raised.Should().ContainSingle().Subject; - miss.ResourceManager.Should().BeSameAs(this.resources.Catalog); - } + private readonly TestResources resources = new(); [Fact] public void EnumMiss_RaisesTranslationNotFound_WithTheConventionKey() @@ -46,21 +20,6 @@ public void EnumMiss_RaisesTranslationNotFound_WithTheConventionKey() raised.Should().ContainSingle().Which.Key.Should().Be("Enum_TestSortOrder_Descending"); } - [Fact] - public void SuccessfulAndEmptyLookups_DoNotRaiseTranslationNotFound() - { - var localizer = this.resources.CreateLocalizer(); - var raised = 0; - localizer.TranslationNotFound += (_, _) => raised++; - - localizer.Get("Greeting"); - localizer.Get("Greeting", this.resources.Catalog); - localizer.Get(String.Empty); - _ = localizer["Greeting"]; - - raised.Should().Be(0); - } - [Fact] public void MissingSatelliteEntry_FallsBackToNeutral_WithoutMissEvent() { @@ -74,15 +33,12 @@ public void MissingSatelliteEntry_FallsBackToNeutral_WithoutMissEvent() } [Fact] - public void SpecificCulture_FallsBackToParentCulture_WithoutMissEvent() + public void MissingTranslationFormat_AcceptsAFormatWithoutAPlaceholder() { var localizer = this.resources.CreateLocalizer(); - localizer.CurrentCulture = new("de-DE"); - var raised = 0; - localizer.TranslationNotFound += (_, _) => raised++; + localizer.MissingTranslationFormat = "missing"; - localizer.Get("Greeting", this.resources.Catalog).Should().Be("Hallo und willkommen!"); - raised.Should().Be(0); + localizer.Get("ThisKeyDoesNotExist").Should().Be("missing"); } [Fact] @@ -92,14 +48,26 @@ public void MissingTranslationFormat_CustomizesTheSentinel() localizer.MissingTranslationFormat = "[missing: {0}]"; localizer.Get("ThisKeyDoesNotExist").Should().Be("[missing: ThisKeyDoesNotExist]"); - localizer.Get("ThisKeyDoesNotExist", this.resources.Catalog) - .Should().Be("[missing: ThisKeyDoesNotExist]"); + localizer.Get("ThisKeyDoesNotExist", this.resources.Catalog).Should().Be("[missing: ThisKeyDoesNotExist]"); } [Fact] public void MissingTranslationFormat_DefaultsToBangKeyBang() => new Localizer().MissingTranslationFormat.Should().Be("!{0}!"); + [Fact] + public void MissingTranslationFormat_RejectsAnInvalidFormat_Eagerly() + { + var localizer = this.resources.CreateLocalizer(); + + // "{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}"; + + act.Should().Throw().WithInnerException(); + localizer.MissingTranslationFormat.Should().Be("!{0}!"); + } + [Fact] public void MissingTranslationFormat_RejectsNull() { @@ -112,26 +80,57 @@ public void MissingTranslationFormat_RejectsNull() } [Fact] - public void MissingTranslationFormat_RejectsAnInvalidFormat_Eagerly() + public void ScopedMiss_RaisesTranslationNotFound_WithTheManager() { var localizer = this.resources.CreateLocalizer(); + var raised = new List(); + localizer.TranslationNotFound += (_, args) => raised.Add(args); - // "{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", this.resources.Catalog); - act.Should().Throw().WithInnerException(); - localizer.MissingTranslationFormat.Should().Be("!{0}!"); + var miss = raised.Should().ContainSingle().Subject; + miss.ResourceManager.Should().BeSameAs(this.resources.Catalog); } [Fact] - public void MissingTranslationFormat_AcceptsAFormatWithoutAPlaceholder() + public void SearchAllMiss_RaisesTranslationNotFound_WithoutAManager() { var localizer = this.resources.CreateLocalizer(); - localizer.MissingTranslationFormat = "missing"; + var raised = new List(); + localizer.TranslationNotFound += (_, args) => raised.Add(args); - localizer.Get("ThisKeyDoesNotExist").Should().Be("missing"); + localizer.Get("ThisKeyDoesNotExist"); + + var miss = raised.Should().ContainSingle().Subject; + miss.Key.Should().Be("ThisKeyDoesNotExist"); + miss.Culture.Should().Be(TestResources.English); + miss.ResourceManager.Should().BeNull(); } - private readonly TestResources resources = new(); + [Fact] + public void SpecificCulture_FallsBackToParentCulture_WithoutMissEvent() + { + var localizer = this.resources.CreateLocalizer(); + localizer.CurrentCulture = new("de-DE"); + var raised = 0; + localizer.TranslationNotFound += (_, _) => raised++; + + localizer.Get("Greeting", this.resources.Catalog).Should().Be("Hallo und willkommen!"); + raised.Should().Be(0); + } + + [Fact] + public void SuccessfulAndEmptyLookups_DoNotRaiseTranslationNotFound() + { + var localizer = this.resources.CreateLocalizer(); + var raised = 0; + localizer.TranslationNotFound += (_, _) => raised++; + + localizer.Get("Greeting"); + localizer.Get("Greeting", this.resources.Catalog); + localizer.Get(string.Empty); + _ = localizer["Greeting"]; + + raised.Should().Be(0); + } } diff --git a/tests/ResXLocalization.Core.Tests/LocalizerRegistrationTests.cs b/tests/ResXLocalization.Core.Tests/LocalizerRegistrationTests.cs index d2bb366..034fdf1 100644 --- a/tests/ResXLocalization.Core.Tests/LocalizerRegistrationTests.cs +++ b/tests/ResXLocalization.Core.Tests/LocalizerRegistrationTests.cs @@ -6,25 +6,34 @@ namespace RentADeveloper.ResXLocalization.Core.Tests; /// public class LocalizerRegistrationTests { + private readonly TestResources resources = new(); + [Fact] - public void Unregister_RemovesTheManagerFromTheSearchOrder() + public void Clear_EmptiesTheSearchSet_ButLeavesScopedLookupsWorking() { var localizer = this.resources.CreateLocalizer(); - localizer.Get("Shared").Should().Be("CatalogShared"); - localizer.UnregisterResourceManager(this.resources.Catalog).Should().BeTrue(); + localizer.ClearResourceManagers(); - // With Catalog gone, the "Shared" collision now falls to Fallback. - localizer.Get("Shared").Should().Be("FallbackShared"); - localizer.Get("CatalogOnly").Should().Be("!CatalogOnly!"); + 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(new ResourceKey("Greeting", this.resources.Catalog)).Should().Be("Hello and welcome!"); } [Fact] - public void Unregister_ReturnsFalse_ForAManagerThatWasNeverRegistered() + public void RegisteringTwice_KeepsASingleEntry_SoOneUnregisterRemovesIt() { var localizer = new Localizer { CurrentCulture = TestResources.English }; + localizer.RegisterResourceManager(this.resources.Catalog); + localizer.RegisterResourceManager(this.resources.Catalog); + + localizer.UnregisterResourceManager(this.resources.Catalog).Should().BeTrue(); + // A second unregister finds nothing left: the duplicate registration was de-duplicated. localizer.UnregisterResourceManager(this.resources.Catalog).Should().BeFalse(); + localizer.Get("CatalogOnly").Should().Be("!CatalogOnly!"); } [Fact] @@ -38,32 +47,23 @@ public void Unregister_RejectsNull() } [Fact] - public void RegisteringTwice_KeepsASingleEntry_SoOneUnregisterRemovesIt() + public void Unregister_RemovesTheManagerFromTheSearchOrder() { - var localizer = new Localizer { CurrentCulture = TestResources.English }; - localizer.RegisterResourceManager(this.resources.Catalog); - localizer.RegisterResourceManager(this.resources.Catalog); + var localizer = this.resources.CreateLocalizer(); + localizer.Get("Shared").Should().Be("CatalogShared"); localizer.UnregisterResourceManager(this.resources.Catalog).Should().BeTrue(); - // A second unregister finds nothing left: the duplicate registration was de-duplicated. - localizer.UnregisterResourceManager(this.resources.Catalog).Should().BeFalse(); + // With Catalog gone, the "Shared" collision now falls to Fallback. + localizer.Get("Shared").Should().Be("FallbackShared"); localizer.Get("CatalogOnly").Should().Be("!CatalogOnly!"); } [Fact] - public void Clear_EmptiesTheSearchSet_ButLeavesScopedLookupsWorking() + public void Unregister_ReturnsFalse_ForAManagerThatWasNeverRegistered() { - var localizer = this.resources.CreateLocalizer(); - - localizer.ClearResourceManagers(); - - localizer.Get("Greeting").Should().Be("!Greeting!"); + var localizer = new Localizer { CurrentCulture = TestResources.English }; - // Scoped and typed lookups never consult the registration set. - localizer.Get("Greeting", this.resources.Catalog).Should().Be("Hello and welcome!"); - localizer.Get(new ResourceKey("Greeting", this.resources.Catalog)).Should().Be("Hello and welcome!"); + localizer.UnregisterResourceManager(this.resources.Catalog).Should().BeFalse(); } - - private readonly TestResources resources = new(); } diff --git a/tests/ResXLocalization.Core.Tests/TestSortOrder.cs b/tests/ResXLocalization.Core.Tests/TestSortOrder.cs index 10dae35..d276071 100644 --- a/tests/ResXLocalization.Core.Tests/TestSortOrder.cs +++ b/tests/ResXLocalization.Core.Tests/TestSortOrder.cs @@ -11,5 +11,5 @@ public enum TestSortOrder Ascending = 0, /// Deliberately missing from every resource file. - Descending = 1 + Descending = 1, } diff --git a/tests/ResXLocalization.SourceGenerators.Tests/ResxKeysGeneratorTests.cs b/tests/ResXLocalization.SourceGenerators.Tests/ResxKeysGeneratorTests.cs index cc88d82..7bacb9d 100644 --- a/tests/ResXLocalization.SourceGenerators.Tests/ResxKeysGeneratorTests.cs +++ b/tests/ResXLocalization.SourceGenerators.Tests/ResxKeysGeneratorTests.cs @@ -21,6 +21,25 @@ public void CollidingSanitizedNames_GetNumericSuffixes() code.Should().Contain("""A_B2 = new("A-B", """); } + [Fact] + public void ControlCharactersInKeyNames_AreEscapedInTheGeneratedLiteral() + { + // A literal newline in an XML attribute would be normalized to a space, so the resx has to + // use a character reference to smuggle one into the key name. + const string resx = """ + + + a + + """; + + var result = RunGenerator(("project/Strings.resx", resx), ("project/Strings.Designer.cs", Designer("My.App"))); + + var code = SingleGeneratedSource(result).SourceText.ToString(); + + code.Should().Contain("""Line_Break = new("Line\nBreak", """); + } + [Fact] public void DesignerInADifferentFolder_DoesNotPair() { @@ -33,6 +52,28 @@ public void DesignerInADifferentFolder_DoesNotPair() result.Results.Single().GeneratedSources.Should().BeEmpty(); } + [Fact] + public void DesignerWithoutAResourceManagerMember_DoesNotPair() + { + // A WinForms Form1.Designer.cs sits next to Form1.resx and matches the *.Designer.cs name, but + // it is not a resx accessor (no ResourceManager member), so it must not pair. + const string formDesigner = """ + namespace My.App { + partial class Strings { + private void InitializeComponent() { } + } + } + """; + + var result = RunGenerator( + ("project/Strings.resx", Resx(("Greeting", "Hello"))), + ("project/Strings.Designer.cs", formDesigner) + ); + + result.Diagnostics.Should().ContainSingle().Which.Id.Should().Be("RXLGEN002"); + result.Results.Single().GeneratedSources.Should().BeEmpty(); + } + [Fact] public void DottedResxBaseName_IsTreatedAsSatelliteAndSkipped() { @@ -44,6 +85,35 @@ public void DottedResxBaseName_IsTreatedAsSatelliteAndSkipped() result.Results.Single().GeneratedSources.Should().BeEmpty(); } + [Fact] + public void EscapedNamespaceSegments_ArePreserved() + { + const string designer = + "namespace @class.Δ { internal class Strings { public static global::System.Resources.ResourceManager ResourceManager => null; } }"; + var result = RunGenerator( + ("project/Strings.resx", Resx(("Greeting", "Hello"))), + ("project/Strings.Designer.cs", designer) + ); + + SingleGeneratedSource(result).SourceText.ToString().Should().Contain("namespace @class.Δ;"); + } + + [Fact] + public void GlobalNamespaceAndInvalidFilename_EmitValidSanitizedClass() + { + const string designer = + "internal class OrderStatus { public static global::System.Resources.ResourceManager ResourceManager => null; }"; + var result = RunGenerator( + ("project/Order-Status.resx", Resx(("Greeting", "Hello"))), + ("project/Order-Status.Designer.cs", designer) + ); + var code = SingleGeneratedSource(result).SourceText.ToString(); + + code.Should().Contain("class Order_StatusKeys"); + code.Should().Contain("global::OrderStatus.ResourceManager"); + code.Should().NotContain("namespace ;"); + } + [Fact] public void InvalidIdentifierCharacters_AreSanitized() { @@ -58,6 +128,35 @@ public void InvalidIdentifierCharacters_AreSanitized() code.Should().Contain("""_2Fast = new("2Fast", """); } + [Fact] + public void KeyEqualToTheGeneratedClassName_GetsANumericSuffix() + { + // A member must not be named like its enclosing type (CS0542), so the key "StringsKeys" + // inside the generated StringsKeys class is renamed like any other collision. + var result = RunGenerator( + ("project/Strings.resx", Resx(("StringsKeys", "a"))), + ("project/Strings.Designer.cs", Designer("My.App")) + ); + + var code = SingleGeneratedSource(result).SourceText.ToString(); + + code.Should().Contain("""StringsKeys2 = new("StringsKeys", """); + } + + [Fact] + public void KeyThatIsACSharpKeyword_IsEscapedWithAnAtPrefix() + { + var result = RunGenerator( + ("project/Strings.resx", Resx(("class", "a"), ("event", "b"))), + ("project/Strings.Designer.cs", Designer("My.App")) + ); + + var code = SingleGeneratedSource(result).SourceText.ToString(); + + code.Should().Contain("""@class = new("class", """); + code.Should().Contain("""@event = new("event", """); + } + [Fact] public void MalformedEligibleResx_ReportsAnActionableDiagnostic() { @@ -84,9 +183,9 @@ public void Members_AreSortedByKeyName() var code = SingleGeneratedSource(result).SourceText.ToString(); - code.IndexOf("Apple", StringComparison.Ordinal).Should().BeLessThan( - code.IndexOf("Zebra", StringComparison.Ordinal) - ); + code.IndexOf("Apple", StringComparison.Ordinal) + .Should() + .BeLessThan(code.IndexOf("Zebra", StringComparison.Ordinal)); } [Fact] @@ -105,19 +204,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);""" - ); + code.Should().Contain("""Greeting = new("Greeting", global::My.App.Resources.Strings.ResourceManager);"""); + code.Should() + .Contain("""WindowTitle = new("WindowTitle", global::My.App.Resources.Strings.ResourceManager);"""); } [Fact] public void NonStringEntries_AreSkipped() { - const String resx = - """ + const string resx = """ Hello @@ -126,10 +221,7 @@ public void NonStringEntries_AreSkipped() """; - var result = RunGenerator( - ("project/Strings.resx", resx), - ("project/Strings.Designer.cs", Designer("My.App")) - ); + var result = RunGenerator(("project/Strings.resx", resx), ("project/Strings.Designer.cs", Designer("My.App"))); var code = SingleGeneratedSource(result).SourceText.ToString(); @@ -139,52 +231,16 @@ public void NonStringEntries_AreSkipped() } [Fact] - public void WinFormsDesignerMetadataEntries_AreSkipped() + public void QuotesInKeyNames_AreEscapedInTheGeneratedLiteral() { - // A WinForms form's resx stores component properties as "$this.Text" / ">>$this.Name"; only - // the real string entry should surface as a key. - const String resx = - """ - - - Form1 - $this - Hello - - """; - var result = RunGenerator( - ("project/Strings.resx", resx), + ("project/Strings.resx", Resx(("""Say "Hi" now""", "a"))), ("project/Strings.Designer.cs", Designer("My.App")) ); var code = SingleGeneratedSource(result).SourceText.ToString(); - code.Should().Contain("""Greeting = new("Greeting", """); - code.Should().NotContain("this"); - } - - [Fact] - public void DesignerWithoutAResourceManagerMember_DoesNotPair() - { - // A WinForms Form1.Designer.cs sits next to Form1.resx and matches the *.Designer.cs name, but - // it is not a resx accessor (no ResourceManager member), so it must not pair. - const String formDesigner = - """ - namespace My.App { - partial class Strings { - private void InitializeComponent() { } - } - } - """; - - var result = RunGenerator( - ("project/Strings.resx", Resx(("Greeting", "Hello"))), - ("project/Strings.Designer.cs", formDesigner) - ); - - result.Diagnostics.Should().ContainSingle().Which.Id.Should().Be("RXLGEN002"); - result.Results.Single().GeneratedSources.Should().BeEmpty(); + code.Should().Contain("""Say__Hi__now = new("Say \"Hi\" now", """); } [Theory] @@ -192,7 +248,7 @@ private void InitializeComponent() { } [InlineData("System.Resources.ResourceManager", "")] [InlineData("ResourceManager", "using System.Resources;")] [InlineData("RM", "using RM = System.Resources.ResourceManager;")] - public void ResourceManagerTypeSpellings_AreRecognized(String typeName, String usingDirective) + public void ResourceManagerTypeSpellings_AreRecognized(string typeName, string usingDirective) { var designer = $$""" {{usingDirective}} @@ -207,58 +263,16 @@ internal class ActualAccessor ("project/Strings.Designer.cs", designer) ); - SingleGeneratedSource(result).SourceText.ToString() - .Should().Contain("global::My.App.ActualAccessor.ResourceManager"); - } - - [Fact] - public void GlobalNamespaceAndInvalidFilename_EmitValidSanitizedClass() - { - const String designer = - "internal class OrderStatus { public static global::System.Resources.ResourceManager ResourceManager => null; }"; - var result = RunGenerator( - ("project/Order-Status.resx", Resx(("Greeting", "Hello"))), - ("project/Order-Status.Designer.cs", designer) - ); - var code = SingleGeneratedSource(result).SourceText.ToString(); - - code.Should().Contain("class Order_StatusKeys"); - code.Should().Contain("global::OrderStatus.ResourceManager"); - code.Should().NotContain("namespace ;"); - } - - [Fact] - public void EscapedNamespaceSegments_ArePreserved() - { - const String designer = - "namespace @class.Δ { internal class Strings { public static global::System.Resources.ResourceManager ResourceManager => null; } }"; - var result = RunGenerator( - ("project/Strings.resx", Resx(("Greeting", "Hello"))), - ("project/Strings.Designer.cs", designer) - ); - - SingleGeneratedSource(result).SourceText.ToString().Should().Contain("namespace @class.Δ;"); - } - - [Fact] - public void QuotesInKeyNames_AreEscapedInTheGeneratedLiteral() - { - var result = RunGenerator( - ("project/Strings.resx", Resx(("""Say "Hi" now""", "a"))), - ("project/Strings.Designer.cs", Designer("My.App")) - ); - - var code = SingleGeneratedSource(result).SourceText.ToString(); - - code.Should().Contain("""Say__Hi__now = new("Say \"Hi\" now", """); + SingleGeneratedSource(result) + .SourceText.ToString() + .Should() + .Contain("global::My.App.ActualAccessor.ResourceManager"); } [Fact] public void ResxWithoutSiblingDesigner_ProducesNoOutput() { - var result = RunGenerator( - ("project/Strings.resx", Resx(("Greeting", "Hello"))) - ); + var result = RunGenerator(("project/Strings.resx", Resx(("Greeting", "Hello")))); var diagnostic = result.Diagnostics.Should().ContainSingle().Subject; diagnostic.Id.Should().Be("RXLGEN002"); @@ -280,109 +294,13 @@ public void SatelliteResx_ProducesNoOutput() result.Results.Single().GeneratedSources.Should().BeEmpty(); } - [Fact] - public void TwoResxWithTheSameBaseNameInDifferentFolders_GetDistinctHintNames() - { - var result = RunGenerator( - ("project/One/Strings.resx", Resx(("Alpha", "a"))), - ("project/One/Strings.Designer.cs", Designer("My.App.One")), - ("project/Two/Strings.resx", Resx(("Beta", "b"))), - ("project/Two/Strings.Designer.cs", Designer("My.App.Two")) - ); - - var sources = result.Results.Single().GeneratedSources; - - sources.Should().HaveCount(2); - sources.Select(s => s.HintName).Should().OnlyHaveUniqueItems(); - sources.Select(s => s.HintName).Should().ContainSingle(h => h.StartsWith("My.App.One.StringsKeys.")); - sources.Select(s => s.HintName).Should().ContainSingle(h => h.StartsWith("My.App.Two.StringsKeys.")); - } - - [Fact] - public void TwoResxWithTheSameBaseNameAndTheSameNamespace_GetDistinctHintNames_AndMergeAsPartials() - { - // 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)) - ); - - result.Diagnostics.Should().BeEmpty(); - - var sources = result.Results.Single().GeneratedSources; - - sources.Should().HaveCount(2); - sources.Select(s => s.HintName).Should().OnlyHaveUniqueItems(); - sources.Select(s => s.SourceText.ToString()) - .Should() - .AllSatisfy(code => code.Should().Contain("public static partial class StringsKeys")); - } - - [Fact] - public void KeyThatIsACSharpKeyword_IsEscapedWithAnAtPrefix() - { - var result = RunGenerator( - ("project/Strings.resx", Resx(("class", "a"), ("event", "b"))), - ("project/Strings.Designer.cs", Designer("My.App")) - ); - - var code = SingleGeneratedSource(result).SourceText.ToString(); - - code.Should().Contain("""@class = new("class", """); - code.Should().Contain("""@event = new("event", """); - } - - [Fact] - public void KeyEqualToTheGeneratedClassName_GetsANumericSuffix() - { - // A member must not be named like its enclosing type (CS0542), so the key "StringsKeys" - // inside the generated StringsKeys class is renamed like any other collision. - var result = RunGenerator( - ("project/Strings.resx", Resx(("StringsKeys", "a"))), - ("project/Strings.Designer.cs", Designer("My.App")) - ); - - var code = SingleGeneratedSource(result).SourceText.ToString(); - - code.Should().Contain("""StringsKeys2 = new("StringsKeys", """); - } - - [Fact] - public void ControlCharactersInKeyNames_AreEscapedInTheGeneratedLiteral() - { - // A literal newline in an XML attribute would be normalized to a space, so the resx has to - // use a character reference to smuggle one into the key name. - const String resx = - """ - - - a - - """; - - var result = RunGenerator( - ("project/Strings.resx", resx), - ("project/Strings.Designer.cs", Designer("My.App")) - ); - - var code = SingleGeneratedSource(result).SourceText.ToString(); - - code.Should().Contain("""Line_Break = new("Line\nBreak", """); - } - [Fact] public void SecondRun_WithEquivalentInputs_CachesTheOutput() { var compilation = CSharpCompilation.Create("Tests"); GeneratorDriver driver = CreateTrackingDriver(); - var resx = new InMemoryAdditionalText( - "project/Strings.resx", - Resx(("Greeting", "Hello"), ("Bye", "Goodbye")) - ); + var resx = new InMemoryAdditionalText("project/Strings.resx", Resx(("Greeting", "Hello"), ("Bye", "Goodbye"))); var designer = new InMemoryAdditionalText("project/Strings.Designer.cs", Designer("My.App")); driver = driver.AddAdditionalTexts([resx, designer]).RunGenerators(compilation); @@ -395,9 +313,10 @@ public void SecondRun_WithEquivalentInputs_CachesTheOutput() .ReplaceAdditionalText(designer, new InMemoryAdditionalText(designer.Path, designer.GetText().ToString())) .RunGenerators(compilation); - OutputReasons(driver).Should().NotBeEmpty().And.OnlyContain( - reason => reason == IncrementalStepRunReason.Cached - ); + OutputReasons(driver) + .Should() + .NotBeEmpty() + .And.OnlyContain(reason => reason == IncrementalStepRunReason.Cached); } [Fact] @@ -409,12 +328,11 @@ public void TouchingOneResx_ReRunsOnlyThatFilesOutput() var oneResx = new InMemoryAdditionalText("project/One/Strings.resx", Resx(("Alpha", "a"))); driver = driver - .AddAdditionalTexts( - [ + .AddAdditionalTexts([ oneResx, new InMemoryAdditionalText("project/Two/Strings.resx", Resx(("Beta", "b"))), new InMemoryAdditionalText("project/One/Strings.Designer.cs", Designer("My.App.One")), - new InMemoryAdditionalText("project/Two/Strings.Designer.cs", Designer("My.App.Two")) + new InMemoryAdditionalText("project/Two/Strings.Designer.cs", Designer("My.App.Two")), ]) .RunGenerators(compilation); @@ -430,9 +348,79 @@ public void TouchingOneResx_ReRunsOnlyThatFilesOutput() var reasons = OutputReasons(driver).ToList(); reasons.Should().Contain(IncrementalStepRunReason.Cached); - reasons.Should().Contain( - reason => reason == IncrementalStepRunReason.Modified || reason == IncrementalStepRunReason.New + reasons + .Should() + .Contain(reason => reason == IncrementalStepRunReason.Modified || reason == IncrementalStepRunReason.New); + } + + [Fact] + public void TwoResxWithTheSameBaseNameAndTheSameNamespace_GetDistinctHintNames_AndMergeAsPartials() + { + // 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) + ) + ); + + result.Diagnostics.Should().BeEmpty(); + + var sources = result.Results.Single().GeneratedSources; + + sources.Should().HaveCount(2); + sources.Select(s => s.HintName).Should().OnlyHaveUniqueItems(); + sources + .Select(s => s.SourceText.ToString()) + .Should() + .AllSatisfy(code => code.Should().Contain("public static partial class StringsKeys")); + } + + [Fact] + public void TwoResxWithTheSameBaseNameInDifferentFolders_GetDistinctHintNames() + { + var result = RunGenerator( + ("project/One/Strings.resx", Resx(("Alpha", "a"))), + ("project/One/Strings.Designer.cs", Designer("My.App.One")), + ("project/Two/Strings.resx", Resx(("Beta", "b"))), + ("project/Two/Strings.Designer.cs", Designer("My.App.Two")) ); + + var sources = result.Results.Single().GeneratedSources; + + sources.Should().HaveCount(2); + sources.Select(s => s.HintName).Should().OnlyHaveUniqueItems(); + sources.Select(s => s.HintName).Should().ContainSingle(h => h.StartsWith("My.App.One.StringsKeys.")); + sources.Select(s => s.HintName).Should().ContainSingle(h => h.StartsWith("My.App.Two.StringsKeys.")); + } + + [Fact] + public void WinFormsDesignerMetadataEntries_AreSkipped() + { + // A WinForms form's resx stores component properties as "$this.Text" / ">>$this.Name"; only + // the real string entry should surface as a key. + const string resx = """ + + + Form1 + $this + Hello + + """; + + var result = RunGenerator(("project/Strings.resx", resx), ("project/Strings.Designer.cs", Designer("My.App"))); + + var code = SingleGeneratedSource(result).SourceText.ToString(); + + code.Should().Contain("""Greeting = new("Greeting", """); + code.Should().NotContain("this"); } private static CSharpGeneratorDriver CreateTrackingDriver() => @@ -440,36 +428,33 @@ private static CSharpGeneratorDriver CreateTrackingDriver() => [new ResxKeysGenerator().AsSourceGenerator()], parseOptions: null, optionsProvider: null, - driverOptions: new( - IncrementalGeneratorOutputKind.None, - trackIncrementalGeneratorSteps: true - ) + driverOptions: new(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true) ); + private static string Designer(string ns) => + $$""" + namespace {{ns}} { + internal class Strings { + public static global::System.Resources.ResourceManager ResourceManager { + get { return null; } + } + } + } + """; + private static IEnumerable OutputReasons(GeneratorDriver driver) => - driver.GetRunResult().Results.Single().TrackedOutputSteps - .SelectMany(step => step.Value) + driver + .GetRunResult() + .Results.Single() + .TrackedOutputSteps.SelectMany(step => step.Value) .SelectMany(run => run.Outputs) .Select(output => output.Reason); - private static String Designer(String ns) => - $$""" - namespace {{ns}} { - internal class Strings { - public static global::System.Resources.ResourceManager ResourceManager { - get { return null; } - } - } - } - """; - - private static String Resx(params (String Name, String Value)[] entries) + private static string Resx(params (string Name, string Value)[] entries) { var builder = new StringBuilder(); - builder - .AppendLine("""""") - .AppendLine(""); + builder.AppendLine("""""").AppendLine(""); foreach (var (name, value) in entries) { @@ -484,17 +469,19 @@ 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) { - var references = ((String)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) .Split(Path.PathSeparator) .Select(static path => MetadataReference.CreateFromFile(path)); var sourceTrees = files .Where(static file => file.Path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) .Select(static file => CSharpSyntaxTree.ParseText(file.Content, path: file.Path)) - .Append(CSharpSyntaxTree.ParseText( - "namespace RentADeveloper.ResXLocalization { public readonly struct ResourceKey { public ResourceKey(string key, global::System.Resources.ResourceManager manager) { } } }" - )); + .Append( + CSharpSyntaxTree.ParseText( + "namespace RentADeveloper.ResXLocalization { public readonly struct ResourceKey { public ResourceKey(string key, global::System.Resources.ResourceManager manager) { } } }" + ) + ); var compilation = CSharpCompilation.Create( "Tests", sourceTrees, @@ -506,10 +493,10 @@ 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); + var driver = CSharpGeneratorDriver.Create(new ResxKeysGenerator()).AddAdditionalTexts(additionalTexts); driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out _); - outputCompilation.GetDiagnostics() + outputCompilation + .GetDiagnostics() .Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) .Should() .BeEmpty(); @@ -523,9 +510,9 @@ private static GeneratedSourceResult SingleGeneratedSource(GeneratorDriverRunRes return result.Results.Single().GeneratedSources.Single(); } - private sealed class InMemoryAdditionalText(String path, String content) : AdditionalText + private sealed class InMemoryAdditionalText(string path, string content) : AdditionalText { - public override String Path => path; + public override string Path => path; public override SourceText GetText(CancellationToken cancellationToken = default) => SourceText.From(content, Encoding.UTF8); diff --git a/tests/ResXLocalization.WPF.Sample.Tests/LocalizeArgsTests.cs b/tests/ResXLocalization.WPF.Sample.Tests/LocalizeArgsTests.cs index 1b11297..be9c281 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/LocalizeArgsTests.cs +++ b/tests/ResXLocalization.WPF.Sample.Tests/LocalizeArgsTests.cs @@ -10,66 +10,43 @@ namespace RentADeveloper.ResXLocalization.WPF.Sample.Tests; /// 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 OneArgument_FormatsTheResolvedString() => WpfThread.Invoke(() => + public void ArgumentChange_UpdatesTheText_Live() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); var textBlock = TestSupport.BindLocalizedText(new(ApplicationStringsKeys.PeopleInvited)); - LocalizeArgs.SetArg0(textBlock, 3); + LocalizeArgs.SetArg0(textBlock, 1); TestSupport.Flush(); + textBlock.Text.Should().Be("1 people invited"); - textBlock.Text.Should().Be("3 people invited"); - } - ); - - [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"); - } - ); + textBlock.Text.Should().Be("2 people invited"); + }); [Fact] - public void ScopedLookup_FormatsWithArguments() => WpfThread.Invoke(() => + public void BoundControlsWithArguments_AreCollected_WhenDiscarded() => + 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"); - } - ); - - [Fact] - public void ArgumentChange_UpdatesTheText_Live() => WpfThread.Invoke(() => - { - TestSupport.ResetToEnglishWithTestCatalogs(); + var references = CreateAndAbandonBoundControls(200); - var textBlock = TestSupport.BindLocalizedText(new(ApplicationStringsKeys.PeopleInvited)); - LocalizeArgs.SetArg0(textBlock, 1); - TestSupport.Flush(); - textBlock.Text.Should().Be("1 people invited"); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); - LocalizeArgs.SetArg0(textBlock, 2); - TestSupport.Flush(); - textBlock.Text.Should().Be("2 people invited"); - } - ); + references.Should().NotContain(reference => reference.IsAlive); + }); [Fact] - public void CultureChange_ReFormats_Live() => WpfThread.Invoke(() => + public void CultureChange_ReFormats_Live() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); @@ -81,38 +58,11 @@ public void CultureChange_ReFormats_Live() => WpfThread.Invoke(() => Localizer.Current.CurrentCulture = TestSupport.German; TestSupport.Flush(); textBlock.Text.Should().Be("3 Personen eingeladen"); - } - ); - - [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 HigherSlotAlone_FillsInteriorGapsWithNull() => WpfThread.Invoke(() => + public void HigherSlotAlone_FillsInteriorGapsWithNull() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); @@ -122,32 +72,18 @@ public void HigherSlotAlone_FillsInteriorGapsWithNull() => WpfThread.Invoke(() = // 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 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 InsideDataTemplate_EachItemFormatsItsOwnArguments() => WpfThread.Invoke(() => + 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 = - """ + const string xaml = """ WpfThread.Inv var texts = VisualTexts(itemsControl); texts.Should().Contain("3 people invited"); texts.Should().Contain("7 people invited"); - } - ); + }); [Fact] - public void BoundControlsWithArguments_AreCollected_WhenDiscarded() => WpfThread.Invoke(() => + public void MissingKey_ReturnsSentinelUnformatted() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); - var references = CreateAndAbandonBoundControls(200); + var textBlock = TestSupport.BindLocalizedText(new("NoSuchKeyEver")); + LocalizeArgs.SetArg0(textBlock, 1); + TestSupport.Flush(); - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); + textBlock.Text.Should().Be("!NoSuchKeyEver!"); + }); - references.Should().NotContain(reference => reference.IsAlive); - } - ); + [Fact] + public void MultipleArguments_FormatAllPlaceholders() => + WpfThread.Invoke(() => + { + TestSupport.ResetToEnglishWithTestCatalogs(); - /// The items rendered by the data-template test; each becomes one Arg0 value. - private static readonly Int32[] TemplateItems = [3, 7]; + var textBlock = TestSupport.BindLocalizedText(new("FormatPair")); + LocalizeArgs.SetArg0(textBlock, 2); + LocalizeArgs.SetArg1(textBlock, 10); + TestSupport.Flush(); + + textBlock.Text.Should().Be("File 2 of 10"); + }); - private static List CreateAndAbandonBoundControls(Int32 count) + [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++) @@ -209,16 +208,6 @@ private static List CreateAndAbandonBoundControls(Int32 count) return references; } - /// 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(); - private static IEnumerable VisualDescendants(DependencyObject root) { for (var index = 0; index < VisualTreeHelper.GetChildrenCount(root); index++) @@ -232,4 +221,14 @@ private static IEnumerable VisualDescendants(DependencyObject } } } + + /// 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 0b15116..53d10c2 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/LocalizeEnumConverterTests.cs +++ b/tests/ResXLocalization.WPF.Sample.Tests/LocalizeEnumConverterTests.cs @@ -7,53 +7,54 @@ namespace RentADeveloper.ResXLocalization.WPF.Sample.Tests; public class LocalizeEnumConverterTests { [Fact] - public void CustomPrefix_NoManager_ResolvesViaSearchAll() => WpfThread.Invoke(() => + public void CustomPrefix_NoManager_ResolvesViaSearchAll() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); var converter = new LocalizeEnumConverter { KeyPrefix = "Display_" }; Convert(converter, FileSortOrder.Ascending).Should().Be("A to Z"); - } - ); + }); [Fact] - public void CustomPrefix_WithManager_ResolvesScoped() => WpfThread.Invoke(() => + public void CustomPrefix_WithManager_ResolvesScoped() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); var converter = new LocalizeEnumConverter { KeyPrefix = "Display_", - ResourceManager = SortingStrings.ResourceManager + ResourceManager = SortingStrings.ResourceManager, }; Convert(converter, FileSortOrder.Ascending).Should().Be("A to Z"); - } - ); + }); [Fact] - public void Default_NoPrefix_NoManager_ResolvesViaSearchAll() => WpfThread.Invoke(() => + public void DefaultPrefix_WithManager_ResolvesScoped() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); - Convert(LocalizeEnumConverter.Default, FileSortOrder.Ascending).Should().Be("Ascending"); - } - ); + var converter = new LocalizeEnumConverter { ResourceManager = ApplicationStrings.ResourceManager }; + + Convert(converter, FileSortOrder.Ascending).Should().Be("Ascending"); + }); [Fact] - public void DefaultPrefix_WithManager_ResolvesScoped() => WpfThread.Invoke(() => + public void Default_NoPrefix_NoManager_ResolvesViaSearchAll() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); - var converter = new LocalizeEnumConverter { ResourceManager = ApplicationStrings.ResourceManager }; - - Convert(converter, FileSortOrder.Ascending).Should().Be("Ascending"); - } - ); + Convert(LocalizeEnumConverter.Default, FileSortOrder.Ascending).Should().Be("Ascending"); + }); [Fact] - public void MultiBinding_SwitchesLive_OnCultureChange() => WpfThread.Invoke(() => + public void MultiBinding_SwitchesLive_OnCultureChange() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); @@ -71,38 +72,40 @@ public void MultiBinding_SwitchesLive_OnCultureChange() => WpfThread.Invoke(() = Localizer.Current.CurrentCulture = TestSupport.German; TestSupport.Flush(); textBlock.Text.Should().Be("Aufsteigend"); - } - ); + }); [Fact] - public void NonEnumOrEmptyInput_ReturnsEmptyString() => WpfThread.Invoke(() => + public void NonEnumOrEmptyInput_ReturnsEmptyString() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); - Convert(LocalizeEnumConverter.Default).Should().Be(String.Empty); - LocalizeEnumConverter.Default.Convert(["not an enum"], typeof(String), null, CultureInfo.InvariantCulture) + Convert(LocalizeEnumConverter.Default).Should().Be(string.Empty); + LocalizeEnumConverter + .Default.Convert(["not an enum"], typeof(string), null, CultureInfo.InvariantCulture) .Should() - .Be(String.Empty); - } - ); + .Be(string.Empty); + }); [Fact] - public void SharedDefaultInstance_IsReadOnly() => WpfThread.Invoke(() => + public void SharedDefaultInstance_IsReadOnly() => + WpfThread.Invoke(() => { // Mutating the process-wide Default would silently reconfigure every default conversion // in the app, so it must refuse; a private instance stays fully configurable. ((Action)(() => LocalizeEnumConverter.Default.KeyPrefix = "Display_")) - .Should().Throw(); + .Should() + .Throw(); ((Action)(() => LocalizeEnumConverter.Default.ResourceManager = ApplicationStrings.ResourceManager)) - .Should().Throw(); + .Should() + .Throw(); var own = new LocalizeEnumConverter { KeyPrefix = "Display_" }; own.KeyPrefix.Should().Be("Display_"); - } - ); + }); - private static String Convert(LocalizeEnumConverter converter, params Object?[] values) => - (String)converter.Convert(values, typeof(String), null, CultureInfo.InvariantCulture); + private static string Convert(LocalizeEnumConverter converter, params object?[] values) => + (string)converter.Convert(values, typeof(string), null, CultureInfo.InvariantCulture); private sealed class Marker(FileSortOrder value) { diff --git a/tests/ResXLocalization.WPF.Sample.Tests/LocalizeEnumExtensionTests.cs b/tests/ResXLocalization.WPF.Sample.Tests/LocalizeEnumExtensionTests.cs index b7b7064..221c73b 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/LocalizeEnumExtensionTests.cs +++ b/tests/ResXLocalization.WPF.Sample.Tests/LocalizeEnumExtensionTests.cs @@ -8,7 +8,8 @@ namespace RentADeveloper.ResXLocalization.WPF.Sample.Tests; public class LocalizeEnumExtensionTests { [Fact] - public void BoundValue_SwitchesLive_OnCultureChange() => WpfThread.Invoke(() => + public void BoundValue_SwitchesLive_OnCultureChange() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); @@ -18,25 +19,22 @@ public void BoundValue_SwitchesLive_OnCultureChange() => WpfThread.Invoke(() => Localizer.Current.CurrentCulture = TestSupport.German; TestSupport.Flush(); textBlock.Text.Should().Be("Absteigend"); - } - ); + }); [Fact] - public void CustomPrefix_NoManager_ResolvesViaSearchAll() => WpfThread.Invoke(() => + public void CustomPrefix_NoManager_ResolvesViaSearchAll() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); - var textBlock = TestSupport.BindLocalizedEnum( - new() { KeyPrefix = "Display_" }, - FileSortOrder.Ascending - ); + var textBlock = TestSupport.BindLocalizedEnum(new() { KeyPrefix = "Display_" }, FileSortOrder.Ascending); textBlock.Text.Should().Be("A to Z"); - } - ); + }); [Fact] - public void CustomPrefix_WithManager_ResolvesScoped() => WpfThread.Invoke(() => + public void CustomPrefix_WithManager_ResolvesScoped() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); @@ -46,22 +44,22 @@ public void CustomPrefix_WithManager_ResolvesScoped() => WpfThread.Invoke(() => ); textBlock.Text.Should().Be("A to Z"); - } - ); + }); [Fact] - public void DefaultPrefix_NoManager_ResolvesViaSearchAll() => WpfThread.Invoke(() => + public void DefaultPrefix_NoManager_ResolvesViaSearchAll() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); var textBlock = TestSupport.BindLocalizedEnum(new(), FileSortOrder.Ascending); textBlock.Text.Should().Be("Ascending"); - } - ); + }); [Fact] - public void DefaultPrefix_WithManager_ResolvesScoped() => WpfThread.Invoke(() => + public void DefaultPrefix_WithManager_ResolvesScoped() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); @@ -71,6 +69,5 @@ public void DefaultPrefix_WithManager_ResolvesScoped() => WpfThread.Invoke(() => ); textBlock.Text.Should().Be("Ascending"); - } - ); + }); } diff --git a/tests/ResXLocalization.WPF.Sample.Tests/LocalizeExtensionTests.cs b/tests/ResXLocalization.WPF.Sample.Tests/LocalizeExtensionTests.cs index 1189956..9300dff 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/LocalizeExtensionTests.cs +++ b/tests/ResXLocalization.WPF.Sample.Tests/LocalizeExtensionTests.cs @@ -7,7 +7,8 @@ namespace RentADeveloper.ResXLocalization.WPF.Sample.Tests; public class LocalizeExtensionTests { [Fact] - public void BoundValue_SwitchesLive_OnCultureChange() => WpfThread.Invoke(() => + public void BoundValue_SwitchesLive_OnCultureChange() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); @@ -17,22 +18,22 @@ public void BoundValue_SwitchesLive_OnCultureChange() => WpfThread.Invoke(() => Localizer.Current.CurrentCulture = TestSupport.German; TestSupport.Flush(); textBlock.Text.Should().Be("Hallo und willkommen!"); - } - ); + }); [Fact] - public void KeyProperty_ResolvesViaSearchAll() => WpfThread.Invoke(() => + public void KeyProperty_ResolvesViaSearchAll() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); var textBlock = TestSupport.BindLocalizedText(new() { Key = "SortingHint" }); textBlock.Text.Should().Be("Choose how documents are ordered."); - } - ); + }); [Fact] - public void KeyProperty_WithResourceManager_ResolvesScoped() => WpfThread.Invoke(() => + public void KeyProperty_WithResourceManager_ResolvesScoped() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); @@ -41,65 +42,63 @@ public void KeyProperty_WithResourceManager_ResolvesScoped() => WpfThread.Invoke ); textBlock.Text.Should().Be("Hello and welcome!"); - } - ); + }); [Fact] - public void ResourceKey_TakesPrecedenceOver_KeyAndResourceManager() => WpfThread.Invoke(() => + public void ResourceKeyConstructor_ResolvesViaTypedKey() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); - // 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 - } - ); + var textBlock = TestSupport.BindLocalizedText(new(ApplicationStringsKeys.Greeting)); textBlock.Text.Should().Be("Hello and welcome!"); - } - ); + }); [Fact] - public void ResourceKeyConstructor_ResolvesViaTypedKey() => WpfThread.Invoke(() => + public void ResourceKeyProperty_ResolvesViaTypedKey() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); - var textBlock = TestSupport.BindLocalizedText(new(ApplicationStringsKeys.Greeting)); + var textBlock = TestSupport.BindLocalizedText(new() { ResourceKey = ApplicationStringsKeys.Greeting }); textBlock.Text.Should().Be("Hello and welcome!"); - } - ); + }); [Fact] - public void ResourceKeyProperty_ResolvesViaTypedKey() => WpfThread.Invoke(() => + public void ResourceKey_TakesPrecedenceOver_KeyAndResourceManager() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); + // ResourceKey is set, so Key and ResourceManager are ignored and the typed key wins. var textBlock = TestSupport.BindLocalizedText( - new() { ResourceKey = ApplicationStringsKeys.Greeting } + new() + { + ResourceKey = ApplicationStringsKeys.Greeting, + Key = "SortingHint", + ResourceManager = SortingStrings.ResourceManager, + } ); textBlock.Text.Should().Be("Hello and welcome!"); - } - ); + }); [Fact] - public void StringConstructor_ResolvesViaSearchAll() => WpfThread.Invoke(() => + public void StringConstructor_ResolvesViaSearchAll() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); var textBlock = TestSupport.BindLocalizedText(new("Greeting")); textBlock.Text.Should().Be("Hello and welcome!"); - } - ); + }); [Fact] - public void StringConstructor_WithResourceManager_ResolvesScoped() => WpfThread.Invoke(() => + public void StringConstructor_WithResourceManager_ResolvesScoped() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); @@ -108,6 +107,5 @@ public void StringConstructor_WithResourceManager_ResolvesScoped() => WpfThread. ); 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 1bfac9e..ae61daa 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/MainWindowLiveSwitchTests.cs +++ b/tests/ResXLocalization.WPF.Sample.Tests/MainWindowLiveSwitchTests.cs @@ -7,7 +7,8 @@ namespace RentADeveloper.ResXLocalization.WPF.Sample.Tests; public class MainWindowLiveSwitchTests { [Fact] - public void PlainStrings_SwitchLive() => WpfThread.Invoke(() => + public void PlainStrings_SwitchLive() => + WpfThread.Invoke(() => { var (window, viewModel) = CreateWindow(); @@ -22,11 +23,11 @@ public void PlainStrings_SwitchLive() => WpfThread.Invoke(() => after.Should().Contain("Hallo und willkommen!"); after.Should().Contain("Bereitgestellt von rent-a-developer"); after.Should().NotContain("Hello and welcome!"); - } - ); + }); [Fact] - public void SelectedEnum_SwitchesLive_AndNeverLeaksIntoTheModel() => WpfThread.Invoke(() => + public void SelectedEnum_SwitchesLive_AndNeverLeaksIntoTheModel() => + WpfThread.Invoke(() => { var (window, viewModel) = CreateWindow(); viewModel.SelectedFileSortOrder.Should().Be(FileSortOrder.Ascending); @@ -41,11 +42,11 @@ public void SelectedEnum_SwitchesLive_AndNeverLeaksIntoTheModel() => WpfThread.I // The view model still holds a clean enum value; only the rendered text was localized. viewModel.SelectedFileSortOrder.Should().Be(FileSortOrder.Ascending); - } - ); + }); [Fact] - public void SwitchingBackAndForth_IsStable() => WpfThread.Invoke(() => + public void SwitchingBackAndForth_IsStable() => + WpfThread.Invoke(() => { var (window, viewModel) = CreateWindow(); @@ -56,8 +57,7 @@ public void SwitchingBackAndForth_IsStable() => WpfThread.Invoke(() => viewModel.SelectedLanguage = TestSupport.English; TestSupport.Flush(); TestSupport.AllVisibleText(window).Should().Contain("Hello and welcome!"); - } - ); + }); [System.Diagnostics.CodeAnalysis.SuppressMessage( "Reliability", diff --git a/tests/ResXLocalization.WPF.Sample.Tests/MainWindowViewModelTests.cs b/tests/ResXLocalization.WPF.Sample.Tests/MainWindowViewModelTests.cs index 4130e43..b42bb30 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/MainWindowViewModelTests.cs +++ b/tests/ResXLocalization.WPF.Sample.Tests/MainWindowViewModelTests.cs @@ -8,7 +8,8 @@ namespace RentADeveloper.ResXLocalization.WPF.Sample.Tests; public class MainWindowViewModelTests { [Fact] - public void Construction_ExposesEnglishValues() => WpfThread.Invoke(() => + public void Construction_ExposesEnglishValues() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); @@ -25,17 +26,17 @@ public void Construction_ExposesEnglishValues() => WpfThread.Invoke(() => viewModel.SortOrderViaSearchAllCustomPrefix.Should().Be("A to Z"); viewModel.SortOrderViaScopedDefaultPrefix.Should().Be("Ascending"); viewModel.SortOrderViaScopedCustomPrefix.Should().Be("A to Z"); - } - ); + }); [Fact] - public void SelectingLanguage_RecomputesAndNotifies_ReadOutProperties() => WpfThread.Invoke(() => + public void SelectingLanguage_RecomputesAndNotifies_ReadOutProperties() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); using var viewModel = new MainWindowViewModel(Localizer.Current); - var changed = new List(); - viewModel.PropertyChanged += (_, args) => changed.Add(args.PropertyName ?? String.Empty); + var changed = new List(); + viewModel.PropertyChanged += (_, args) => changed.Add(args.PropertyName ?? string.Empty); viewModel.SelectedLanguage = TestSupport.German; @@ -46,11 +47,11 @@ public void SelectingLanguage_RecomputesAndNotifies_ReadOutProperties() => WpfTh changed.Should().Contain(nameof(MainWindowViewModel.GreetingViaSearchAll)); changed.Should().Contain(nameof(MainWindowViewModel.SortOrderViaSearchAllDefaultPrefix)); changed.Should().Contain(nameof(MainWindowViewModel.PoweredByViaTypedKey)); - } - ); + }); [Fact] - public void SelectingLanguage_SwitchesTheAmbientCulture() => WpfThread.Invoke(() => + public void SelectingLanguage_SwitchesTheAmbientCulture() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); @@ -58,17 +59,17 @@ public void SelectingLanguage_SwitchesTheAmbientCulture() => WpfThread.Invoke(() viewModel.SelectedLanguage = TestSupport.German; Localizer.Current.CurrentCulture.Name.Should().Be("de"); - } - ); + }); [Fact] - public void SelectingSortOrder_RecomputesAndNotifies_EnumReadOuts() => WpfThread.Invoke(() => + public void SelectingSortOrder_RecomputesAndNotifies_EnumReadOuts() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); using var viewModel = new MainWindowViewModel(Localizer.Current); - var changed = new List(); - viewModel.PropertyChanged += (_, args) => changed.Add(args.PropertyName ?? String.Empty); + var changed = new List(); + viewModel.PropertyChanged += (_, args) => changed.Add(args.PropertyName ?? string.Empty); viewModel.SelectedFileSortOrder = FileSortOrder.Descending; @@ -76,17 +77,16 @@ public void SelectingSortOrder_RecomputesAndNotifies_EnumReadOuts() => WpfThread viewModel.SortOrderViaSearchAllCustomPrefix.Should().Be("Z to A"); changed.Should().Contain(nameof(MainWindowViewModel.SortOrderViaSearchAllDefaultPrefix)); changed.Should().Contain(nameof(MainWindowViewModel.SortOrderViaScopedCustomPrefix)); - } - ); + }); [Fact] - public void SupportedLanguages_AndSortOrders_AreExposedForTheComboBoxes() => WpfThread.Invoke(() => + public void SupportedLanguages_AndSortOrders_AreExposedForTheComboBoxes() => + WpfThread.Invoke(() => { TestSupport.ResetToEnglishWithTestCatalogs(); using var viewModel = new MainWindowViewModel(Localizer.Current); viewModel.SupportedLanguages.Select(culture => culture.Name).Should().Equal("en", "de"); viewModel.FileSortOrders.Should().Equal(Enum.GetValues()); - } - ); + }); } diff --git a/tests/ResXLocalization.WPF.Sample.Tests/MemoryLeakTests.cs b/tests/ResXLocalization.WPF.Sample.Tests/MemoryLeakTests.cs index d170b26..b914e1f 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/MemoryLeakTests.cs +++ b/tests/ResXLocalization.WPF.Sample.Tests/MemoryLeakTests.cs @@ -9,7 +9,8 @@ namespace RentADeveloper.ResXLocalization.WPF.Sample.Tests; public class MemoryLeakTests { [Fact] - public void BoundControls_AreCollected_WhenDiscarded() => WpfThread.Invoke(static () => + public void BoundControls_AreCollected_WhenDiscarded() => + WpfThread.Invoke(static () => { TestSupport.ResetToEnglishWithTestCatalogs(); @@ -24,11 +25,11 @@ public void BoundControls_AreCollected_WhenDiscarded() => WpfThread.Invoke(stati GC.Collect(); references.Should().NotContain(static reference => reference.IsAlive); - } - ); + }); [Fact] - public void DisposedViewModel_IsCollected_WhileAmbientLocalizerLives() => WpfThread.Invoke(static () => + public void DisposedViewModel_IsCollected_WhileAmbientLocalizerLives() => + WpfThread.Invoke(static () => { TestSupport.ResetToEnglishWithTestCatalogs(); var reference = CreateAndDisposeViewModel(); @@ -39,18 +40,9 @@ public void DisposedViewModel_IsCollected_WhileAmbientLocalizerLives() => WpfThr reference.IsAlive.Should().BeFalse(); Localizer.Current.Should().NotBeNull(); - } - ); - - private static WeakReference CreateAndDisposeViewModel() - { - var viewModel = new MainWindowViewModel(Localizer.Current); - var reference = new WeakReference(viewModel); - viewModel.Dispose(); - return reference; - } + }); - private static List CreateAndAbandonBoundControls(Int32 count) + private static List CreateAndAbandonBoundControls(int count) { var references = new List(count); for (var index = 0; index < count; index++) @@ -61,4 +53,12 @@ private static List CreateAndAbandonBoundControls(Int32 count) return references; } + + private static WeakReference CreateAndDisposeViewModel() + { + var viewModel = new MainWindowViewModel(Localizer.Current); + var reference = new WeakReference(viewModel); + viewModel.Dispose(); + return reference; + } } diff --git a/tests/ResXLocalization.WPF.Sample.Tests/TestSupport.cs b/tests/ResXLocalization.WPF.Sample.Tests/TestSupport.cs index 66f6e65..e1fd0c9 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/TestSupport.cs +++ b/tests/ResXLocalization.WPF.Sample.Tests/TestSupport.cs @@ -7,13 +7,31 @@ namespace RentADeveloper.ResXLocalization.WPF.Sample.Tests; /// internal static class TestSupport { + /// 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", + typeof(TestSupport).Assembly + ); + /// Collects the non-empty text of every descendant in the logical tree. /// The root of the logical tree to inspect. /// The non-empty text values in logical-tree traversal order. - public static String[] AllVisibleText(DependencyObject root) => + public static string[] AllVisibleText(DependencyObject root) => LogicalDescendants(root) .OfType() - .Select(textBlock => textBlock.Text ?? String.Empty) + .Select(textBlock => textBlock.Text ?? string.Empty) .Where(text => text.Length > 0) .ToArray(); @@ -74,12 +92,6 @@ public static void ResetToEnglishWithTestCatalogs() Localizer.Current.RegisterResourceManager(FallbackResources); } - /// 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"); - private static IEnumerable LogicalDescendants(DependencyObject root) { foreach (var child in LogicalTreeHelper.GetChildren(root).OfType()) @@ -93,18 +105,10 @@ private static IEnumerable LogicalDescendants(DependencyObject } } - /// 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", typeof(TestSupport).Assembly); - private sealed class EmptyServiceProvider : IServiceProvider { - public Object? GetService(Type serviceType) => null; - public static readonly EmptyServiceProvider Instance = new(); + + public object? GetService(Type serviceType) => null; } } diff --git a/tests/ResXLocalization.WPF.Sample.Tests/WpfThread.cs b/tests/ResXLocalization.WPF.Sample.Tests/WpfThread.cs index b3794d1..0c05341 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/WpfThread.cs +++ b/tests/ResXLocalization.WPF.Sample.Tests/WpfThread.cs @@ -8,10 +8,11 @@ namespace RentADeveloper.ResXLocalization.WPF.Sample.Tests; /// internal static class WpfThread { + private static readonly Dispatcher SharedDispatcher = StartDispatcher(); + /// Runs the supplied action synchronously on the shared WPF dispatcher thread. /// The test body to execute on the WPF thread. - public static void Invoke(Action action) => - SharedDispatcher.Invoke(action); + public static void Invoke(Action action) => SharedDispatcher.Invoke(action); private static Dispatcher StartDispatcher() { @@ -19,31 +20,30 @@ private static Dispatcher StartDispatcher() Dispatcher? dispatcher = null; var thread = new Thread(() => + { + try { - try - { - // One Application for the whole test run registers the WPF pack:// scheme. The sample's own - // pack URIs are assembly-qualified (…;component/…), so they resolve without touching - // Application.ResourceAssembly (which the test host has already set and forbids changing). - if (Application.Current is null) - { - _ = new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown }; - } - - dispatcher = Dispatcher.CurrentDispatcher; - } - finally + // One Application for the whole test run registers the WPF pack:// scheme. The sample's own + // pack URIs are assembly-qualified (…;component/…), so they resolve without touching + // Application.ResourceAssembly (which the test host has already set and forbids changing). + if (Application.Current is null) { - // Always release the starter, even on failure, so the main thread never deadlocks waiting. - ready.Set(); + _ = new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown }; } - Dispatcher.Run(); + dispatcher = Dispatcher.CurrentDispatcher; } - ) + finally + { + // Always release the starter, even on failure, so the main thread never deadlocks waiting. + ready.Set(); + } + + Dispatcher.Run(); + }) { IsBackground = true, - Name = "WpfTestThread" + Name = "WpfTestThread", }; thread.SetApartmentState(ApartmentState.STA); @@ -52,6 +52,4 @@ private static Dispatcher StartDispatcher() return dispatcher!; } - - private static readonly Dispatcher SharedDispatcher = StartDispatcher(); } diff --git a/tests/package-consumption/CombinedConsumer/Program.cs b/tests/package-consumption/CombinedConsumer/Program.cs index 1d1bd2b..0dc710b 100644 --- a/tests/package-consumption/CombinedConsumer/Program.cs +++ b/tests/package-consumption/CombinedConsumer/Program.cs @@ -2,7 +2,8 @@ _ = new RentADeveloper.ResXLocalization.Avalonia.LocalizeExtension("Probe"); _ = new RentADeveloper.ResXLocalization.WPF.LocalizeExtension("Probe"); -var coreAssemblies = AppDomain.CurrentDomain.GetAssemblies() +var coreAssemblies = AppDomain + .CurrentDomain.GetAssemblies() .Where(static assembly => assembly.GetName().Name == "ResXLocalization.Core") .ToArray(); diff --git a/tests/package-consumption/WpfConsumer/Program.cs b/tests/package-consumption/WpfConsumer/Program.cs index c44b7a4..73f68d5 100644 --- a/tests/package-consumption/WpfConsumer/Program.cs +++ b/tests/package-consumption/WpfConsumer/Program.cs @@ -30,7 +30,11 @@ void Check(String description, String actual, String expected) Localizer.Current.RegisterResourceManager(Strings.ResourceManager); Localizer.Current.CurrentCulture = new CultureInfo("en"); - Check("typed key (generated StringsKeys)", Localizer.Current.Get(StringsKeys.Greeting), "Hello from the package!"); + Check( + "typed key (generated StringsKeys)", + Localizer.Current.Get(StringsKeys.Greeting), + "Hello from the package!" + ); Check("search-all lookup", Localizer.Current.Get("Greeting"), "Hello from the package!"); var localizedView = new LocalizedView(); From 35fd5aa54aa4a0d569ee7b3b7391923b5581faeb Mon Sep 17 00:00:00 2001 From: David Liebeherr Date: Mon, 7 Sep 2026 19:12:34 +0200 Subject: [PATCH 2/6] style: reformat the XAML with XamlStyler Tool-produced and nothing else. The three reordering transformations are off in Settings.XamlStyler, because each of them changes what the user sees rather than how the markup is laid out, so no element, setter or attached property moved. XamlStyler writes the host operating system's newline and cannot be told otherwise, so scripts/tidy-code.ps1 normalizes the files it processed back to LF afterwards. It also writes a UTF-8 byte order mark, which is why these two files now carry one like the four the repository already had. Run it yourself with: pwsh -File scripts/tidy-code.ps1 -Scope all Co-Authored-By: Claude Opus 5 --- .../AvaloniaConsumer/LocalizedView.axaml | 6 +++--- tests/package-consumption/WpfConsumer/LocalizedView.xaml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/package-consumption/AvaloniaConsumer/LocalizedView.axaml b/tests/package-consumption/AvaloniaConsumer/LocalizedView.axaml index b1019bb..ff44856 100644 --- a/tests/package-consumption/AvaloniaConsumer/LocalizedView.axaml +++ b/tests/package-consumption/AvaloniaConsumer/LocalizedView.axaml @@ -1,7 +1,7 @@ - + xmlns:resources="using:AvaloniaConsumer.Resources"> diff --git a/tests/package-consumption/WpfConsumer/LocalizedView.xaml b/tests/package-consumption/WpfConsumer/LocalizedView.xaml index 58cbc3d..5f0b1de 100644 --- a/tests/package-consumption/WpfConsumer/LocalizedView.xaml +++ b/tests/package-consumption/WpfConsumer/LocalizedView.xaml @@ -1,4 +1,4 @@ - Date: Mon, 7 Sep 2026 19:12:41 +0200 Subject: [PATCH 3/6] style: re-indent the structured configuration to two spaces Tool-produced and nothing else, by docs/../tools/Reindent-Xml.py in the harmonization evidence: it replaces the leading whitespace of a line and cannot change a non-whitespace byte. `git diff -w` against the parent commit is empty. Nothing in the tidy pipeline does this. CSharpier is kept away from XML by .csharpierignore, dotnet format does not touch XML whitespace, and the ReSharper profile only reorders C# members - so the .editorconfig rule is applied here, once, and reviewed as its own change. Co-Authored-By: Claude Opus 5 --- build/ResXLocalization.Packaging.targets | 50 +++++++++---------- build/ResXLocalization.Resx.targets | 50 +++++++++---------- ...XLocalization.Avalonia.Sample.Tests.csproj | 22 ++++---- .../ResXLocalization.Core.Tests.csproj | 14 +++--- .../ResXLocalization.WPF.Sample.Tests.csproj | 14 +++--- .../AvaloniaConsumer/AvaloniaConsumer.csproj | 30 +++++------ .../CombinedConsumer/CombinedConsumer.csproj | 24 ++++----- .../package-consumption/Directory.Build.props | 10 ++-- .../Directory.Build.targets | 2 +- .../Directory.Packages.props | 10 ++-- .../WpfConsumer/WpfConsumer.csproj | 34 ++++++------- 11 files changed, 130 insertions(+), 130 deletions(-) diff --git a/build/ResXLocalization.Packaging.targets b/build/ResXLocalization.Packaging.targets index 5585fb7..ae89700 100644 --- a/build/ResXLocalization.Packaging.targets +++ b/build/ResXLocalization.Packaging.targets @@ -1,33 +1,33 @@ - + - - - - + + + + + + + + $(TargetsForTfmSpecificContentInPackage);_AddAnalyzersToPackage + + + - - $(TargetsForTfmSpecificContentInPackage);_AddAnalyzersToPackage + <_FirstTargetFramework>$(TargetFrameworks.Split(';')[0]) - - - - - <_FirstTargetFramework>$(TargetFrameworks.Split(';')[0]) - - - - - + + + + diff --git a/build/ResXLocalization.Resx.targets b/build/ResXLocalization.Resx.targets index f99ee49..769a968 100644 --- a/build/ResXLocalization.Resx.targets +++ b/build/ResXLocalization.Resx.targets @@ -1,31 +1,31 @@ - - - - - - - - + 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 + NOT receive the package's build assets). + * As a NuGet package: each UI package (ResXLocalization.Avalonia, ResXLocalization.WPF) packs this + file to buildTransitive/.targets (the file name must equal the PackageId so NuGet + auto-imports it for direct and transitive consumers). +--> + + + + + + + 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 34e5a62..305b90c 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/ResXLocalization.Avalonia.Sample.Tests.csproj +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/ResXLocalization.Avalonia.Sample.Tests.csproj @@ -1,17 +1,17 @@ - - true - $(CurrentTargetFramework) - + + true + $(CurrentTargetFramework) + - - - + + + - - - - + + + + diff --git a/tests/ResXLocalization.Core.Tests/ResXLocalization.Core.Tests.csproj b/tests/ResXLocalization.Core.Tests/ResXLocalization.Core.Tests.csproj index 385bb99..fdf88d6 100644 --- a/tests/ResXLocalization.Core.Tests/ResXLocalization.Core.Tests.csproj +++ b/tests/ResXLocalization.Core.Tests/ResXLocalization.Core.Tests.csproj @@ -1,12 +1,12 @@ - - - $(LibraryTargetFrameworks) - + + + $(LibraryTargetFrameworks) + - - - + + + 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 57a6b90..8cb4809 100644 --- a/tests/ResXLocalization.WPF.Sample.Tests/ResXLocalization.WPF.Sample.Tests.csproj +++ b/tests/ResXLocalization.WPF.Sample.Tests/ResXLocalization.WPF.Sample.Tests.csproj @@ -1,12 +1,12 @@ - - $(CurrentWindowsTargetFramework) - true - + + $(CurrentWindowsTargetFramework) + true + - - - + + + diff --git a/tests/package-consumption/AvaloniaConsumer/AvaloniaConsumer.csproj b/tests/package-consumption/AvaloniaConsumer/AvaloniaConsumer.csproj index 8f1b2e0..efc128a 100644 --- a/tests/package-consumption/AvaloniaConsumer/AvaloniaConsumer.csproj +++ b/tests/package-consumption/AvaloniaConsumer/AvaloniaConsumer.csproj @@ -1,20 +1,20 @@ - - Exe - - net8.0 - enable - enable - en;de - - 1.0.0 - + + Exe + + net8.0 + enable + enable + en;de + + 1.0.0 + - - - - + + + + diff --git a/tests/package-consumption/CombinedConsumer/CombinedConsumer.csproj b/tests/package-consumption/CombinedConsumer/CombinedConsumer.csproj index 23436fd..82f1344 100644 --- a/tests/package-consumption/CombinedConsumer/CombinedConsumer.csproj +++ b/tests/package-consumption/CombinedConsumer/CombinedConsumer.csproj @@ -1,17 +1,17 @@ - - Exe - net8.0-windows - true - enable - enable - 1.0.0 - + + Exe + net8.0-windows + true + enable + enable + 1.0.0 + - - - - + + + + diff --git a/tests/package-consumption/Directory.Build.props b/tests/package-consumption/Directory.Build.props index 1f54307..bea0c32 100644 --- a/tests/package-consumption/Directory.Build.props +++ b/tests/package-consumption/Directory.Build.props @@ -1,7 +1,7 @@ - + diff --git a/tests/package-consumption/Directory.Build.targets b/tests/package-consumption/Directory.Build.targets index 4bd4546..68b6268 100644 --- a/tests/package-consumption/Directory.Build.targets +++ b/tests/package-consumption/Directory.Build.targets @@ -1,3 +1,3 @@ - + diff --git a/tests/package-consumption/Directory.Packages.props b/tests/package-consumption/Directory.Packages.props index 569b774..95d1a82 100644 --- a/tests/package-consumption/Directory.Packages.props +++ b/tests/package-consumption/Directory.Packages.props @@ -1,7 +1,7 @@ - - - false - + + + false + diff --git a/tests/package-consumption/WpfConsumer/WpfConsumer.csproj b/tests/package-consumption/WpfConsumer/WpfConsumer.csproj index 1176805..8000d0a 100644 --- a/tests/package-consumption/WpfConsumer/WpfConsumer.csproj +++ b/tests/package-consumption/WpfConsumer/WpfConsumer.csproj @@ -1,22 +1,22 @@ - - Exe - - net8.0-windows - true - enable - enable - en;de - - 1.0.0 - + + Exe + + net8.0-windows + true + enable + enable + en;de + + 1.0.0 + - - - - + + + + From 777a7eaa0c4fe7220b08ddfdcaf28537c537da1d Mon Sep 17 00:00:00 2001 From: David Liebeherr Date: Mon, 7 Sep 2026 19:25:04 +0200 Subject: [PATCH 4/6] build: standardize repository tooling, checks and documentation Split the build settings into repository-root, src/ and tests/ layers; move the files every package carries into the root targets; give each shipping project its own Description and PackageTags; add dependency auditing and a package validation baseline; and state the two shipping compatibility floors centrally, per project, instead of as a VersionOverride in each project file. Pin the tool versions, replace StyleCop.Analyzers with NewStyleCop.Analyzers, add CSharpier in check mode and SonarAnalyzer, and rewrite .editorconfig so each tool owns exactly one concern. The analyzer findings were triaged one by one before the error gate went back on; nothing is blanket-suppressed. Give the repository predictable script entry points: pre-commit-gate.ps1 per commit and pre-release-gate.ps1 before a push, with tidy-code.ps1, the public API scripts, the line-ending check and the Native AOT gate underneath them. Every check reports by default and writes only when asked; -Check never touches the working tree or the git index. Turn the packed-package Native AOT check into a real gate. AvaloniaConsumer now targets both of the packages' frameworks and asserts typed, scoped and search-all lookups, enum localization, dynamic format arguments and culture fallback - before and after a culture change, and after an argument change. The gate publishes it natively, requires zero IL2xxx/IL3xxx diagnostics, and runs the binary; a file that exists is no longer treated as a pass. Align CI with the local scripts, give the test reports per-project and per-framework directories, add three native legs, and correct the minimum-SDK consumer job. Move Dependabot to quarterly grouped updates with conventional commit prefixes. Shorten the README and move the long material into docs/guides and docs/reference; move the DocFX configuration to build/docfx and its output under artifacts, keeping the published API URLs. State one commit and branch policy, and add the AI integration: a canonical .agents/ tree with two read-only reviewers and an explicit-invocation commit skill, plus thin Claude and Codex wrappers and scoped PostToolUse hooks. The public API, the resource values, the package contracts and the shipping dependency floors are unchanged. No functionality changed, so there is no changelog entry. Co-Authored-By: Claude Opus 5 --- .agents/README.md | 106 ++ .agents/references/code-style.md | 113 +++ .../references/reviews/aot-package-compat.md | 91 ++ .agents/references/reviews/ui-parity.md | 70 ++ .agents/skills/commit/SKILL.md | 87 ++ .agents/skills/commit/agents/openai.yaml | 7 + .claude/agents/aot-package-compat-reviewer.md | 18 + .claude/agents/ui-parity-reviewer.md | 16 + .claude/hooks/public-api-guard.ps1 | 57 ++ .claude/hooks/tidy-code.ps1 | 129 +++ .claude/settings.json | 22 + .claude/skills/commit/SKILL.md | 9 + .../agents/aot-package-compat-reviewer.toml | 6 + .codex/agents/ui-parity-reviewer.toml | 6 + .codex/hooks.json | 24 + .codex/hooks/public-api-guard.ps1 | 132 +++ .codex/hooks/tidy-code.ps1 | 207 ++++ .config/dotnet-tools.json | 16 +- .csharpierignore | 22 + .editorconfig | 371 +++++-- .github/ISSUE_TEMPLATE/bug_report.yml | 42 +- .github/PULL_REQUEST_TEMPLATE.md | 48 +- .github/dependabot.yml | 76 +- .github/workflows/ci.yml | 200 ++-- .gitignore | 47 +- AGENTS.md | 165 +++- CHANGELOG.md | 2 + CLAUDE.md | 18 +- CONTRIBUTING.md | 293 ++++-- Directory.Build.props | 179 ++-- Directory.Build.targets | 39 +- Directory.Packages.props | 116 ++- PACKAGE_README.md | 109 +-- README.md | 252 +---- ResXLocalization.sln.DotSettings | 5 - ResXLocalization.slnx.DotSettings | 239 +++++ Settings.XamlStyler | 6 +- build/docfx/docfx.json | 69 ++ docs/docfx.json | 48 - docs/guides/enums.md | 85 ++ docs/guides/format-arguments.md | 48 + docs/guides/lookup-and-fallback.md | 109 +++ docs/guides/native-aot.md | 69 ++ docs/index.md | 54 +- docs/reference/generated-keys.md | 64 ++ docs/toc.yml | 28 +- global.json | 3 +- .../ResXLocalization.Avalonia.Sample.csproj | 194 ++-- .../ResXLocalization.WPF.Sample.csproj | 180 ++-- scripts/clean-build-artifacts.ps1 | 131 ++- scripts/extract-release-notes.ps1 | 43 +- scripts/pre-commit-gate.ps1 | 329 +++++++ scripts/pre-release-gate.ps1 | 538 +++++++++++ scripts/public-api-guard.ps1 | 92 ++ scripts/tidy-code.ps1 | 912 +++++++++++++++--- scripts/update-public-api.ps1 | 177 ++++ scripts/verify-line-endings.ps1 | 118 +++ scripts/verify-package-aot.ps1 | 504 ++++++++++ src/Directory.Build.props | 114 +++ .../ResXLocalization.Avalonia.csproj | 70 +- .../ResXLocalization.Core.csproj | 41 +- .../ResXLocalization.SourceGenerators.csproj | 73 +- .../ResXLocalization.WPF.csproj | 51 +- stylecop.json | 27 +- tests/Directory.Build.props | 48 + .../TestAppBuilder.cs | 6 + .../AmbientLocalizerGroup.cs | 3 + .../LocalizerContractTests.cs | 2 +- .../LocalizerFormatTests.cs | 2 +- ...Localization.SourceGenerators.Tests.csproj | 27 +- .../AvaloniaConsumer/AvaloniaConsumer.csproj | 6 +- .../AvaloniaConsumer/ConsumerSortOrder.cs | 15 + .../AvaloniaConsumer/LocalizedView.axaml | 35 +- .../AvaloniaConsumer/LocalizedView.axaml.cs | 14 +- .../AvaloniaConsumer/Program.cs | 96 +- .../Resources/Catalog.Designer.cs | 20 + .../Resources/Catalog.de.resx | 18 + .../AvaloniaConsumer/Resources/Catalog.resx | 21 + .../Resources/Strings.de.resx | 9 + .../AvaloniaConsumer/Resources/Strings.resx | 12 + tests/package-consumption/nuget.config | 18 +- 81 files changed, 6562 insertions(+), 1306 deletions(-) create mode 100644 .agents/README.md create mode 100644 .agents/references/code-style.md create mode 100644 .agents/references/reviews/aot-package-compat.md create mode 100644 .agents/references/reviews/ui-parity.md create mode 100644 .agents/skills/commit/SKILL.md create mode 100644 .agents/skills/commit/agents/openai.yaml create mode 100644 .claude/agents/aot-package-compat-reviewer.md create mode 100644 .claude/agents/ui-parity-reviewer.md create mode 100644 .claude/hooks/public-api-guard.ps1 create mode 100644 .claude/hooks/tidy-code.ps1 create mode 100644 .claude/settings.json create mode 100644 .claude/skills/commit/SKILL.md create mode 100644 .codex/agents/aot-package-compat-reviewer.toml create mode 100644 .codex/agents/ui-parity-reviewer.toml create mode 100644 .codex/hooks.json create mode 100644 .codex/hooks/public-api-guard.ps1 create mode 100644 .codex/hooks/tidy-code.ps1 create mode 100644 .csharpierignore delete mode 100644 ResXLocalization.sln.DotSettings create mode 100644 ResXLocalization.slnx.DotSettings create mode 100644 build/docfx/docfx.json delete mode 100644 docs/docfx.json create mode 100644 docs/guides/enums.md create mode 100644 docs/guides/format-arguments.md create mode 100644 docs/guides/lookup-and-fallback.md create mode 100644 docs/guides/native-aot.md create mode 100644 docs/reference/generated-keys.md create mode 100644 scripts/pre-commit-gate.ps1 create mode 100644 scripts/pre-release-gate.ps1 create mode 100644 scripts/public-api-guard.ps1 create mode 100644 scripts/update-public-api.ps1 create mode 100644 scripts/verify-line-endings.ps1 create mode 100644 scripts/verify-package-aot.ps1 create mode 100644 src/Directory.Build.props create mode 100644 tests/Directory.Build.props create mode 100644 tests/package-consumption/AvaloniaConsumer/ConsumerSortOrder.cs create mode 100644 tests/package-consumption/AvaloniaConsumer/Resources/Catalog.Designer.cs create mode 100644 tests/package-consumption/AvaloniaConsumer/Resources/Catalog.de.resx create mode 100644 tests/package-consumption/AvaloniaConsumer/Resources/Catalog.resx diff --git a/.agents/README.md b/.agents/README.md new file mode 100644 index 0000000..0e30d94 --- /dev/null +++ b/.agents/README.md @@ -0,0 +1,106 @@ +# AI-agent integration + +Reusable AI-agent instructions have one canonical location: + +```text +AGENTS.md Repository guidance +.agents/skills/*/SKILL.md Workflow procedures +.agents/references/*.md Reference material AGENTS.md links to +.agents/references/reviews/*.md Review checklists +scripts/*.ps1 Executable checks and workflows +``` + +Tool-specific directories contain only discovery metadata or protocol adapters: + +```text +.claude/skills/*/SKILL.md Claude skill metadata plus a shared-procedure reference +.claude/agents/*.md Claude agent metadata plus a shared-checklist reference +.claude/settings.json Claude hook wiring +.claude/hooks/*.ps1 Claude hook protocol adapters +.codex/agents/*.toml Codex agent metadata plus a shared-checklist reference +.codex/hooks.json Codex hook wiring +.codex/hooks/*.ps1 Codex hook protocol adapters +``` + +Codex discovers the canonical skills directly from `.agents/skills/`. Their `agents/openai.yaml` files +contain Codex-only interface metadata and the explicit-invocation policy. Claude needs a thin skill wrapper +because its `disable-model-invocation` policy lives in `SKILL.md` frontmatter. + +The hook adapters differ because Claude and Codex use different payload and response contracts. Both delegate +all substantive behavior to the same scripts: `scripts/tidy-code.ps1` and `scripts/public-api-guard.ps1`. + +## The reviewers + +| Agent | Invoke it when | Checklist | +| --- | --- | --- | +| `aot_package_compat_reviewer` | resource lookup, culture fallback, satellite discovery, the generator's output, or the analyzer and `buildTransitive` wiring changes | [`reviews/aot-package-compat.md`](references/reviews/aot-package-compat.md) | +| `ui_parity_reviewer` | one UI package changes, or one of their samples or test suites | [`reviews/ui-parity.md`](references/reviews/ui-parity.md) | + +Invoke a reviewer only when its scope applies; there is nothing to be gained from a parity review of a +change to the source generator. + +Both are **read-only by construction**. The Claude definitions grant `Read`, `Grep` and `Glob` and nothing +else; the Codex definitions set `sandbox_mode = "read-only"`. Neither has a shell, and that is deliberate: a +reviewer that can run a shell can also write a file, and "please do not edit" is not a sandbox. Where a +checklist needs a build, a pack or the Native AOT gate, the reviewer says so and the caller runs it. + +Neither definition pins a model or a reasoning effort. Which model reviews is the operator's choice, and a +pinned one goes stale in a file nobody re-reads. + +## What the hooks do, and what they will not do + +Both `PostToolUse` hooks are **scoped to the file the triggering edit touched**. Claude reports it as +`tool_input.file_path`; Codex reports an `apply_patch` whose patch text is in `tool_input.command`, so the +adapter reads the `*** Add File:`, `*** Update File:` and `*** Move to:` headers out of it and skips +`*** Delete File:`. A rename arrives as an Update of the old name and a Move to the new one; the old name no +longer exists, so it drops out. + +These rules follow, and they are the point of the design: + +- **No fallback.** If the payload cannot be parsed, the hook formats nothing and says so in one line. It does + not fall back to "every file git reports as changed" — that would rewrite work in progress that this edit + never touched. +- **No path outside the repository.** Every path is resolved and then checked to be under the repository + root, so `../` and a symbolic link that points out of the tree are both refused. `bin/` and `obj/` are + refused too; generated output is not ours to format. +- **They never fail an edit.** A `PostToolUse` failure cannot undo an edit that already happened, so both + adapters exit 0 whatever went wrong and report it as text. Neither one stages a file, changes an API + snapshot, installs a tool, or runs a style, ordering, build or test pass. + +The child scripts run in a **child `pwsh` process**. They end with `exit`, which run in-process would end the +adapter before it could emit its protocol output — and a Codex hook that writes nothing is a hook that +failed. Concurrent edits serialize on a named mutex derived from the repository path, so two formatters +cannot run over one file, and the two agents serialize against each other as well. + +### Codex needs the hooks trusted, once per clone + +Codex does not run a repository's hooks until the project is trusted. Until then **nothing fires**. Run +`/hooks` in Codex to see the hook definitions this repository declares, review them, and trust the project. +Review them again whenever `.codex/hooks.json` or anything under `.codex/hooks/` changes in a pull request: a +hook is code that runs on your machine after every edit, and "it was already trusted" is not a review. + +Never bypass project trust to make a hook fire. + +### When no hook covers the edit + +The hooks only see edits made through a tool that reports one. An edit made another way — a shell +redirection, an editor outside the agent, a `git apply`, a Codex session whose hooks are not trusted yet — is +not formatted by anything. So: + +```bash +pwsh -File scripts/tidy-code.ps1 # format what git reports as changed +pwsh -File scripts/pre-commit-gate.ps1 # and before committing, check the whole tree +``` + +`pre-commit-gate.ps1` is the backstop for all of it, and the build is the backstop for it: formatting, style +and member ordering are build errors, so an unformatted file cannot reach a green pull request whether a hook +fired or not. + +The tidy hook runs the **default scope only**, on the file the edit touched — CSharpier for C#, XamlStyler +for XAML and AXAML, under a second either way. Code style and member ordering are not run on every edit: +`dotnet format style` needs MSBuild and ReSharper loads the whole solution, and neither belongs on the +critical path of a single edit. All of them are build errors, and `scripts/pre-commit-gate.ps1` checks +`-Scope all` before a commit (`-Fix` applies it), so nothing reaches a pull request untidied. + +When changing behavior, edit the canonical file. Keep only required names, descriptions, policies, tool +settings and reference instructions in tool-specific files. diff --git a/.agents/references/code-style.md b/.agents/references/code-style.md new file mode 100644 index 0000000..19f8ec9 --- /dev/null +++ b/.agents/references/code-style.md @@ -0,0 +1,113 @@ +# Code style details + +Background for the rules in [AGENTS.md](../../AGENTS.md#code-style-formatting-and-ordering). Read this when a +tool does something you did not expect, or when you are about to write a type name in a place the build does +not check. + +## The concerns, and the tool that owns each + +| Concern | Tool | Configured in | +| --- | --- | --- | +| C# formatting — whitespace, line breaks, wrapping | **CSharpier** | `.editorconfig` (`max_line_length`, `indent_size`) | +| C# style — `var`, `=>`, `this.`, null checks, usings | **Roslyn analyzers** | `.editorconfig` | +| C# ordering — types and their members | **ReSharper** applies it, **NewStyleCop** checks *part* of it | `ResXLocalization.slnx.DotSettings` and `stylecop.json` | +| XAML and AXAML layout | **XamlStyler** | `Settings.XamlStyler` | + +The word *part* is load-bearing. StyleCop checks kind, access, constant, static and readonly — `SA1201`, +`SA1202`, `SA1203`, `SA1204`, `SA1214`. It has no notion of alphabetical order **within** one of those +groups, which the ReSharper file layout applies and nothing checks. A member that is in the right group but +the wrong place inside it compiles, passes the analyzers, and is only visible by running the pipeline and +looking at what it moves. + +Each tool owns its concern completely, and the C# ones are build errors rather than warnings, in `samples/` +and `tests/` as much as in `src/`. Two different mechanisms, both in the repository-root +`Directory.Build.props`: `EnforceCodeStyleInBuild=true` with `TreatWarningsAsErrors=true` covers style and +ordering, and the `CSharpier.MsBuild` package covers formatting. It runs in check mode, so a build never +rewrites your files — an unformatted file fails the build and names itself. `-p:CSharpier_Bypass=true` skips +it. + +Run them all with one command: + +```powershell +pwsh -File scripts/tidy-code.ps1 -Scope all +``` + +## Where the build misses a BCL type name + +The rule is: write `string`, `object?`, `int`, `bool`, `nint`, `nuint` — never `String`, `Object?`, `Int32`, +`Boolean`, `IntPtr`, `UIntPtr`. `dotnet_style_predefined_type_for_*` is `true:error`, and `IDE0049` is the +diagnostic. + +**`IDE0049` is not reported by an ordinary build.** Measured on this repository: with +`EnforceCodeStyleInBuild=true` the build is green while +`dotnet format style --diagnostics IDE0049 --severity error` reports every occurrence. So the style pipeline +is what enforces this rule, not the compiler — which is why CI runs `tidy-code.ps1 -Check` rather than +relying on the build alone, and why an editor that only builds will not tell you. + +Two further blind spots, both in the analyzer itself: + +- **`nint`/`nuint` are not on its list.** They arrived in C# 9 as their own feature and only became aliases + for `IntPtr`/`UIntPtr` in C# 11; the analyzer was never extended. `IntPtr` is invisible to it. +- **It never looks inside `nameof(...)`.** With good reason: `nameof(int)` does not compile at all, so a + blanket skip is the safe choice. Where `nameof` names a CLR type on purpose, leave it and say why in a + comment. + +A third gap is not the analyzer's fault: **`tests/package-consumption/` is outside the solution**, and its +deliberately empty `Directory.Build.props` gives it no style gate at all. CSharpier and XamlStyler still +format those files — neither needs a project — but the style rules there are on the author. + +## `var`, in all three cases + +```ini +csharp_style_var_for_built_in_types = true:error +csharp_style_var_when_type_is_apparent = true:error +csharp_style_var_elsewhere = true:silent +``` + +The third one is silent on purpose. Every local declaration under `src/` already uses `var` — 69 of them, +none with an explicit type — so the preference matches what the code does; it stays silent because a +declaration whose type nothing on the line reveals is a readability judgement rather than something a build +should reject. + +## Primary constructor parameters + +A primary constructor parameter is assigned to a `private readonly` backing field, and members read +`this.field` rather than the parameter. Nothing enforces it — a parameter is not an instance member, so +`dotnet_style_qualification_for_field` cannot see it — but a captured parameter compiles to a field with no +`readonly`, and using one directly silently drops the guarantee that it cannot be reassigned. + +## Member order, in one place + +```text +constants, fields, constructors, finalizers, delegates, events, enums, interfaces, properties, indexers, +conversion operators, operators, methods, nested structs and classes +``` + +Within a group: public, internal, protected internal, protected, private protected, private; static before +instance; readonly before mutable; then alphabetical by name. + +Explicit interface implementations are the exception worth knowing. ReSharper ranks one below private, +because in C# it carries no access modifier. StyleCop counts an explicit property, indexer or method as +public and wants it first in its group — but counts an explicit **event** as private. That is why +`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`. + +## XAML + +XamlStyler owns `.xaml` and `.axaml`, and CSharpier is kept away from them by `.csharpierignore`. + +Three of its settings are switched **off**, and they are not formatting: `ReorderGridChildren` and +`ReorderCanvasChildren` change which child is drawn on top when two overlap, and `ReorderSetters` changes +which setter wins when a style sets the same property twice. Movement that changes what the user sees is not +formatting. + +XamlStyler writes the host operating system's newline and has no setting for it, so on Windows every file it +touches comes back CRLF. `scripts/tidy-code.ps1` normalizes exactly the files it processed, byte by byte, and +stages nothing. + +## XML and configuration + +Two-space indentation for `.csproj`, `.props`, `.targets`, `.slnx`, `.config`, `.xml` and `.json`. Nothing in +the tidy pipeline applies it — CSharpier is kept away by `.csharpierignore`, `dotnet format` does not touch +XML whitespace, and the ReSharper profile only reorders C# members — so it is an editor rule, and +`.editorconfig` is where it is stated. diff --git a/.agents/references/reviews/aot-package-compat.md b/.agents/references/reviews/aot-package-compat.md new file mode 100644 index 0000000..efe2875 --- /dev/null +++ b/.agents/references/reviews/aot-package-compat.md @@ -0,0 +1,91 @@ +# Native AOT, trimming and packaging compatibility review + +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. + +Read [docs/guides/native-aot.md](../../../docs/guides/native-aot.md) before reviewing. + +This is a **review**: report findings, cite file and line, do not edit files. + +## Why this checklist exists at all + +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 +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 +exists: the gate runs it and asserts exact strings for exactly this reason. + +## Blocking findings + +- **A new suppressed `IL2xxx` or `IL3xxx` anywhere in `src/`.** These diagnostics are the only + build-time proof that a reflection path survives trimming; suppressing one voids it. Restructure + instead. There are no sanctioned suppressions in this repository today — a first one is a finding + that needs the maintainer, not a reviewer. + + `[RequiresUnreferencedCode]` and `[RequiresDynamicCode]` are **not** suppressions: they propagate + 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. + +- **A new reflection call** — `Type.GetType`, `Assembly.Load`, `Activator.CreateInstance`, + `GetMethod`, `GetProperty`, `MakeGenericType` — reachable from a public entry point without a + `[DynamicallyAccessedMembers]` annotation covering exactly what it asks for. Grep for these; the + library's own lookups go through `ResourceManager`, which is annotated by the framework, and a new + 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. + +## The generator's output + +The typed key classes are the other half of the contract, and they are generated at build time rather +than published, so trimming cannot reach them — but packaging can drop them. + +- **Exactly one generator DLL per UI package**, at `analyzers/dotnet/cs`. Two copies are NU5118 at + pack time; zero means the consumer gets no typed keys and a build failure that names a missing type + rather than a missing generator. +- **`buildTransitive/.targets`** must be present in each UI package, and must be the same + `build/ResXLocalization.Resx.targets` the repository imports itself. That file is what hands each + `.resx` and `.Designer.cs` to the generator; without it the generator runs and emits nothing. +- **The generator's Roslyn floor stays at 4.8.0**, set for that project in + `Directory.Packages.props`. A generator built against a newer Roslyn does not load in an older + compiler: `CS9057`, no generated keys, and a consumer build that fails on missing types. Raising + the floor is a breaking change for every consumer on the .NET 8 or 9 SDK. +- **No generator or compiler dependency in a runtime nuspec.** The `ProjectReference` to the + generator carries `PrivateAssets="all"` and `ReferenceOutputAssembly="false"` for that reason; + losing either leaks `Microsoft.CodeAnalysis.CSharp` into the package's dependency list. + +## What the tooling cannot tell you + +- **`TrimmerSingleWarn`.** Left at its default, ILC collapses every diagnostic from an assembly into + one `IL2104` line and the individual codes never appear. A change that stops + `verify-package-aot.ps1` passing `-p:TrimmerSingleWarn=false` turns the gate into a check that + counts zero because it cannot see. +- **`SatelliteResourceLanguages`.** A consumer that does not declare it ships no satellites at all + and silently renders the neutral language. The README and the AOT guide both say so; a change that + removes that instruction is a documentation finding with a runtime consequence. +- **The package source mapping** in `tests/package-consumption/nuget.config`. It binds + `ResXLocalization.*` to the local feed. Without it, a missing local package resolves the published + package of the same version from nuget.org and the whole gate passes against the last release. + +## What to report + +For each finding: the file and line, what the diagnostic or the packaging consequence is, and which +check would have caught it. Where the answer needs a build, a pack or the AOT gate run, say so and +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 +``` + +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 new file mode 100644 index 0000000..c8c5be9 --- /dev/null +++ b/.agents/references/reviews/ui-parity.md @@ -0,0 +1,70 @@ +# Avalonia and WPF parity review + +Use this whenever `src/ResXLocalization.Avalonia` or `src/ResXLocalization.WPF` changes, or when +their samples or test suites do. + +This is a **review**: report findings, cite file and line, do not edit files. + +## The contract + +The two UI packages are deliberate mirrors. A developer who knows one is expected to be able to read +the other, and an application that uses both gets the same markup, the same property names and the +same behaviour from each. The shared engine lives in `ResXLocalization.Core` and is referenced by +both, so anything that is not framework-specific belongs there rather than in one of the mirrors. + +| 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` | + +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. + +## What to check + +1. **The public surface.** For every public type, member, property name and default value added, + removed or renamed in one package, is the mirror present in the other with the same name and the + same default? Compare `src/ResXLocalization.Avalonia/PublicAPI.Shipped.txt` and + `src/ResXLocalization.WPF/PublicAPI.Shipped.txt` together with their `Unshipped` files — the two + lists are the fastest way to see an asymmetry, and a change that updates only one of them is a + finding on its own. + +2. **The markup.** Does the same XAML work in both? A new markup-extension property that only one + package accepts means the same view cannot be shared, which is the thing this parity exists to + protect. + +3. **The behaviour, not just the signature.** A property that exists in both but resolves differently + — a different key prefix default, a different fallback, a different moment at which it re-reads + the culture — is a worse finding than a missing member, because it looks correct. + +4. **Where the code belongs.** Logic that is not framework-specific and exists in both packages belongs + in `ResXLocalization.Core`. `EnumKeyConvention` is the precedent: it is internal to Core and shared + with both UI packages through `InternalsVisibleTo`, so both build identical keys by construction + rather than by review. + +5. **The tests.** `tests/ResXLocalization.Avalonia.Sample.Tests` and + `tests/ResXLocalization.WPF.Sample.Tests` mirror each other too. A new test in one and not the + other means the mirrored behaviour is asserted once. + +6. **The samples.** Both sample applications demonstrate every feature. A feature added to one sample + and not the other leaves the two showcases claiming different things. + +7. **The documentation.** The guides under `docs/` describe both frameworks in one voice, and say + "the same XAML in Avalonia and WPF" in several places. A change that makes that untrue needs the + sentence changed, not left. + +## When the answer is "it does not apply" + +That is a legitimate outcome, and Native AOT is the standing example: WPF does not support it, so an +AOT-related property or annotation on the Avalonia side has no WPF counterpart. Say which of the +differences above explains it. An unexplained asymmetry is the finding; an explained one is a note. + +## What to report + +For each finding: the file and line in the package that changed, the file and line where the mirror +would go, and whether it is a missing member, a differing default, or a behavioural difference. If +you need the diff of the change or the result of a build, ask the caller for it. diff --git a/.agents/skills/commit/SKILL.md b/.agents/skills/commit/SKILL.md new file mode 100644 index 0000000..df3f597 --- /dev/null +++ b/.agents/skills/commit/SKILL.md @@ -0,0 +1,87 @@ +--- +name: commit +description: Review, verify, deliberately stage, and commit the current ResXLocalization repository changes. Use only when the user explicitly asks you to create a Git commit; do not use for ordinary code changes, reviews, or status checks. +--- + +# Commit + +Write a commit that matches how `main` is written, and check the repository's own release-hygiene rules first. + +**This skill commits. It never pushes, never opens a pull request, and never tags.** Those are separate acts +and the user asks for them separately. + +## 1. Look at what changed + +```bash +git status +``` + +```bash +git diff HEAD +``` + +Read the actual diff. Do not write a message from file names alone. + +## 2. Apply the CONTRIBUTING.md checklist + +Before committing, check whether the change requires companion edits and raise anything missing with the user: + +- **Public API changed?** The build already told you — an undeclared public member is `RS0016` and a vanished + one `RS0017`. Record it with `pwsh -File scripts/update-public-api.ps1` and review the + `PublicAPI.Unshipped.txt` diff; a `*REMOVED*` line is a break. +- **User-facing change?** `CHANGELOG.md` needs an entry under `## [Unreleased]`, in Keep-a-Changelog format + (`### Added` / `### Changed` / `### Fixed`). Breaking changes are written `- **BREAKING:** …`. Internal + formatting and tooling work needs no entry. +- **Interface or behaviour change?** The affected pages under `docs/` carry the examples, and the README + carries the quick start. `PACKAGE_README.md` (the NuGet package page) only needs touching if the change + makes its short overview wrong. +- **Touched one UI package?** Was it mirrored into the other? The Avalonia and WPF markup extensions and + converters are deliberate mirrors. Delegate the check to the `ui_parity_reviewer` custom agent when the + change meets that agent's scope. +- **Touched resource lookup, the generated keys, or the packaging that carries them?** + `pwsh -File scripts/verify-package-aot.ps1 -Pack` — nothing is trimmed on the just-in-time compiler, so the + test suites cannot see the damage. Delegate the review to the `aot_package_compat_reviewer` custom agent. + +⚠️ **Do not bump a version, and do not date a changelog section.** The version in the repository-root +`Directory.Build.props`, the release date, promoting `PublicAPI.Unshipped.txt` to `Shipped`, and the tag are +the maintainer's, at release time. If the change looks like it needs a release, say so — do not perform one. + +## 3. Verify it builds + +`TreatWarningsAsErrors=true` means a style slip is a build break, and CONTRIBUTING.md requires that the build +succeeds with no warnings and the tests pass. + +```bash +pwsh -File scripts/pre-commit-gate.ps1 +``` + +That runs the public-API reminder, checks line endings, style, formatting and member ordering, builds Release +and runs the test suites. It does not edit your files; if it reports the tree as untidy, run +`pwsh -File scripts/pre-commit-gate.ps1 -Fix` and review what changed before committing it. + +If it fails, report the failure and stop — do not commit over it. + +## 4. Write the message + +[Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/), with a **lowercase, imperative** +summary: + +```text +feat: add dynamic format arguments to the localize extension +fix: stop the enum converter caching the previous culture +build: standardize repository tooling +``` + +- Types in use: `feat`, `fix`, `docs`, `test`, `build`, `ci`, `chore`. +- A breaking change is `feat!:` or `fix!:` plus a `BREAKING CHANGE:` **footer** saying what breaks and what to + do about it. `BREAKING CHANGE` is a footer, never a type. +- Add a body when the *why* is not obvious from the subject. +- If the branch is `/issue--`, reference the issue number in the body. + +Stage deliberately — `git add` the relevant paths rather than `git add -A`, and confirm nothing unintended +(build output, local scratch files) is included. A `PublicAPI.*.txt` change belongs in the same commit as the +code that caused it, and a change to one UI package belongs in the same commit as its mirror in the other. + +## 5. Commit + +Commit only. Do not push and do not open a pull request unless the user asks. diff --git a/.agents/skills/commit/agents/openai.yaml b/.agents/skills/commit/agents/openai.yaml new file mode 100644 index 0000000..7278c30 --- /dev/null +++ b/.agents/skills/commit/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Commit Changes" + short_description: "Review, verify, and commit repository changes" + default_prompt: "Use $commit to review, verify, stage, and commit the current repository changes." + +policy: + allow_implicit_invocation: false diff --git a/.claude/agents/aot-package-compat-reviewer.md b/.claude/agents/aot-package-compat-reviewer.md new file mode 100644 index 0000000..7fe7625 --- /dev/null +++ b/.claude/agents/aot-package-compat-reviewer.md @@ -0,0 +1,18 @@ +--- +name: aot-package-compat-reviewer +description: Reviews changes for Native AOT, trimming and packaging regressions. Use whenever code touches resource lookup, culture fallback, satellite discovery, the source generator's output, or the analyzer and buildTransitive wiring the packages carry. +tools: Read, Grep, Glob +--- + +# Native AOT and packaging compatibility reviewer + +Read [.agents/references/reviews/aot-package-compat.md](../../.agents/references/reviews/aot-package-compat.md) +in full before doing anything, then follow it exactly. + +This is a **review**: report findings, cite file and line for each, and change nothing. The tools above are +read-only by construction — there is no Edit, no Write, and no Bash, because a reviewer that can run a shell +can also write a file, and "please do not edit" is not a sandbox. + +The checklist's text searches are Grep searches. Where it names a build, a pack or the Native AOT gate as the +way to measure something, report that it needs running and let the caller run it — a reviewer states what it +found, not what it fixed. diff --git a/.claude/agents/ui-parity-reviewer.md b/.claude/agents/ui-parity-reviewer.md new file mode 100644 index 0000000..f34eb8c --- /dev/null +++ b/.claude/agents/ui-parity-reviewer.md @@ -0,0 +1,16 @@ +--- +name: ui-parity-reviewer +description: Reviews a change to one UI package for the matching change in the other. Use whenever src/ResXLocalization.Avalonia or src/ResXLocalization.WPF changes, or when their samples or test suites do. +tools: Read, Grep, Glob +--- + +# Avalonia and WPF parity reviewer + +Read [.agents/references/reviews/ui-parity.md](../../.agents/references/reviews/ui-parity.md) in full before +doing anything, then follow it exactly. + +This is a **review**: report findings, cite file and line for each, and change nothing. The tools above are +read-only by construction — there is no Edit, no Write, and no Bash, because a reviewer that can run a shell +can also write a file, and "please do not edit" is not a sandbox. + +Where the checklist asks for a diff or the result of a build, ask the caller for it rather than producing one. diff --git a/.claude/hooks/public-api-guard.ps1 b/.claude/hooks/public-api-guard.ps1 new file mode 100644 index 0000000..eda67a2 --- /dev/null +++ b/.claude/hooks/public-api-guard.ps1 @@ -0,0 +1,57 @@ +# Claude Code PostToolUse hook: remind about the companion edits a public API change needs. +# +# The checklist itself lives in scripts/public-api-guard.ps1, so that Codex's hook and the pre-commit gate produce +# exactly the same text. This file is only the hook wiring: read the tool payload off stdin, pull the edited +# path out of it, and delegate. +# +# SCOPED TO THE TRIGGERING EDIT: it passes the one path the payload names. If the payload cannot be parsed it +# says so and checks nothing - it never falls back to every file git reports as changed, which would nag +# about work this edit did not touch. +# +# READ-ONLY. The shared script only ever prints; it stages nothing, rewrites no API snapshot, and installs +# nothing. Never fails the edit - a problem here is surfaced as text and the hook still exits 0. + +$ErrorActionPreference = 'Stop' + +try { + # .claude/hooks/ - two levels up. + $repositoryRoot = (Resolve-Path -LiteralPath (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))).Path + + $raw = [Console]::In.ReadToEnd() + if ([String]::IsNullOrWhiteSpace($raw)) { exit 0 } + + try { + $payload = $raw | ConvertFrom-Json + } + catch { + Write-Output 'public-api-guard hook: could not parse the tool payload. Run: pwsh -File scripts/public-api-guard.ps1' + exit 0 + } + + $filePath = $payload.tool_input.file_path + if ([String]::IsNullOrWhiteSpace($filePath)) { exit 0 } + + # Only the two snapshot files matter, so the check costs nothing on every other edit. + if ((Split-Path -Leaf $filePath) -notin @('PublicAPI.Shipped.txt', 'PublicAPI.Unshipped.txt')) { exit 0 } + + if (-not [System.IO.Path]::IsPathRooted($filePath)) { $filePath = Join-Path $repositoryRoot $filePath } + if (-not (Test-Path -LiteralPath $filePath -PathType Leaf)) { exit 0 } + + $resolved = (Resolve-Path -LiteralPath $filePath).Path + $prefix = $repositoryRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar + if (-not $resolved.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { exit 0 } + + $script = Join-Path $repositoryRoot 'scripts/public-api-guard.ps1' + if (-not (Test-Path -LiteralPath $script -PathType Leaf)) { + Write-Output "public-api-guard hook: scripts/public-api-guard.ps1 not found at $script" + exit 0 + } + + # A CHILD pwsh: the shared script ends with `exit`, which in this process would end the hook there. + & pwsh -NoProfile -NonInteractive -File $script -Path $resolved +} +catch { + Write-Output "public-api-guard hook error: $($_.Exception.Message)" +} + +exit 0 diff --git a/.claude/hooks/tidy-code.ps1 b/.claude/hooks/tidy-code.ps1 new file mode 100644 index 0000000..e8632ce --- /dev/null +++ b/.claude/hooks/tidy-code.ps1 @@ -0,0 +1,129 @@ +# Claude Code PostToolUse hook: format the file this edit touched - C# with CSharpier, XAML and AXAML with +# XamlStyler. +# +# The logic itself lives in scripts/tidy-code.ps1, so that Codex's hook and a human run exactly the same +# thing. This file is only the hook wiring: read the tool payload off stdin, pull the edited path out of it, +# check that the path is one we are allowed to touch, and delegate. +# +# SCOPED TO THE TRIGGERING EDIT. It formats the file named in the payload and nothing else. If the payload +# cannot be parsed, or names a path outside this repository, it says so and formats NOTHING - it never falls +# back to "every file git reports as changed", which would reformat work the user has in progress and did not +# ask this hook to touch. +# +# Formatting only, which is the default scope and takes under a second. Style and member ordering are not run +# here: `dotnet format style` needs MSBuild and ReSharper loads the whole solution, and neither belongs on the +# critical path of every single edit. All three are build errors, and scripts/pre-commit-gate.ps1 checks `-Scope +# all` before a commit, so nothing slips through. +# +# Never fails the edit - a formatter problem is surfaced as text and the hook still exits 0. A PostToolUse +# failure does not undo an edit that has already happened, so failing here would only be noise. + +$ErrorActionPreference = 'Stop' + +function Get-RepositoryRoot +{ + # .claude/hooks/ - two levels up. Resolved, so that the comparison below is against a real + # path rather than against a string with ".." in it. + return (Resolve-Path -LiteralPath (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))).Path +} + +function Resolve-EligibleFile +{ + <# + A path from the payload, turned into an absolute path this hook is allowed to format - or nothing. + + Rejected: anything that is not a .cs, .xaml or .axaml file, anything that no longer exists (a delete, + or a move's old name), anything under bin/ or obj/, and anything that resolves outside the repository + root. The last one is what stops a payload naming ../../etc/something, and it is checked AFTER + resolution, so a symbolic link or junction that points out of the tree is rejected too - Resolve-Path + follows it and the result no longer starts with the root. + #> + param([Parameter(Mandatory)] [String] $Root, [String] $Path) + + if ([String]::IsNullOrWhiteSpace($Path)) { return $null } + if ([System.IO.Path]::GetExtension($Path) -notin @('.cs', '.xaml', '.axaml')) { return $null } + + if (-not [System.IO.Path]::IsPathRooted($Path)) + { + $Path = Join-Path $Root $Path + } + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } + + $resolved = (Resolve-Path -LiteralPath $Path).Path + + $prefix = $Root.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar + if (-not $resolved.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { return $null } + + # Generated output is not ours to format, and reformatting it would fight the tool that wrote it. + if ($resolved -match '[\\/](bin|obj)[\\/]') { return $null } + + return $resolved +} + +try +{ + $repositoryRoot = Get-RepositoryRoot + + $raw = [Console]::In.ReadToEnd() + if ([String]::IsNullOrWhiteSpace($raw)) { exit 0 } + + try + { + $payload = $raw | ConvertFrom-Json + } + catch + { + Write-Output 'tidy-code hook: could not parse the tool payload, so nothing was formatted. Run: pwsh -File scripts/tidy-code.ps1' + exit 0 + } + + # Edit and Write both carry the path here. Nothing else is inferred: no path, no formatting. + $file = Resolve-EligibleFile -Root $repositoryRoot -Path $payload.tool_input.file_path + if (-not $file) { exit 0 } + + $script = Join-Path $repositoryRoot 'scripts/tidy-code.ps1' + if (-not (Test-Path -LiteralPath $script -PathType Leaf)) + { + Write-Output "tidy-code hook: scripts/tidy-code.ps1 not found at $script" + exit 0 + } + + # A named mutex, so two edits landing at once cannot run two formatters over the same file. Global\ so it + # is shared across sessions of both agents; the name is derived from the repository path, so two clones + # do not block each other. + $mutexName = 'Global\resxlocalization-tidy-' + + [BitConverter]::ToString( + [System.Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($repositoryRoot.ToLowerInvariant())) + ).Replace('-', '').Substring(0, 32) + + $mutex = [System.Threading.Mutex]::new($false, $mutexName) + try + { + # Waiting, but not forever: a stuck hold must not wedge every later edit. + [void] $mutex.WaitOne([TimeSpan]::FromSeconds(30)) + + # A CHILD pwsh, not dot-sourcing: scripts/tidy-code.ps1 ends with `exit`, and running it in this + # process would end the hook there - before it could report anything. + $output = & pwsh -NoProfile -NonInteractive -File $script -Scope format -Path $file 2>&1 | Out-String + $exitCode = $LASTEXITCODE + } + finally + { + try { $mutex.ReleaseMutex() } catch { } + $mutex.Dispose() + } + + # Quiet on success. On failure say so explicitly and say what it means, because the build treats + # formatting as an error - the same contract Codex's adapter reports through additionalContext. + if ($exitCode -ne 0) + { + Write-Output "Formatting failed on $file. The build treats formatting as an error, so fix this before building:`n$output" + } +} +catch +{ + Write-Output "tidy-code hook error: $($_.Exception.Message)" +} + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..66af451 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "pwsh -NoProfile -NonInteractive -File \"$CLAUDE_PROJECT_DIR/.claude/hooks/tidy-code.ps1\"", + "timeout": 60 + }, + { + "type": "command", + "command": "pwsh -NoProfile -NonInteractive -File \"$CLAUDE_PROJECT_DIR/.claude/hooks/public-api-guard.ps1\"", + "timeout": 15 + } + ] + } + ] + } +} diff --git a/.claude/skills/commit/SKILL.md b/.claude/skills/commit/SKILL.md new file mode 100644 index 0000000..0503d2d --- /dev/null +++ b/.claude/skills/commit/SKILL.md @@ -0,0 +1,9 @@ +--- +name: commit +description: Stage and commit the current changes using this repository's Conventional Commits convention, with the CONTRIBUTING.md checklist applied. +disable-model-invocation: true +--- + +# Commit + +Read [.agents/skills/commit/SKILL.md](../../../.agents/skills/commit/SKILL.md) in full and follow it exactly. diff --git a/.codex/agents/aot-package-compat-reviewer.toml b/.codex/agents/aot-package-compat-reviewer.toml new file mode 100644 index 0000000..6b3e7de --- /dev/null +++ b/.codex/agents/aot-package-compat-reviewer.toml @@ -0,0 +1,6 @@ +name = "aot_package_compat_reviewer" +description = "Review changes for Native AOT, trimming and packaging regressions when code touches resource lookup, culture fallback, satellite discovery, the source generator's output, or the analyzer and buildTransitive wiring the packages carry." +sandbox_mode = "read-only" +developer_instructions = """ +Read `.agents/references/reviews/aot-package-compat.md` in full before doing anything, then follow it exactly. +""" diff --git a/.codex/agents/ui-parity-reviewer.toml b/.codex/agents/ui-parity-reviewer.toml new file mode 100644 index 0000000..11f33f1 --- /dev/null +++ b/.codex/agents/ui-parity-reviewer.toml @@ -0,0 +1,6 @@ +name = "ui_parity_reviewer" +description = "Review a change to one UI package for the matching change in the other. Use whenever src/ResXLocalization.Avalonia or src/ResXLocalization.WPF changes, or when their samples or test suites do." +sandbox_mode = "read-only" +developer_instructions = """ +Read `.agents/references/reviews/ui-parity.md` in full before doing anything, then follow it exactly. +""" diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..fb13f98 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,24 @@ +{ + "description": "ResXLocalization: format the files an edit touched and guard the public API snapshot, the same two checks Claude Code runs from .claude/settings.json.", + "hooks": { + "PostToolUse": [ + { + "matcher": "apply_patch|Edit|Write", + "hooks": [ + { + "type": "command", + "command": "pwsh -NoProfile -NonInteractive -Command \"& (Join-Path (git rev-parse --show-toplevel) '.codex/hooks/tidy-code.ps1')\"", + "statusMessage": "Formatting the edited files", + "timeout": 120 + }, + { + "type": "command", + "command": "pwsh -NoProfile -NonInteractive -Command \"& (Join-Path (git rev-parse --show-toplevel) '.codex/hooks/public-api-guard.ps1')\"", + "statusMessage": "Checking the public API snapshot", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/.codex/hooks/public-api-guard.ps1 b/.codex/hooks/public-api-guard.ps1 new file mode 100644 index 0000000..b2a4932 --- /dev/null +++ b/.codex/hooks/public-api-guard.ps1 @@ -0,0 +1,132 @@ +# Codex PostToolUse hook: remind about the companion edits a public API change needs. +# +# The checklist itself lives in scripts/public-api-guard.ps1, which Claude Code's hook runs too. This file is +# only the hook wiring. +# +# SCOPED TO THE TRIGGERING EDIT. For a file edit Codex reports tool_name "apply_patch" and puts the patch TEXT +# in tool_input.command, so the paths come from the patch headers - Add File, Update File and Move to; a +# Delete File is skipped, because a deleted snapshot has nothing to check. If the patch cannot be parsed this +# hook checks nothing and says so. It never falls back to every file git reports as changed, which would nag +# about work this edit did not touch. +# +# READ-ONLY. The shared script only ever prints; it stages nothing, rewrites no API snapshot, and installs +# nothing. +# +# Contract (https://learn.chatgpt.com/docs/hooks): exit 0 and write the response JSON to stdout. The checklist +# is returned as additionalContext, so it reaches the model rather than only the user. + +$ErrorActionPreference = 'Stop' + +function Write-HookResult +{ + param([String] $AdditionalContext) + + if ([String]::IsNullOrWhiteSpace($AdditionalContext)) + { + $result = @{ continue = $true; suppressOutput = $true } + } + else + { + $result = @{ + continue = $true + hookSpecificOutput = @{ + hookEventName = 'PostToolUse' + additionalContext = $AdditionalContext + } + } + } + + $result | ConvertTo-Json -Depth 5 -Compress | Write-Output +} + +function Get-PatchPath +{ + param([String] $Command) + + $paths = New-Object System.Collections.Generic.List[String] + + if ([String]::IsNullOrWhiteSpace($Command)) { return $paths } + + foreach ($line in ($Command -split "`r?`n")) + { + $match = [Regex]::Match($line, '^\s*\*\*\*\s+(Add File|Update File|Move to):\s*(.+?)\s*$') + if ($match.Success) + { + $paths.Add($match.Groups[2].Value) + } + } + + return $paths +} + +try +{ + # .codex/hooks/ - two levels up. Anchored to this file rather than asked of git, so the answer + # does not depend on the current directory or on git being on PATH. + $repositoryRoot = (Resolve-Path -LiteralPath (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))).Path + + $raw = [Console]::In.ReadToEnd() + if ([String]::IsNullOrWhiteSpace($raw)) + { + Write-HookResult + exit 0 + } + + try + { + $payload = $raw | ConvertFrom-Json + } + catch + { + Write-HookResult -AdditionalContext 'public-api-guard hook: the tool payload could not be parsed. Run `pwsh -File scripts/public-api-guard.ps1` yourself.' + exit 0 + } + + $candidates = New-Object System.Collections.Generic.List[String] + # [string[]] @(...) on purpose: PowerShell unrolls a one-element list to a bare string on the way + # out of a function, and AddRange cannot take one. + $candidates.AddRange([string[]] @(Get-PatchPath -Command $payload.tool_input.command)) + if ($payload.tool_input.file_path) { $candidates.Add([String] $payload.tool_input.file_path) } + + $prefix = $repositoryRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar + + $files = New-Object System.Collections.Generic.List[String] + foreach ($candidate in $candidates) + { + $path = $candidate.Trim('"') + if ((Split-Path -Leaf $path) -notin @('PublicAPI.Shipped.txt', 'PublicAPI.Unshipped.txt')) { continue } + + if (-not [System.IO.Path]::IsPathRooted($path)) { $path = Join-Path $repositoryRoot $path } + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { continue } + + $resolved = (Resolve-Path -LiteralPath $path).Path + if (-not $resolved.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { continue } + + if (-not $files.Contains($resolved)) { $files.Add($resolved) } + } + + if ($files.Count -eq 0) + { + Write-HookResult + exit 0 + } + + $script = Join-Path $repositoryRoot 'scripts/public-api-guard.ps1' + if (-not (Test-Path -LiteralPath $script -PathType Leaf)) + { + Write-HookResult -AdditionalContext "public-api-guard hook: scripts/public-api-guard.ps1 not found at $script" + exit 0 + } + + # A CHILD pwsh: the shared script ends with `exit`, which in this process would end the hook before it + # could write its protocol response - and a Codex hook that writes nothing is a hook that failed. + $output = & pwsh -NoProfile -NonInteractive -File $script -Path @files 2>&1 | Out-String + + Write-HookResult -AdditionalContext $output.Trim() +} +catch +{ + Write-HookResult -AdditionalContext "public-api-guard hook error: $($_.Exception.Message)" +} + +exit 0 diff --git a/.codex/hooks/tidy-code.ps1 b/.codex/hooks/tidy-code.ps1 new file mode 100644 index 0000000..b204ed5 --- /dev/null +++ b/.codex/hooks/tidy-code.ps1 @@ -0,0 +1,207 @@ +# Codex PostToolUse hook: format the files this edit touched - C# with CSharpier, XAML and AXAML with +# XamlStyler. +# +# The logic itself lives in scripts/tidy-code.ps1, which Claude Code's hook runs too. This file is only the +# hook wiring. +# +# SCOPED TO THE TRIGGERING EDIT, which for Codex means reading the patch. For a file edit Codex reports +# tool_name "apply_patch" and puts the patch TEXT in tool_input.command, not a file path - so the paths are +# parsed out of the patch headers: +# +# *** Add File: src/Foo.cs formatted +# *** Update File: src/Foo.cs formatted +# *** Move to: src/Bar.cs formatted (the new name; the old one no longer exists) +# *** Delete File: src/Foo.cs skipped +# +# If the patch cannot be parsed, this hook formats NOTHING and says so. It does not fall back to "every file +# git reports as changed": that would reformat work in progress that this edit did not touch, which is +# exactly the surprise a scoped hook exists to avoid. +# +# Formatting only, which is the default scope. Style and member ordering are build errors and +# scripts/pre-commit-gate.ps1 checks `-Scope all` before a commit; neither belongs on the critical path of every +# edit. +# +# Contract (https://learn.chatgpt.com/docs/hooks): exit 0 and write the response JSON to stdout. Exit code 2 +# would block the operation - this hook never does that, because a formatter problem must not stop an edit. + +$ErrorActionPreference = 'Stop' + +function Write-HookResult +{ + param([String] $AdditionalContext) + + if ([String]::IsNullOrWhiteSpace($AdditionalContext)) + { + $result = @{ continue = $true; suppressOutput = $true } + } + else + { + $result = @{ + continue = $true + hookSpecificOutput = @{ + hookEventName = 'PostToolUse' + additionalContext = $AdditionalContext + } + } + } + + $result | ConvertTo-Json -Depth 5 -Compress | Write-Output +} + +function Get-RepositoryRoot +{ + # .codex/hooks/ - two levels up. Anchored to this file rather than asked of git, so that the + # answer does not depend on the current directory or on git being on PATH. + return (Resolve-Path -LiteralPath (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))).Path +} + +function Get-PatchPath +{ + <# + The paths an apply_patch command touches, from its headers. Deletions are skipped: there is nothing + left to format. A rename is reported as an Update of the old name followed by a Move to the new one, + so both are collected and the filter below drops the old name, which no longer exists. + #> + param([String] $Command) + + $paths = New-Object System.Collections.Generic.List[String] + + if ([String]::IsNullOrWhiteSpace($Command)) { return $paths } + + foreach ($line in ($Command -split "`r?`n")) + { + $match = [Regex]::Match($line, '^\s*\*\*\*\s+(Add File|Update File|Move to):\s*(.+?)\s*$') + if ($match.Success) + { + $paths.Add($match.Groups[2].Value) + } + } + + return $paths +} + +function Resolve-EligibleFile +{ + <# + A path from the patch, turned into an absolute path this hook is allowed to format - or nothing. + + Rejected: anything that is not a .cs, .xaml or .axaml file, anything that no longer exists, anything + under bin/ or obj/, and anything that resolves outside the repository root. The last check happens + AFTER resolution, so a symbolic link or junction pointing out of the tree is rejected too. + #> + param([Parameter(Mandatory)] [String] $Root, [String] $Path) + + if ([String]::IsNullOrWhiteSpace($Path)) { return $null } + + $Path = $Path.Trim('"') + if ([System.IO.Path]::GetExtension($Path) -notin @('.cs', '.xaml', '.axaml')) { return $null } + + if (-not [System.IO.Path]::IsPathRooted($Path)) + { + $Path = Join-Path $Root $Path + } + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } + + $resolved = (Resolve-Path -LiteralPath $Path).Path + + $prefix = $Root.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar + if (-not $resolved.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { return $null } + + if ($resolved -match '[\\/](bin|obj)[\\/]') { return $null } + + return $resolved +} + +try +{ + $repositoryRoot = Get-RepositoryRoot + + $raw = [Console]::In.ReadToEnd() + if ([String]::IsNullOrWhiteSpace($raw)) + { + Write-HookResult + exit 0 + } + + try + { + $payload = $raw | ConvertFrom-Json + } + catch + { + Write-HookResult -AdditionalContext 'tidy-code hook: the tool payload could not be parsed, so nothing was formatted. Run `pwsh -File scripts/tidy-code.ps1` yourself.' + exit 0 + } + + $candidates = New-Object System.Collections.Generic.List[String] + + # apply_patch puts the patch in .command; Edit and Write carry a plain path. Both are read, and nothing + # is inferred beyond them. + # [string[]] @(...) on purpose: PowerShell unrolls a one-element list to a bare string on the way + # out of a function, and AddRange cannot take one. + $candidates.AddRange([string[]] @(Get-PatchPath -Command $payload.tool_input.command)) + if ($payload.tool_input.file_path) { $candidates.Add([String] $payload.tool_input.file_path) } + + $files = @( + $candidates | + ForEach-Object { Resolve-EligibleFile -Root $repositoryRoot -Path $_ } | + Where-Object { $_ } | + Select-Object -Unique + ) + + if ($files.Count -eq 0) + { + Write-HookResult + exit 0 + } + + $script = Join-Path $repositoryRoot 'scripts/tidy-code.ps1' + if (-not (Test-Path -LiteralPath $script -PathType Leaf)) + { + Write-HookResult -AdditionalContext "tidy-code hook: scripts/tidy-code.ps1 not found at $script" + exit 0 + } + + # A named mutex, so two edits landing at once cannot run two formatters over the same file. The name is + # derived from the repository path, so two clones do not block each other, and it is the same name the + # Claude adapter uses - the two agents serialize against each other as well. + $mutexName = 'Global\resxlocalization-tidy-' + + [BitConverter]::ToString( + [System.Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($repositoryRoot.ToLowerInvariant())) + ).Replace('-', '').Substring(0, 32) + + $mutex = [System.Threading.Mutex]::new($false, $mutexName) + try + { + [void] $mutex.WaitOne([TimeSpan]::FromSeconds(30)) + + # A CHILD pwsh, not dot-sourcing: scripts/tidy-code.ps1 ends with `exit`, which in this process would + # end the hook before it could write its protocol response - and a Codex hook that writes nothing is + # a hook that failed. + $output = & pwsh -NoProfile -NonInteractive -File $script -Scope format -Path @files 2>&1 | Out-String + $exitCode = $LASTEXITCODE + } + finally + { + try { $mutex.ReleaseMutex() } catch { } + $mutex.Dispose() + } + + # Quiet on success: the agent does not need to be told that nothing needed formatting. A failure is + # reported, because it means the next build breaks on a formatting rule. + if ($exitCode -ne 0) + { + Write-HookResult -AdditionalContext "Formatting failed. The build treats formatting as an error, so fix this before building:`n$output" + } + else + { + Write-HookResult + } +} +catch +{ + Write-HookResult -AdditionalContext "tidy-code hook error: $($_.Exception.Message)" +} + +exit 0 diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 4d69ae1..afc8e6b 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -2,6 +2,13 @@ "version": 1, "isRoot": true, "tools": { + "csharpier": { + "version": "1.3.0", + "commands": [ + "csharpier" + ], + "rollForward": false + }, "docfx": { "version": "2.78.5", "commands": [ @@ -9,6 +16,13 @@ ], "rollForward": false }, + "jetbrains.resharper.globaltools": { + "version": "2026.2.1", + "commands": [ + "jb" + ], + "rollForward": false + }, "xamlstyler.console": { "version": "3.2501.8", "commands": [ @@ -17,4 +31,4 @@ "rollForward": false } } -} \ No newline at end of file +} diff --git a/.csharpierignore b/.csharpierignore new file mode 100644 index 0000000..4df72c6 --- /dev/null +++ b/.csharpierignore @@ -0,0 +1,22 @@ +# CSharpier formats C# in this repository, and nothing else. +# +# Since version 1.0 it can also format XML, which here means the .csproj, .props, .targets, .slnx and +# .config files. Those are hand-maintained: they carry long explanatory comments, deliberate blank lines +# between the property groups, and one item per line. CSharpier's XML formatter reflows all of that - +# it splits short attribute lists across several lines each and re-indents elements without re-indenting +# the comment bodies inside them. .editorconfig already fixes their indentation, which is all they need. +# +# XAML and AXAML belong to XamlStyler, configured in Settings.XamlStyler. +# +# Build output and generated documentation are not listed: CSharpier honours .gitignore, and obj, bin, +# artifacts and docs/_site are all in there already. + +*.csproj +*.props +*.targets +*.slnx +*.config +*.xml +*.DotSettings +*.xaml +*.axaml diff --git a/.editorconfig b/.editorconfig index a89d434..cf05184 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,110 +10,277 @@ root = true [*] charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true +end_of_line = lf +indent_size = 4 indent_style = space +insert_final_newline = true tab_width = 4 -indent_size = 4 -end_of_line = lf +trim_trailing_whitespace = true + +# Two spaces for the structured configuration formats, including the MSBuild files. That is the Visual +# Studio and Rider default for XML, so an editor that reindents one of these files agrees with the +# repository instead of fighting it. CSharpier does not format XML here - see .csharpierignore. +[*.{csproj,props,targets,slnx,config,xml}] +indent_size = 2 + +# JSON and YAML conventionally use 2-space indentation, which is what these files already carry. +[*.json] +indent_size = 2 -# YAML conventionally uses 2-space indentation. [*.{yml,yaml}] indent_size = 2 +# ====================================================================================================== +# C# +# +# The tools below share this file, and each one owns exactly one concern: +# +# CSharpier whitespace, line breaks, wrapping. Reads max_line_length and indent_size. +# Roslyn analyzers code style (the settings below). Fixed by `dotnet format style`. +# NewStyleCop.Analyzers member ordering (checking only). Fixed by ReSharper, see the ordering +# section further down. +# +# Run scripts/tidy-code.ps1 to apply them. +# ====================================================================================================== + [*.cs] -indent_style = space + +#### Layout #### + indent_size = 4 +tab_width = 4 + +# CSharpier reads this as its print width. It is the ONLY place the line width is configured. max_line_length = 120 -csharp_using_directive_placement = outside_namespace:silent -csharp_prefer_simple_using_statement = true:suggestion -csharp_prefer_braces = true:error -csharp_style_namespace_declarations = file_scoped:silent -csharp_style_prefer_method_group_conversion = true:silent -csharp_style_prefer_top_level_statements = true:silent -csharp_style_prefer_primary_constructors = true:error -csharp_prefer_system_threading_lock = true:suggestion -csharp_style_prefer_simple_property_accessors = true:suggestion -csharp_style_expression_bodied_methods = true:error +#### Types: C# keywords, never BCL type names #### + +# `string`, `int`, `bool` - not `String`, `Int32`, `Boolean`. Both settings are IDE0049. +dotnet_style_predefined_type_for_locals_parameters_members = true:error +dotnet_style_predefined_type_for_member_access = true:error + +# `var` for a local declaration, in all three of the cases the analyzers distinguish: a built-in type, a +# right-hand side that names the type, and everything else. The first two are errors; the third is +# silent. Every local declaration under src/ already uses `var`, so the preference is what the code does +# - but a declaration whose type nothing on the line reveals is a judgement call about readability +# rather than something a build should reject. +csharp_style_var_for_built_in_types = true:error +csharp_style_var_when_type_is_apparent = true:error +csharp_style_var_elsewhere = true:silent + +#### Member access: always `this.` #### + +# Instance members are always read and written through `this.`, and fields never begin with an +# underscore (SA1309, in the ordering section below). +# +# A primary constructor parameter is assigned to a `private readonly` backing field and read through +# `this.field`, never used directly in a member body. Nothing here enforces that - a parameter is not an +# instance member, so these rules cannot see it - but a captured parameter compiles to a field with no +# `readonly`, and using one directly would silently drop the guarantee that it cannot be reassigned. +dotnet_style_qualification_for_event = true:error +dotnet_style_qualification_for_field = true:error +dotnet_style_qualification_for_method = true:error +dotnet_style_qualification_for_property = true:error + +#### Expression-bodied members #### + +# Use `=>` wherever a member is a single expression. +csharp_style_expression_bodied_accessors = true:error csharp_style_expression_bodied_constructors = true:error -csharp_style_expression_bodied_operators = true:error -csharp_style_expression_bodied_properties = true:error csharp_style_expression_bodied_indexers = true:error -csharp_style_expression_bodied_accessors = true:error csharp_style_expression_bodied_lambdas = true:error csharp_style_expression_bodied_local_functions = true:error -csharp_style_throw_expression = true:suggestion -csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_style_expression_bodied_methods = true:error +csharp_style_expression_bodied_operators = true:error +csharp_style_expression_bodied_properties = true:error +csharp_style_prefer_simple_property_accessors = true:suggestion + +#### Namespaces and using directives #### + +# An unused using is a build error. This needs the documentation file that Directory.Build.props turns +# on for every project - without one, IDE0005 is simply never reported at build time. +dotnet_diagnostic.IDE0005.severity = error + +csharp_style_namespace_declarations = file_scoped:error +csharp_using_directive_placement = outside_namespace:error +dotnet_style_namespace_match_folder = true:suggestion + +#### Declarations #### + +csharp_prefer_braces = true:error +csharp_style_prefer_primary_constructors = true:error +csharp_style_prefer_top_level_statements = true:silent +dotnet_style_prefer_auto_properties = true:silent + +#### Modern language features #### + csharp_prefer_simple_default_expression = true:suggestion -csharp_style_prefer_local_over_anonymous_function = true:suggestion -csharp_style_prefer_index_operator = true:suggestion +csharp_prefer_simple_using_statement = true:suggestion +csharp_prefer_system_threading_lock = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion csharp_style_prefer_implicitly_typed_lambda_expression = true:suggestion -csharp_style_prefer_unbound_generic_type_in_nameof = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_prefer_method_group_conversion = true:silent csharp_style_prefer_not_pattern = true:suggestion -csharp_style_var_for_built_in_types = true:error -csharp_style_var_when_type_is_apparent = true:error csharp_style_prefer_range_operator = true:suggestion csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_unbound_generic_type_in_nameof = true:suggestion csharp_style_prefer_utf8_string_literals = true:suggestion -csharp_style_inlined_variable_declaration = true:suggestion -csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_throw_expression = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion + +#### Null handling #### + +csharp_style_prefer_null_check_over_type_check = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion + +#### Expressions #### + +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent csharp_style_unused_value_assignment_preference = discard_variable:suggestion csharp_style_unused_value_expression_statement_preference = discard_variable:silent -dotnet_analyzer_diagnostic.category-roslynator.severity = error +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion -# RCS1037: Remove trailing white-space -dotnet_diagnostic.rcs1037.severity = none +#### Formatting is CSharpier's job #### -# Microsoft .NET properties -csharp_style_var_elsewhere = true:error -csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent +# IDE0055 is "fix formatting" - every whitespace rule Roslyn has, under one ID. A number of its options +# disagree with CSharpier, and CSharpier is the tool that owns whitespace here, so this stays off. +# CSharpier's own documentation recommends exactly this. +dotnet_diagnostic.IDE0055.severity = none -# CA1716: Identifiers should not match keywords -dotnet_diagnostic.CA1716.severity = none +# RCS1037: Remove trailing white-space. Same reason - CSharpier removes it, and trim_trailing_whitespace +# in the [*] section above tells the editor to. +dotnet_diagnostic.RCS1037.severity = none -# CA1062: Validate arguments of public methods -dotnet_diagnostic.CA1062.severity = none +#### Member ordering: NewStyleCop.Analyzers #### -# CA1515: Consider making public types internal -dotnet_diagnostic.CA1515.severity = none +# StyleCop is here for ONE job: reporting types and members that are in the wrong order. Everything else +# it does is either CSharpier's concern or already covered by the Roslyn, Roslynator and Sonar rules in +# this file, so every category is switched off first and only the rules we want are switched back on. +# Adding a StyleCop rule to this repository means adding it explicitly below. +# +# StyleCop can only REPORT a wrong order - its ordering code fix is disabled upstream. ReSharper does the +# fixing: in Rider through Code Cleanup, and on the command line through scripts/tidy-code.ps1. So the +# order is defined twice, and the two definitions have to stay in step: +# stylecop.json -> what is checked +# ResXLocalization.slnx.DotSettings -> what is applied +# +# They do not cover the same ground. StyleCop checks kind, access, constant, static and readonly; it has +# no notion of alphabetical order WITHIN one of those groups, which the file layout applies and nothing +# checks. A misplaced member that is otherwise in the right group is therefore visible only by running +# the full pipeline and looking at what it changes - which is what scripts/tidy-code.ps1 -Check +# -Scope all does, and why CI runs it. +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.DocumentationRules.severity = none +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.LayoutRules.severity = none +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.MaintainabilityRules.severity = none +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.NamingRules.severity = none +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.OrderingRules.severity = none +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.ReadabilityRules.severity = none +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.SpacingRules.severity = none +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.SpecialRules.severity = none + +# SA1208, SA1209, SA1210, SA1211, SA1217: the order of using directives. +# +# CSharpier already sorts using directives, so these five do not fix anything - they CHECK that what +# CSharpier produced is what we want, and catch a file that reached the repository without it. +# +# SA1208 is also what makes `systemUsingDirectivesFirst` in stylecop.json mean something. With the rule +# off, that setting is read by nothing. +# +# SA1216 (using static placed after the regular usings) is deliberately ABSENT: CSharpier sorts by +# namespace and ignores the `static` keyword, so enabling it would make the build unsatisfiable - +# CSharpier runs last and always has the final say. +# +# SA1200 is absent for a different reason: `csharp_using_directive_placement` above already requires +# usings outside the namespace, and two rules for one decision is how they end up disagreeing. +dotnet_diagnostic.SA1208.severity = error +dotnet_diagnostic.SA1209.severity = error +dotnet_diagnostic.SA1210.severity = error +dotnet_diagnostic.SA1211.severity = error +dotnet_diagnostic.SA1217.severity = error + +# SA1201: Elements should appear in the correct order (by kind). +dotnet_diagnostic.SA1201.severity = error + +# SA1202: Elements should be ordered by access. +# +# Explicit interface implementations need a word, because the checker and the fixer classify them +# differently. ReSharper ranks one below private, since in C# it carries no access modifier, so left +# alone it lands at the bottom of its kind group. +# +# StyleCop disagrees, but NOT uniformly - which is why the file layout has one entry per kind rather +# than one shared entry: +# +# properties, indexers, methods counted as public, so they must come FIRST in their group. +# The layout has an "Explicit interface " entry for each. +# events counted as private, so ReSharper's default placement is already +# right. Giving events an entry actively breaks the build - it puts +# the explicit event ahead of a public one, which is SA1202. +# +# The entries match on ImplementsInterface AND Access Is="Private", and both halves matter. +# ImplementsInterface on its own also matches IMPLICIT implementations, which drags an interface method +# away from its overloads. An implicit implementation has to be public, so the access test is what +# narrows each entry to the explicit ones. +# +# One shared entry across the kinds would not work either: it would sit an explicit property next to +# an explicit method and break SA1201's kind order. +dotnet_diagnostic.SA1202.severity = error -# CA1031: Do not catch general exception types -dotnet_diagnostic.CA1031.severity = none +# SA1203: Constants should appear before fields. +dotnet_diagnostic.SA1203.severity = error -# CA1707: Identifiers should not contain underscores -dotnet_diagnostic.CA1707.severity = none +# SA1204: Static elements should appear before instance elements. +dotnet_diagnostic.SA1204.severity = error -# CA1819: Properties should not return arrays -dotnet_diagnostic.CA1819.severity = none +# SA1214: Readonly fields should appear before non-readonly fields. +dotnet_diagnostic.SA1214.severity = error -# SA1600: Elements should be documented -dotnet_diagnostic.SA1600.severity = none +# SA1309: Field names should not begin with an underscore. Nothing else enforces this, and it is the +# other half of the "always `this.`" decision - see the member access section above. +dotnet_diagnostic.SA1309.severity = error -# SA1121: Use built-in type alias -dotnet_diagnostic.SA1121.severity = none +#### Roslynator #### -# SA1009: Closing parenthesis should be spaced correctly -dotnet_diagnostic.SA1009.severity = none +dotnet_analyzer_diagnostic.category-roslynator.severity = error + +#### .NET analyzers #### + +# CA1031: Do not catch general exception types +dotnet_diagnostic.CA1031.severity = none -# SA1111: Closing parenthesis should be on line of last parameter -dotnet_diagnostic.SA1111.severity = none +# CA1062: Validate arguments of public methods +dotnet_diagnostic.CA1062.severity = none -# SA1633: The file header is missing or not located at the top of the file. -dotnet_diagnostic.SA1633.severity = none +# CA1515: Consider making public types internal +# This is a library; its types are public because consumers use them. +dotnet_diagnostic.CA1515.severity = none -# SA1642: Constructor summary documentation should begin with standard text -dotnet_diagnostic.SA1642.severity = none +# CA1707: Identifiers should not contain underscores +# The test method name is Method_Scenario_ShouldExpectedOutcome - see the naming section below. +dotnet_diagnostic.CA1707.severity = none -# SA1413: Use trailing comma in multi-line initializers -dotnet_diagnostic.SA1413.severity = none +# CA1716: Identifiers should not match keywords +dotnet_diagnostic.CA1716.severity = none -# SA0001: XML comment analysis is disabled due to project configuration -dotnet_diagnostic.SA0001.severity = none +# CA1819: Properties should not return arrays +dotnet_diagnostic.CA1819.severity = none -# SA1601: Partial elements should be documented -dotnet_diagnostic.SA1601.severity = none +#### Public API tracking #### # RS0026/RS0027: the enum-lookup overloads deliberately share a defaulted keyPrefix parameter # (Get(Enum, [prefix]) and Get(Enum, ResourceManager, [prefix])); overload resolution is unambiguous @@ -121,14 +288,17 @@ dotnet_diagnostic.SA1601.severity = none dotnet_diagnostic.RS0026.severity = none dotnet_diagnostic.RS0027.severity = none -# SA1500: Braces for multi-line statements should not share line -dotnet_diagnostic.SA1500.severity = none +#### xUnit #### + +# xUnit1051: Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken +# The tests are synchronous and do not use the xUnit v3 cancellation-token overloads. +dotnet_diagnostic.xUnit1051.severity = none -# SA1513: Closing brace should be followed by blank line -dotnet_diagnostic.SA1513.severity = none +# ====================================================================================================== +# Naming +# ====================================================================================================== [*.{cs,vb}] -#### Naming styles #### # Naming rules @@ -140,9 +310,12 @@ dotnet_naming_rule.types_should_be_pascal_case.severity = warning dotnet_naming_rule.types_should_be_pascal_case.symbols = types dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case -dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion -dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members -dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case +# Underscores, because the mandated test method name is Method_Scenario_ShouldExpectedOutcome. A plain +# pascal_case style rejects every one of those - and a rule that contradicts a convention AGENTS.md +# requires is the rule that has to change, not the members. +dotnet_naming_rule.non_field_members_should_be_pascal_with_underscores.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_with_underscores.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_with_underscores.style = pascal_with_underscores # Symbol specifications @@ -170,30 +343,32 @@ dotnet_naming_style.pascal_case.required_suffix = dotnet_naming_style.pascal_case.word_separator = dotnet_naming_style.pascal_case.capitalization = pascal_case -dotnet_style_operator_placement_when_wrapping = beginning_of_line -dotnet_style_coalesce_expression = true:suggestion -dotnet_style_null_propagation = true:suggestion -dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion -dotnet_style_prefer_auto_properties = true:silent -dotnet_style_object_initializer = true:suggestion -dotnet_style_collection_initializer = true:suggestion -dotnet_style_prefer_simplified_boolean_expressions = true:suggestion -dotnet_style_prefer_conditional_expression_over_assignment = true:silent -dotnet_style_prefer_conditional_expression_over_return = true:silent -dotnet_style_explicit_tuple_names = true:suggestion -dotnet_style_prefer_inferred_tuple_names = true:suggestion -dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion -dotnet_style_prefer_compound_assignment = true:suggestion -dotnet_style_prefer_simplified_interpolation = true:suggestion -dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion -dotnet_style_namespace_match_folder = true:suggestion +dotnet_naming_style.pascal_with_underscores.required_prefix = +dotnet_naming_style.pascal_with_underscores.required_suffix = +dotnet_naming_style.pascal_with_underscores.word_separator = _ +dotnet_naming_style.pascal_with_underscores.capitalization = pascal_case -# xunit1051: Use overloads that take a CancellationToken - the tests are synchronous and do not use -# the xUnit v3 cancellation-token overloads, so the diagnostic is noise here. -dotnet_diagnostic.xunit1051.severity = none -dotnet_style_predefined_type_for_locals_parameters_members = false:error -dotnet_style_predefined_type_for_member_access = false:error -dotnet_style_qualification_for_field = true:error -dotnet_style_qualification_for_property = true:error -dotnet_style_qualification_for_method = true:error -dotnet_style_qualification_for_event = true:error +# ====================================================================================================== +# The init-only marker polyfill +# ====================================================================================================== + +[src/ResXLocalization.SourceGenerators/IsExternalInit.cs] + +# S2094: Classes should not be empty +# This type has to be empty and has to carry that exact name in that exact namespace: it is the marker +# the C# compiler looks for to allow `init` accessors and `record` types, and netstandard2.0 does not +# ship one. +dotnet_diagnostic.S2094.severity = none + +# ====================================================================================================== +# Tests +# +# Held to the same style, formatting and ordering as the shipping libraries. +# ====================================================================================================== + +[tests/**/*.cs] + +# S1215: "GC.Collect" should not be called +# Forcing a collection is the measurement in the memory-leak tests: they assert that a weak reference is +# gone once nothing holds it, which is only observable after a collection. +dotnet_diagnostic.S1215.severity = none diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 1f4aa51..5e1e6de 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -6,18 +6,22 @@ body: id: package attributes: label: Package - description: Which package is affected? + 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. options: - ResXLocalization.Avalonia - ResXLocalization.WPF - - Both / not sure + - ResXLocalization.Core + - More than one, or not sure validations: required: true - type: input id: version attributes: label: Package version - placeholder: "1.0.0" + placeholder: "1.1.0" validations: required: true - type: dropdown @@ -30,8 +34,24 @@ body: - Scoped (Key + ResourceManager) - Search-all (key only) - Enum ({l:LocalizeEnum} / LocalizeEnumConverter) + - Dynamic format arguments (LocalizeArgs.Arg0…Arg8) + - Culture fallback / satellite discovery - Source generator / build - Other / not applicable + - type: dropdown + id: publish-mode + attributes: + label: How is the application published? + description: > + Trimming and Native AOT remove code the just-in-time compiler keeps, so a problem that appears only + after publishing has a different cause from one that appears in a normal run. If you are not sure, + "Ordinary build" is the answer. + options: + - Ordinary build or run (no trimming) + - PublishTrimmed + - PublishAot (Native AOT) + validations: + required: true - type: textarea id: what-happened attributes: @@ -44,15 +64,25 @@ body: attributes: label: Minimal reproduction description: > - The smallest snippet that shows the problem - usually a .resx entry, the XAML or C# lookup, - and the culture you switched to. A link to a minimal repository works too. + The smallest thing that shows the problem: the .resx entry (and whether the translated file has it), + the XAML or C# lookup, and the culture you switched to. A link to a minimal repository works too. + Please cut it down - a reproduction that is already minimal is the difference between a fix this + week and a fix eventually. render: text validations: required: true + - type: textarea + id: exception + attributes: + label: Exception, if any + description: The full exception type, message and stack trace. Leave empty if nothing was thrown. + render: text - type: input id: environment attributes: label: Environment - placeholder: ".NET 10.0.x, Avalonia 12.1.0 / WPF, Windows 11 / Ubuntu 24.04, Native AOT yes/no" + description: > + The .NET version you run on, the UI framework version, and the operating system. + placeholder: ".NET 10.0.x, Avalonia 12.1.0 / WPF, Windows 11 / Ubuntu 24.04, win-x64" validations: required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 605d3cd..8484a94 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -6,11 +6,43 @@ ## Checklist -- [ ] `dotnet build ResXLocalization.slnx -c Release` succeeds with **zero warnings**. -- [ ] All four test suites pass (Core, source generator, Avalonia, WPF); new behavior / fixed bugs are covered by tests. -- [ ] Avalonia/WPF symmetry preserved (mirrored change in the other UI package, if applicable). -- [ ] No new `IL2026`/`IL3050` warnings (Avalonia/Core stay Native-AOT clean). -- [ ] Public API changes are declared in the affected `PublicAPI.Unshipped.txt`. -- [ ] XML docs and `README.md` updated for public API changes. -- [ ] `CHANGELOG.md` updated under *Unreleased*. -- [ ] Code formatted (`pwsh -File scripts/tidy-code.ps1`). +Everything under **Always** applies to every pull request. The rest applies only when its trigger does — +tick it, or leave it and say why in the description. A box that does not apply is not a box to tick. + +### Always + +- [ ] `pwsh -File scripts/pre-commit-gate.ps1` passes. It runs the public-API reminder, checks line endings, + style, formatting and member ordering, builds Release and runs the test suites. Use `-Fix` to have it + apply the tidying rather than only report it. +- [ ] The Release build produces **zero warnings**. `TreatWarningsAsErrors` is on, so this also covers style, + member ordering, trim and public-API diagnostics. +- [ ] Branch name follows `/issue--`, or `/` when there is no issue — see + [CONTRIBUTING.md](../CONTRIBUTING.md#branches). + +### If the change adds behaviour or fixes a bug + +- [ ] It is covered by tests. +- [ ] `CHANGELOG.md` has an entry under `## [Unreleased]`. Do **not** bump a version — the version, the + release date and the tag are the maintainer's, at release time. + +### If the change touches one UI package + +- [ ] It is mirrored into the other, or it genuinely does not apply there — see + [CONTRIBUTING.md](../CONTRIBUTING.md#avalonia-and-wpf-symmetry). The Avalonia and WPF markup + extensions and converters are deliberate mirrors of each other. + +### If the change touches resource lookup, the generated keys, or the packaging that carries them + +Culture fallback, satellite discovery, the source generator's output, the `buildTransitive` wiring, or a +dependency version. Nothing is trimmed on the just-in-time compiler, so no other check in this repository +can see the damage a mistake here does to a trimmed application. + +- [ ] `pwsh -File scripts/verify-package-aot.ps1 -Pack` passes for **both** frameworks + (`-Framework net8.0` and the `net10.0` default). +- [ ] No new `IL2xxx` / `IL3xxx` diagnostics, and none suppressed. + +### If the change touches the public API + +- [ ] The affected `PublicAPI.Unshipped.txt` is updated with `pwsh -File scripts/update-public-api.ps1`, and + the diff was reviewed line by line. A `*REMOVED*` entry is a breaking change. +- [ ] The XML documentation and the affected pages under `docs/` are updated. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d68b27d..aac8ae2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,26 +1,94 @@ +# Dependabot configuration. +# +# This file governs VERSION updates only - the routine "a new version exists" pull requests. Dependabot +# SECURITY updates are a separate, repository-level setting and are deliberately left enabled and immediate: +# the quarterly cadence below is about noise, and a published advisory is not noise. `NuGetAudit` in +# Directory.Build.props reports the same advisories in every local build and in CI, so a vulnerable +# transitive package is visible long before a pull request arrives. +# +# Quarterly rather than monthly because these dependencies move faster than this repository needs them to. +# A grouped quarterly pull request is reviewed; twelve monthly ones are skimmed. version: 2 updates: - package-ecosystem: nuget directory: "/" schedule: interval: quarterly + commit-message: + # Conventional Commits, so a Dependabot branch reads like every other commit in the history and the + # changelog tooling does not have to special-case it. `build:` is right for a dependency that ships, + # `chore:` for one that only the build or the tests see. + prefix: build + prefix-development: chore + include: scope groups: - # Avalonia packages are version-locked to each other; update them together. + # Analyzers and the formatter. They only ever change what the build REJECTS, never what ships, so a + # group of them is one review of "what does the build complain about now" instead of one per package. + # + # CSharpier.MsBuild is in this group and needs a companion edit every time: the csharpier entry in + # .config/dotnet-tools.json has to move with it, or the build demands formatting the tool does not + # produce. Dependabot cannot make that edit. + analyzers-and-formatters: + applies-to: version-updates + update-types: + - minor + - patch + patterns: + - "CSharpier.MsBuild" + - "ErrorProne.NET.*" + - "Microsoft.CodeAnalysis.PublicApiAnalyzers" + - "NewStyleCop.Analyzers" + - "Roslynator.*" + - "SonarAnalyzer.CSharp" + # The test stack. A bump here can never affect a consumer: none of these packages is referenced by a + # shipping project or appears in any package's dependency graph. + test-stack: + applies-to: version-updates + update-types: + - minor + - patch + patterns: + - "AwesomeAssertions" + - "coverlet.*" + - "GitHubActionsTestLogger" + - "Microsoft.NET.Test.Sdk" + - "xunit*" + # Coupled families, at EVERY update type including major. These are packages that are versioned and + # released together and that will not resolve against each other across a major boundary, so + # splitting a major across pull requests produces a branch that cannot restore. + # + # Avalonia carries an intentional exception with it: the SHIPPED floor in Directory.Packages.props + # is 12.0.5 and is NOT what this group updates. Avalonia 12.1's XAML generator requires Roslyn 4.14, + # which the .NET 8 SDK cannot load, so raising that floor would make the package unusable on the SDK + # it promises to support. A pull request here moves the version the samples and tests resolve; the + # floor moves only when someone decides to drop .NET 8 SDK support. avalonia: + applies-to: version-updates patterns: - "Avalonia*" + xunit: + applies-to: version-updates + patterns: + - "xunit*" ignore: - # 3.x targets Microsoft.Testing.Platform; the repo tests through VSTest - # (dotnet test --logger GitHubActions), so 2.x is the correct major. + # GitHubActionsTestLogger 3.x targets Microsoft.Testing.Platform. This repository runs its tests + # through VSTest, so 2.x is the correct major - see the note beside the version in + # Directory.Packages.props. Re-check this against the package's own release notes before removing it. - dependency-name: GitHubActionsTestLogger update-types: - "version-update:semver-major" + - package-ecosystem: github-actions directory: "/" schedule: interval: quarterly + commit-message: + prefix: chore + include: scope groups: - # Keep CodeQL action variants in sync since they're released together + # The CodeQL actions are released together and their versions have to match, so they move together at + # every update type - including major. codeql: + applies-to: version-updates patterns: - "github/codeql-action/*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b05476..7c79de4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,15 +25,15 @@ env: COMBINED_CONSUMER_PROJECT: tests/package-consumption/CombinedConsumer/CombinedConsumer.csproj CONFIGURATION: Release CONSUMER_DIRECTORY: tests/package-consumption - DOCFX_CONFIG: docs/docfx.json - DOCS_SITE: docs/_site + DOCFX_CONFIG: build/docfx/docfx.json + DOCS_SITE: artifacts/docs/site DOTNET_CLI_TELEMETRY_OPTOUT: true DOTNET_NOLOGO: true DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages NUGET_CONFIG: NuGet.config PACKAGE_OUTPUT: artifacts/packages - RELEASE_NOTES_FILE: release-notes.md + RELEASE_NOTES_FILE: artifacts/release/release-notes.md RELEASE_NOTES_SCRIPT: scripts/extract-release-notes.ps1 SOLUTION: ResXLocalization.slnx SOLUTION_FILTER_NON_WINDOWS: ResXLocalization.NonWindows.slnf @@ -42,16 +42,14 @@ env: WPF_TEST_PROJECT: tests/ResXLocalization.WPF.Sample.Tests/ResXLocalization.WPF.Sample.Tests.csproj jobs: - # Enforce the formatting the repo ships tooling for: a rule that is not enforced in CI drifts with - # the first external pull request. + # Enforce the hygiene and formatting the repository ships tooling for: a rule that is not enforced in + # CI drifts with the first external pull request. # - # This runs scripts/tidy-code.ps1, the same entry point a developer uses. -Check formats the checkout - # for real and then asks whether anything changed: XamlStyler has no usable check mode here, because - # it compares against its own CRLF output and so fails on LF files on Windows. Writing to the - # checkout is harmless in CI; it is thrown away at the end of the job. + # Every substantive check here is a call to the script a developer runs, so the two cannot disagree. # - # Ubuntu is a cost and speed choice, not a correctness one: -Check gives the same answer on every OS, - # and on Linux the non-Windows solution filter keeps the WPF projects out of the restore. + # Ubuntu is a cost and speed choice, not a correctness one: the tidiness check gives the same answer on + # every OS, and on Linux the non-Windows solution filter keeps the WPF projects out of the restore - + # which the script reports rather than passing over in silence. lint: name: Lint runs-on: ubuntu-latest @@ -68,16 +66,10 @@ jobs: # .gitattributes only applies when a file is ADDED. A file committed past it, or a rule added # after the file, stays wrong forever and nothing complains - and commits created server-side, - # such as a bot's, bypass the filter entirely. Renormalizing and asking whether anything changed - # is the exact question, and it needs no toolchain. - - name: Verify line endings are normalized - run: | - git add --renormalize . - if ! git diff --cached --quiet; then - git --no-pager diff --cached --name-only - echo "::error title=Line endings::The files above are not stored as .gitattributes requires. Run 'git add --renormalize .' and commit the result." - exit 1 - fi + # such as a bot's, bypass the filter entirely. The script asks the same question of both what git + # stored and what is on disk, and says what fixes each. + - name: Verify line endings + run: pwsh -NoProfile -NonInteractive -File scripts/verify-line-endings.ps1 # .git-blame-ignore-revs names commits by raw SHA, and git does NOT complain about one it cannot # resolve - it silently skips the entry. A rebase-merge or squash-merge rewrites every SHA on a @@ -139,13 +131,10 @@ jobs: - name: Restore run: dotnet restore ${{ env.SOLUTION_FILTER_NON_WINDOWS }} --configfile ${{ env.NUGET_CONFIG }} - - name: Verify formatting - run: pwsh -NoProfile -NonInteractive -File scripts/tidy-code.ps1 -Check - - # The check above only says that something is unformatted. This says what. - - name: Show the changes that would fix it - if: failure() - run: git --no-pager diff --stat && git --no-pager diff + # -Check runs the tools on a disposable copy of the tree, so this job cannot rewrite the checkout, + # and prints the diff that would fix whatever it found. + - name: Verify style, formatting and member ordering + run: pwsh -NoProfile -NonInteractive -File scripts/tidy-code.ps1 -Scope all -Check # Cross-platform: build and test the shared Core, the source generator, and the Avalonia stack. These # projects target net8.0/net10.0 and build/test identically on Linux and Windows. The WPF projects are @@ -192,8 +181,14 @@ jobs: # The solution filter is the source of truth for the cross-platform project set. dotnet test # discovers its test projects and runs every target framework automatically. + # + # VSTestResultsDirectory rather than --results-directory: MSBuild expands it per project, so each + # project and each target framework writes into its own folder. With one shared directory the + # reports collide - two suites both produce a net10.0 report, and VSTest resolves that by appending + # "[1]" to whichever arrives second, which is not an identity anyone can read. - name: Test - run: dotnet test ${{ env.SOLUTION_FILTER_NON_WINDOWS }} --configuration ${{ env.CONFIGURATION }} --no-build --logger GitHubActions --logger trx --collect:"XPlat Code Coverage" --results-directory "${{ env.TEST_RESULTS }}" + shell: bash + run: dotnet test ${{ env.SOLUTION_FILTER_NON_WINDOWS }} --configuration ${{ env.CONFIGURATION }} --no-build --logger GitHubActions --logger trx --collect:"XPlat Code Coverage" -p:VSTestResultsDirectory='${{ github.workspace }}/${{ env.TEST_RESULTS }}/$(MSBuildProjectName)/$(TargetFramework)' - name: Upload coverage to Codecov uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 @@ -248,7 +243,8 @@ jobs: run: dotnet build ${{ env.WPF_TEST_PROJECT }} --configuration ${{ env.CONFIGURATION }} --no-restore /p:ContinuousIntegrationBuild=true - name: Test - run: dotnet test ${{ env.WPF_TEST_PROJECT }} --configuration ${{ env.CONFIGURATION }} --no-build --logger GitHubActions --logger "trx;LogFileName=wpf-test-results.trx" --collect:"XPlat Code Coverage" --results-directory "${{ env.TEST_RESULTS }}" + shell: bash + run: dotnet test ${{ env.WPF_TEST_PROJECT }} --configuration ${{ env.CONFIGURATION }} --no-build --logger GitHubActions --logger trx --collect:"XPlat Code Coverage" -p:VSTestResultsDirectory='${{ github.workspace }}/${{ env.TEST_RESULTS }}/$(MSBuildProjectName)/$(TargetFramework)' - name: Upload coverage to Codecov uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 @@ -266,9 +262,9 @@ jobs: if-no-files-found: warn retention-days: 14 - # Windows-only: build the whole solution (Avalonia + WPF), generate DocFX documentation for all three - # runtime assemblies, and pack all three NuGet packages. Runs on Windows because the WPF assemblies must build - # for both the DocFX metadata and the WPF package. + # Windows-only: build the whole solution (Avalonia + WPF), generate DocFX documentation for the runtime + # assemblies, and pack every package. Runs on Windows because the WPF assemblies must build for both the + # DocFX metadata and the WPF package. package-and-docs: name: Package and docs runs-on: windows-latest @@ -314,10 +310,13 @@ jobs: - name: Build DocFX metadata run: dotnet tool run docfx metadata ${{ env.DOCFX_CONFIG }} + # --warningsAsErrors: a docfx warning is a broken cross-reference or a file the configuration does + # not reach, and both of those arrive on the published site as a hole. - name: Build DocFX site - run: dotnet tool run docfx build ${{ env.DOCFX_CONFIG }} + run: dotnet tool run docfx build ${{ env.DOCFX_CONFIG }} --warningsAsErrors # IsPackable in the project files is the source of truth for which projects ship as packages. + # Package validation runs here, against the last published version. - name: Pack NuGet packages run: dotnet pack ${{ env.SOLUTION }} --configuration ${{ env.CONFIGURATION }} --no-build --output ${{ env.PACKAGE_OUTPUT }} /p:ContinuousIntegrationBuild=true @@ -350,8 +349,7 @@ jobs: # Prove the source generator's Roslyn floor forever: consume the packed packages with ONLY the .NET 8 # SDK. A generator compiled against a newer Roslyn than the .NET 8 compiler would emit CS9057, skip - # generation, and fail the consumer build on the missing …Keys types. The temporary global.json pins - # the 8.0 SDK for the consumer subtree only and is never committed. + # generation, and fail the consumer build on the missing …Keys types. verify-net8-consumers: name: Consume packages on .NET 8 SDK (${{ matrix.os }}) runs-on: ${{ matrix.os }} @@ -382,6 +380,9 @@ jobs: name: nuget-packages path: ${{ env.PACKAGE_OUTPUT }} + # The pin covers the consumer subtree only, and every step below runs from there: `dotnet` resolves + # global.json from the current directory upward, so a command run from the repository root would + # pick up the root global.json instead and prove nothing. - name: Pin the .NET 8 SDK for the consumers (CI only, not committed) shell: bash run: | @@ -394,24 +395,49 @@ jobs: } JSON - - name: Confirm the active SDK is 8.0 + - name: Assert the active SDK is 8.0 shell: bash working-directory: ${{ env.CONSUMER_DIRECTORY }} - run: dotnet --version - + run: | + version="$(dotnet --version)" + echo "Active SDK: ${version}" + case "${version}" in + 8.*) ;; + *) echo "::error title=Wrong SDK::Expected an 8.0 SDK, got ${version}."; exit 1 ;; + esac + + # -p:TargetFrameworks=net8.0 as well as --framework: `dotnet run --framework` still EVALUATES every + # entry in TargetFrameworks during restore, and an 8.0 SDK cannot evaluate net10.0 (NETSDK1045). - name: Run Avalonia package consumer (net8.0 SDK) - run: dotnet run --project ${{ env.AVALONIA_CONSUMER_PROJECT }} --configuration ${{ env.CONFIGURATION }} -p:ResXLocalizationVersion=${{ needs.package-and-docs.outputs.version }} + 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 WPF package consumer (net8.0 SDK) if: runner.os == 'Windows' - run: dotnet run --project ${{ env.WPF_CONSUMER_PROJECT }} --configuration ${{ env.CONFIGURATION }} -p:ResXLocalizationVersion=${{ needs.package-and-docs.outputs.version }} + working-directory: ${{ env.CONSUMER_DIRECTORY }} + run: dotnet run --project WpfConsumer/WpfConsumer.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' - run: dotnet run --project ${{ env.COMBINED_CONSUMER_PROJECT }} --configuration ${{ env.CONFIGURATION }} -p:ResXLocalizationVersion=${{ needs.package-and-docs.outputs.version }} + working-directory: ${{ env.CONSUMER_DIRECTORY }} + run: dotnet run --project CombinedConsumer/CombinedConsumer.csproj --configuration ${{ env.CONFIGURATION }} -p:ResXLocalizationVersion=${{ needs.package-and-docs.outputs.version }} + # The pin is a CI-only file. Removing it keeps a reused workspace from carrying an 8.0 SDK + # selection into whatever runs there next. + - name: Remove the temporary SDK pin + if: always() + 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. + # + # Three legs. 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.runtime }}) + name: Verify packed-package Native AOT (${{ matrix.framework }}, ${{ matrix.runtime }}) runs-on: ${{ matrix.os }} timeout-minutes: 45 needs: package-and-docs @@ -421,10 +447,13 @@ jobs: include: - os: ubuntu-latest runtime: linux-x64 - executable: AvaloniaConsumer + framework: net8.0 + - os: ubuntu-latest + runtime: linux-x64 + framework: net10.0 - os: windows-latest runtime: win-x64 - executable: AvaloniaConsumer.exe + framework: net10.0 steps: - name: Checkout @@ -435,23 +464,74 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: - dotnet-version: 10.0.x + # The 8.0 runtime is what the net8.0 leg's build-time tooling runs on. + dotnet-version: | + 8.0.x + 10.0.x dotnet-quality: ga + # ILC links the native image with the platform toolchain. Without these the link step fails with a + # message about a missing linker rather than about a missing prerequisite. + - name: Provision the Linux native toolchain + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install --yes clang zlib1g-dev + + - name: Verify the Windows native toolchain + if: runner.os == 'Windows' + shell: pwsh + run: | + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio/Installer/vswhere.exe' + + if (-not (Test-Path -LiteralPath $vswhere)) + { + Write-Output "::error title=Missing toolchain::vswhere.exe was not found at $vswhere. The Native AOT link step needs it and fails with MSB3073 without it." + exit 1 + } + + $installation = & $vswhere -latest -products '*' -property installationPath ` + -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 + + if (-not $installation) + { + Write-Output '::error title=Missing toolchain::No Visual Studio installation with the MSVC x64 C++ tools was found.' + exit 1 + } + + Write-Output "MSVC toolchain: $installation" + + $windowsSdk = Get-ChildItem -Path 'C:/Program Files (x86)/Windows Kits/10/Lib' -Directory -ErrorAction SilentlyContinue | + Sort-Object Name | + Select-Object -Last 1 + + if (-not $windowsSdk) + { + Write-Output '::error title=Missing Windows SDK::No Windows SDK was found under Windows Kits/10/Lib; the native link step needs its import libraries.' + exit 1 + } + + Write-Output "Windows SDK: $($windowsSdk.Name)" + - name: Download exact packages to be published uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: nuget-packages path: ${{ env.PACKAGE_OUTPUT }} - - name: Native AOT publish package-only consumer - run: dotnet publish ${{ env.AVALONIA_CONSUMER_PROJECT }} --configuration ${{ env.CONFIGURATION }} -r ${{ matrix.runtime }} --self-contained true -p:PublishAot=true -p:ResXLocalizationVersion=${{ needs.package-and-docs.outputs.version }} --output ${{ env.AOT_OUTPUT }}/${{ matrix.runtime }} + # 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. + - 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 }} - - name: Assert native binary and German satellite - shell: bash - run: | - test -f "${AOT_OUTPUT}/${{ matrix.runtime }}/${{ matrix.executable }}" - test -f "${AOT_OUTPUT}/${{ matrix.runtime }}/de/AvaloniaConsumer.resources.dll" + - name: Upload the Native AOT logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: package-aot-logs-${{ matrix.framework }}-${{ matrix.runtime }} + path: ${{ env.AOT_OUTPUT }}/logs + if-no-files-found: warn + retention-days: 14 # Publish the DocFX site to GitHub Pages on every push to main (requires Pages to be set to # "GitHub Actions" as the source in the repository settings). @@ -473,15 +553,20 @@ jobs: id: deployment uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5 - # Publishing is deliberate, not automatic: it runs only when a version tag (v1.2.3) is pushed, never - # on regular pushes to main. + # Publishing is deliberate, not automatic: it runs only when a version tag (v1.2.3) is pushed, never on + # regular pushes to main. + # + # Nothing reaches NuGet.org that has not been built, tested, validated on pack, published natively and + # installed as a package first: the needs below name every one of those jobs, so a green publish cannot + # be reached by any path that skipped one. publish-nuget: name: Publish to NuGet.org runs-on: ubuntu-latest timeout-minutes: 15 needs: + - verify-avalonia + - verify-wpf - package-and-docs - # Nothing reaches NuGet.org that a real consumer could not install and run. - verify-package-aot - verify-net8-consumers if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') @@ -519,7 +604,8 @@ jobs: shell: bash # The Version property in Directory.Build.props is the single source of truth; tagging a # different version would otherwise publish stale packages (or silently publish nothing, - # thanks to --skip-duplicate). + # thanks to --skip-duplicate). The expected set is named here as well, so a pack that produced + # fewer packages than it should cannot reach NuGet.org. run: | tag_version="${GITHUB_REF_NAME#v}" status=0 diff --git a/.gitignore b/.gitignore index d8e559f..0d1457f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,49 @@ -# Build outputs -bin/ -obj/ +# Only generated output belongs here. +# +# An ignore rule that matches an AUTHORED file is worse than no rule: the file is invisible to `git status`, +# so it is never added, and the failure shows up as a build that works for you and for nobody else. So no +# rule here names a source file, and none uses a pattern broad enough to swallow one - the hand-authored +# *.Designer.cs accessors beside each .resx, for instance, look generated and are not. + +# --- Build output ------------------------------------------------------------------------------------- +bin +obj + +# --- Repository-level generated directories ----------------------------------------------------------- +# artifacts/ holds everything this repository generates on purpose: the packages, the Native AOT publish +# and its logs, the generated API metadata, the documentation site, the test results and the extracted +# release notes. artifacts/ + +# --- Test output -------------------------------------------------------------------------------------- TestResults/ +*.trx +*.coverage +coverage.*.xml + +# --- Diagnostic logs ---------------------------------------------------------------------------------- *.binlog +msbuild.log + +# --- Tool and package caches -------------------------------------------------------------------------- +.dotnet/ +.nuget/ -# Isolated package cache of the consumption tests +# The package consumers' isolated NuGet cache. See tests/package-consumption/nuget.config. tests/package-consumption/.packages/ -# Generated documentation -docs/_site/ -docs/api/ +# Written by the CI "Consume packages on .NET 8 SDK" job to pin the SDK for the consumer subtree, and by a +# developer reproducing that job locally. Never committed - the repository's own global.json pins 10.0. +tests/package-consumption/global.json -# IDE and user-specific files +# --- Editor and machine-local files ------------------------------------------------------------------- .vs/ .vscode/ .idea/ -*.user +**/*.suo +**/*.user +**/*.DotSettings.user +**/Properties/launchSettings.json -# OS cruft +# --- OS cruft ------------------------------------------------------------------------------------- .DS_Store diff --git a/AGENTS.md b/AGENTS.md index d8c997b..f0f2f73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,56 +1,135 @@ # AGENTS.md -Machine-oriented guide for AI-assisted contributors. This distills [`CONTRIBUTING.md`](CONTRIBUTING.md) -into the conventions that are easy to get wrong; read `CONTRIBUTING.md` for the full rationale. +Machine-oriented guidance for this repository. [`CONTRIBUTING.md`](CONTRIBUTING.md) is the human document and +carries the rationale; this file is the commands, the constraints and the triggers. -## Build & test +Everything reusable lives in one place — see [`.agents/README.md`](.agents/README.md) for how the AI files fit +together. + +## Project map + +| 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.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/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 | + +## Build and test Requires the **.NET 10 SDK** (pinned in `global.json`). From the repository root: ```shell -# Build. Windows builds everything; Linux/macOS must use the filter (WPF is Windows-only). +# Build. Windows builds everything; Linux and macOS must use the filter, because WPF is Windows-only. dotnet build ResXLocalization.slnx -c Release # Windows dotnet build ResXLocalization.NonWindows.slnf -c Release # Linux/macOS -# Test. The first three run on every OS; the WPF suite is Windows-only. -dotnet test tests/ResXLocalization.Core.Tests/ResXLocalization.Core.Tests.csproj -c Release -dotnet test tests/ResXLocalization.SourceGenerators.Tests/ResXLocalization.SourceGenerators.Tests.csproj -c Release -dotnet test tests/ResXLocalization.Avalonia.Sample.Tests/ResXLocalization.Avalonia.Sample.Tests.csproj -c Release -dotnet test tests/ResXLocalization.WPF.Sample.Tests/ResXLocalization.WPF.Sample.Tests.csproj -c Release # Windows only +# Test. One command per solution; dotnet test discovers the suites and every target framework. +dotnet test ResXLocalization.slnx -c Release # Windows +dotnet test ResXLocalization.NonWindows.slnf -c Release # Linux/macOS, no WPF suite +``` -# Format before committing (CI fails otherwise). -pwsh -File scripts/tidy-code.ps1 +Before committing: + +```shell +pwsh -File scripts/pre-commit-gate.ps1 # checks; -Fix applies the tidying ``` -## Conventions the build enforces (get these right or CI fails) - -- **BCL type names, not keyword aliases.** Write `String`, `Int32`, `Boolean`, `Object` - never - `string`, `int`, `bool`, `object`. The whole codebase does this deliberately; `SA1121` is disabled - to allow it. -- **Zero warnings.** `TreatWarningsAsErrors` + `AnalysisMode=All` + StyleCop + Roslynator + - ErrorProne.NET. A warning is a build failure. Match the surrounding style; `.editorconfig` decides. -- **Line endings are LF everywhere.** `.gitattributes` and `.editorconfig` enforce this and CI verifies - it. Never hand-convert line endings, and never compare a multi-line source literal against - `Environment.NewLine` - the literal carries the file's bytes, `Environment.NewLine` carries the host's. -- **Avalonia/WPF symmetry.** `src/ResXLocalization.Avalonia` and `src/ResXLocalization.WPF` mirror - each other (markup extensions, converters, behaviors). A change to one almost always needs the - mirrored change in the other, plus mirrored tests. -- **Native AOT stays intact (Avalonia + Core).** No reflection over resources; no new `IL2026`/ - `IL3050`. The WPF package is exempt (WPF has no AOT). -- **Public API tracking.** When you add or change public API, the `PublicApiAnalyzers` build error - tells you the exact line to add to that project's `PublicAPI.Unshipped.txt`. Add it. (On release the - maintainer promotes `Unshipped` → `Shipped`.) -- **Docs + changelog.** Public API changes need XML doc comments; user-facing changes need a - `README.md` update and a `CHANGELOG.md` entry under *Unreleased*. Do **not** bump version numbers. - -## Repo shape - -- `src/ResXLocalization.Core` - the ambient `Localizer` engine (`ILocalizer`, `ResourceKey`, events). -- `src/ResXLocalization.SourceGenerators` - incremental generator emitting typed `…Keys` classes from - `.resx`. Targets `netstandard2.0`; pinned to Microsoft.CodeAnalysis **4.8.0** so it loads in the - .NET 8/9 SDK compilers. Keep incremental-pipeline models value-equatable (see `EquatableArray.cs`). -- `src/ResXLocalization.Avalonia` / `src/ResXLocalization.WPF` - the two UI packages, each bundling - Core + the generator. -- `samples/` - demonstration apps (excluded from coverage). -- `tests/package-consumption` - E2E consumers that get everything from the packed NuGet packages; - the repo `Directory.Build.*` deliberately does not apply there. +The gate is the loop. It runs the public-API reminder, checks line endings, style, formatting and member +ordering, builds Release and runs the test suites. Before pushing a branch you want CI to go green on, run +`pwsh -File scripts/pre-release-gate.ps1`, which adds the documentation build, the pack, the Native AOT gate +and the package consumers. + +## Code style, formatting and ordering + +Four tools, one concern each; all of the C# ones are build errors, in `samples/` and `tests/` as much as in +`src/`. Apply them with `pwsh -File scripts/tidy-code.ps1 -Scope all`. + +- **C# keywords, never BCL type names.** `string`, `int`, `bool`, `object?` — not `String`, `Int32`, + `Boolean`, `Object?`. +- **`var` for every local declaration.** Two of the three cases are errors; the third, where nothing on the + line names the type, is a judgement call and stays silent. +- **Always `this.`** for instance members, and no field begins with an underscore. +- **Expression bodies** wherever a member is a single expression; **file-scoped namespaces** with the usings + outside; braces always. +- **Primary constructors**, with each parameter assigned to a `private readonly` field that members read + through `this.`. +- **Line endings are LF everywhere**, in the repository and in the working tree. Never hand-convert them, and + never compare a multi-line source literal against `Environment.NewLine`: the literal carries the file's + bytes, `Environment.NewLine` carries the host's. +- **Two spaces** for `.csproj`, `.props`, `.targets`, `.slnx`, `.config`, `.xml` and `.json`. + +`IDE0049` — the keyword-alias rule — is **not** reported by an ordinary build, and the member order is only +half-checked by the analyzers. Both details, and the XamlStyler settings that are switched off because they +change rendering rather than layout, are in +[`.agents/references/code-style.md`](.agents/references/code-style.md). + +## The constraints that are not obvious + +- **The generator's compiler floor is Roslyn 4.8.0**, set for that project in `Directory.Packages.props`. A + generator built against a newer Roslyn does not load in an older compiler: `CS9057`, no generated keys, and + a consumer build that fails on missing types. Every other Roslyn reference in the repository, including the + generator's own test project, uses the current version. +- **The shipped Avalonia floor is 12.0.5**, also set per project in `Directory.Packages.props`. Avalonia + 12.1's XAML generator requires Roslyn 4.14, which the .NET 8 SDK cannot load. The samples and tests resolve + 12.1.0; the floor is a promise to consumers, not a stale version. +- **WPF re-invokes its own projects.** The markup compiler builds a temporary copy under a randomized + `__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. +- **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 + analyzer or a local generator import there. + +## Triggers: when a change needs more than the gate + +| Trigger | What to run | Reviewer | +| --- | --- | --- | +| 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` | — | + +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). + +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 +`PublicAPI.Unshipped.txt`. An undeclared public member is `RS0016`; a declared one that is gone is `RS0017`. +Both are build errors. + +```shell +pwsh -File scripts/update-public-api.ps1 # record the current surface in Unshipped +``` + +Review that diff line by line — a `*REMOVED*` entry is a break. The generator has no tracked public API; its +equivalent is analyzer release tracking in `AnalyzerReleases.*.md`. + +**Never bump a version, and never date a changelog section.** The version in the repository-root +`Directory.Build.props`, the release date, promoting `Unshipped` to `Shipped`, and the tag are the +maintainer's, at release time. + +## Commits and branches + +[Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) with a lowercase, imperative summary: +`build: standardize repository tooling`. Types in use: `feat`, `fix`, `docs`, `test`, `build`, `ci`, `chore`. +A breaking change is `feat!:` or `fix!:` plus a `BREAKING CHANGE:` **footer** — never a `BREAKING CHANGE` +type. + +Branches are `/issue--`, with the types `feature`, `bugfix`, `hotfix`, `release` and +`chore`; drop the issue segment when there is no issue. The same policy applies to humans and to agents. + +The [`commit`](.agents/skills/commit/SKILL.md) skill is explicit-invocation only: it inspects the diff, runs +the required checks, stages only what was authorized, and commits when asked. It never pushes. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e69645..8168561 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/) and this project adheres to [Semantic Versioning](https://semver.org/). +## [Unreleased] + ## [1.1.0] - 2026-07-20 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index f329436..491101a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,2 +1,16 @@ -See [AGENTS.md](AGENTS.md) for the build/test commands and the repo conventions (BCL type names, -Avalonia/WPF symmetry, Native AOT, the PublicAPI workflow, and formatting). +# CLAUDE.md + +Read [AGENTS.md](AGENTS.md) first — it is the canonical guidance, and all of it applies to Claude Code. + +`.claude/` holds only discovery metadata and hook adapters; the procedures live in `.agents/` and `scripts/`. + +| File | What it wires | +| --- | --- | +| `.claude/settings.json` | The two `PostToolUse` hooks that run after `Edit` and `Write` | +| `.claude/hooks/tidy-code.ps1` | Formats the file the edit touched, by calling `scripts/tidy-code.ps1` | +| `.claude/hooks/public-api-guard.ps1` | Prints the companion-edit checklist when a `PublicAPI.*.txt` file moves | +| `.claude/skills/commit/SKILL.md` | `disable-model-invocation: true`; points at `.agents/skills/commit/SKILL.md` | +| `.claude/agents/*.md` | The two reviewers, granted `Read`, `Grep` and `Glob` only | + +[`.agents/README.md`](.agents/README.md) explains what the hooks will and will not do, and when to run +`scripts/tidy-code.ps1` yourself because no hook saw the edit. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eddcaa2..d8b6bea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,23 +1,40 @@ # Contributing to ResXLocalization -Thank you for considering a contribution! This document explains how to propose changes and what a -pull request needs to be merged. +Thank you for considering a contribution! This document explains how to propose changes and what a pull +request needs to be merged. -Please note that this project has a [Code of Conduct](CODE_OF_CONDUCT.md); by participating, you -agree to abide by it. +This project has a [Code of Conduct](CODE_OF_CONDUCT.md); by participating, you agree to abide by it. ## Before you start -- **Bugs:** open an issue with a minimal reproduction (a small `.resx` + XAML/C# snippet is usually - enough). If you can, say which package (`ResXLocalization.Avalonia` / `ResXLocalization.WPF`) and - which lookup mode (typed / scoped / search-all / enum) is affected. -- **Features and larger changes:** open an issue first and describe what you want to change and why, - so we can discuss the approach before you invest time in an implementation. -- **Small fixes** (typos, doc corrections, obvious one-liners) can go straight to a pull request. +- **Bugs:** open an issue with a minimal reproduction — usually a small `.resx` entry plus the XAML or C# + lookup, and the culture you switched to. Say which package and which lookup mode is affected; the issue + form asks for both. +- **Features and larger changes:** open an issue first and describe what you want to change and why, so the + approach can be discussed before you invest time in an implementation. +- **Small fixes** — typos, documentation corrections, obvious one-liners — can go straight to a pull request. -## Development setup +## Prerequisites -You need the **.NET 10 SDK** (see `global.json`). Everything builds from the repository root: +| 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 | +| **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 | + +Restore the local tools once per clone: + +```shell +dotnet tool restore --configfile NuGet.config +``` + +That installs CSharpier, DocFX, the ReSharper command-line tools and XamlStyler. **No script in this +repository installs a tool for you** — a formatter that installs software behind your back is a worse problem +than an unformatted file. + +## Setting up ```shell # Windows: build everything. @@ -25,85 +42,221 @@ dotnet build ResXLocalization.slnx -c Release # Linux/macOS: build everything except the Windows-only WPF projects. dotnet build ResXLocalization.NonWindows.slnf -c Release +``` -# Run the test suites (the first three work on every OS). -dotnet test tests/ResXLocalization.Core.Tests/ResXLocalization.Core.Tests.csproj -c Release -dotnet test tests/ResXLocalization.SourceGenerators.Tests/ResXLocalization.SourceGenerators.Tests.csproj -c Release -dotnet test tests/ResXLocalization.Avalonia.Sample.Tests/ResXLocalization.Avalonia.Sample.Tests.csproj -c Release -dotnet test tests/ResXLocalization.WPF.Sample.Tests/ResXLocalization.WPF.Sample.Tests.csproj -c Release # Windows only +```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. +dotnet test ResXLocalization.slnx -c Release # Windows +dotnet test ResXLocalization.NonWindows.slnf -c Release # Linux/macOS, without the WPF suite ``` -Format the code before committing - CI enforces it: +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 +dispatcher on its own thread and is Windows-only. + +### One optional per-clone setting ```shell -pwsh -File scripts/tidy-code.ps1 +git config --local blame.ignoreRevsFile .git-blame-ignore-revs ``` -## Line endings +`.git-blame-ignore-revs` lists the repository-wide mechanical commits — a line-ending renormalization, a +formatting pass — so that `git blame` attributes a line to the change that wrote it rather than to the tool +that reformatted it. GitHub applies the file automatically; your local git does not until you tell it to. +It is optional, it is per clone, and no script sets it for you. + +## The two gates + +The names say when to run them. + +```shell +pwsh -File scripts/pre-commit-gate.ps1 # before every commit +pwsh -File scripts/pre-release-gate.ps1 # before a push you want CI to go green on +``` -Every text file is LF, in the repository and in the working tree, on every OS. `.gitattributes` -enforces this whatever your `core.autocrlf` is set to, so there is nothing to configure, and CI fails -if a wrongly stored file lands anyway. +**`pre-commit-gate.ps1`** is the per-commit loop: the public-API reminder, line endings, the full style, +formatting and ordering check, a Release build, and the test suites. By default it **writes nothing but build +output** — it does not edit your source files and it does not touch the git index; the tidiness step runs the +tools on a disposable copy of the tree and prints the diff that would fix it. -`.editorconfig` also asks editors and formatters to write LF. XamlStyler cannot: it always writes the -host OS newline, so `scripts/tidy-code.ps1` rewrites the XAML back to LF after running it. If some -other tool writes CRLF, git still stores LF, but `git status` lists the file as modified while -`git diff` shows nothing. Run `pwsh -File scripts/tidy-code.ps1` to fix it, or `git checkout -- `. +Pass `-Fix` to have it tidy your working tree first, then review what changed and include it in your commit. -If you have set `git config core.safecrlf true`, git refuses to add such a file with "CRLF would be -replaced by LF". Run the tidy script first, or use `core.safecrlf warn`. +Two checks are deliberately left out of it, because each takes minutes and neither applies to every change. +The script names them, with their trigger and their command, in its own summary output: -One more one-time step after cloning, so `git blame` skips whole-repository mechanical commits (GitHub -does this automatically): +| 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 | + +**`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.` + +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. + +## Checking versus fixing + +Every check in this repository reports by default and fixes only when asked. That is deliberate: a check that +rewrites your tree makes it impossible to tell what you wrote from what a tool wrote. ```shell -git config blame.ignoreRevsFile .git-blame-ignore-revs +pwsh -File scripts/tidy-code.ps1 # format the files git reports as changed +pwsh -File scripts/tidy-code.ps1 -Scope all # style, then member ordering, then formatting, everywhere +pwsh -File scripts/tidy-code.ps1 -Scope all -Check # report only - writes nothing, touches no index ``` -To refresh a clone made before this policy (commit or stash your changes first - the second command -discards uncommitted work): +`-Check` never writes to your working tree at any scope. Where a tool has no verify mode — XamlStyler's +passive check rejects every LF file on Windows, and ReSharper has none at all — the tools run for real on a +disposable copy outside the repository and the diff from there is what you see. + +## Style + +Four tools, one concern each, and the C# ones are build errors rather than warnings: + +| Concern | Tool | +| --- | --- | +| C# formatting | CSharpier | +| C# style | the Roslyn analyzers, through `dotnet format style` | +| C# member ordering | ReSharper applies it; NewStyleCop checks part of it | +| XAML and AXAML layout | XamlStyler | + +Write `string`, not `String`. Use `var` for locals. Qualify instance members with `this.`. Use expression +bodies for single-expression members, file-scoped namespaces with the usings outside, and braces always. +Fields never begin with an underscore. + +If the build is clean and `tidy-code.ps1 -Scope all -Check` passes, the style is fine. The details a tool +cannot tell you are in +[`.agents/references/code-style.md`](https://github.com/rent-a-developer/ResXLocalization/blob/main/.agents/references/code-style.md) +— an absolute link, because the documentation site publishes this page and not that one. + +## Line endings + +Every text file is LF, in the repository and in the working tree, on every OS. `.gitattributes` enforces this +whatever your `core.autocrlf` is set to, so there is nothing to configure, and CI fails if a wrongly stored +file lands anyway. + +XamlStyler cannot write LF on Windows — it always writes the host newline — so `scripts/tidy-code.ps1` +rewrites exactly the XAML files it processed back to LF afterwards. + +If some other tool writes CRLF, git still stores LF, but `git status` lists the file as modified while +`git diff` shows nothing. `pwsh -File scripts/verify-line-endings.ps1` reports both what git stored and what +is on disk, and says what fixes each. If you have set `core.safecrlf true`, git refuses to add such a file +with "CRLF would be replaced by LF"; run the tidy script first. + +## Avalonia and WPF symmetry + +`src/ResXLocalization.Avalonia` and `src/ResXLocalization.WPF` are deliberate mirrors: the same markup +extensions, the same converter, the same attached properties, the same names and defaults. A change to one +almost always needs the mirrored change in the other, and mirrored tests. + +Where the frameworks genuinely differ the difference is expected to be local and explained — Avalonia binds +through an observable and weak events, WPF through a `MultiBinding` and its own weak binding-target +references, and WPF has no Native AOT. None of those is a reason for a different public API. + +Anything that is not framework-specific belongs in `ResXLocalization.Core`, where both packages share it. + +## 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 +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. + +The WPF package is exempt — WPF does not support Native AOT. + +## Public API + +The three 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. ```shell -git rm -r --cached . -q -git reset --hard +pwsh -File scripts/update-public-api.ps1 +``` + +That writes the missing entries into the project's `PublicAPI.Unshipped.txt`. **Review that diff line by +line**: it *is* the public-API change, and an entry starting with `*REMOVED*` is a break. + +A public-surface change also needs XML documentation on the new members, the affected pages under `docs/` +updated, and a `CHANGELOG.md` entry. + +**Do not bump a version.** The version in the repository-root `Directory.Build.props`, the release date, +promoting `Unshipped` to `Shipped`, and the tag are all the maintainer's, at release time. Describing the +change accurately under `## [Unreleased]` is what lets them choose the number. + +## Commits and branches + +[Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/), with a **lowercase, imperative** +summary: + +```text +feat: add dynamic format arguments to the localize extension +fix: stop the enum converter caching the previous culture +build: standardize repository tooling +``` + +Types in use: `feat`, `fix`, `docs`, `test`, `build`, `ci`, `chore`. A breaking change is `feat!:` or `fix!:` +plus a `BREAKING CHANGE:` **footer** saying what breaks and what to do about it — `BREAKING CHANGE` is a +footer, never a type. + +### Branches + +```text +/issue-- feature/issue-42-scoped-enum-lookup +/ chore/tidy-sample-resources ``` +Types: `feature`, `bugfix`, `hotfix`, `release`, `chore`. Omit the issue segment when there is no issue. The +same policy applies to people and to AI agents. + +## Changelog + +`CHANGELOG.md` follows [Keep a Changelog](https://keepachangelog.com/). Add your entry under +`## [Unreleased]`, in the right category (`### Added`, `### Changed`, `### Fixed`, …). Write a breaking +change as `- **BREAKING:** …`. + +Internal formatting and tooling work needs no entry: nothing about it reaches a consumer. + ## Pull request checklist -1. **Zero warnings.** The build treats warnings as errors and runs a strict analyzer set - (`AnalysisMode=All`, StyleCop, Roslynator, ErrorProne.NET). `dotnet build -c Release` must - succeed cleanly. -2. **Tests pass - and new behavior is tested.** All four suites must be green (Core engine, source - generator, Avalonia, WPF). Bug fixes should include a test that fails without the fix; features - need coverage for the new behavior (both the Avalonia and the WPF side, if applicable - the two - engines deliberately mirror each other). -3. **Keep the Avalonia/WPF symmetry.** A change to a markup extension, converter, or behavior in one - UI package almost always needs the mirrored change in the other. -4. **Keep Native AOT support intact (Avalonia/Core).** No reflection over resources, no new - `IL2026`/`IL3050` warnings. The WPF package is exempt (WPF does not support AOT). -5. **Declare public API changes.** The libraries track their public surface with - [PublicApiAnalyzers](https://github.com/dotnet/roslyn/tree/main/src/RoslynAnalyzers/PublicApiAnalyzers): - when you add or change public API, the build tells you exactly which line to add to the - project's `PublicAPI.Unshipped.txt`. That file is part of the review. (On release, the maintainer - promotes `PublicAPI.Unshipped.txt` entries into `PublicAPI.Shipped.txt`.) -6. **Update the documentation.** Public API changes need XML doc comments and, where user-facing, a - matching update to `README.md`. -7. **Update `CHANGELOG.md`** under the *Unreleased* heading, following - [Keep a Changelog](https://keepachangelog.com/). Do **not** bump version numbers - versioning - ([SemVer](https://semver.org/)) and releases are handled by the maintainer. -8. **Match the existing code style.** It is enforced by `.editorconfig` on build; if the build is - clean, the style is fine. - -## A note on `String` vs. `string` - -This codebase deliberately uses the BCL type names (`String`, `Int32`, `Boolean`, …) instead of the -C# keyword aliases - the maintainer strongly prefers seeing the actual type. This deviates from the -common C# convention, it is a conscious choice, and it is enforced by the build -(`dotnet_style_predefined_type_*` = error). Please follow it in contributions rather than debating -it in pull requests. - -A maintainer will review your pull request, possibly request changes, and merge it once it is -approved and CI is green. +The template in the repository states this as a conditional list. In short: + +1. `pwsh -File scripts/pre-commit-gate.ps1` passes. +2. The Release build produces zero warnings. +3. New behaviour and fixed bugs are covered by tests, on both UI sides where both apply. +4. A change to one UI package is mirrored into the other, or the description says why not. +5. Public API changes are declared and reviewed; documentation and the changelog are updated. +6. The branch name follows the pattern above. + +A maintainer will review your pull request, possibly request changes, and merge it once it is approved and CI +is green. + +## For the maintainer: releasing + +1. Land everything for the release on `main`, with the changelog entries under `## [Unreleased]`. +2. Move those entries into a new `## [x.y.z] - YYYY-MM-DD` section and add the link reference definition. +3. Set `` in the repository-root `Directory.Build.props` to the same number, and set + `PackageValidationBaselineVersion` in `src/Directory.Build.props` to the version being replaced. +4. `pwsh -File scripts/update-public-api.ps1 -MarkShipped` — folds `Unshipped` into `Shipped` for each + runtime project. +5. `pwsh -File scripts/pre-release-gate.ps1 -Version x.y.z`, on Windows. The `-Version` switch adds the two + checks CI runs immediately before it publishes: that the declared version matches, and that the changelog + holds exactly one dated, non-empty section for it. +6. Commit, then push the tag `vx.y.z`. Pushing the tag is what publishes: CI packs, verifies every gate, + pushes to NuGet.org and creates the GitHub release from the changelog section. + +Nothing about a release happens on a branch push, and nothing in this repository publishes anything locally. ## Questions diff --git a/Directory.Build.props b/Directory.Build.props index da9c74a..b0fa9dd 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,46 +1,48 @@ - + + + - + 1.1.0 + + David Liebeherr, rent-a-developer © David Liebeherr, rent-a-developer - - true - - true - true - false - logo-128.png - MIT - https://github.com/rent-a-developer/ResXLocalization - PACKAGE_README.md - $(PackageProjectUrl)/blob/main/CHANGELOG.md - git - $(PackageProjectUrl).git - snupkg - - - The shared, UI-agnostic runtime engine for ResXLocalization, with live culture switching, multiple resource managers, typed resource keys, and Native AOT support. Installed automatically as a dependency of ResXLocalization.Avalonia and ResXLocalization.WPF - most applications should install one of those packages instead. - localization;resx;i18n;l10n;aot - - - Type-safe .resx localization for Avalonia: compile-checked resource keys generated from your .resx files, live language switching without reloads, enum localization, and multiple-.resx support. Fully Native AOT and trimming compatible. - avalonia;localization;resx;i18n;l10n;aot;source-generator;mvvm + + enable + latest + en + enable - - Type-safe .resx localization for WPF: compile-checked resource keys generated from your .resx files, live language switching without reloads, enum localization, and multiple-.resx support. Windows-only; Native AOT is not supported for WPF. - wpf;localization;resx;i18n;l10n;source-generator;mvvm + + + + false - @@ -50,24 +52,13 @@ net8.0-windows;net10.0-windows - - - latest - All - true - true - enable - latest - enable - true - - + the RentADeveloper vendor prefix (ResXLocalization.Core overrides RootNamespace because its + types sit directly in RentADeveloper.ResXLocalization). + WPF's markup compiler builds a temporary copy of the project under a randomized + __wpftmp name; _TargetAssemblyProjectName then carries the real project name, + so prefer it to keep AssemblyName/RootNamespace stable during that pass. _ConventionProjectName + is also what the conditions in src/Directory.Build.props test, for the same reason. --> <_ConventionProjectName>$(MSBuildProjectName) <_ConventionProjectName Condition="'$(_TargetAssemblyProjectName)' != ''">$(_TargetAssemblyProjectName) @@ -75,51 +66,81 @@ RentADeveloper.$(_ConventionProjectName) - - en + + true + + + + true + true + + + true + + + + + true + all + moderate + NU1901;NU1902;NU1903;NU1904 + projects run ResolveReferences while packing (see _AddAnalyzersToPackage), which would + otherwise invoke the Build target on their ProjectReferences and fail with NETSDK1085. + Everything is already built, so just stop project references from building again. --> false - - - - - + - + + + - - - - - - - - - - - + diff --git a/Directory.Build.targets b/Directory.Build.targets index c58f04d..6fc2cf5 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -1,12 +1,35 @@ - - + + + + + + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index cefd3db..7d07660 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,37 +1,83 @@ - - - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PACKAGE_README.md b/PACKAGE_README.md index 8c4ede6..6a71c4c 100644 --- a/PACKAGE_README.md +++ b/PACKAGE_README.md @@ -7,16 +7,35 @@ runtime, and a language change that only takes effect after a restart. ResXLocal you keep the `.resx` files and editors you already use, and get **compile-checked keys** and **instant, in-place language switching**. +## 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. - 📦 **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. +- 🧮 **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. - 🌐 **Language-picker ready** - `GetAvailableCultures()` discovers the cultures your app actually ships. - 🚀 **Native AOT & trim clean** (Avalonia package) - no reflection over your resources. +## Install + +```shell +dotnet add package ResXLocalization.Avalonia # Avalonia +dotnet add package ResXLocalization.WPF # WPF +``` + +| Package | UI framework | Targets | Native AOT | +| ------- | ------------ | ------- | ---------- | +| `ResXLocalization.Avalonia` | Avalonia 12 | `net8.0` · `net10.0` | ✅ | +| `ResXLocalization.WPF` | WPF | `net8.0-windows` · `net10.0-windows` | ❌ (WPF limitation) | + +Both share the same UI-agnostic engine (`ILocalizer` / `Localizer.Current`, `ResourceKey`), the same +source generator and the same MSBuild wiring, all of which arrive with the package you install. +`ResXLocalization.Core` comes along as a dependency, and one copy of it satisfies both UI packages in +an application that uses them together. + ## Requirements - An app targeting **.NET 8** or later, built with the **.NET 8 SDK or later**. @@ -24,43 +43,24 @@ you keep the `.resx` files and editors you already use, and get **compile-checke - `.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). -## Quick start - -### 1. Add your strings +## In three steps -Add a `.resx` file the normal way - for example `Resources/AppStrings.resx` (your neutral/default -language) - and a satellite file per culture, e.g. `Resources/AppStrings.de.resx`: +**1. Add a neutral `.resx` and a satellite file per culture** - `Resources/AppStrings.resx` and +`Resources/AppStrings.de.resx`, with the same key names. -| Key | `AppStrings.resx` (English) | `AppStrings.de.resx` (German) | -| ------------- | --------------------------- | ----------------------------- | -| `WindowTitle` | `My Application` | `Meine Anwendung` | -| `Greeting` | `Hello and welcome!` | `Hallo und willkommen!` | - -### 2. Build - typed keys are generated - -On every build, the source generator emits a typed key class per `.resx`, named `Keys`, -in the same namespace as your resource file. Only string entries become keys. +**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. ```csharp // - for AppStrings.resx: public static partial class AppStringsKeys { - public static readonly ResourceKey Greeting = new("Greeting", AppStrings.ResourceManager); + public static readonly ResourceKey Greeting = new("Greeting", AppStrings.ResourceManager); public static readonly ResourceKey WindowTitle = new("WindowTitle", AppStrings.ResourceManager); } ``` -### 3. Register your resources at startup - -```csharp -using RentADeveloper.ResXLocalization; -using YourApp.Resources; - -Localizer.Current.RegisterResourceManager(AppStrings.ResourceManager); -Localizer.Current.CurrentCulture = new CultureInfo("en"); -``` - -### 4. Use it in XAML +**3. Bind, and switch.** ```xml ``` -For **WPF**, map `xmlns:l` to the WPF assembly instead: - -```text -xmlns:l="clr-namespace:RentADeveloper.ResXLocalization.WPF;assembly=ResXLocalization.WPF" -``` - -### 5. Switch language - live - ```csharp Localizer.Current.CurrentCulture = new CultureInfo("de"); ``` -Every `{l:Localize}` binding re-resolves **immediately**. No reload, no flicker. - -## Localizing enums +Every `{l:Localize}` binding re-resolves immediately. No reload, no flicker. -Add one string entry per enum member to your `.resx`, named `Enum__` - -e.g. `Enum_FileSortOrder_Ascending` for `FileSortOrder.Ascending`. No attributes, no extra code. - -In item templates (`ComboBox`, `ListBox`, …), where each item *is* the enum value, use -`{l:LocalizeEnum}`: +For **WPF**, map `xmlns:l` to the WPF assembly instead: -```xml - - - - - +```text +xmlns:l="clr-namespace:RentADeveloper.ResXLocalization.WPF;assembly=ResXLocalization.WPF" ``` -In code: +### When you have to register a resource manager + +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: ```csharp -Localizer.Current.Get(FileSortOrder.Ascending); // "Ascending (A-Z)" / "Aufsteigend (A-Z)" +Localizer.Current.RegisterResourceManager(AppStrings.ResourceManager); ``` -A custom key prefix (`KeyPrefix`) and scoping to a single `.resx` file (`ResourceManager`) are -supported, and `LocalizeEnumConverter` localizes enum values bound as data - see the full -documentation. - -## Two packages, one engine - -| Package | UI framework | Targets | Native AOT | -| ------- | ------------ | ------- | ---------- | -| `ResXLocalization.Avalonia` | Avalonia 12 | `net8.0` · `net10.0` | ✅ | -| `ResXLocalization.WPF` | WPF | `net8.0-windows` · `net10.0-windows` | ❌ (WPF limitation) | - -Both share the same UI-agnostic engine (`ILocalizer` / `Localizer.Current`, `ResourceKey`), the same source generator, and the same MSBuild wiring. - ## Documentation -The full documentation - lookup modes and fallback, enum localization, multiple `.resx` files, Native AOT publishing, troubleshooting, and complete runnable sample apps for both frameworks - lives in the [project README](https://github.com/rent-a-developer/ResXLocalization#readme); the [API reference](https://rent-a-developer.github.io/ResXLocalization/) documents every type. +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 +[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 +and two complete runnable sample apps, one per UI framework. ## License diff --git a/README.md b/README.md index 5d0ad5a..acd0607 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,8 @@ Localizer.Current.CurrentCulture = new CultureInfo("de"); - 📦 **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. See [Localizing enums](#localizing-enums). -- 🧮 **Format arguments** - `Get(key, args…)` formats the translation in the active culture; in XAML, bind the `LocalizeArgs.Arg0`…`Arg8` attached properties. See [Dynamic format arguments](#dynamic-format-arguments). +- 🔤 **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. - 🌐 **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. @@ -57,31 +57,13 @@ Localizer.Current.CurrentCulture = new CultureInfo("de"); --- -## Table of Contents - -- [Requirements](#requirements) -- [Installation](#installation) -- [Quick start](#quick-start) -- [Localizing enums](#localizing-enums) -- [Dynamic format arguments](#dynamic-format-arguments) -- [Guides and API reference](#guides-and-api-reference) -- [Troubleshooting](#troubleshooting) -- [The sample applications](#the-sample-applications) -- [Building from source](#building-from-source) -- [Versioning](#versioning) -- [Contributing](#contributing) -- [License](#license) -- [Author](#author) - ---- - ## Requirements - 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 [Troubleshooting](#troubleshooting). + eligible - see [Generated keys](docs/reference/generated-keys.md). ## Installation @@ -104,7 +86,8 @@ dotnet add package ResXLocalization.WPF That's it - the source generator and the build wiring for your `.resx` files are included; there is nothing else to configure. -A few project properties are recommended. For **Avalonia** (required if you publish with Native AOT): +A few project properties are recommended. For **Avalonia** (required if you publish with Native AOT - +see [Native AOT and trimming](docs/guides/native-aot.md)): ```xml @@ -140,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 `.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 each eligible `.resx` and emits a typed key class named `Keys` in the same namespace as your resource file. For `AppStrings.resx` you get: ```csharp // @@ -154,10 +137,7 @@ public static partial class AppStringsKeys } ``` -Each `ResourceKey` carries **both** the key name and the `ResourceManager` it belongs to - that is what makes typed lookups direct and collision-free. - -> [!NOTE] -> Only **string** entries become keys. Binary resources, images, colors, and any entry carrying a `type`/`mimetype` are skipped, and resource names that aren't valid C# identifiers are sanitized into valid 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). ### 3. Register your resources at startup @@ -241,211 +221,61 @@ Localizer.Current.CurrentCulture = new CultureInfo("de"); Every `{l:Localize}` binding re-resolves **immediately**. No reload, no flicker. -## Localizing enums - -Enum members are localized **by naming convention**: add one string entry per member to any of your -`.resx` files, named `Enum__`. No attributes on the enum, no extra code. - -```csharp -public enum FileSortOrder { Unsorted, Ascending, Descending } -``` - -| Key | `AppStrings.resx` (English) | `AppStrings.de.resx` (German) | -| ------------------------------ | --------------------------- | ----------------------------- | -| `Enum_FileSortOrder_Unsorted` | `Unsorted` | `Unsortiert` | -| `Enum_FileSortOrder_Ascending` | `Ascending (A-Z)` | `Aufsteigend (A-Z)` | -| `Enum_FileSortOrder_Descending`| `Descending (Z-A)` | `Absteigend (Z-A)` | - -Like every other lookup, enum labels update live when the culture changes. The same three usages -work identically in Avalonia and WPF: - -### In item templates: `{l:LocalizeEnum}` - -Inside a `ComboBox`/`ListBox` item template each item *is* the enum value (the `DataContext`), so -`{l:LocalizeEnum}` localizes it directly: - -```xml - - - - - - - -``` - -### As a bound value: `LocalizeEnumConverter` - -When the enum is a bound property rather than the `DataContext`, use `LocalizeEnumConverter` in a -`MultiBinding`. The second binding - to the current culture - re-triggers the conversion on every -language switch: - -```xml - - - - - - - - - -``` +## Essential behavior -### In code +Three lookup modes, one fallback chain: ```csharp -Localizer.Current.Get(FileSortOrder.Ascending); // "Ascending (A-Z)" / "Aufsteigend (A-Z)" +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 ``` -### 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 - useful -for giving the same enum different label sets, or for keeping enum labels in their own file: +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 +value is not reported as missing. -```xml - -``` - -```csharp -Localizer.Current.Get(FileSortOrder.Ascending, SortingStrings.ResourceManager, "Display_"); -``` - -To customize the converter, declare your own instance in resources -(`LocalizeEnumConverter.Default` is read-only): - -```xml - -``` +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. -## Dynamic format arguments +## Documentation -A resource value can be a composite format string, and the arguments can come straight from your -view model. Add the entry as usual: - -| Key | `AppStrings.resx` (English) | `AppStrings.de.resx` (German) | -| --------------- | --------------------------- | ----------------------------- | -| `PeopleInvited` | `{0} people invited` | `{0} Personen eingeladen` | - -Then bind the **`LocalizeArgs.Arg0`…`Arg8` attached properties** on the element that carries the -localized property - 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 as -`5 Personen eingeladen` in place. - -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. -- **Per-argument format specifiers belong in the resource string** - e.g. - `{0:N0} people invited` - where translators can adjust them per language. Formatting runs in - the active culture via `String.Format`. -- **No arguments set - no formatting.** An element without any `ArgN` resolves exactly as before, - so existing resource values containing literal `{` or `}` keep working without `{{` escaping. - The missing-key sentinel (`!key!`) is likewise never formatted. -- **Keep it simple.** For very complex strings, compose the text in your view model with - `Get(key, args…)` instead of wiring many argument slots. And composite formatting does not handle - pluralization - `1 people invited` is on the resource author; use separate singular/plural - resources when it matters. - ---- - -## Guides and API reference - -The [API reference](https://rent-a-developer.github.io/ResXLocalization/) documents every public type and member. The sections below cover the operational details that go beyond the quick start. - -### Namespaces - -In C# you almost always need only the first namespace: - -| Namespace | Contains | -| ------------------------------------------ | ------------------------------------------------------------------------------ | -| `RentADeveloper.ResXLocalization` | `ILocalizer`, `Localizer`, `ResourceKey` - the shared engine | -| `RentADeveloper.ResXLocalization.Avalonia` | Avalonia `LocalizeExtension`, `LocalizeEnumExtension`, `LocalizeEnumConverter` | -| `RentADeveloper.ResXLocalization.WPF` | WPF `LocalizeExtension`, `LocalizeEnumExtension`, `LocalizeEnumConverter` | - -### Dependency injection - -Register the ambient instance so it can be injected as `ILocalizer`: - -```csharp -services.AddSingleton(_ => Localizer.Current); -``` - -Alternatively, assign a DI-owned implementation to `Localizer.Current` before creating views. The -property rejects `null`, and the markup extensions always use its current value. - -### Lookups and fallback - -Prefer generated `ResourceKey` values: they bind a compile-checked name directly to its resource -manager. Scoped string lookups accept a key and manager, while search-all lookups inspect registered -managers in registration order. - -Normal .NET `ResourceManager` fallback applies: - -| Situation | Result | `TranslationNotFound` | -| --- | --- | --- | -| `de-DE` entry exists | `de-DE` value | No | -| Missing in `de-DE`, exists in `de` | Parent `de` value | No | -| Missing in satellite, exists in neutral resources | Neutral value | No | -| Missing across the complete fallback chain | `!key!` by default | Yes | - -`Localizer.MissingTranslationFormat` changes the sentinel. Because fallback runs first, a key that resolves -from a parent or neutral value does not count as missing - the sentinel and `Localizer.TranslationNotFound` -report unresolvable keys, not incomplete per-language coverage. - -Formatting overloads (`Get(key, args…)`) pass the resolved text and arguments to `String.Format` -using the localizer's current culture. - -`Localizer.Current` lives for the whole process, so a strong `CultureChanged` subscription keeps its -subscriber alive. Short-lived subscribers - a view model owned by a window, for example - should -unsubscribe when they are disposed (the sample view models show the pattern). +| 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 | +| [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 | +| [Native AOT and trimming](docs/guides/native-aot.md) | What to declare, how to publish, and what a trimming failure looks like | +| [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 | ## Troubleshooting **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 via -`RegisterResourceManager`. Subscribe to `TranslationNotFound` to log every miss. +`{l:Localize Greeting}` or `{l:LocalizeEnum}` - that the resource manager was registered with +`RegisterResourceManager`. Subscribe to `TranslationNotFound` to log every miss. See +[Lookup modes and culture fallback](docs/guides/lookup-and-fallback.md). -**No typed `…Keys` class is generated.** +**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). -- Ensure the neutral filename is dot-free and a same-folder classic `.Designer.cs` exists. -- Use the .NET 8 SDK or later. An older compiler may reject the generator with `CS9057`. -- SDK `GenerateResxSource` output lives under `obj` and does not qualify; use - `PublicResXFileCodeGenerator` or `ResXFileCodeGenerator`. -- `RXLGEN001` identifies malformed eligible `.resx` XML and points at the source file. -- `RXLGEN002` identifies a missing or unrecognized same-folder classic accessor. - -**Native AOT publishing (Avalonia).** Publish with `PublishAot=true` and list the shipped cultures -in `SatelliteResourceLanguages` so the satellite assemblies are retained. WPF does not support -Native AOT. +**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 +[Native AOT and trimming](docs/guides/native-aot.md). **Avalonia version resolution.** The Avalonia package declares 12.0.5 as its minimum so it still -builds under the .NET 8 SDK; applications building with a current SDK resolve Avalonia 12.1+ +builds under the .NET 8 SDK; applications building with a current SDK resolve Avalonia 12.1 or later normally. ## The sample applications Two complete, runnable showcases exercise **every** feature and combination - a scrolling window with a live language `ComboBox`: -- **Avalonia:** [`samples/ResXLocalization.Avalonia.Sample`](samples/ResXLocalization.Avalonia.Sample) -- **WPF:** [`samples/ResXLocalization.WPF.Sample`](samples/ResXLocalization.WPF.Sample) - -Run them: - ```shell dotnet run --project samples/ResXLocalization.Avalonia.Sample dotnet run --project samples/ResXLocalization.WPF.Sample # Windows only @@ -461,7 +291,7 @@ dotnet build ResXLocalization.slnx -c Release # Windows (includes dotnet build ResXLocalization.NonWindows.slnf -c Release # Linux/macOS (skips WPF) ``` -See [CONTRIBUTING.md](CONTRIBUTING.md) for running the test suites, packing the NuGet packages, and formatting the code before committing. +See [CONTRIBUTING.md](CONTRIBUTING.md) for the gate scripts, the test suites, packing, and the Native AOT check. ## Versioning @@ -469,7 +299,7 @@ This project follows [Semantic Versioning](https://semver.org/). See the [CHANGE ## Contributing -Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) first. In short: open an issue to discuss larger changes, keep the build warning-free, update the `CHANGELOG.md`, and make sure the tests pass. +Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) first. In short: open an issue to discuss larger changes, keep the build warning-free, add a `CHANGELOG.md` entry under *Unreleased*, and make sure the tests pass. ## License diff --git a/ResXLocalization.sln.DotSettings b/ResXLocalization.sln.DotSettings deleted file mode 100644 index dfd0e2a..0000000 --- a/ResXLocalization.sln.DotSettings +++ /dev/null @@ -1,5 +0,0 @@ - - True - True - True - True \ No newline at end of file diff --git a/ResXLocalization.slnx.DotSettings b/ResXLocalization.slnx.DotSettings new file mode 100644 index 0000000..3f67319 --- /dev/null +++ b/ResXLocalization.slnx.DotSettings @@ -0,0 +1,239 @@ + + <?xml version="1.0" encoding="utf-16"?> +<Patterns xmlns="urn:schemas-jetbrains-com:member-reordering-patterns" StaticFieldReorderingPolicy="Strict"> + <TypePattern DisplayName="Types marked [NoReorder]" Priority="100"> + <TypePattern.Match> + <HasAttribute Name="JetBrains.Annotations.NoReorderAttribute" /> + </TypePattern.Match> + </TypePattern> + <TypePattern DisplayName="StyleCop order" RemoveRegions="None"> + <Entry DisplayName="Constants"> + <Entry.Match> + <Kind Is="Constant" /> + </Entry.Match> + <Entry.SortBy> + <Access Order="Public Internal ProtectedInternal Protected PrivateProtected Private" /> + <Static /> + <Readonly /> + <Name /> + </Entry.SortBy> + </Entry> + <Entry DisplayName="Fields"> + <Entry.Match> + <Kind Is="Field" /> + </Entry.Match> + <Entry.SortBy> + <Access Order="Public Internal ProtectedInternal Protected PrivateProtected Private" /> + <Static /> + <Readonly /> + <Name /> + </Entry.SortBy> + </Entry> + <Entry DisplayName="Constructors"> + <Entry.Match> + <Kind Is="Constructor" /> + </Entry.Match> + <Entry.SortBy> + <Static /> + <Access Order="Public Internal ProtectedInternal Protected PrivateProtected Private" /> + <Readonly /> + <Name /> + </Entry.SortBy> + </Entry> + <Entry DisplayName="Finalizers"> + <Entry.Match> + <Kind Is="Destructor" /> + </Entry.Match> + <Entry.SortBy> + <Access Order="Public Internal ProtectedInternal Protected PrivateProtected Private" /> + <Static /> + <Readonly /> + <Name /> + </Entry.SortBy> + </Entry> + <Entry DisplayName="Delegates"> + <Entry.Match> + <Kind Is="Delegate" /> + </Entry.Match> + <Entry.SortBy> + <Access Order="Public Internal ProtectedInternal Protected PrivateProtected Private" /> + <Static /> + <Readonly /> + <Name /> + </Entry.SortBy> + </Entry> + <Entry DisplayName="Events"> + <Entry.Match> + <Kind Is="Event" /> + </Entry.Match> + <Entry.SortBy> + <Access Order="Public Internal ProtectedInternal Protected PrivateProtected Private" /> + <Static /> + <Readonly /> + <Name /> + </Entry.SortBy> + </Entry> + <Entry DisplayName="Enums"> + <Entry.Match> + <Kind Is="Enum" /> + </Entry.Match> + <Entry.SortBy> + <Access Order="Public Internal ProtectedInternal Protected PrivateProtected Private" /> + <Static /> + <Readonly /> + <Name /> + </Entry.SortBy> + </Entry> + <Entry DisplayName="Interfaces"> + <Entry.Match> + <Kind Is="Interface" /> + </Entry.Match> + <Entry.SortBy> + <Access Order="Public Internal ProtectedInternal Protected PrivateProtected Private" /> + <Static /> + <Readonly /> + <Name /> + </Entry.SortBy> + </Entry> + <Entry DisplayName="Explicit interface properties"> + <Entry.Match> + <And> + <Kind Is="Property" /> + <ImplementsInterface /> + <Access Is="Private" /> + </And> + </Entry.Match> + <Entry.SortBy> + <Access Order="Public Internal ProtectedInternal Protected PrivateProtected Private" /> + <Static /> + <Readonly /> + <Name /> + </Entry.SortBy> + </Entry> + <Entry DisplayName="Properties"> + <Entry.Match> + <Kind Is="Property" /> + </Entry.Match> + <Entry.SortBy> + <Access Order="Public Internal ProtectedInternal Protected PrivateProtected Private" /> + <Static /> + <Readonly /> + <Name /> + </Entry.SortBy> + </Entry> + <Entry DisplayName="Explicit interface indexers"> + <Entry.Match> + <And> + <Kind Is="Indexer" /> + <ImplementsInterface /> + <Access Is="Private" /> + </And> + </Entry.Match> + <Entry.SortBy> + <Access Order="Public Internal ProtectedInternal Protected PrivateProtected Private" /> + <Static /> + <Readonly /> + <Name /> + </Entry.SortBy> + </Entry> + <Entry DisplayName="Indexers"> + <Entry.Match> + <Kind Is="Indexer" /> + </Entry.Match> + <Entry.SortBy> + <Access Order="Public Internal ProtectedInternal Protected PrivateProtected Private" /> + <Static /> + <Readonly /> + <Name /> + </Entry.SortBy> + </Entry> + <Entry DisplayName="Conversion operators"> + <Entry.Match> + <And> + <Kind Is="Operator" /> + <Or> + <Name Is="op_Implicit" /> + <Name Is="op_Explicit" /> + </Or> + </And> + </Entry.Match> + <Entry.SortBy> + <Access Order="Public Internal ProtectedInternal Protected PrivateProtected Private" /> + <Static /> + <Readonly /> + <Name /> + </Entry.SortBy> + </Entry> + <Entry DisplayName="Operators"> + <Entry.Match> + <Kind Is="Operator" /> + </Entry.Match> + <Entry.SortBy> + <Access Order="Public Internal ProtectedInternal Protected PrivateProtected Private" /> + <Static /> + <Readonly /> + <Name /> + </Entry.SortBy> + </Entry> + <Entry DisplayName="Explicit interface methods"> + <Entry.Match> + <And> + <Kind Is="Method" /> + <ImplementsInterface /> + <Access Is="Private" /> + </And> + </Entry.Match> + <Entry.SortBy> + <Access Order="Public Internal ProtectedInternal Protected PrivateProtected Private" /> + <Static /> + <Readonly /> + <Name /> + </Entry.SortBy> + </Entry> + <Entry DisplayName="Methods"> + <Entry.Match> + <Kind Is="Method" /> + </Entry.Match> + <Entry.SortBy> + <Access Order="Public Internal ProtectedInternal Protected PrivateProtected Private" /> + <Static /> + <Readonly /> + <Name /> + </Entry.SortBy> + </Entry> + <Entry DisplayName="Structs"> + <Entry.Match> + <Kind Is="Struct" /> + </Entry.Match> + <Entry.SortBy> + <Access Order="Public Internal ProtectedInternal Protected PrivateProtected Private" /> + <Static /> + <Readonly /> + <Name /> + </Entry.SortBy> + </Entry> + <Entry DisplayName="Classes"> + <Entry.Match> + <Kind Is="Class" /> + </Entry.Match> + <Entry.SortBy> + <Access Order="Public Internal ProtectedInternal Protected PrivateProtected Private" /> + <Static /> + <Readonly /> + <Name /> + </Entry.SortBy> + </Entry> + </TypePattern> +</Patterns> + <?xml version="1.0" encoding="utf-16"?><Profile name="ReorderMembers"><CSReorderTypeMembers>True</CSReorderTypeMembers></Profile> + True + True + True + True + True + True + True + True + True + True + True \ No newline at end of file diff --git a/Settings.XamlStyler b/Settings.XamlStyler index 1e53a1e..a38fb39 100644 --- a/Settings.XamlStyler +++ b/Settings.XamlStyler @@ -4,7 +4,7 @@ "AttributesTolerance": 1, "AttributeIndentationStyle": 1, "SeparateByGroups": true, - "ReorderGridChildren": true, - "ReorderCanvasChildren": true, - "ReorderSetters": 3 + "ReorderGridChildren": false, + "ReorderCanvasChildren": false, + "ReorderSetters": 0 } \ No newline at end of file diff --git a/build/docfx/docfx.json b/build/docfx/docfx.json new file mode 100644 index 0000000..301c60f --- /dev/null +++ b/build/docfx/docfx.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/docfx/main/schemas/docfx.schema.json", + "metadata": [ + { + "src": [ + { + "src": "../../src", + "files": [ + "**/*.csproj" + ], + "exclude": [ + "**/ResXLocalization.SourceGenerators.csproj" + ] + } + ], + "dest": "../../artifacts/docs/api", + "noRestore": true + } + ], + "build": { + "content": [ + { + "src": "../../docs", + "files": [ + "**/*.{md,yml}" + ], + "dest": "." + }, + { + "src": "../../artifacts/docs/api", + "files": [ + "**/*.{md,yml}" + ], + "dest": "api" + }, + { + "src": "../../", + "files": [ + "CONTRIBUTING.md", + "CODE_OF_CONDUCT.md", + "SECURITY.md", + "CHANGELOG.md" + ], + "dest": "." + } + ], + "resource": [ + { + "src": "../../docs", + "files": [ + "assets/**" + ], + "dest": "." + } + ], + "output": "../../artifacts/docs/site", + "template": [ + "default", + "modern" + ], + "globalMetadata": { + "_appName": "ResXLocalization", + "_appTitle": "ResXLocalization documentation", + "_appLogoPath": "assets/logo.svg", + "_appFaviconPath": "assets/favicon.ico", + "_enableSearch": true + } + } +} diff --git a/docs/docfx.json b/docs/docfx.json deleted file mode 100644 index e776267..0000000 --- a/docs/docfx.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/dotnet/docfx/main/schemas/docfx.schema.json", - "metadata": [ - { - "src": [ - { - "src": "../src", - "files": [ - "**/*.csproj" - ], - "exclude": [ - "**/ResXLocalization.SourceGenerators.csproj" - ] - } - ], - "dest": "api", - "noRestore": true - } - ], - "build": { - "content": [ - { - "files": [ - "**/*.{md,yml}" - ], - "exclude": [ - "_site/**" - ] - } - ], - "resource": [ - { - "files": [ - "assets/**" - ] - } - ], - "output": "_site", - "template": ["default", "modern"], - "globalMetadata": { - "_appName": "ResXLocalization API Documentation", - "_appTitle": "ResXLocalization API Documentation", - "_appLogoPath": "assets/logo.svg", - "_appFaviconPath": "assets/favicon.ico", - "_enableSearch": true - } - } -} diff --git a/docs/guides/enums.md b/docs/guides/enums.md new file mode 100644 index 0000000..7037676 --- /dev/null +++ b/docs/guides/enums.md @@ -0,0 +1,85 @@ +# Localizing enum values + +Enum members are localized **by naming convention**: add one string entry per member to any of your +`.resx` files, named `Enum__`. No attributes on the enum, no extra code. + +```csharp +public enum FileSortOrder { Unsorted, Ascending, Descending } +``` + +| Key | `AppStrings.resx` (English) | `AppStrings.de.resx` (German) | +| --- | --- | --- | +| `Enum_FileSortOrder_Unsorted` | `Unsorted` | `Unsortiert` | +| `Enum_FileSortOrder_Ascending` | `Ascending (A-Z)` | `Aufsteigend (A-Z)` | +| `Enum_FileSortOrder_Descending` | `Descending (Z-A)` | `Absteigend (Z-A)` | + +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}` + +Inside a `ComboBox` or `ListBox` item template each item *is* the enum value — it is the +`DataContext` — so the markup extension localizes it directly: + +```xml + + + + + + + +``` + +## As a bound value: `LocalizeEnumConverter` + +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: + +```xml + + + + + + + + + +``` + +## In code + +```csharp +Localizer.Current.Get(FileSortOrder.Ascending); // "Ascending (A-Z)" / "Aufsteigend (A-Z)" +``` + +## 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 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_"); +``` + +`LocalizeEnumConverter.Default` is read-only, so a converter with a custom prefix is declared as its +own instance in resources: + +```xml + +``` + +## 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 +[Lookup modes and culture fallback](lookup-and-fallback.md). diff --git a/docs/guides/format-arguments.md b/docs/guides/format-arguments.md new file mode 100644 index 0000000..025a334 --- /dev/null +++ b/docs/guides/format-arguments.md @@ -0,0 +1,48 @@ +# Dynamic format arguments + +A resource value can be a composite format string, and the arguments can come straight from your +view model. Add the entry as usual: + +| Key | `AppStrings.resx` (English) | `AppStrings.de.resx` (German) | +| --- | --- | --- | +| `PeopleInvited` | `{0} people invited` | `{0} Personen eingeladen` | + +## In code + +```csharp +Localizer.Current.Get(AppStringsKeys.PeopleInvited, 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. + +## In XAML + +Bind the **`LocalizeArgs.Arg0`…`Arg8` attached properties** on the element that carries the localized +property — 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`. + +## 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. +- **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. +- **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. diff --git a/docs/guides/lookup-and-fallback.md b/docs/guides/lookup-and-fallback.md new file mode 100644 index 0000000..532f887 --- /dev/null +++ b/docs/guides/lookup-and-fallback.md @@ -0,0 +1,109 @@ +# Lookup modes and culture fallback + +Three ways to ask for a string, one fallback story, and one way to find out when a key does not +resolve. + +## The three lookup modes + +| Mode | What you pass | When to use it | +| --- | --- | --- | +| **Typed** | a generated `ResourceKey` | Almost always. The key name is compile-checked, and the key carries its own `ResourceManager`, so the lookup goes straight to one file | +| **Scoped** | a key name and a `ResourceManager` | A key that is not eligible for generation, or a name assembled at run time, in a known file | +| **Search-all** | a key name | A key that could live in any of several files, and you would rather not say which | + +```csharp +// Typed: the generated key knows which .resx it came from. +Localizer.Current.Get(AppStringsKeys.Greeting); + +// Scoped: this key, in this file. +Localizer.Current.Get("Greeting", AppStrings.ResourceManager); + +// Search-all: this key, in whichever registered file has it first. +Localizer.Current.Get("Greeting"); +``` + +The same three modes exist in XAML: + +```xml + + + +``` + +## Registration is only for search-all + +```csharp +Localizer.Current.RegisterResourceManager(AppStrings.ResourceManager); +``` + +Search-all inspects the registered managers **in registration order** and takes the first hit, so +registration order is what decides which file wins when two of them carry the same key name. Typed +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. + +> [!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. + +## Culture fallback + +Fallback is .NET's own `ResourceManager` behaviour, unchanged. The localizer sits on top of it. + +| Situation | Result | Raises `TranslationNotFound` | +| --- | --- | --- | +| `de-DE` entry exists | the `de-DE` value | No | +| Missing in `de-DE`, exists in `de` | the parent `de` value | No | +| Missing in every satellite, exists in the neutral resources | the neutral value | No | +| 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. + +## Missing-translation diagnostics + +```csharp +// Change the sentinel, or make it invisible in production. +Localizer.MissingTranslationFormat = "«{0}»"; + +// Log every unresolvable key. +Localizer.Current.TranslationNotFound += (_, args) => logger.LogWarning("No translation for {Key}", args.Key); +``` + +The default sentinel is `!key!`, which is deliberately ugly: a missing translation should be +noticeable in a screenshot. + +## Available cultures + +```csharp +foreach (var culture in Localizer.Current.GetAvailableCultures()) +{ + // The invariant culture stands for the neutral resources compiled into the assembly. +} +``` + +`GetAvailableCultures()` reports the cultures the application actually ships, by asking each +registered resource manager which satellite assemblies exist. It is what a language picker binds to. + +## Dependency injection + +```csharp +services.AddSingleton(_ => Localizer.Current); +``` + +Alternatively, assign a container-owned implementation to `Localizer.Current` before any view is +created. The property rejects `null`, and the markup extensions always read its current value, so a +replacement is picked up everywhere. + +## Lifetime and subscriptions + +`Localizer.Current` lives for the whole process, so a strong `CultureChanged` subscription keeps its +subscriber alive for the whole process too. A short-lived subscriber — a view model owned by a +window, say — should unsubscribe when it is disposed. Both sample view models show the pattern. + +The bindings themselves need no care: Avalonia's markup extensions subscribe through weak events, +and WPF binds to the singleton through WPF's own weak binding-target references, so a discarded +control stays collectable. diff --git a/docs/guides/native-aot.md b/docs/guides/native-aot.md new file mode 100644 index 0000000..56bbc5b --- /dev/null +++ b/docs/guides/native-aot.md @@ -0,0 +1,69 @@ +# 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 +public member, so nothing about a trimmed publish is a warning you have to read and dismiss. + +**`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. + +## What you have to declare + +One property, and it is the one that matters: + +```xml + + + en;de + +``` + +A satellite assembly is a separate file, and a publish that does not know a culture ships has no +reason to keep it. Without this property a trimmed application silently falls back to the neutral +resources for every culture — it starts, it renders, and it is in the wrong language. + +Two more are worth setting for an Avalonia application in general: + +```xml + + + true + + + true + +``` + +## Publishing + +```shell +dotnet publish -c Release -r win-x64 --self-contained true -p:PublishAot=true +``` + +Read the log rather than the exit code. If you want the individual diagnostics rather than one +collapsed line per assembly, add `-p:TrimmerSingleWarn=false`: left at its default, the compiler +reports "assembly produced trim warnings" and hides the codes inside it. + +## Why the check runs the binary + +Trimming damage does not announce itself. A resource that is no longer found, a satellite that is no +longer loaded, a typed key whose resource manager was trimmed away — each of them produces a binary +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 +linker ran, and nothing else. + +## If a lookup returns the sentinel only after publishing + +In order of likelihood: + +1. `SatelliteResourceLanguages` does not list the culture. +2. The publish output does not contain the culture's folder — check for `de/YourApp.resources.dll` + next to the executable. +3. The key is missing from the neutral `.resx` as well, in which case it was never resolving and the + just-in-time run was falling back to something you did not notice. diff --git a/docs/index.md b/docs/index.md index 732fcc2..b74e32c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,18 +1,52 @@ -# ResXLocalization API Documentation +# ResXLocalization Type-safe `.resx` localization for [Avalonia](https://avaloniaui.net/) and -[WPF](https://learn.microsoft.com/dotnet/desktop/wpf/) - switch language **live**, no reload, -no restart. +[WPF](https://learn.microsoft.com/dotnet/desktop/wpf/) — switch language **live**, no reload, no +restart. -This site hosts the generated **[API reference](xref:RentADeveloper.ResXLocalization)** for the -three runtime assemblies: +Start at the [repository README](https://github.com/rent-a-developer/ResXLocalization#readme) for +installation and a quick start. These pages are the full documentation. + +## Guides + +| 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 | +| [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 | +| [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 | + +## Reference + +| Page | What it covers | +| --- | --- | +| [Generated keys](reference/generated-keys.md) | Which `.resx` files qualify, what becomes a key, the generator diagnostics, and the Roslyn floor | + +## API reference + +The generated reference covers the runtime assemblies: | Namespace | Assembly | Contains | -| --------- | -------- | -------- | -| [`RentADeveloper.ResXLocalization`](xref:RentADeveloper.ResXLocalization) | `ResXLocalization.Core` | `ILocalizer`, `Localizer`, `ResourceKey` - the shared engine | +| --- | --- | --- | +| [`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 | -See the [project README](https://github.com/rent-a-developer/ResXLocalization#readme) for -installation, quick start, enum localization, lookup modes, fallback behavior, Native AOT, and -troubleshooting. +## The packages + +| Package | UI framework | Targets | Native AOT | +| --- | --- | --- | --- | +| `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 | + +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. + +## Project + +- [Change log](../CHANGELOG.md) +- [Contributing](../CONTRIBUTING.md) +- [Code of conduct](../CODE_OF_CONDUCT.md) +- [Security policy](../SECURITY.md) diff --git a/docs/reference/generated-keys.md b/docs/reference/generated-keys.md new file mode 100644 index 0000000..78df7fe --- /dev/null +++ b/docs/reference/generated-keys.md @@ -0,0 +1,64 @@ +# Generated keys: what qualifies, and what you get + +The source generator ships inside both UI packages as an analyzer, together with the MSBuild wiring +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: + +| 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 | + +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. + +## What becomes a key + +Only **string** entries. An entry carrying a `type` or `mimetype` attribute — a binary resource, an +image, a colour, an icon — is skipped, because its value is not a string a lookup could return. + +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. + +```csharp +// - for Resources/AppStrings.resx +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); + // …one ResourceKey per string entry, sorted by name. +} +``` + +Each `ResourceKey` carries **both** the key name and the `ResourceManager` it belongs to. That is +what makes a typed lookup direct: it never searches, and two files may use the same key name without +colliding. + +The class is `partial`, so you can add members of your own to it in a file of your own. + +## 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 | + +`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. + +## The compiler floor + +The generator is built against Roslyn 4.8 — the compiler in the .NET 8 SDK. A generator built +against a newer Roslyn simply does not load in an older compiler: the build reports `CS9057`, skips +generation, and then fails on the missing `…Keys` types. Building the generator against the floor is +what keeps the packages usable on every SDK from .NET 8 upward, and CI proves it on every run by +consuming the packed packages with nothing but the .NET 8 SDK installed. diff --git a/docs/toc.yml b/docs/toc.yml index db5f212..26e4dda 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -1,4 +1,28 @@ - name: Home href: index.md -- name: API Reference - href: api/ +- name: Guides + items: + - name: Lookup modes and culture fallback + href: guides/lookup-and-fallback.md + - name: Localizing enum values + href: guides/enums.md + - name: Dynamic format arguments + href: guides/format-arguments.md + - name: Native AOT and trimming + href: guides/native-aot.md +- name: Reference + items: + - name: Generated keys + href: reference/generated-keys.md +- name: Project + items: + - name: Change log + href: ../CHANGELOG.md + - name: Contributing + href: ../CONTRIBUTING.md + - name: Code of conduct + href: ../CODE_OF_CONDUCT.md + - name: Security policy + href: ../SECURITY.md +- name: API reference + href: ../artifacts/docs/api/toc.yml diff --git a/global.json b/global.json index 512142d..d46d21e 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,7 @@ { "sdk": { "version": "10.0.100", - "rollForward": "latestFeature" + "rollForward": "latestFeature", + "allowPrerelease": false } } diff --git a/samples/ResXLocalization.Avalonia.Sample/ResXLocalization.Avalonia.Sample.csproj b/samples/ResXLocalization.Avalonia.Sample/ResXLocalization.Avalonia.Sample.csproj index d404f58..6c6bc1c 100644 --- a/samples/ResXLocalization.Avalonia.Sample/ResXLocalization.Avalonia.Sample.csproj +++ b/samples/ResXLocalization.Avalonia.Sample/ResXLocalization.Avalonia.Sample.csproj @@ -1,103 +1,117 @@ - - ..\..\assets\logo.ico - app.manifest - - true - true - - true - WinExe - $(CurrentTargetFramework) - + + ..\..\assets\logo.ico + app.manifest + + true + true + + true + WinExe + $(CurrentTargetFramework) + - - - true - - en;de - + + + true + + en;de + - - - - - - - + + - - - - + This is NoWarn rather than an .editorconfig severity because .editorconfig cannot reach a + source-generated document: Roslyn resolves analyzer configuration from a syntax tree's path, + and a generated tree has none, so no section - not even [*] - applies to it. Under src/ both + diagnostics stay errors, which is what keeps the public API documented. --> + $(NoWarn);CS1574;CS1591 + - - - - + + + + + + + - - - - + + + + + + + - - - - + + + + - - - - PublicResXFileCodeGenerator - ApplicationStrings.Designer.cs - - - ApplicationStrings.resx - - - ApplicationStrings.resx - True - True - + + + + - - ResXFileCodeGenerator - BrandingStrings.Designer.cs - - - BrandingStrings.resx - - - BrandingStrings.resx - True - True - + + + + - - PublicResXFileCodeGenerator - SortingStrings.Designer.cs - - - SortingStrings.resx - - - SortingStrings.resx - True - True - - + + + + PublicResXFileCodeGenerator + ApplicationStrings.Designer.cs + + + ApplicationStrings.resx + + + ApplicationStrings.resx + True + True + + + + ResXFileCodeGenerator + BrandingStrings.Designer.cs + + + BrandingStrings.resx + + + BrandingStrings.resx + True + True + + + + PublicResXFileCodeGenerator + SortingStrings.Designer.cs + + + SortingStrings.resx + + + SortingStrings.resx + True + True + + diff --git a/samples/ResXLocalization.WPF.Sample/ResXLocalization.WPF.Sample.csproj b/samples/ResXLocalization.WPF.Sample/ResXLocalization.WPF.Sample.csproj index 9329486..09dadac 100644 --- a/samples/ResXLocalization.WPF.Sample/ResXLocalization.WPF.Sample.csproj +++ b/samples/ResXLocalization.WPF.Sample/ResXLocalization.WPF.Sample.csproj @@ -1,96 +1,110 @@ - - ..\..\assets\logo.ico - app.manifest - WinExe - - $(CurrentWindowsTargetFramework) - true - + + ..\..\assets\logo.ico + app.manifest + WinExe + + $(CurrentWindowsTargetFramework) + true + - - - true - - en;de - + + + true + + en;de + - - - + + - - - - - + This is NoWarn rather than an .editorconfig severity because .editorconfig cannot reach a + source-generated document: Roslyn resolves analyzer configuration from a syntax tree's path, + and a generated tree has none, so no section - not even [*] - applies to it. Under src/ both + diagnostics stay errors, which is what keeps the public API documented. --> + $(NoWarn);CS1574;CS1591 + - - - - + + + - - - - + + + + + + + - - - - + + + + - - - - PublicResXFileCodeGenerator - ApplicationStrings.Designer.cs - - - ApplicationStrings.resx - - - ApplicationStrings.resx - True - True - + + + + - - ResXFileCodeGenerator - BrandingStrings.Designer.cs - - - BrandingStrings.resx - - - BrandingStrings.resx - True - True - + + + + - - PublicResXFileCodeGenerator - SortingStrings.Designer.cs - - - SortingStrings.resx - - - SortingStrings.resx - True - True - - + + + + PublicResXFileCodeGenerator + ApplicationStrings.Designer.cs + + + ApplicationStrings.resx + + + ApplicationStrings.resx + True + True + + + + ResXFileCodeGenerator + BrandingStrings.Designer.cs + + + BrandingStrings.resx + + + BrandingStrings.resx + True + True + + + + PublicResXFileCodeGenerator + SortingStrings.Designer.cs + + + SortingStrings.resx + + + SortingStrings.resx + True + True + + diff --git a/scripts/clean-build-artifacts.ps1 b/scripts/clean-build-artifacts.ps1 index d67bfa7..a1ec812 100644 --- a/scripts/clean-build-artifacts.ps1 +++ b/scripts/clean-build-artifacts.ps1 @@ -3,19 +3,31 @@ Deletes generated build and documentation artifacts in the repository. .DESCRIPTION - MSBuild keeps stale assemblies, generated files and NuGet asset lists in bin/ and obj/. Documentation and - packaging tooling also creates repository-level artifact folders. When a project is renamed, a package + MSBuild keeps stale assemblies, generated files and NuGet asset lists in bin/ and obj/. The packaging, + Native AOT and documentation tooling also creates repository-level artifact folders. When a project is renamed, a package is downgraded or a source generator changes its output, those leftovers are what make a build fail or succeed for the wrong reason. Deleting them forces the next build to start from source. The scan is anchored to the repository root - the parent of this script's directory - not to the current working directory, so it deletes the same set no matter where you run it from. - Generated XML documentation files are identified by matching each src project AssemblyName to an XML file - beside its project file. Authored XML files with other names are left untouched. + WHAT IT DELETES, and nothing else: + + - a fixed list of known generated directories, named below; + - every bin/ and obj/ directory found INSIDE the resolved repository root. + + It does not guess. In particular it never deletes an XML file because its name matches a project's + AssemblyName: an authored XML file is allowed to have that name, and a delete based on a name pattern + cannot tell a generated documentation file from a hand-written one. Generated files that this script does + not know about are covered by .gitignore, so `git clean -X` removes them with git's own knowledge of what + is generated. + + Directories that are reparse points - symbolic links, junctions, mount points - are skipped rather than + followed: deleting "recursively" through one deletes the target, which may be anywhere on the machine. The + .git directory is skipped too; it holds no build output and walking it is pure cost. .PARAMETER WhatIf - List the folders and files that would be deleted without deleting anything. + List the folders that would be deleted without deleting anything. .EXAMPLE pwsh -File scripts/clean-build-artifacts.ps1 @@ -23,48 +35,74 @@ .EXAMPLE pwsh -File scripts/clean-build-artifacts.ps1 -WhatIf #> +#requires -Version 7.0 [CmdletBinding(SupportsShouldProcess = $true)] param() $ErrorActionPreference = 'Stop' -$repositoryRoot = Split-Path -Parent $PSScriptRoot +$repositoryRoot = (Resolve-Path -LiteralPath (Split-Path -Parent $PSScriptRoot)).Path Write-Output "Cleaning build artifacts under $repositoryRoot..." -$additionalArtifactDirectories = - 'artifacts', - 'docs/api', - 'docs/_site', - 'tests/package-consumption/.packages' | - ForEach-Object { Get-Item -LiteralPath (Join-Path $repositoryRoot $_) -Force -ErrorAction SilentlyContinue } | - Where-Object { $_ -is [System.IO.DirectoryInfo] } - -# -Force so that hidden or system directories are enumerated too; .git is skipped because it never holds -# build output and walking it is pure cost. -$artifactDirectories = - @($additionalArtifactDirectories) + - @(Get-ChildItem -LiteralPath $repositoryRoot -Directory -Recurse -Force | - Where-Object { $_.Name -in 'bin', 'obj' -and $_.FullName -notmatch '(^|\\|/)\.git(\\|/)' }) - -$documentationFiles = - Get-ChildItem -LiteralPath (Join-Path $repositoryRoot 'src') -Filter '*.csproj' -File -Recurse | - ForEach-Object { - [xml] $project = Get-Content -LiteralPath $_.FullName - $assemblyName = $project.Project.PropertyGroup.AssemblyName | Select-Object -First 1 - - if ($assemblyName) - { - $documentationFilePath = Join-Path $_.DirectoryName "$assemblyName.xml" - Get-Item -LiteralPath $documentationFilePath -Force -ErrorAction SilentlyContinue - } - } | - Where-Object { $_ -is [System.IO.FileInfo] } +# The generated directories this repository creates by name. Each one is written by a tool and holds nothing +# authored. Keep this list in step with .gitignore. +$knownGeneratedDirectories = @( + # Everything this repository generates on purpose lives under artifacts/: the packages, the Native AOT + # publish, the generated API metadata, the documentation site, the test results and the extracted + # release notes. + 'artifacts' + 'tests/package-consumption/.packages' +) + +function Test-IsReparsePoint +{ + param([Parameter(Mandatory)] [System.IO.DirectoryInfo] $Directory) + + return $Directory.Attributes.HasFlag([System.IO.FileAttributes]::ReparsePoint) +} + +function Test-IsInsideRepository +{ + <# + A last check before a recursive delete: the resolved full path has to sit under the resolved + repository root. A reparse point that was somehow followed, or a path assembled from a variable that + turned out to be empty, cannot get past this. + #> + param([Parameter(Mandatory)] [String] $FullPath) + + $normalizedRoot = $repositoryRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + + [System.IO.Path]::DirectorySeparatorChar + + return $FullPath.StartsWith($normalizedRoot, [StringComparison]::OrdinalIgnoreCase) +} + +$candidates = New-Object System.Collections.Generic.List[System.IO.DirectoryInfo] + +foreach ($relativePath in $knownGeneratedDirectories) +{ + $directory = Get-Item -LiteralPath (Join-Path $repositoryRoot $relativePath) -Force -ErrorAction SilentlyContinue + + if ($directory -is [System.IO.DirectoryInfo] -and -not (Test-IsReparsePoint -Directory $directory)) + { + $candidates.Add($directory) + } +} + +# -Force so that hidden or system directories are enumerated too. -Attributes !ReparsePoint stops the walk +# from descending through a link, which is what keeps a recursive delete inside this repository. +$discovered = Get-ChildItem -LiteralPath $repositoryRoot -Directory -Recurse -Force -Attributes !ReparsePoint | + Where-Object { $_.Name -in 'bin', 'obj' } | + Where-Object { $_.FullName -notmatch '(^|\\|/)\.git(\\|/)' } + +foreach ($directory in $discovered) +{ + $candidates.Add($directory) +} $deletedDirectories = 0 -$deletedFiles = 0 -foreach ($directory in $artifactDirectories) +foreach ($directory in $candidates) { # Already removed as part of an ancestor that matched earlier in the enumeration. if (-not (Test-Path -LiteralPath $directory.FullName)) @@ -72,28 +110,23 @@ foreach ($directory in $artifactDirectories) continue } - if ($PSCmdlet.ShouldProcess($directory.FullName, 'Delete folder')) + if (-not (Test-IsInsideRepository -FullPath $directory.FullName)) { - Write-Output "Deleting folder: $($directory.FullName)" - - Remove-Item -LiteralPath $directory.FullName -Recurse -Force + Write-Output "Skipping (outside the repository): $($directory.FullName)" - $deletedDirectories++ + continue } -} -foreach ($file in $documentationFiles) -{ - if ($PSCmdlet.ShouldProcess($file.FullName, 'Delete generated documentation file')) + if ($PSCmdlet.ShouldProcess($directory.FullName, 'Delete folder')) { - Write-Output "Deleting generated documentation file: $($file.FullName)" + Write-Output "Deleting folder: $($directory.FullName)" - Remove-Item -LiteralPath $file.FullName -Force + Remove-Item -LiteralPath $directory.FullName -Recurse -Force - $deletedFiles++ + $deletedDirectories++ } } -Write-Output "Done. Deleted $deletedDirectories folder(s) and $deletedFiles generated documentation file(s)." +Write-Output "Done. Deleted $deletedDirectories folder(s)." exit 0 diff --git a/scripts/extract-release-notes.ps1 b/scripts/extract-release-notes.ps1 index be2fb37..16d19d7 100644 --- a/scripts/extract-release-notes.ps1 +++ b/scripts/extract-release-notes.ps1 @@ -1,15 +1,23 @@ <# .SYNOPSIS - Validates the CHANGELOG entry for a release and writes its section to release-notes.md. + Validates the CHANGELOG entry for a release and writes its section to artifacts/release/release-notes.md. .DESCRIPTION Shared by the CI publish and GitHub-release jobs, so the notes attached to a release and the checks that gate the publication come from one place. The CHANGELOG must contain exactly one dated `## [x.y.z] - YYYY-MM-DD` heading for the version, with no - TBD placeholder and a non-empty body. Those three failures all mean the same thing - a release was tagged - before its changelog entry was finished - and it is much cheaper to fail here than to publish three - immutable packages pointing at an empty section. + TBD placeholder and a non-empty body. Those failures all mean the same thing - a release was tagged + before its changelog entry was finished - and it is much cheaper to fail here than to publish immutable + packages pointing at an empty section. + + The output goes under artifacts/, which is generated output and is ignored by git, so nothing about it + looks authored and nothing about it can be committed by accident. + + Keep-a-Changelog link reference definitions - the `[1.1.0]: https://...` lines that a changelog collects + at the end of the file, and that a section can also carry - are dropped from the extracted notes. They + resolve against the changelog, not against a GitHub release page, where they render as nothing or as a + broken link. On failure the script emits a GitHub Actions ::error annotation and exits non-zero. @@ -17,12 +25,13 @@ The version to extract, without the leading "v" - for example 1.0.0. .PARAMETER OutputFile - Where to write the extracted section. Defaults to release-notes.md in the repository root, which is what - the workflow attaches to the GitHub release. + Where to write the extracted section. Defaults to artifacts/release/release-notes.md in the repository, + which is what the workflow attaches to the GitHub release. .EXAMPLE pwsh -File scripts/extract-release-notes.ps1 -Version 1.0.0 #> +#requires -Version 7.0 [CmdletBinding()] param( [Parameter(Mandatory = $true)] @@ -33,22 +42,23 @@ param( $ErrorActionPreference = 'Stop' -$repositoryRoot = Split-Path -Parent $PSScriptRoot +# scripts/ - the repository root is one level up, whatever the current directory is. +$repositoryRoot = (Resolve-Path -LiteralPath (Split-Path -Parent $PSScriptRoot)).Path $changelog = Join-Path $repositoryRoot 'CHANGELOG.md' if (-not $OutputFile) { - $OutputFile = Join-Path $repositoryRoot 'release-notes.md' + $OutputFile = Join-Path $repositoryRoot 'artifacts/release/release-notes.md' } -if (-not (Test-Path $changelog)) +if (-not (Test-Path -LiteralPath $changelog)) { Write-Host "::error title=Missing changelog::$changelog does not exist." exit 1 } -$lines = Get-Content -Path $changelog -Encoding utf8 +$lines = Get-Content -LiteralPath $changelog -Encoding utf8 # --- 1. Exactly one dated heading, and no TBD --------------------------------------------------------- @@ -68,6 +78,9 @@ if ($datedHeadingCount -ne 1 -or $hasTbd) # --- 2. The section body ------------------------------------------------------------------------------ +# A link reference definition: `[1.1.0]: https://github.com/...`, at the start of a line. +$linkDefinition = '^\[[^\]]+\]:\s' + $section = New-Object System.Collections.Generic.List[String] $inSection = $false @@ -86,7 +99,7 @@ foreach ($line in $lines) break } - if ($inSection) + if ($inSection -and ($line -notmatch $linkDefinition)) { $section.Add($line) } @@ -99,7 +112,13 @@ if (-not ($section | Where-Object { $_.Trim() })) exit 1 } -Set-Content -Path $OutputFile -Value $section -Encoding utf8 +$outputDirectory = Split-Path -Parent $OutputFile +if ($outputDirectory -and -not (Test-Path -LiteralPath $outputDirectory)) +{ + New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null +} + +Set-Content -LiteralPath $OutputFile -Value $section -Encoding utf8 Write-Host "Wrote the CHANGELOG section for $Version to $OutputFile ($($section.Count) line(s))." diff --git a/scripts/pre-commit-gate.ps1 b/scripts/pre-commit-gate.ps1 new file mode 100644 index 0000000..6bb87e3 --- /dev/null +++ b/scripts/pre-commit-gate.ps1 @@ -0,0 +1,329 @@ +<# +.SYNOPSIS + The pre-commit gate: repository hygiene, line endings, style/formatting/ordering, a Release build, and + the test suites this machine can run. + +.DESCRIPTION + CONTRIBUTING.md requires that all tests pass and the build succeeds with no warnings. Because + TreatWarningsAsErrors=true, the build is also the style, trim-analyzer and public-API gate: IL2xxx and + IL3xxx diagnostics fail it, and so does an undeclared or vanished public member (RS0016 / RS0017). + + AI agents have hooks that nag about the public API files as you edit, but a hook only sees edits made + through a tool - and Codex only runs its hooks once they are trusted. This script repeats that check + over the whole working tree. Run it before every commit, whichever agent you are. + + WHAT THIS SCRIPT WRITES. By default: build output and package caches, and nothing else. It does not + edit your source files and it does not touch the git index - the tidiness step runs as a CHECK, on a + disposable copy of the tree. Pass -Fix to have it tidy the working tree first. + + TWO GATES ARE DELIBERATELY NOT RUN HERE, because each takes minutes and neither applies to every + change. Run them when their trigger applies: + + Native AOT gate Trigger: any change to resource lookup, culture fallback, satellite discovery, + the generated typed keys, or the packaging that carries them. Needs a C++ + toolchain. It is the ONLY check in the repository that can see silent trimming + damage, because nothing is trimmed on the JIT. + 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 + 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. + +.PARAMETER Fix + Apply style, formatting and member ordering to the working tree before the checks, instead of only + reporting what would change. This rewrites your files; review the diff and include it in your commit. + +.PARAMETER SkipTidy + Skip the style, formatting and ordering step entirely. The build still fails on any of them. + +.PARAMETER SkipBuild + Skip the Release build (implies -SkipTests). + +.PARAMETER SkipTests + Skip the test run. + +.PARAMETER Configuration + Build configuration. Release by default, because that is what CI and CONTRIBUTING.md use. + +.EXAMPLE + pwsh -File scripts/pre-commit-gate.ps1 + +.EXAMPLE + pwsh -File scripts/pre-commit-gate.ps1 -Fix +#> +#requires -Version 7.0 +[CmdletBinding()] +param( + [Switch] $Fix, + [Switch] $SkipBuild, + [Switch] $SkipTests, + [Switch] $SkipTidy, + [String] $Configuration = 'Release' +) + +$ErrorActionPreference = 'Stop' + +# scripts/ - the repository root is one level up. Everything below is anchored to it, so the +# script behaves the same whatever the current directory is. +$repositoryRoot = (Resolve-Path -LiteralPath (Split-Path -Parent $PSScriptRoot)).Path + +# The WPF projects only load on Windows. Elsewhere the solution filter is what the build and the test run +# use, and the WPF suite is reported as not run rather than counted as passed. +$onWindows = $IsWindows +$solutionFileName = if ($onWindows) { 'ResXLocalization.slnx' } else { 'ResXLocalization.NonWindows.slnf' } + +$publicApiGuard = Join-Path $repositoryRoot 'scripts/public-api-guard.ps1' +$lineEndings = Join-Path $repositoryRoot 'scripts/verify-line-endings.ps1' +$tidy = Join-Path $repositoryRoot 'scripts/tidy-code.ps1' + +$failures = New-Object System.Collections.Generic.List[String] + +function Write-Section +{ + param([Parameter(Mandatory)] [String] $Title) + + Write-Output '' + Write-Output "=== $Title ===" +} + +function Invoke-FromRepositoryRoot +{ + <# + Runs a native command with the repository root as the working directory. The location is restored + in a finally block, so an interrupted run does not leave the caller's shell somewhere else. + + It deliberately returns NOTHING and the caller reads $LASTEXITCODE afterwards. Returning the exit + code would put it on the pipeline together with everything the command printed, so the caller would + receive an array of build output with a number on the end - and the build log would vanish into a + variable instead of reaching the screen, which is exactly where it is needed when the build is what + failed. + + The working directory is not cosmetic here. `dotnet` resolves global.json from the CURRENT + directory upward, and this repository's global.json is what pins the SDK. + #> + param([Parameter(Mandatory)] [ScriptBlock] $Command) + + Push-Location -LiteralPath $repositoryRoot + try + { + & $Command + } + finally + { + Pop-Location + } +} + +foreach ($required in @($publicApiGuard, $lineEndings, $tidy)) +{ + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) + { + Write-Output "pre-commit-gate: FAILED - $required does not exist." + + exit 1 + } +} + +# --- 1. Public API ------------------------------------------------------------------------------------ +# Not a failure - a reminder. An accidental public-surface change is already a build error (RS0016 / +# RS0017 from Microsoft.CodeAnalysis.PublicApiAnalyzers, below); what this catches is a deliberate one that +# arrived without the companion edits CONTRIBUTING.md requires. It reads; it never writes. + +Write-Section 'Hygiene: public API' + +$guardOutput = & pwsh -NoProfile -NonInteractive -File $publicApiGuard +$guardExitCode = $LASTEXITCODE + +if ($guardExitCode -ne 0) +{ + $failures.Add('public-api-guard') + Write-Output "FAIL - public-api-guard.ps1 exited with code $guardExitCode." +} +elseif ($guardOutput) +{ + $guardOutput | ForEach-Object { Write-Output $_ } +} +else +{ + Write-Output 'Unchanged.' +} + +# --- 2. Line endings ---------------------------------------------------------------------------------- +# Cheap, and it has to come before the tidiness check rather than after it, because the tidiness check +# cannot see this: it commits a disposable copy of the tree before running the tools, and committing is +# what normalizes line endings away. A CRLF tree passes step 3 and fails step 4 with one +# "Was not formatted." error per file, none of which mentions a line ending. +# +# It reads; it never writes, and it never touches the git index. scripts/verify-line-endings.ps1 says why +# both columns of `git ls-files --eol` are checked and what fixes each. + +Write-Section 'Hygiene: line endings' + +& pwsh -NoProfile -NonInteractive -File $lineEndings + +if ($LASTEXITCODE -ne 0) +{ + $failures.Add('line endings') +} + +# --- 3. Style, formatting and member ordering --------------------------------------------------------- +# All of it is a build error too, so leaving it to step 4 only means a slower way of finding out - and the +# check prints the exact diff that would fix things, where the build only names the file. +# +# By default this REPORTS. -Fix applies. The editor hooks format on every edit, but they deliberately skip +# the two slow tools - the code-style fixers and the member reordering - and this is where those run. + +Write-Section 'Style, formatting and ordering' + +if ($SkipTidy) +{ + Write-Output 'Skipped (-SkipTidy).' +} +elseif ($Fix) +{ + # Snapshot the dirty source files BEFORE tidying. `git diff` afterwards lists your own edits too, so + # reporting its count would say "tidied 40 files" when the tools touched one of them. What the run + # actually changed is the difference between the two lists. + $dirtyBefore = @(& git -C $repositoryRoot diff --name-only -- '*.cs' '*.xaml' '*.axaml' 2>$null | Where-Object { $_ }) + + & pwsh -NoProfile -NonInteractive -File $tidy -Scope all + $tidyExitCode = $LASTEXITCODE + + if ($tidyExitCode -ne 0) + { + $failures.Add('tidy') + Write-Output 'FAIL - tidy-code could not finish. Run `dotnet tool restore --configfile NuGet.config` if the tools are missing.' + } + else + { + $dirtyAfter = @(& git -C $repositoryRoot diff --name-only -- '*.cs' '*.xaml' '*.axaml' 2>$null | Where-Object { $_ }) + $tidied = @($dirtyAfter | Where-Object { $_ -notin $dirtyBefore }) + + Write-Output '' + if ($tidied) + { + Write-Output "Tidied $($tidied.Count) file(s) that you had not already changed:" + $tidied | ForEach-Object { Write-Output " $_" } + Write-Output 'Review the diff and include it in your commit.' + } + elseif ($dirtyBefore) + { + # Everything the tools touched was already in your diff, so there is nothing new to point at - + # but the tools may still have rewritten those files, and that is worth one line. + Write-Output "Tidy ran clean. Your $($dirtyBefore.Count) changed file(s) may have been rewritten - review the diff." + } + else + { + Write-Output 'Already tidy.' + } + } +} +else +{ + & pwsh -NoProfile -NonInteractive -File $tidy -Scope all -Check + $tidyExitCode = $LASTEXITCODE + + if ($tidyExitCode -ne 0) + { + $failures.Add('tidy') + Write-Output '' + Write-Output 'FAIL - the tree is not tidy, or tidy-code could not finish. The diff above is what' + Write-Output 'would fix it. Apply it with: pwsh -File scripts/pre-commit-gate.ps1 -Fix' + } + else + { + # The check above asks whether the tools would change a COPY of the tree. CSharpier.MsBuild asks + # about the tree itself, during the build, and anything that lives around the files rather than in + # them can make the two disagree - the line endings step 2 covers, or a .csharpierrc or + # .editorconfig in a directory ABOVE this repository, which a copy under the temp directory never + # sees. Asking CSharpier here costs about a second and names the file and the reason; the build + # names every file in the solution and calls all of them unformatted. + Invoke-FromRepositoryRoot { & dotnet csharpier check . } + + if ($LASTEXITCODE -ne 0) + { + $failures.Add('formatting') + Write-Output '' + Write-Output 'FAIL - CSharpier rejects the tree as it stands on disk, though the check above' + Write-Output 'passed on a copy of it. The build fails the same way, less legibly.' + } + } +} + +# --- 4. Build ----------------------------------------------------------------------------------------- + +if ($SkipBuild) +{ + Write-Section 'Build' + Write-Output 'Skipped (-SkipBuild).' +} +else +{ + Write-Section "Build ($Configuration)" + + Invoke-FromRepositoryRoot { & dotnet build $solutionFileName -c $Configuration } + $buildExitCode = $LASTEXITCODE + + if ($buildExitCode -ne 0) + { + $failures.Add('build') + Write-Output 'FAIL - build did not succeed. TreatWarningsAsErrors=true, so a style slip, a misplaced' + Write-Output 'member or an IL2xxx/IL3xxx trim diagnostic fails here too. Never suppress an IL warning' + Write-Output 'to get green.' + } + + if (-not $onWindows) + { + Write-Output '' + Write-Output 'NOT BUILT: the WPF projects. They need Windows and are outside the solution filter.' + } +} + +# --- 5. Tests ----------------------------------------------------------------------------------------- +# The solution (or the filter) is the source of truth for the project set, and `dotnet test` discovers its +# test projects and runs every target framework automatically - so the Core suite runs on net8.0 and +# net10.0 from one command, and the summary names each one. + +if ($SkipBuild -or $SkipTests -or $failures.Contains('build')) +{ + Write-Section 'Tests' + Write-Output 'Skipped.' +} +else +{ + Write-Section 'Tests' + + Invoke-FromRepositoryRoot { & dotnet test $solutionFileName -c $Configuration --no-build } + $testExitCode = $LASTEXITCODE + + if ($testExitCode -ne 0) { $failures.Add('tests') } + + if (-not $onWindows) + { + Write-Output '' + Write-Output 'NOT RUN: the WPF sample tests, including the WPF threading tests. They need Windows.' + } +} + +# --- Summary ------------------------------------------------------------------------------------------ + +Write-Section 'Pre-commit gate summary' + +if ($failures.Count -gt 0) +{ + Write-Output "FAILED: $($failures -join ', ')" + Write-Output 'Do not commit over this - fix it and re-run.' + + exit 1 +} + +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 ' (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 new file mode 100644 index 0000000..b186177 --- /dev/null +++ b/scripts/pre-release-gate.ps1 @@ -0,0 +1,538 @@ +<# +.SYNOPSIS + The pre-release gate: everything CI checks that can honestly be checked on this machine, in the order + CI checks it. + +.DESCRIPTION + 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. + + 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 + produce a second, misleading one. + + WHAT IT DOES NOT COVER, and why. None of this is a judgement about importance; each one either needs + infrastructure this machine does not have or is meaningless outside CI: + + CodeQL, dependency review Need GitHub's analysis and advisory services. + Codecov upload Needs the OIDC token CI holds. The coverage numbers are produced by the + test run here; it is only the upload that cannot happen. + GitHub Pages deployment Needs the Pages environment. The site itself IS built here. + NuGet publication Needs Trusted Publishing and a pushed version tag. Nothing here + 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. + 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. + + 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. + +.PARAMETER Version + The version you are about to release, for example 1.2.0. When given, two extra checks run - the same + two CI runs immediately before it publishes: the in the repository-root Directory.Build.props + must match, and CHANGELOG.md must hold exactly one dated, non-empty section for it. Omit it and both + are skipped, because a branch push publishes nothing. + +.PARAMETER SkipNativeAot + Skip the Native AOT gate. It needs a C++ toolchain - MSVC and the Windows SDK on Windows, clang and + zlib1g-dev on Linux. + +.PARAMETER SkipConsumers + Skip the package consumers. They restore from the packages the pack step wrote, which takes a minute. + +.PARAMETER SkipDocumentation + Skip the DocFX metadata and site build. It needs the docfx local tool. + +.PARAMETER Configuration + Build configuration. Release by default, because that is what CI uses and what gets published. + +.EXAMPLE + pwsh -File scripts/pre-release-gate.ps1 + +.EXAMPLE + pwsh -File scripts/pre-release-gate.ps1 -Version 1.2.0 +#> +#requires -Version 7.0 +[CmdletBinding()] +param( + [String] $Version, + [Switch] $SkipNativeAot, + [Switch] $SkipConsumers, + [Switch] $SkipDocumentation, + [String] $Configuration = 'Release' +) + +$ErrorActionPreference = 'Stop' + +# scripts/ - the repository root is one level up, whatever the current directory is. +$repositoryRoot = (Resolve-Path -LiteralPath (Split-Path -Parent $PSScriptRoot)).Path + +# The WPF projects only load on Windows. Off Windows the filter is what builds and tests, and the WPF and +# combined consumers do not run at all - both are named in the summary rather than silently missing. +$onWindows = $IsWindows +$solutionFileName = if ($onWindows) { 'ResXLocalization.slnx' } else { 'ResXLocalization.NonWindows.slnf' } + +$consumerDirectory = Join-Path $repositoryRoot 'tests/package-consumption' +$packageOutput = Join-Path $repositoryRoot 'artifacts/packages' +$docfxConfiguration = 'build/docfx/docfx.json' + +$failure = $null +$stepSucceeded = $true + +function Write-Section +{ + param([Parameter(Mandatory)] [String] $Title) + + Write-Output '' + Write-Output "=== $Title ===" +} + +function Invoke-FromRepositoryRoot +{ + <# + Runs a command with the repository root as the working directory, restoring the caller's location + in a finally block. It returns NOTHING and the caller reads $LASTEXITCODE - returning the exit code + would mix it into the pipeline with everything the command printed, and that output is exactly what + you need when this step is the one that failed. + + The working directory is not cosmetic: `dotnet` resolves global.json from the CURRENT directory + upward, and this repository's global.json is what pins the SDK. + #> + param([Parameter(Mandatory)] [ScriptBlock] $Command) + + Push-Location -LiteralPath $repositoryRoot + try + { + & $Command + } + finally + { + Pop-Location + } +} + +function Get-DeclaredVersion +{ + # The single source of truth for the version of every package, and what `dotnet pack` produces. + $sharedProperties = Join-Path $repositoryRoot 'Directory.Build.props' + + return ([Xml] (Get-Content -Raw -LiteralPath $sharedProperties)).Project.PropertyGroup.Version | + Where-Object { $_ } | + Select-Object -First 1 +} + +function Invoke-Step +{ + <# + Runs one step unless an earlier one failed. $script:failure holds the name of the first failure and + every later step becomes a no-op, so the output ends with the failure that matters rather than with + whatever fell over as a consequence of it. + + A step reports its result through $script:stepSucceeded, and NOT by returning a value. In + PowerShell a script block's output IS its return value, so `if (-not (& $Command))` would capture + every line the build, the test run and the AOT gate print - the caller would see an array of build + output rather than a result, nothing would reach the screen until the step ended, and a failing + step that printed anything at all would evaluate as true and be recorded as a pass. Calling it bare + lets its output stream straight to the console, which is where "See output" points. + #> + param( + [Parameter(Mandatory)] [String] $Name, + [Parameter(Mandatory)] [ScriptBlock] $Command + ) + + if ($script:failure) + { + return + } + + Write-Section $Name + + # A step that returns without saying otherwise passed. + $script:stepSucceeded = $true + + & $Command + + if (-not $script:stepSucceeded) + { + $script:failure = $Name + } +} + +# --- Working tree ------------------------------------------------------------------------------------- + +Write-Section 'Working tree' + +$uncommitted = @(& git -C $repositoryRoot status --porcelain | Where-Object { $_ }) + +if ($uncommitted) +{ + Write-Output "$($uncommitted.Count) uncommitted change(s). CI tests the commit you push, so these are not covered:" + $uncommitted | Select-Object -First 10 | ForEach-Object { Write-Output " $_" } + + if ($uncommitted.Count -gt 10) + { + Write-Output " ... and $($uncommitted.Count - 10) more" + } +} +else +{ + Write-Output 'Clean.' +} + +# --- 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 +# produces a release whose notes are empty. Neither runs on a branch push, so both are opt-in here. + +Invoke-Step 'Release identity' { + if (-not $Version) + { + Write-Output 'Skipped - pass -Version to check the declared version and the changelog section.' + + return + } + + $declared = Get-DeclaredVersion + + if ($declared -ne $Version) + { + Write-Output "FAILED - Directory.Build.props declares $declared, not $Version." + + $script:stepSucceeded = $false + + return + } + + Write-Output "Directory.Build.props declares $declared." + + Invoke-FromRepositoryRoot { + & pwsh -NoProfile -NonInteractive -File 'scripts/extract-release-notes.ps1' -Version $Version + } + + $script:stepSucceeded = ($LASTEXITCODE -eq 0) +} + +# --- Ignored revisions -------------------------------------------------------------------------------- +# The lint job's first check, and the cheapest. A revision in .git-blame-ignore-revs that no longer +# resolves is skipped silently by git, and blame goes back to pointing at the tool that reformatted the +# line - which is exactly what squashing or rebasing a branch that added one does. + +Invoke-Step 'Ignored revisions' { + $revisions = @(Get-Content -LiteralPath (Join-Path $repositoryRoot '.git-blame-ignore-revs') | + Where-Object { $_ -match '^[0-9a-f]{40}$' }) + + if (-not $revisions) + { + Write-Output 'FAILED - .git-blame-ignore-revs lists no revisions.' + + $script:stepSucceeded = $false + + return + } + + $unresolved = @($revisions | Where-Object { + & git -C $repositoryRoot cat-file -e "$_^{commit}" 2>$null + + $LASTEXITCODE -ne 0 + }) + + if ($unresolved) + { + Write-Output 'FAILED - these revisions are not commits in this repository:' + $unresolved | ForEach-Object { Write-Output " $_" } + Write-Output 'A rebase or a squash merge rewrote them. Replace each with the SHA it became.' + + $script:stepSucceeded = $false + + return + } + + Write-Output "$($revisions.Count) revision(s), all resolve." + + return +} + +# --- Line endings ------------------------------------------------------------------------------------- +# The lint job's second check, and the one whose absence is expensive: a tree whose files are CRLF fails +# the build two steps below with one "Was not formatted." error per file, every one of them naming the +# formatter rather than the line endings, and nothing before it - not the working-tree step above, not the +# tidiness check below - can see it. scripts/verify-line-endings.ps1 says why in full. + +Invoke-Step 'Line endings' { + Invoke-FromRepositoryRoot { + & pwsh -NoProfile -NonInteractive -File 'scripts/verify-line-endings.ps1' + } + + $script:stepSucceeded = ($LASTEXITCODE -eq 0) +} + +# --- Style, formatting and ordering ------------------------------------------------------------------- +# The lint job. All of it is also a build error, so this is only the faster way to find out - but it prints +# the diff that would fix things, where the build names the file and stops. It checks a disposable copy of +# the tree and never writes to yours. + +Invoke-Step 'Style, formatting and ordering' { + Invoke-FromRepositoryRoot { + & pwsh -NoProfile -NonInteractive -File 'scripts/tidy-code.ps1' -Scope all -Check + } + + if ($LASTEXITCODE -ne 0) + { + Write-Output '' + Write-Output 'FAILED - the tree is not tidy. Apply it with: pwsh -File scripts/pre-commit-gate.ps1 -Fix' + + $script:stepSucceeded = $false + + return + } + + # And then the same question of THIS tree, which is not the same question. + # + # The check above runs the tools for real on a copy placed outside the repository and asks git whether + # anything changed there. That is the only honest way to check ordering - ReSharper has no check mode - + # but it answers about the copy, and two things that decide how CSharpier formats are not copied with + # the files: the line endings, which git normalizes away the moment the copy is committed, and anything + # a directory ABOVE the repository contributes, which a copy under the temp directory does not have. + Invoke-FromRepositoryRoot { & dotnet csharpier check . } + + if ($LASTEXITCODE -ne 0) + { + Write-Output '' + Write-Output 'FAILED - CSharpier rejects the tree as it stands on disk, though the check above passed' + Write-Output 'on a copy of it. The build would fail the same way. When the files themselves look' + Write-Output 'right, the difference is around them: line endings (the step above), or a .csharpierrc' + Write-Output 'or .editorconfig in a directory above this repository that the copy never saw.' + + $script:stepSucceeded = $false + + return + } + + return +} + +# --- Build -------------------------------------------------------------------------------------------- +# TreatWarningsAsErrors is on for every project, so this is also the style, member-ordering, trim-analyzer +# and public-API gate: an IL2xxx/IL3xxx diagnostic fails here, and so does an undeclared or vanished public +# member. + +Invoke-Step "Build ($Configuration)" { + Invoke-FromRepositoryRoot { & dotnet build $solutionFileName -c $Configuration } + + $script:stepSucceeded = ($LASTEXITCODE -eq 0) + + if (-not $onWindows) + { + Write-Output '' + Write-Output 'NOT BUILT: the WPF projects. They need Windows.' + } +} + +# --- Tests -------------------------------------------------------------------------------------------- +# One command, because the solution is the source of truth for the project set: `dotnet test` discovers its +# test projects and runs every target framework, so the Core suite runs on net8.0 and net10.0 here. +# --no-build and the configuration are both required - without them `dotnet test` silently rebuilds in +# Debug and tests that instead of the Release build above. + +Invoke-Step 'Tests' { + Invoke-FromRepositoryRoot { & dotnet test $solutionFileName -c $Configuration --no-build } + + $script:stepSucceeded = ($LASTEXITCODE -eq 0) + + if (-not $onWindows) + { + Write-Output '' + Write-Output 'NOT RUN: the WPF sample tests, including the WPF threading tests. They need Windows.' + } +} + +# --- Documentation ------------------------------------------------------------------------------------ +# --warningsAsErrors, because a docfx warning is a broken cross-reference or a file the configuration does +# not reach, and both of those reach the published site as a hole. `docfx metadata` takes no such flag. + +Invoke-Step 'Documentation' { + if ($SkipDocumentation) + { + Write-Output 'Skipped (-SkipDocumentation).' + + return + } + + Invoke-FromRepositoryRoot { & dotnet tool run docfx metadata $docfxConfiguration } + + if ($LASTEXITCODE -ne 0) + { + Write-Output 'FAILED - docfx metadata. Run `dotnet tool restore --configfile NuGet.config` if docfx is missing.' + + $script:stepSucceeded = $false + + return + } + + Invoke-FromRepositoryRoot { & dotnet tool run docfx build $docfxConfiguration --warningsAsErrors } + + $script:stepSucceeded = ($LASTEXITCODE -eq 0) + + if (-not $onWindows) + { + Write-Output '' + Write-Output 'PARTIAL: the API metadata excludes ResXLocalization.WPF, which needs Windows to build.' + } +} + +# --- Pack --------------------------------------------------------------------------------------------- +# Package validation runs here, against the last published version: removing or changing a public +# signature fails the pack rather than reaching nuget.org. Both steps below consume what this writes. + +Invoke-Step 'Pack' { + Invoke-FromRepositoryRoot { + & dotnet pack $solutionFileName -c $Configuration --no-build -o $packageOutput + } + + if ($LASTEXITCODE -ne 0) + { + $script:stepSucceeded = $false + + return + } + + # `dotnet pack --no-build` says nothing at all when it succeeds, which in a gate reads like a step that + # did not run. Name what it produced instead. + Get-ChildItem -LiteralPath $packageOutput -Filter '*.nupkg' | + Sort-Object Name | + ForEach-Object { Write-Output " $($_.Name)" } + + if (-not $onWindows) + { + Write-Output '' + Write-Output 'NOT PACKED: ResXLocalization.WPF. Release packaging happens on Windows.' + } +} + +# --- Native AOT gate ---------------------------------------------------------------------------------- +# The only check in the repository that can see silent trimming damage: nothing is trimmed on the JIT, so +# the whole test suite passes while a trimmed application finds no translation at all. Both frameworks, +# because net8.0 is the LTS floor the packages promise and net10.0 is what most consumers use. + +Invoke-Step 'Native AOT gate' { + if ($SkipNativeAot) + { + Write-Output 'Skipped (-SkipNativeAot).' + + return + } + + foreach ($framework in @('net8.0', 'net10.0')) + { + Invoke-FromRepositoryRoot { + & pwsh -NoProfile -NonInteractive -File 'scripts/verify-package-aot.ps1' ` + -Framework $framework -Configuration $Configuration + } + + if ($LASTEXITCODE -ne 0) + { + Write-Output "FAILED - the Native AOT gate failed on $framework." + + $script:stepSucceeded = $false + + return + } + } + + Write-Output '' + Write-Output "Ran on this machine's runtime only. CI also runs net8.0 and net10.0 on linux-x64." + + return +} + +# --- Package consumers -------------------------------------------------------------------------------- +# Installs the packages the way a stranger would, and asserts that the engine, the packed source generator +# and the buildTransitive resx wiring all arrive through one PackageReference - none of which a +# 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 + } + + $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) + { + $consumers += @{ Name = 'WpfConsumer'; Framework = $null } + $consumers += @{ Name = 'CombinedConsumer'; Framework = $null } + } + + Push-Location -LiteralPath $consumerDirectory + try + { + $env:NUGET_PACKAGES = Join-Path $consumerDirectory '.packages' + + foreach ($consumer in $consumers) + { + $name = $consumer.Name + $framework = @(if ($consumer.Framework) { @('--framework', $consumer.Framework) } else { @() }) + + Write-Output '' + Write-Output "--- $name$(if ($consumer.Framework) { " ($($consumer.Framework))" }) ---" + + & dotnet run --project "$name/$name.csproj" -c $Configuration @framework ` + -p:RestoreConfigFile=$consumerNuGetConfig -p:ResXLocalizationVersion=$packageVersion + + if ($LASTEXITCODE -ne 0) + { + $script:stepSucceeded = $false + + return + } + } + + if (-not $onWindows) + { + Write-Output '' + Write-Output 'NOT RUN: WpfConsumer and CombinedConsumer. They need Windows.' + } + } + finally + { + $env:NUGET_PACKAGES = $originalNuGetPackages + Pop-Location + } +} + +# --- Summary ------------------------------------------------------------------------------------------ + +Write-Section 'Pre-release gate summary' + +if ($failure) +{ + Write-Output "FAILED: Check $failure failed. See output." + + exit 1 +} + +Write-Output 'PASSED: All checks passed.' + +exit 0 diff --git a/scripts/public-api-guard.ps1 b/scripts/public-api-guard.ps1 new file mode 100644 index 0000000..29fda98 --- /dev/null +++ b/scripts/public-api-guard.ps1 @@ -0,0 +1,92 @@ +<# +.SYNOPSIS + Prints the CONTRIBUTING.md companion-edit checklist when a project's public API files change. + +.DESCRIPTION + The public surface of the shipping projects is declared in their PublicAPI.Shipped.txt and + PublicAPI.Unshipped.txt files and enforced by Microsoft.CodeAnalysis.PublicApiAnalyzers - the build + fails on a public member that is not declared (RS0016) or declared but gone (RS0017), so the build + already stops an *accidental* change. + + What the build cannot know is whether a *deliberate* one was accompanied by its companion edits. + CONTRIBUTING.md requires an Unreleased changelog entry and a documentation update, and both are easy + to forget, so this reminds you when one of those files moves. It never asks for a version bump: the + version belongs to the maintainer and moves at release time. + + This is the shared implementation. AI agents call it from a PostToolUse hook - Claude Code through + .claude/hooks/public-api-guard.ps1 and Codex through .codex/hooks/public-api-guard.ps1 - and + scripts/pre-commit-gate.ps1 runs it over the whole working tree. It only ever reports; it never fails + anything. + +.PARAMETER Path + One or more paths that were just edited. With no Path, every file git reports as changed (tracked + modifications plus untracked files) is examined, which is what an agent whose tool payload does not + carry a file path needs. + +.EXAMPLE + pwsh -File scripts/public-api-guard.ps1 + +.EXAMPLE + pwsh -File scripts/public-api-guard.ps1 src/ResXLocalization.Core/PublicAPI.Unshipped.txt +#> +#requires -Version 7.0 +[CmdletBinding()] +param( + [Parameter(Position = 0, ValueFromRemainingArguments = $true)] + [String[]] $Path +) + +$ErrorActionPreference = 'Stop' + +# scripts/ - the repository root is one level up, whatever the current directory is. +$repositoryRoot = (Resolve-Path -LiteralPath (Split-Path -Parent $PSScriptRoot)).Path + +function Get-ChangedFile +{ + param([String] $RepositoryRoot) + + # 2>$null: git warns about line-ending normalization per file, which is noise here. + $tracked = & git -C $RepositoryRoot diff --name-only HEAD 2>$null + $untracked = & git -C $RepositoryRoot ls-files --others --exclude-standard 2>$null + + return @($tracked) + @($untracked) | Where-Object { $_ } +} + +if (-not $Path -or $Path.Count -eq 0) +{ + $Path = Get-ChangedFile -RepositoryRoot $repositoryRoot +} + +$changedApiFiles = @($Path) | + Where-Object { $_ } | + Where-Object { (Split-Path -Leaf $_) -in @('PublicAPI.Shipped.txt', 'PublicAPI.Unshipped.txt') } | + ForEach-Object { $_ -replace '\\', '/' } | + Select-Object -Unique + +if ($changedApiFiles.Count -eq 0) { exit 0 } + +Write-Output @" +The declared public API changed: + +$(($changedApiFiles | ForEach-Object { " $_" }) -join "`n") + +Per CONTRIBUTING.md a public-surface change also requires: + + 1. CHANGELOG.md - an entry under '## [Unreleased]', in Keep-a-Changelog format. Write a breaking + change as '- **BREAKING:** ...'. + 2. Documentation - the guide or reference page under docs/ that describes the API, and any example the + change makes wrong. + +A change to one UI package usually needs the same change in the other: ResXLocalization.Avalonia and +ResXLocalization.WPF mirror each other's markup extensions and converters. If only one of them moved, +say why in the pull request. + +You do NOT bump a version. in the repository-root Directory.Build.props, the release date in +the CHANGELOG, promoting PublicAPI.Unshipped.txt to Shipped, and the tag are all the maintainer's, at +release time. Describing the change accurately under Unreleased is what lets them choose the number. + +Review the diff line by line first - it is the guard that this change is deliberate, not accidental. An +entry starting with *REMOVED* is a break: it means a member that shipped is gone. +"@ + +exit 0 diff --git a/scripts/tidy-code.ps1 b/scripts/tidy-code.ps1 index 680a96a..ded5188 100644 --- a/scripts/tidy-code.ps1 +++ b/scripts/tidy-code.ps1 @@ -1,208 +1,880 @@ <# .SYNOPSIS - Applies this repository's formatting: C# whitespace, XAML layout and line endings. + Applies this repository's C# style, formatting and member ordering, and its XAML layout. .DESCRIPTION - Three concerns, three tools, no overlap between them: + Four concerns, four tools, no overlap between them: - C# whitespace indentation, blank lines, wrapping dotnet format whitespace - XAML layout attribute placement and ordering XamlStyler (xstyler) - line endings LF everywhere, per .gitattributes this script + C# formatting whitespace, line breaks, wrapping CSharpier + C# style var, =>, this., null checks, usings Roslyn analyzers, via `dotnet format style` + C# ordering the order of types and their members ReSharper, via `jb cleanupcode` + XAML layout attribute placement and grouping XamlStyler (xstyler) - Semantic style and the analyzer rules are not here: the build enforces them itself, through - EnforceCodeStyleInBuild and TreatWarningsAsErrors. + The C# steps run in that reverse order - style, then ordering, then formatting - because each one + leaves whitespace behind for the next. CSharpier is always last and always has the final say. - Line endings come last because XamlStyler cannot produce them. It always writes the host OS's - newline and has no setting for it, so on Windows it turns every XAML file it touches into CRLF. - Git stores LF regardless, but the working tree would keep reporting those files as modified with an - empty `git diff`. Rewriting them to LF here removes that. On Linux and macOS there is nothing to do. + Line endings come after XamlStyler, because XamlStyler cannot produce them: it always writes the host + OS's newline and has no setting for it, so on Windows it turns every XAML file it touches into CRLF. + Only the files it actually processed are normalized, byte by byte, and nothing is staged. - Needs the local tools: run `dotnet tool restore` once per clone, or let this script do it. + The build enforces the C# side, in the samples and tests as much as in the libraries: + EnforceCodeStyleInBuild with TreatWarningsAsErrors makes a style slip or a misplaced member an error, + and CSharpier.MsBuild does the same for an unformatted file. Running this first is much cheaper than + finding out at build time. + + Needs the local tools: run `dotnet tool restore` once per clone. This script never installs them. + +.PARAMETER Path + One or more .cs, .xaml or .axaml files. With no Path, every such file git reports as changed - + tracked modifications plus untracked files. Ignored by -Scope all, which always covers the whole + repository. + + A path given explicitly must exist and must be one of those three kinds. It is an error if it is + not, rather than a silent skip: a typo that formats nothing looks exactly like a file that needed + nothing. + +.PARAMETER Scope + How much runs: + + format seconds CSharpier and XamlStyler only. The default, and what the editor hooks use. + style ~15s + the Roslyn code-style fixers. + all minutes + member reordering. Whole repository only - ReSharper loads all of it either + way. This is the one to run before committing; scripts/pre-commit-gate.ps1 does + it for you. .PARAMETER Check - Report whether the tree is already formatted, and exit non-zero if it is not. This is what CI runs. + Report violations instead of fixing them, and exit non-zero if there are any. This is what CI runs. - It still WRITES. XamlStyler has no check mode that is meaningful here: on Windows its passive check - fails on the LF files this repository stores, whatever their layout, because the tool compares - against its own CRLF output. So the honest question is not "does each tool approve?" but "does - formatting the tree change it?" - which is what this asks, and which gives the same answer on every - OS. Do not point -Check at a working tree you are not ready to have formatted. + -Check NEVER writes to this working tree, at any scope, and never touches the git index. Where a tool + has a verify mode that reads without writing, that mode is used. Where none exists - XamlStyler's + passive check compares against its own CRLF output and so rejects every LF file on Windows, and + ReSharper has no check mode at all - the tools run for real on a DISPOSABLE COPY of the current tree, + outside the repository, and what they changed there is reported here. Nothing is ever copied back. .EXAMPLE pwsh -File scripts/tidy-code.ps1 .EXAMPLE - pwsh -File scripts/tidy-code.ps1 -Check -#> + pwsh -File scripts/tidy-code.ps1 -Scope all +.EXAMPLE + pwsh -File scripts/tidy-code.ps1 src/ResXLocalization.Core/Localizer.cs +#> +#requires -Version 7.0 [CmdletBinding()] param( + [Parameter(Position = 0, ValueFromRemainingArguments = $true)] + [String[]] $Path, + + [ValidateSet('format', 'style', 'all')] + [String] $Scope = 'format', + [Switch] $Check ) $ErrorActionPreference = 'Stop' -# scripts/ - the repository root is one level up. -$repositoryRoot = Split-Path -Parent $PSScriptRoot +# scripts/ - the repository root is one level up. Every path below is anchored to this, so the +# script behaves the same whatever the current directory is. +$repositoryRoot = (Resolve-Path -LiteralPath (Split-Path -Parent $PSScriptRoot)).Path -# Windows PowerShell 5.1 has no $IsWindows variable, and an undefined variable is $null - so testing -# $IsWindows alone reports "not Windows" on the one host that is always Windows. The version test comes -# first for that reason. -$onWindows = $PSVersionTable.PSVersion.Major -lt 6 -or $IsWindows +# The WPF projects only load on Windows, so everything project-aware - `dotnet format style`, ReSharper - +# uses the solution filter elsewhere and the WPF C# is reported as unchecked rather than as checked. +# CSharpier and XamlStyler need no project and cover those files on every host. +$onWindows = $IsWindows +$solutionFileName = if ($onWindows) { 'ResXLocalization.slnx' } else { 'ResXLocalization.NonWindows.slnf' } -# The WPF projects only load on Windows; elsewhere the solution filter leaves them out. -$solution = if ($onWindows) { 'ResXLocalization.slnx' } else { 'ResXLocalization.NonWindows.slnf' } +$sourceExtensions = @('.cs', '.xaml', '.axaml') +$xamlExtensions = @('.xaml', '.axaml') $failures = New-Object System.Collections.Generic.List[String] -function Invoke-Tool { +# --- Running things ------------------------------------------------------------------------------- + +function Invoke-Git +{ + <# + Runs git and returns its output lines. Arguments are passed as an ARRAY, never as a command + string, so a path with a space or a quote in it cannot become two arguments or a shell fragment. + #> + param( + [Parameter(Mandatory)] [String] $WorkingDirectory, + [Parameter(Mandatory)] [String[]] $Arguments, + [Switch] $AllowFailure + ) + + $previous = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try + { + # 2>$null: git warns about line-ending normalization per file, which is noise here. + $output = & git -C $WorkingDirectory @Arguments 2>$null + } + finally + { + $ErrorActionPreference = $previous + } + + if ($LASTEXITCODE -ne 0 -and -not $AllowFailure) + { + throw "tidy-code: git $($Arguments -join ' ') failed with exit code $LASTEXITCODE." + } + + return @($output) +} + +function Invoke-Tool +{ <# - Runs `dotnet ...` and returns everything it printed. The caller decides success from $LASTEXITCODE. + Runs `dotnet ...` from $WorkingDirectory and returns everything it printed. The caller decides + success from $LASTEXITCODE, which is checked immediately after the call. + + The working directory matters and is not cosmetic: `dotnet` resolves global.json from the current + directory upward, and this repository's global.json is what pins the SDK. Run from somewhere else + and a different SDK answers. $ErrorActionPreference is deliberately relaxed for the call. With it at 'Stop', PowerShell turns - anything a native program writes to stderr into a terminating error, so a harmless warning would - abort the whole script with NativeCommandError. Exit codes decide success here, not stderr. + anything a native program writes to stderr into a terminating error, so a harmless warning aborts + the whole script with NativeCommandError. Exit codes decide success here, not stderr. #> - param([Parameter(Mandatory)] [String[]] $Arguments) + param( + [Parameter(Mandatory)] [String[]] $Arguments, + [Parameter(Mandatory)] [String] $WorkingDirectory + ) $previous = $ErrorActionPreference - $ErrorActionPreference = 'Continue' - try { return (& dotnet @Arguments 2>&1 | Out-String) } - finally { $ErrorActionPreference = $previous } -} - -function Get-Fingerprint { - # A hash over the content of every file the tools touch, used by -Check to tell whether formatting - # changed anything. - # - # The list comes from git, not from Get-ChildItem: build output under obj/ and bin/ is on disk but - # ignored, and hashing it would make the check fail on files nothing formatted. - $patterns = @('*.cs', '*.xaml', '*.axaml', '*.resx') - $relativePaths = & git -C $repositoryRoot ls-files --cached --others --exclude-standard -- $patterns 2>$null | + Push-Location -LiteralPath $WorkingDirectory + try + { + $ErrorActionPreference = 'Continue' + + return (& dotnet @Arguments 2>&1 | Out-String) + } + finally + { + $ErrorActionPreference = $previous + Pop-Location + } +} + +function Assert-Prerequisite +{ + <# + A missing prerequisite is an explicit failure, never a silent skip. This script does not install + anything: `dotnet tool restore` is a deliberate act, and a formatting hook that installs software + behind your back is a worse problem than an unformatted file. + + The tool manifest is READ rather than `dotnet tool list` being run. This function is on the + critical path of every editor hook, which formats a single file in about a second; spawning a + dotnet process just to be told what the manifest already says would roughly double that. A tool + that is declared but not restored is caught by the invocation that needs it, and Add-RestoreHint + says what to do about it. + #> + param([Parameter(Mandatory)] [String] $Root) + + foreach ($executable in @('git', 'dotnet')) + { + if (-not (Get-Command -Name $executable -CommandType Application -ErrorAction SilentlyContinue)) + { + throw "tidy-code: $executable is not on PATH." + } + } + + $manifestPath = Join-Path $Root '.config/dotnet-tools.json' + if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) + { + throw "tidy-code: $manifestPath does not exist. The formatters and the reordering tool are declared there." + } + + try + { + $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + } + catch + { + throw "tidy-code: $manifestPath is not valid JSON: $($_.Exception.Message)" + } + + $declared = @($manifest.tools.PSObject.Properties.Name) + + foreach ($tool in @('csharpier', 'jetbrains.resharper.globaltools', 'xamlstyler.console')) + { + if ($tool -notin $declared) + { + throw "tidy-code: '$tool' is not declared in $manifestPath." + } + } +} + +function Add-RestoreHint +{ + <# + A tool that is declared in the manifest but not restored fails with a message about the command + not being found, which reads like a bug in this script rather than a missing restore. The hint is + a SINGLE-quoted string on purpose: in a double-quoted one the backticks around the command would + be escape characters. + #> + param([Parameter(Mandatory)] [String] $Output) + + if ($Output -match 'was not found|could not be found|is not recognized') + { + $hint = 'Run `dotnet tool restore --configfile NuGet.config` once per clone - this script never installs tools.' + + return "$Output`n$hint" + } + + return $Output +} + +# --- Selecting files ------------------------------------------------------------------------------ + +function Get-ChangedSourceFile +{ + param([Parameter(Mandatory)] [String] $Root) + + $patterns = @('*.cs', '*.xaml', '*.axaml') + $tracked = Invoke-Git -WorkingDirectory $Root -Arguments (@('diff', '--name-only', 'HEAD', '--') + $patterns) + $untracked = Invoke-Git -WorkingDirectory $Root -Arguments (@('ls-files', '--others', '--exclude-standard', '--') + $patterns) + + return @($tracked) + @($untracked) | Where-Object { $_ } | - Sort-Object + ForEach-Object { Join-Path $Root $_ } | + # A file that git reports as changed can be one that was DELETED. Nothing to format there. + Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } +} - # Fail loudly rather than hashing nothing. git's stderr goes to $null above, so a git that fails - # returns no paths instead of an error - and two hashes of an empty stream compare equal, which - # would report a formatted tree without having looked at a single file. This is CI's only - # formatting gate; it must not be able to pass by accident. - if (-not $relativePaths) { - throw 'tidy-code: git listed no files. Is this a git repository, and is git on PATH?' +function Get-OwningProject +{ + param([Parameter(Mandatory)] [String] $FilePath) + + $directory = Split-Path -Parent $FilePath + while ($directory) + { + $candidate = Get-ChildItem -LiteralPath $directory -Filter '*.csproj' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($candidate) { return $candidate.FullName } + $directory = Split-Path -Parent $directory } - $sha = [System.Security.Cryptography.SHA256]::Create() - try { - $accumulator = New-Object System.IO.MemoryStream - foreach ($relativePath in $relativePaths) { - $fullPath = Join-Path $repositoryRoot $relativePath - if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) { continue } + return $null +} + +function Resolve-ExplicitFile +{ + <# + Paths the caller typed. Every one of them has to be a source file that exists - a typo must fail + rather than quietly format nothing. + #> + param([Parameter(Mandatory)] [String[]] $Candidates) + + $resolved = New-Object System.Collections.Generic.List[String] + + foreach ($candidate in $Candidates) + { + if (-not $candidate) { continue } + + if ([System.IO.Path]::GetExtension($candidate) -notin $sourceExtensions) + { + throw "tidy-code: '$candidate' is not a .cs, .xaml or .axaml file." + } + + if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) + { + throw "tidy-code: '$candidate' does not exist." + } - # The path goes into the hash too, so that adding or removing a file is a change. - $pathBytes = [System.Text.Encoding]::UTF8.GetBytes($relativePath) - $accumulator.Write($pathBytes, 0, $pathBytes.Length) + $full = (Resolve-Path -LiteralPath $candidate).Path - $bytes = [System.IO.File]::ReadAllBytes($fullPath) - $accumulator.Write($bytes, 0, $bytes.Length) + # Generated output and build output are not ours to touch. + if ($full -match '[\\/](bin|obj)[\\/]') + { + throw "tidy-code: '$candidate' is build output. Nothing under bin/ or obj/ is formatted." } - return [System.BitConverter]::ToString($sha.ComputeHash($accumulator.ToArray())) + if (-not $resolved.Contains($full)) { $resolved.Add($full) } } - finally { $sha.Dispose() } + + return $resolved.ToArray() } -# --- C# whitespace -------------------------------------------------------------------------------- +function Resolve-DerivedFile +{ + <# + Paths this script worked out for itself, from git. Anything unsuitable is dropped rather than + reported: git listing a deleted or generated file is normal, not a mistake the caller made. + #> + param([String[]] $Candidates) -function Invoke-CSharpFormat { - # No --no-restore: this has to work on a fresh clone. When the packages are already restored - - # locally after a build, and in CI after the restore step - it costs little. - $output = Invoke-Tool -Arguments @('format', 'whitespace', $solution) - if ($LASTEXITCODE -ne 0) { $failures.Add("dotnet format whitespace:`n$output") } + return @($Candidates) | + Where-Object { $_ } | + Where-Object { [System.IO.Path]::GetExtension($_) -in $sourceExtensions } | + Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | + ForEach-Object { (Resolve-Path -LiteralPath $_).Path } | + Where-Object { $_ -notmatch '[\\/](bin|obj)[\\/]' } | + Where-Object { $_ -notmatch '\.Designer\.cs$' } | + Select-Object -Unique +} + +# --- C# style ------------------------------------------------------------------------------------- +# The IDExxxx code-style rules, and the third-party analyzer fixers that have one. Whitespace rules are +# deliberately not included: IDE0055 is off in .editorconfig, because whitespace belongs to CSharpier. + +function Invoke-StyleFix +{ + <# + `--no-restore` is deliberately NOT passed, and that is not a performance oversight. + + `dotnet format style` fixes IDE0005 - "unnecessary using directive" - by DELETING the directive. + It decides what is unnecessary from the compilation, and on an unrestored project the compilation + has no package references at all, so every using of a type from a package looks unnecessary. It + reports success while leaving source that does not compile. + + A restore is a second or two on a warm cache and is a no-op when the tree is already restored. + That is the entire cost of the guarantee that this step cannot delete code it only thinks is + unused. + #> + param( + [String[]] $Files, + [Boolean] $VerifyOnly, + [Parameter(Mandatory)] [String] $Root + ) + + $verify = if ($VerifyOnly) { @('--verify-no-changes') } else { @() } + + if (-not $Files) + { + if ($VerifyOnly) + { + $arguments = @('format', 'style', $solutionFileName, '-v', 'q', '--verify-no-changes') + $output = Invoke-Tool -Arguments $arguments -WorkingDirectory $Root + if ($LASTEXITCODE -ne 0) { $failures.Add("dotnet format style:`n$output") } + } + else + { + # Fixed points, not one pass. `dotnet format style` computes its fixes from one compilation + # and applies them once, and some of them make another rule applicable: rewriting `String` to + # `string` in code leaves `` in the documentation comment above it, + # which the NEXT pass rewrites. Measured on this repository: pass 1 changes many files, pass 2 + # changes one, pass 3 changes nothing. + # + # So run until a verify pass agrees, with a bound - a rule pair that disagreed with each other + # would otherwise loop for ever, and "the fixers do not converge" is a real failure that has to + # be reported rather than waited out. + $maximumPasses = 5 + + for ($pass = 1; $pass -le $maximumPasses; $pass++) + { + $output = Invoke-Tool -Arguments @('format', 'style', $solutionFileName, '-v', 'q') -WorkingDirectory $Root + if ($LASTEXITCODE -ne 0) + { + $failures.Add("dotnet format style:`n$output") + + return + } + + $output = Invoke-Tool ` + -Arguments @('format', 'style', $solutionFileName, '-v', 'q', '--verify-no-changes') ` + -WorkingDirectory $Root + if ($LASTEXITCODE -eq 0) { break } + + if ($pass -eq $maximumPasses) + { + $failures.Add("dotnet format style did not settle after $maximumPasses passes:`n$output") + + return + } + } + } + + if (-not $onWindows) + { + Write-Output 'tidy-code: WPF C# style NOT checked - the WPF projects need Windows and are outside the solution filter.' + } + + return + } + + # tests/package-consumption/ is out of reach for this step, and the failure would be confusing rather + # than useful: those projects restore from the PACKED packages, so `dotnet format style` on one cannot + # even load it until `dotnet pack` has run. CSharpier and XamlStyler still format them - neither needs + # a project - and the style rules there are on the author. + $consumers = [System.IO.Path]::Combine($Root, 'tests', 'package-consumption') + $skipped = @($Files) | Where-Object { $_.StartsWith($consumers, [StringComparison]::OrdinalIgnoreCase) } + if ($skipped) + { + Write-Output "tidy-code: $($skipped.Count) file(s) under tests/package-consumption - no style pass, see AGENTS.md." + } + + $csharpFiles = @($Files) | + Where-Object { [System.IO.Path]::GetExtension($_) -eq '.cs' } | + Where-Object { -not $_.StartsWith($consumers, [StringComparison]::OrdinalIgnoreCase) } + + if (-not $csharpFiles) { return } + + if (-not $onWindows) + { + $wpfFiles = @($csharpFiles) | Where-Object { $_ -match 'ResXLocalization\.WPF' } + if ($wpfFiles) + { + Write-Output "tidy-code: $($wpfFiles.Count) WPF file(s) NOT style-checked - the WPF projects need Windows." + $csharpFiles = @($csharpFiles) | Where-Object { $_ -notmatch 'ResXLocalization\.WPF' } + } + + if (-not $csharpFiles) { return } + } + + # One invocation per owning project, so MSBuild loads one project rather than the whole solution. + $csharpFiles | Group-Object { Get-OwningProject -FilePath $_ } | ForEach-Object { + $project = $_.Name + if ([String]::IsNullOrWhiteSpace($project) -or -not (Test-Path -LiteralPath $project)) + { + $failures.Add("no owning .csproj for $($_.Group -join ', ')") + + return + } + + $projectDirectory = Split-Path -Parent $project + + # `--include` matches RELATIVE paths only. Handed an absolute path it matches nothing, reports + # success and formats nothing - a silent no-op that looks exactly like a clean file. So run from + # the project directory and pass each file relative to it. + # + # The PROJECT has to be relative too, and that is the half that is easy to miss. Given an absolute + # project path, `dotnet format` reports "Formatted 0 of 0 files" and exits 0 whatever --include + # says. Neither failure is visible without -v d. + $relativePaths = $_.Group | ForEach-Object { [System.IO.Path]::GetRelativePath($projectDirectory, $_) } + $projectFileName = Split-Path -Leaf $project + + $arguments = + @('format', 'style', $projectFileName, '--include') + + $relativePaths + + @('-v', 'q') + + $verify + + $output = Invoke-Tool -Arguments $arguments -WorkingDirectory $projectDirectory + + if ($LASTEXITCODE -ne 0) + { + $failures.Add("dotnet format style ($projectFileName):`n$output") + } + } +} + +# --- C# ordering ---------------------------------------------------------------------------------- +# ReSharper is the only tool that can reorder C# members. StyleCop reports a wrong order but cannot fix +# one: its ordering code fix is marked [NoCodeFix] and never registered. +# +# The order itself is the file layout in ResXLocalization.slnx.DotSettings, and the ReorderMembers +# profile in the same file enables member reordering and nothing else - no reformatting, because that is +# CSharpier's job. + +function Invoke-ReorderMembers +{ + param([Parameter(Mandatory)] [String] $Root) + + $output = Invoke-Tool ` + -Arguments @('tool', 'run', 'jb', 'cleanupcode', $solutionFileName, '--profile=ReorderMembers', '--no-build') ` + -WorkingDirectory $Root + if ($LASTEXITCODE -ne 0) { $failures.Add("jb cleanupcode:`n$(Add-RestoreHint -Output $output)") } + + if (-not $onWindows) + { + Write-Output 'tidy-code: WPF member ordering NOT checked - the WPF projects need Windows and are outside the solution filter.' + } +} + +# --- C# formatting -------------------------------------------------------------------------------- +# Always last among the C# steps: both steps above move code around and leave whitespace that is not +# CSharpier's. + +function Invoke-Format +{ + param( + [String[]] $Files, + [Boolean] $VerifyOnly, + [Parameter(Mandatory)] [String] $Root + ) + + $csharpFiles = @($Files) | Where-Object { [System.IO.Path]::GetExtension($_) -eq '.cs' } + if ($Files -and -not $csharpFiles) { return } + + # @(...) around the whole thing on purpose: an `if` writes its result to the pipeline, which + # enumerates a one-element array back down to a bare string. Splatting that passes "C" as the path. + $target = [String[]] @(if ($csharpFiles) { $csharpFiles } else { $Root }) + $command = if ($VerifyOnly) { 'check' } else { 'format' } + + $output = Invoke-Tool -Arguments (@('csharpier', $command) + $target) -WorkingDirectory $Root + if ($LASTEXITCODE -ne 0) { $failures.Add("csharpier $command`:`n$(Add-RestoreHint -Output $output)") } } # --- XAML layout ---------------------------------------------------------------------------------- +# XamlStyler covers src/, samples/ and tests/ - including the package consumers, whose compiled XAML is +# part of what the consumption checks exercise. + +function Invoke-XamlFormat +{ + <# + Returns the files it processed, so the caller can normalize exactly those and nothing else. + Writes its own failures to $failures. + #> + param( + [String[]] $Files, + [Parameter(Mandatory)] [String] $Root + ) + + $configPath = Join-Path $Root 'Settings.XamlStyler' + $processed = New-Object System.Collections.Generic.List[String] + + if ($Files) + { + $xamlFiles = @($Files) | Where-Object { [System.IO.Path]::GetExtension($_) -in $xamlExtensions } + if (-not $xamlFiles) { return $processed.ToArray() } + + # -f takes a comma-separated list, so a path containing a comma would be split into two. None + # exists here, and one would be an error rather than a silent mis-format. + foreach ($xamlFile in $xamlFiles) + { + if ($xamlFile.Contains(',')) + { + $failures.Add("xstyler cannot be given '$xamlFile': its file list is comma-separated.") + + continue + } + + $processed.Add($xamlFile) + } + + if (-not $processed.Count) { return $processed.ToArray() } -function Invoke-XamlFormat { - foreach ($directory in @('src', 'samples')) { - $target = Join-Path $repositoryRoot $directory + $output = Invoke-Tool ` + -Arguments @('xstyler', '--config', $configPath, '--file', ($processed -join ',')) ` + -WorkingDirectory $Root + if ($LASTEXITCODE -ne 0) { $failures.Add("xstyler:`n$(Add-RestoreHint -Output $output)") } + + return $processed.ToArray() + } + + foreach ($directory in @('src', 'samples', 'tests')) + { + $target = Join-Path $Root $directory if (-not (Test-Path -LiteralPath $target)) { continue } - $output = Invoke-Tool -Arguments @('xstyler', '--recursive', '--directory', $target) - if ($LASTEXITCODE -ne 0) { $failures.Add("dotnet xstyler ($directory):`n$output") } + $output = Invoke-Tool ` + -Arguments @('xstyler', '--config', $configPath, '--recursive', '--directory', $target) ` + -WorkingDirectory $Root + if ($LASTEXITCODE -ne 0) { $failures.Add("xstyler ($directory):`n$(Add-RestoreHint -Output $output)") } + + foreach ($extension in $xamlExtensions) + { + Get-ChildItem -LiteralPath $target -Filter "*$extension" -File -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -notmatch '[\\/](bin|obj|\.packages)[\\/]' } | + ForEach-Object { $processed.Add($_.FullName) } + } } + + return $processed.ToArray() } # --- Line endings --------------------------------------------------------------------------------- -# Always last: XamlStyler above writes the host newline and cannot be told otherwise. -function Invoke-LineEndingFix { - # Byte-level, so the encoding and any byte order mark survive. A file that needs no change is not - # written at all, so unchanged files keep their timestamp. - $patterns = @('*.xaml', '*.axaml', '*.resx') - $relativePaths = @(& git -C $repositoryRoot ls-files -- $patterns 2>$null) - - $rewritten = New-Object System.Collections.Generic.List[String] +function Repair-LineEnding +{ + <# + XamlStyler writes the host newline and cannot be told otherwise, so on Windows every file it + touched comes back CRLF. Only those files are considered - never the whole tree, and never a + resource value, which is why .resx is not in the list. + + Byte-level, so the encoding and any byte order mark survive. A file that needs no change is not + written at all, so unchanged files keep their timestamp. Nothing is staged: leaving git's cached + stat information alone is the caller's problem to notice, and staging on someone's behalf is + worse than a file that shows as modified. + #> + param([String[]] $Files) - foreach ($relativePath in $relativePaths) { - if ([String]::IsNullOrWhiteSpace($relativePath)) { continue } + $rewritten = 0 - $fullPath = Join-Path $repositoryRoot $relativePath - if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) { continue } + foreach ($file in @($Files)) + { + if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { continue } - $bytes = [System.IO.File]::ReadAllBytes($fullPath) + $bytes = [System.IO.File]::ReadAllBytes($file) $output = New-Object System.Collections.Generic.List[Byte] - # Drop every CR that is directly followed by LF. A lone CR is left alone, and a UTF-16 file - # never matches this pattern - its CR is followed by a NUL - so neither can be corrupted here. - for ($index = 0; $index -lt $bytes.Length; $index++) { - if ($bytes[$index] -eq 13 -and ($index + 1) -lt $bytes.Length -and $bytes[$index + 1] -eq 10) { + # Drop every CR that is directly followed by LF. A lone CR is left alone, and a UTF-16 file never + # matches this pattern - its CR is followed by a NUL - so neither can be corrupted here. + for ($index = 0; $index -lt $bytes.Length; $index++) + { + if ($bytes[$index] -eq 13 -and ($index + 1) -lt $bytes.Length -and $bytes[$index + 1] -eq 10) + { continue } $output.Add($bytes[$index]) } - if ($output.Count -ne $bytes.Length) { - [System.IO.File]::WriteAllBytes($fullPath, $output.ToArray()) - $rewritten.Add($relativePath) + if ($output.Count -ne $bytes.Length) + { + [System.IO.File]::WriteAllBytes($file, $output.ToArray()) + $rewritten++ } } - # Rewriting a file leaves git's cached stat information stale, which on its own keeps the file - # listed as modified by `git status`. Clear that, but only where doing so cannot stage anything: - # the file's blob must already equal what the index holds. A file with real changes is left alone, - # so this never stages work on the developer's behalf. - foreach ($relativePath in $rewritten) { - $indexBlob = (& git -C $repositoryRoot ls-files -s -- $relativePath) -split '\s+' | Select-Object -Index 1 - $fileBlob = & git -C $repositoryRoot hash-object --path $relativePath -- (Join-Path $repositoryRoot $relativePath) + return $rewritten +} + +# --- The disposable copy -------------------------------------------------------------------------- +# What -Check runs on wherever a tool has no verify mode. It is a copy of the CURRENT tree - tracked +# content as it stands on disk, including uncommitted edits, plus the untracked files git does not +# ignore - placed outside the repository so that nothing the tools do can reach the original. +# +# It is a git repository of its own, with one commit, because the tools want one: CSharpier reads +# .gitignore to decide what to skip, and the commit is what makes `git diff` inside the copy state the +# proposed change exactly. Staging and committing THERE is not the same act as staging in your +# repository, which this script never does. + +function New-DisposableTreeCopy +{ + param([Parameter(Mandatory)] [String] $Root) + + $temporaryDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "resxlocalization-tidy-$([Guid]::NewGuid())" + New-Item -ItemType Directory -Path $temporaryDirectory -Force | Out-Null + + # Tracked content plus untracked-but-not-ignored files: exactly the files a commit from here could + # contain. Caches and build output are ignored, so they are excluded by construction rather than by a + # list this script would have to keep in step. + $relativePaths = Invoke-Git -WorkingDirectory $Root -Arguments @('ls-files', '--cached', '--others', '--exclude-standard') | + Where-Object { $_ } | + Sort-Object -Unique + + if (-not $relativePaths) + { + throw 'tidy-code: git listed no files. Is this a git repository, and is git on PATH?' + } + + $copied = 0 + foreach ($relativePath in $relativePaths) + { + $source = Join-Path $Root $relativePath + + # `--cached` lists a file that is tracked but deleted in the working tree. There is nothing to copy. + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { continue } + + $destination = Join-Path $temporaryDirectory $relativePath + $destinationDirectory = Split-Path -Parent $destination + if (-not (Test-Path -LiteralPath $destinationDirectory)) + { + New-Item -ItemType Directory -Path $destinationDirectory -Force | Out-Null + } + + Copy-Item -LiteralPath $source -Destination $destination -Force + $copied++ + } + + if (-not (Get-ChildItem -LiteralPath $temporaryDirectory -Recurse -File -Filter '*.cs' | Select-Object -First 1)) + { + throw 'tidy-code: the copied tree contains no .cs file, so a check of it would prove nothing.' + } + + # The identity is supplied per command rather than written into a config, and it never touches the + # original repository: -c applies to this invocation only, and the invocation runs in the copy. + $identity = @( + '-c', 'user.name=tidy-code', + '-c', 'user.email=tidy-code@localhost', + '-c', 'commit.gpgsign=false' + ) + + Invoke-Git -WorkingDirectory $temporaryDirectory -Arguments @('init', '--quiet') | Out-Null + Invoke-Git -WorkingDirectory $temporaryDirectory -Arguments @('add', '--all') | Out-Null + Invoke-Git -WorkingDirectory $temporaryDirectory -Arguments ($identity + @('commit', '--quiet', '-m', 'tidy-code baseline')) | Out-Null + + # Write-Host, not Write-Output: anything written to the pipeline here would be returned to the caller + # alongside the path, and the caller wants one string. + Write-Host "tidy-code: checking a disposable copy of $copied file(s) in $temporaryDirectory" + + return $temporaryDirectory +} + +function Remove-DisposableTreeCopy +{ + param([Parameter(Mandatory)] [String] $TemporaryDirectory) + + # Only ever the directory this script created, identified by the prefix it created it with. A path + # that does not look like one is left alone rather than deleted on the strength of a variable. + $expectedPrefix = Join-Path ([System.IO.Path]::GetTempPath()) 'resxlocalization-tidy-' + + if (-not $TemporaryDirectory.StartsWith($expectedPrefix, [StringComparison]::OrdinalIgnoreCase)) + { + Write-Output "tidy-code: refusing to delete '$TemporaryDirectory' - it is not a directory this script created." + + return + } + + if (Test-Path -LiteralPath $TemporaryDirectory) + { + Remove-Item -LiteralPath $TemporaryDirectory -Recurse -Force -ErrorAction SilentlyContinue + } +} + +function Invoke-CheckOnCopy +{ + <# + The whole pipeline, run for real, in a copy. $ScopeName decides how much of it: at `all` + everything runs; at the file scopes only the steps whose tools have no verify mode, which here + means XamlStyler. + #> + param( + [Parameter(Mandatory)] [String] $Root, + [Parameter(Mandatory)] [String] $ScopeName, + [String[]] $Files + ) + + $copy = New-DisposableTreeCopy -Root $Root + try + { + if ($ScopeName -eq 'all') + { + # The copy has no obj/, so nothing in it can load until it is restored. Restoring here rather + # than leaving it to the first tool that needs it turns "the copy could not restore" into its + # own, legible failure instead of a confusing formatter error. + $output = Invoke-Tool -Arguments @('restore', $solutionFileName) -WorkingDirectory $copy + if ($LASTEXITCODE -ne 0) + { + $failures.Add("dotnet restore (in the disposable copy):`n$output") + + return + } + + Invoke-StyleFix -Files @() -VerifyOnly $false -Root $copy + if ($failures.Count) { return } + + Invoke-ReorderMembers -Root $copy + if ($failures.Count) { return } + + Invoke-Format -Files @() -VerifyOnly $false -Root $copy + if ($failures.Count) { return } + + $processed = [String[]] @(Invoke-XamlFormat -Files @() -Root $copy) + if ($failures.Count) { return } + + Repair-LineEnding -Files $processed | Out-Null + } + else + { + # Only the XAML files reach here; the C# tools were verified in place, without writing. + $copiedFiles = [String[]] @( + @($Files) | + Where-Object { [System.IO.Path]::GetExtension($_) -in $xamlExtensions } | + ForEach-Object { Join-Path $copy ([System.IO.Path]::GetRelativePath($Root, $_)) } | + Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } + ) + + if (-not $copiedFiles) { return } + + $processed = [String[]] @(Invoke-XamlFormat -Files $copiedFiles -Root $copy) + if ($failures.Count) { return } + + Repair-LineEnding -Files $processed | Out-Null + } - if ($indexBlob -and $fileBlob -and $indexBlob -eq $fileBlob) { - & git -C $repositoryRoot add -- $relativePath + $changed = Invoke-Git -WorkingDirectory $copy -Arguments @('diff', '--name-only') + if (-not $changed) + { + return } + + $statistics = Invoke-Git -WorkingDirectory $copy -Arguments @('diff', '--stat') + $diff = Invoke-Git -WorkingDirectory $copy -Arguments @('--no-pager', 'diff') + + $applyWith = if ($ScopeName -eq 'all') { '-Scope all' } else { "-Scope $ScopeName" } + + $failures.Add( + "the tree is not tidy. $($changed.Count) file(s) would change:`n" + + "$($statistics -join "`n")`n`n" + + "$($diff -join "`n")`n`n" + + "Apply it with: pwsh -File scripts/tidy-code.ps1 $applyWith" + ) + } + finally + { + Remove-DisposableTreeCopy -TemporaryDirectory $copy } } # --- Run ------------------------------------------------------------------------------------------ -$output = Invoke-Tool -Arguments @('tool', 'restore', '--configfile', (Join-Path $repositoryRoot 'NuGet.config')) -if ($LASTEXITCODE -ne 0) { - Write-Output "tidy-code: dotnet tool restore:`n$output" - exit 1 -} +Assert-Prerequisite -Root $repositoryRoot -$before = if ($Check) { Get-Fingerprint } else { $null } +if ($Scope -eq 'all') +{ + if ($Path) { Write-Output 'tidy-code: -Scope all covers the whole repository; the paths given are ignored.' } -Invoke-CSharpFormat -Invoke-XamlFormat -Invoke-LineEndingFix + if ($Check) + { + Invoke-CheckOnCopy -Root $repositoryRoot -ScopeName 'all' -if ($Check -and -not $failures.Count -and (Get-Fingerprint) -ne $before) { - $failures.Add('the tree is not formatted. Run: pwsh -File scripts/tidy-code.ps1') + if (-not $failures.Count) { Write-Output 'tidy-code: repository checked - the tree is tidy.' } + } + else + { + Invoke-StyleFix -Files @() -VerifyOnly $false -Root $repositoryRoot + Invoke-ReorderMembers -Root $repositoryRoot + Invoke-Format -Files @() -VerifyOnly $false -Root $repositoryRoot + $processed = [String[]] @(Invoke-XamlFormat -Files @() -Root $repositoryRoot) + $rewritten = Repair-LineEnding -Files $processed + + if (-not $failures.Count) + { + Write-Output "tidy-code: repository tidied ($rewritten XAML file(s) normalized to LF)." + } + } } +else +{ + if ($Path) + { + $files = [String[]] @(Resolve-ExplicitFile -Candidates $Path) + } + else + { + $files = [String[]] @(Resolve-DerivedFile -Candidates (Get-ChangedSourceFile -Root $repositoryRoot)) + } + + if (-not $files) + { + Write-Output 'tidy-code: nothing to do.' -if (-not $failures.Count) { - Write-Output "tidy-code: repository $(if ($Check) { 'checked' } else { 'formatted' })." + exit 0 + } + + if ($Scope -eq 'style') { Invoke-StyleFix -Files $files -VerifyOnly $Check.IsPresent -Root $repositoryRoot } + Invoke-Format -Files $files -VerifyOnly $Check.IsPresent -Root $repositoryRoot + + if ($Check) + { + # XamlStyler has no verify mode that is meaningful here, so the XAML half of the check runs in a + # copy. The C# half above already reported without writing. + if (@($files) | Where-Object { [System.IO.Path]::GetExtension($_) -in $xamlExtensions }) + { + Invoke-CheckOnCopy -Root $repositoryRoot -ScopeName $Scope -Files $files + } + } + else + { + $processed = [String[]] @(Invoke-XamlFormat -Files $files -Root $repositoryRoot) + Repair-LineEnding -Files $processed | Out-Null + } + + if (-not $failures.Count) + { + Write-Output "tidy-code: $($files.Count) file(s) $(if ($Check) { 'checked' } else { 'tidied' })." + } } -else { + +if ($failures.Count) +{ $failures | ForEach-Object { Write-Output "tidy-code: $_" } + exit 1 } + +exit 0 diff --git a/scripts/update-public-api.ps1 b/scripts/update-public-api.ps1 new file mode 100644 index 0000000..e4457ec --- /dev/null +++ b/scripts/update-public-api.ps1 @@ -0,0 +1,177 @@ +<# +.SYNOPSIS + Records the current public surface of the shipping projects in their PublicAPI.Unshipped.txt files. + +.DESCRIPTION + The shipping projects are guarded by Microsoft.CodeAnalysis.PublicApiAnalyzers: a public member that + is not listed in the project's PublicAPI.Shipped.txt or PublicAPI.Unshipped.txt is RS0016, and a + listed member that no longer exists is RS0017. Both are build errors here, because + TreatWarningsAsErrors is on - so an unintended change to the public surface breaks the build rather + than slipping through review. + + This script applies the RS0016 code fix, which writes the missing entries into + 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 + 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. + + Review the diff it produces. That diff IS the public-API change, and per CONTRIBUTING.md a real one + also needs a CHANGELOG entry under `## [Unreleased]` and a documentation update. It does NOT need a + version bump from you: the version, the release date and the tag are the maintainer's, at release + time. + + At release time the accumulated entries move from PublicAPI.Unshipped.txt to PublicAPI.Shipped.txt, + and a removal is recorded in PublicAPI.Unshipped.txt as `*REMOVED*`. That promotion is a + 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/. + +.PARAMETER MarkShipped + The MAINTAINER's release step, not the edit step: fold PublicAPI.Unshipped.txt into + PublicAPI.Shipped.txt and leave Unshipped empty. `*REMOVED*` entries delete the matching Shipped line + rather than being carried over. Run this when a version is released, so that the next release's + Unshipped.txt again means "new since the last release". + +.EXAMPLE + pwsh -File scripts/update-public-api.ps1 + +.EXAMPLE + pwsh -File scripts/update-public-api.ps1 src/ResXLocalization.Core/ResXLocalization.Core.csproj + +.EXAMPLE + pwsh -File scripts/update-public-api.ps1 -MarkShipped +#> +#requires -Version 7.0 +[CmdletBinding()] +param( + [Parameter(Position = 0, ValueFromRemainingArguments = $true)] + [String[]] $Project, + + [Switch] $MarkShipped +) + +$ErrorActionPreference = 'Stop' + +# scripts/ - the repository root is one level up, whatever the current directory is. +$repositoryRoot = (Resolve-Path -LiteralPath (Split-Path -Parent $PSScriptRoot)).Path + +# 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') + +if (-not $Project -or $Project.Count -eq 0) +{ + $Project = $runtimeProjectNames | ForEach-Object { Join-Path $repositoryRoot "src/$_/$_.csproj" } +} + +$failed = $false + +foreach ($projectFile in $Project) +{ + if (-not (Test-Path -LiteralPath $projectFile)) + { + Write-Output "update-public-api: $projectFile not found - skipped." + + continue + } + + $projectFile = (Resolve-Path -LiteralPath $projectFile).Path + $projectDirectory = Split-Path -Parent $projectFile + $projectName = Split-Path -Leaf $projectFile + + # dotnet format applies the RS0016 fix but never creates these files, and the analyzer stays silent + # while they are absent - so creating them is what switches the guard on for a project. + # + # PublicAPI.Shipped.txt starts with '#nullable enable': the shipping projects compile with + # Nullable=enable, and without that header the analyzer records no nullability at all and reports + # RS0037 for every annotated member. With it, `string` and `string?` are different API entries, so a + # nullability change to a public signature shows up as the source-breaking change it is. + $headerPerFileName = @{ + 'PublicAPI.Shipped.txt' = '#nullable enable' + 'PublicAPI.Unshipped.txt' = '' + } + + foreach ($fileName in $headerPerFileName.Keys) + { + $filePath = Join-Path $projectDirectory $fileName + + if (-not (Test-Path -LiteralPath $filePath)) + { + Set-Content -LiteralPath $filePath -Value $headerPerFileName[$fileName] -NoNewline:($headerPerFileName[$fileName] -eq '') + Write-Output "update-public-api: created $fileName for $projectName." + } + } + + if ($MarkShipped) + { + $shippedPath = Join-Path $projectDirectory 'PublicAPI.Shipped.txt' + $unshippedPath = Join-Path $projectDirectory 'PublicAPI.Unshipped.txt' + + $unshipped = @(Get-Content -LiteralPath $unshippedPath | Where-Object { $_.Trim() }) + + if ($unshipped.Count -eq 0) + { + Write-Output "update-public-api: nothing unshipped in $projectName." + + continue + } + + $shipped = @(Get-Content -LiteralPath $shippedPath | Where-Object { $_.Trim() -and $_ -ne '#nullable enable' }) + + $removed = @($unshipped | Where-Object { $_.StartsWith('*REMOVED*', [StringComparison]::Ordinal) }) | + ForEach-Object { $_.Substring('*REMOVED*'.Length) } + $added = @($unshipped | Where-Object { -not $_.StartsWith('*REMOVED*', [StringComparison]::Ordinal) }) + + $shipped = @($shipped | Where-Object { $removed -notcontains $_ }) + $added | + Sort-Object -Unique + + Set-Content -LiteralPath $shippedPath -Value (@('#nullable enable') + $shipped) + Set-Content -LiteralPath $unshippedPath -Value '' -NoNewline + + Write-Output "update-public-api: marked $($added.Count) added and $($removed.Count) removed API(s) as shipped in $projectName." + + continue + } + + # From the repository root: `dotnet` resolves global.json from the CURRENT directory upward, and this + # repository's global.json is what pins the SDK. The location is restored in the finally block. + Push-Location -LiteralPath $repositoryRoot + try + { + $output = & dotnet format analyzers $projectFile --diagnostics RS0016 --severity info -v q 2>&1 + } + finally + { + Pop-Location + } + + if ($LASTEXITCODE -ne 0) + { + $failed = $true + Write-Output "update-public-api: dotnet format failed for ${projectName}:`n$output" + } + else + { + Write-Output "update-public-api: updated $projectName." + } +} + +if ($failed) { exit 1 } + +Write-Output '' + +if ($MarkShipped) +{ + Write-Output 'PublicAPI.Unshipped.txt is empty again. The next entry that appears there is new since this release.' +} +else +{ + Write-Output 'Review the PublicAPI.*.txt diff - it is the public-API change, and a real one also needs an' + Write-Output 'entry under ## [Unreleased] in CHANGELOG.md and a documentation update (see CONTRIBUTING.md).' + Write-Output 'Do not bump a version: that is the maintainer''s, at release time.' +} + +exit 0 diff --git a/scripts/verify-line-endings.ps1 b/scripts/verify-line-endings.ps1 new file mode 100644 index 0000000..1a7092e --- /dev/null +++ b/scripts/verify-line-endings.ps1 @@ -0,0 +1,118 @@ +<# +.SYNOPSIS + Verifies that every file's line endings match what .gitattributes declares - both as git stored them + and as they stand on disk. + +.DESCRIPTION + The check CI runs as "Verify line endings are normalized", plus the half CI cannot run. + + It matters here more than anywhere, because a tree with the wrong endings does not fail with a message + about line endings. .editorconfig sets end_of_line = lf and CSharpier writes what it asks for, so a + CRLF file is unformatted by definition and CSharpier.MsBuild fails the build with one + "Was not formatted." error per file - naming the formatter, which is not the problem, in every one of + them. + + Nothing else in the repository sees it first. Git normalizes on read, so `git status` reports a clean + tree and the tidiness check reports a tidy one: scripts/tidy-code.ps1 -Check -Scope all commits the + disposable copy before it runs the tools, which stores every file as LF whatever was on disk, and a + formatter rewriting CRLF to LF is then a change `git diff` reports as nothing at all. + + `git ls-files --eol` is the one question that is not laundered, and it writes nothing: it reports, per + file, what git STORED (i/), what is ON DISK (w/), and the attributes that decide both. Both columns are + checked, and only one of them is CI's: + + i/ is CI's question, which it asks by renormalizing. It fails when a file was committed past + .gitattributes - by a rule added after the file, or by a commit created server-side on GitHub, + which bypasses the filter entirely. + w/ only a working copy can answer, and it is what the compiler and the formatter actually read. A CI + checkout is written from the index seconds earlier, so its working tree cannot disagree with it; + a clone made with a filter that overrode the attribute, an unzipped archive, an editor that + rewrote a file, or a copy through a tool that "helpfully" converts, all can. + + A drifted working tree is not repaired by checking it out again, which is the first thing anyone + reaches for: git skips every file whose stat information matches the index BEFORE it considers --force, + and a tree written wrong by whatever produced it matches perfectly. `git checkout-index --force --all` + exits 0 without writing a byte. The files have to be deleted first, and the failure message says so. + + The expected ending is read from each file's own eol attribute rather than assumed, so this keeps + checking the right thing if .gitattributes ever declares something else. A file that declares no eol + attribute is not this script's business. + +.EXAMPLE + pwsh -File scripts/verify-line-endings.ps1 +#> +#requires -Version 7.0 +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' + +# scripts/ - the repository root is one level up, whatever the current directory is. +$repositoryRoot = (Resolve-Path -LiteralPath (Split-Path -Parent $PSScriptRoot)).Path + +$violations = New-Object System.Collections.Generic.List[String] + +foreach ($entry in @(& git -C $repositoryRoot ls-files --eol)) +{ + # i/ w/ attr/. The path is tab-separated because it can + # contain spaces; the three columns before it are space-padded to a fixed width. + $pattern = '^i/(?\S+)\s+w/(?\S+)\s+attr/(?.*?)\s*\t(?.+)$' + $parsed = [Regex]::Match($entry, $pattern) + + if (-not $parsed.Success) + { + continue + } + + $declared = [Regex]::Match($parsed.Groups['attributes'].Value, '(?:^|\s)eol=(?lf|crlf)(?:\s|$)') + + if (-not $declared.Success) + { + continue + } + + $expected = $declared.Groups['eol'].Value + $stored = $parsed.Groups['index'].Value + $onDisk = $parsed.Groups['worktree'].Value + + # 'none' is an empty file or one with no line breaks and '-text' is binary. Neither has a line ending + # to be wrong about. + $wrong = @($stored, $onDisk) | Where-Object { $_ -notin @('none', '-text', $expected) } + + if ($wrong) + { + $violations.Add(" $($parsed.Groups['path'].Value) - stored $stored, on disk $onDisk, expected $expected") + } +} + +if (-not $violations.Count) +{ + Write-Output 'Every file matches the ending .gitattributes declares.' + + exit 0 +} + +Write-Output "FAILED - $($violations.Count) file(s) do not match the ending .gitattributes declares:" +$violations | Select-Object -First 10 | ForEach-Object { Write-Output $_ } + +if ($violations.Count -gt 10) +{ + Write-Output " ... and $($violations.Count - 10) more" +} + +Write-Output '' +Write-Output 'Wrong on disk (i/lf w/crlf): the working tree drifted from the index. Re-checking-out over it' +Write-Output 'does NOT fix it - git skips every file whose stat matches the index, which is exactly these' +Write-Output 'files, so `git checkout-index --force --all` and `git checkout -- .` both exit 0 having done' +Write-Output 'nothing. Deleting them first is what makes git write them again:' +Write-Output '' +Write-Output ' git ls-files | ForEach-Object { Remove-Item -LiteralPath $_ -Force }' +Write-Output ' git checkout -- .' +Write-Output '' +Write-Output 'That deletes tracked files before restoring them from the index, so commit or stash anything' +Write-Output 'uncommitted first. Re-run this check afterwards: if the endings come back, they are being' +Write-Output 'written by whatever produced this working tree rather than by git.' +Write-Output '' +Write-Output 'Wrong in the repository (i/crlf): git add --renormalize . and commit the result.' + +exit 1 diff --git a/scripts/verify-package-aot.ps1 b/scripts/verify-package-aot.ps1 new file mode 100644 index 0000000..50a07bb --- /dev/null +++ b/scripts/verify-package-aot.ps1 @@ -0,0 +1,504 @@ +<# +.SYNOPSIS + Publishes the Avalonia package consumer with Native AOT, gates its IL diagnostics, and runs the native + binary. + +.DESCRIPTION + This is the only check in the repository that can catch silent trimming damage. The library finds + translations through ResourceManager and satellite assemblies, and the generated typed keys carry + resource names as strings - none of which the JIT can lose, and all of which a trimmer can. Nothing is + 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 + 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: + + 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 + 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 + the closure, and not at the consumer's own call sites. + 5. The native binary RUNS and every assertion in it passes. A file that exists is not a check. + + ResXLocalization.WPF is out of scope: WPF does not support Native AOT. + +.PARAMETER Framework + The target framework to publish. net8.0 is the LTS floor the packages support; net10.0 is current and + the default. + +.PARAMETER Runtime + The runtime identifier to publish for. Defaults to win-x64 on Windows and linux-x64 elsewhere. + +.PARAMETER PackageVersion + The version of the packages to consume. Defaults to the in the repository-root + Directory.Build.props, which is what `dotnet pack` produces. CI passes the exact version of the + artifacts the publish job produced. + +.PARAMETER Configuration + Build configuration. Release by default, because that is what CI uses. + +.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. + + CI does not use this: it downloads the exact packages the publish job produced. + +.NOTES + A native publish needs a C++ toolchain. On Windows that is MSVC, and vswhere.exe must be resolvable or + the link step fails with a misleading MSB3073 - this script puts the Visual Studio Installer directory + on PATH for that reason. On Linux it needs clang and zlib1g-dev. + +.EXAMPLE + pwsh -File scripts/verify-package-aot.ps1 -Pack + +.EXAMPLE + pwsh -File scripts/verify-package-aot.ps1 -Framework net8.0 +#> +#requires -Version 7.0 +[CmdletBinding()] +param( + [ValidateSet('net8.0', 'net10.0')] + [String] $Framework = 'net10.0', + + [String] $Runtime, + + [String] $PackageVersion, + + [String] $Configuration = 'Release', + + [Switch] $Pack +) + +$ErrorActionPreference = 'Stop' + +if (-not $Runtime) +{ + $Runtime = $IsWindows ? 'win-x64' : 'linux-x64' +} + +$supportedRuntimes = @('win-x64', 'win-arm64', 'linux-x64', 'linux-arm64', 'osx-x64', 'osx-arm64') + +if ($Runtime -notin $supportedRuntimes) +{ + Write-Host "FAILED. '$Runtime' is not one of the runtime identifiers this gate supports: $($supportedRuntimes -join ', ')." -ForegroundColor Red + + exit 1 +} + +# 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') + +if (-not $PackageVersion) +{ + # The single source of truth for the version of every package. Reading it here keeps this script + # correct across a release bump without a second place to edit. + $sharedProperties = Join-Path $repositoryRoot 'Directory.Build.props' + $PackageVersion = ([Xml] (Get-Content -Raw -LiteralPath $sharedProperties)).Project.PropertyGroup.Version | + Where-Object { $_ } | + Select-Object -First 1 + + if (-not $PackageVersion) + { + Write-Host "FAILED. No found in $sharedProperties." -ForegroundColor Red + + exit 1 + } +} + +# Everything below runs inside a try/finally that restores the caller's PATH, environment and working +# location, so an interrupted run leaves the shell as it found it. `exit` inside a try block still runs the +# finally. +# +# The working directory is not cosmetic: `dotnet` resolves global.json from the CURRENT directory upward, +# and this repository's global.json is what pins the SDK the packages are built with. +$originalPath = $env:PATH +$originalNuGetPackages = $env:NUGET_PACKAGES + +Push-Location -LiteralPath $repositoryRoot +try +{ + if ($IsWindows) + { + # Without vswhere.exe on PATH the native link step fails with MSB3073 and a message about a command + # that is not recognized, which names neither the toolchain nor this script's own prerequisite. + $visualStudioInstaller = 'C:\Program Files (x86)\Microsoft Visual Studio\Installer' + + if ((Test-Path -LiteralPath $visualStudioInstaller) -and ($env:PATH -notlike "*$visualStudioInstaller*")) + { + $env:PATH = "$visualStudioInstaller;$env:PATH" + } + } + + # --- The packages under test --------------------------------------------------------------------- + + if ($Pack) + { + if ($IsWindows) + { + Write-Host "Packing the shipping projects ($PackageVersion)..." -ForegroundColor Cyan + + & dotnet pack (Join-Path $repositoryRoot 'ResXLocalization.slnx') --configuration $Configuration --output $packageDirectory + } + else + { + # ResXLocalization.WPF targets net*-windows and cannot build here. Packing the two 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 + + foreach ($packageId in $requiredPackageIds) + { + & dotnet pack (Join-Path $repositoryRoot "src/$packageId/$packageId.csproj") ` + --configuration $Configuration --output $packageDirectory + + if ($LASTEXITCODE -ne 0) { break } + } + } + + if ($LASTEXITCODE -ne 0) + { + Write-Host 'FAILED. dotnet pack did not succeed.' -ForegroundColor Red + + exit 1 + } + } + + # The consumer's package cache is emptied on EVERY run, not only after a pack. NuGet resolves by + # version and not by content, so a cache entry for 1.1.0 satisfies a reference to 1.1.0 whatever bytes + # produced it - and this script exists to test THESE bytes. A stale entry would turn the gate into a + # re-run of whatever passed last time. + # + # Which is why a cache that CANNOT be deleted is a hard failure rather than a warning. NuGet extracts + # packages read-only, and a build server started by an earlier run can still hold a handle to one of + # them for a few seconds, so the delete is retried - but if it never succeeds, the gate stops instead + # of running against whatever is left behind. + if (Test-Path -LiteralPath $packageCache) + { + Get-ChildItem -LiteralPath $packageCache -Recurse -Force -File -ErrorAction SilentlyContinue | + Where-Object { $_.IsReadOnly } | + ForEach-Object { $_.IsReadOnly = $false } + + # 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. + $removed = $false + + for ($attempt = 1; $attempt -le 8; $attempt++) + { + & dotnet build-server shutdown 2>&1 | Out-Null + + try + { + Remove-Item -Recurse -Force -LiteralPath $packageCache -ErrorAction Stop + $removed = $true + + break + } + catch + { + # A recursive delete stops at the first file it cannot open, so retry it file by file: + # everything that is not held goes, and the next attempt has less left to do. + Get-ChildItem -LiteralPath $packageCache -Recurse -Force -File -ErrorAction SilentlyContinue | + ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue } + + Start-Sleep -Seconds 3 + } + } + + if (-not $removed -or (Test-Path -LiteralPath $packageCache)) + { + Write-Host "FAILED. The consumer package cache at $packageCache could not be emptied." -ForegroundColor Red + Write-Host 'Running against it would test whatever was cached last, not the packages under test.' -ForegroundColor Red + Write-Host 'Close anything holding a file there and re-run.' -ForegroundColor Red + + exit 1 + } + } + + # --- The artifact set, validated from the nuspec -------------------------------------------------- + # + # From the metadata inside each package, never from its file name. A file name is a claim: renaming + # ResXLocalization.Core.1.0.0.nupkg to ResXLocalization.Core.1.1.0.nupkg would satisfy a name check and + # then test a package whose nuspec still says 1.0.0. The id and the version that matter are the ones + # NuGet reads. + + Add-Type -AssemblyName System.IO.Compression.FileSystem + + function Get-NuspecMetadata + { + param([Parameter(Mandatory)] [String] $PackagePath) + + $archive = [System.IO.Compression.ZipFile]::OpenRead($PackagePath) + try + { + $entry = $archive.Entries | + Where-Object { $_.FullName -like '*.nuspec' -and $_.FullName -notlike '*/*' } | + Select-Object -First 1 + + if ($null -eq $entry) { return $null } + + $reader = [System.IO.StreamReader]::new($entry.Open()) + try { $nuspec = [Xml] $reader.ReadToEnd() } + finally { $reader.Dispose() } + } + finally + { + $archive.Dispose() + } + + return [PSCustomObject] @{ + Id = $nuspec.package.metadata.id + Version = $nuspec.package.metadata.version + } + } + + if (-not (Test-Path -LiteralPath $packageDirectory)) + { + Write-Host "FAILED. $packageDirectory does not exist." -ForegroundColor Red + Write-Host '' + Write-Host 'This script consumes the packed packages, not the projects. Produce them first:' -ForegroundColor Red + Write-Host ' pwsh -File scripts/verify-package-aot.ps1 -Pack' -ForegroundColor Red + + exit 1 + } + + $available = @{} + foreach ($package in (Get-ChildItem -LiteralPath $packageDirectory -Filter '*.nupkg')) + { + $metadata = Get-NuspecMetadata -PackagePath $package.FullName + if ($null -eq $metadata) + { + Write-Host "FAILED. $($package.Name) contains no nuspec." -ForegroundColor Red + + exit 1 + } + + $available["$($metadata.Id)/$($metadata.Version)"] = $package.Name + } + + $missing = @($requiredPackageIds | Where-Object { -not $available.ContainsKey("$_/$PackageVersion") }) + + if ($missing.Count -gt 0) + { + Write-Host "FAILED. $packageDirectory does not contain version $PackageVersion of:" -ForegroundColor Red + $missing | ForEach-Object { Write-Host " $_" -ForegroundColor Red } + Write-Host '' + Write-Host 'Present, by the id and version in each nuspec:' -ForegroundColor Red + if ($available.Count -eq 0) + { + Write-Host ' (nothing)' -ForegroundColor Red + } + else + { + $available.GetEnumerator() | Sort-Object Key | ForEach-Object { + Write-Host " $($_.Key) ($($_.Value))" -ForegroundColor Red + } + } + Write-Host '' + Write-Host 'Produce them with: pwsh -File scripts/verify-package-aot.ps1 -Pack' -ForegroundColor Red + + exit 1 + } + + 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 + # that config file exists to provide, and the consumer could restore a ResXLocalization assembly that + # 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 + { + $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 + + $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() + } + } + + # 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 '' + + 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 + + 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 } + } + + 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 + } + + 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 + } + + # --- The published output ------------------------------------------------------------------------- + + $executableName = $IsWindows ? 'AvaloniaConsumer.exe' : 'AvaloniaConsumer' + $executable = Join-Path $publishDirectory $executableName + $germanSatellite = Join-Path $publishDirectory 'de/AvaloniaConsumer.resources.dll' + + 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 + + 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. + + Write-Host '' + Write-Host 'Running the native binary...' -ForegroundColor Cyan + Write-Host '' + + $runOutput = & $executable 2>&1 + $runExitCode = $LASTEXITCODE + + $runOutput | ForEach-Object { Write-Host $_ } + + $runLog = Join-Path $logDirectory "run-$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." ` + -ForegroundColor Green + + exit 0 +} +finally +{ + $env:PATH = $originalPath + $env:NUGET_PACKAGES = $originalNuGetPackages + Pop-Location +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..e796194 --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,114 @@ + + + + + + + + + <_IsShippingPackage Condition="'$(_ConventionProjectName)' != 'ResXLocalization.SourceGenerators'">true + <_IsAotCompatiblePackage Condition="'$(_IsShippingPackage)' == 'true' AND '$(_ConventionProjectName)' != 'ResXLocalization.WPF'">true + + + + + latest + All + true + true + + + + + true + + + + + true + true + snupkg + + + + + logo-128.png + MIT + https://github.com/rent-a-developer/ResXLocalization + PACKAGE_README.md + $(PackageProjectUrl)/blob/main/CHANGELOG.md + false + git + $(PackageProjectUrl).git + + + + + true + 1.1.0 + + + + + + + + diff --git a/src/ResXLocalization.Avalonia/ResXLocalization.Avalonia.csproj b/src/ResXLocalization.Avalonia/ResXLocalization.Avalonia.csproj index 7951a52..6246323 100644 --- a/src/ResXLocalization.Avalonia/ResXLocalization.Avalonia.csproj +++ b/src/ResXLocalization.Avalonia/ResXLocalization.Avalonia.csproj @@ -1,43 +1,35 @@ - - true - true - - $(LibraryTargetFrameworks) - - - - true - - - - - - - - - - - - - - - - - - - - - - + + true + + $(LibraryTargetFrameworks) + + + + Type-safe .resx localization for Avalonia: compile-checked resource keys generated from your .resx files, live language switching without reloads, enum localization, and multiple-.resx support. Fully Native AOT and trimming compatible. + avalonia;localization;resx;i18n;l10n;aot;source-generator;mvvm + + + + + + + + + + + + + + + + + diff --git a/src/ResXLocalization.Core/ResXLocalization.Core.csproj b/src/ResXLocalization.Core/ResXLocalization.Core.csproj index f65b39e..a562ffe 100644 --- a/src/ResXLocalization.Core/ResXLocalization.Core.csproj +++ b/src/ResXLocalization.Core/ResXLocalization.Core.csproj @@ -1,29 +1,26 @@ - - true - true - true - - $(LibraryTargetFrameworks) - + + true + + $(LibraryTargetFrameworks) + - - - RentADeveloper.ResXLocalization - + + + RentADeveloper.ResXLocalization + - - - - - + + The shared, UI-agnostic runtime engine for ResXLocalization, with live culture switching, multiple resource managers, typed resource keys, and Native AOT support. Installed automatically as a dependency of ResXLocalization.Avalonia and ResXLocalization.WPF - most applications should install one of those packages instead. + localization;resx;i18n;l10n;aot + - - - - + + + + + diff --git a/src/ResXLocalization.SourceGenerators/ResXLocalization.SourceGenerators.csproj b/src/ResXLocalization.SourceGenerators/ResXLocalization.SourceGenerators.csproj index 273780d..2d3b0f3 100644 --- a/src/ResXLocalization.SourceGenerators/ResXLocalization.SourceGenerators.csproj +++ b/src/ResXLocalization.SourceGenerators/ResXLocalization.SourceGenerators.csproj @@ -1,46 +1,41 @@ - - true - true - false - true - - netstandard2.0 - + + true + false + true + + netstandard2.0 + - - - - + + + + - - - - + + + + - - - - false - false - false - - false - false - false - false - - + + + + false + false + false + + false + false + false + false + + diff --git a/src/ResXLocalization.WPF/ResXLocalization.WPF.csproj b/src/ResXLocalization.WPF/ResXLocalization.WPF.csproj index 4b3a440..480e62e 100644 --- a/src/ResXLocalization.WPF/ResXLocalization.WPF.csproj +++ b/src/ResXLocalization.WPF/ResXLocalization.WPF.csproj @@ -1,36 +1,31 @@ - - true - - $(WindowsLibraryTargetFrameworks) - true - + + true + + $(WindowsLibraryTargetFrameworks) + true + - - true - + + Type-safe .resx localization for WPF: compile-checked resource keys generated from your .resx files, live language switching without reloads, enum localization, and multiple-.resx support. Windows-only; Native AOT is not supported for WPF. + wpf;localization;resx;i18n;l10n;source-generator;mvvm + - - - + + + - - - + + + - - - - - - - + + diff --git a/stylecop.json b/stylecop.json index 8f06e9c..e9f7425 100644 --- a/stylecop.json +++ b/stylecop.json @@ -1,32 +1,9 @@ { "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", "settings": { - "indentation": { - "useTabs": false, - "indentationSize": 4, - "tabSize": 4 - }, "orderingRules": { - "usingDirectivesPlacement": "outsideNamespace", - "systemUsingDirectivesFirst": true, - "elementOrder": [ - "accessibility" - ] - }, - "layoutRules": { - "newlineAtEndOfFile": "require" - }, - "maintainabilityRules": { - "topLevelTypes": [ - "class", - "interface", - "struct", - "enum", - "delegate" - ] - }, - "documentationRules": { - "documentInternalElements": false + "elementOrder": ["kind", "accessibility", "constant", "static", "readonly"], + "systemUsingDirectivesFirst": true } } } diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 0000000..d91fff7 --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,48 @@ + + + + + + + + + $(NoWarn);CS1574;CS1591 + + + + + + + + + + + + + diff --git a/tests/ResXLocalization.Avalonia.Sample.Tests/TestAppBuilder.cs b/tests/ResXLocalization.Avalonia.Sample.Tests/TestAppBuilder.cs index 9ba3caa..98eac87 100644 --- a/tests/ResXLocalization.Avalonia.Sample.Tests/TestAppBuilder.cs +++ b/tests/ResXLocalization.Avalonia.Sample.Tests/TestAppBuilder.cs @@ -9,6 +9,12 @@ namespace RentADeveloper.ResXLocalization.Avalonia.Sample.Tests; /// public sealed class TestAppBuilder { + /// + /// Avalonia.Headless only ever calls the static factory below through the assembly attribute, so the + /// type is never instantiated. + /// + private TestAppBuilder() { } + public static AppBuilder BuildAvaloniaApp() { Localizer.Current.RegisterResourceManager(ApplicationStrings.ResourceManager); diff --git a/tests/ResXLocalization.Core.Tests/AmbientLocalizerGroup.cs b/tests/ResXLocalization.Core.Tests/AmbientLocalizerGroup.cs index 00681b6..a1a3304 100644 --- a/tests/ResXLocalization.Core.Tests/AmbientLocalizerGroup.cs +++ b/tests/ResXLocalization.Core.Tests/AmbientLocalizerGroup.cs @@ -12,4 +12,7 @@ public sealed class AmbientLocalizerGroup { /// The collection name shared by the classes that use . public const string Name = "Ambient Localizer.Current"; + + /// xUnit reads the attribute on this type and never constructs it. + private AmbientLocalizerGroup() { } } diff --git a/tests/ResXLocalization.Core.Tests/LocalizerContractTests.cs b/tests/ResXLocalization.Core.Tests/LocalizerContractTests.cs index 24d2587..340905b 100644 --- a/tests/ResXLocalization.Core.Tests/LocalizerContractTests.cs +++ b/tests/ResXLocalization.Core.Tests/LocalizerContractTests.cs @@ -65,7 +65,7 @@ public void CultureChanged_CarriesOldAndNewCulture() localizer.CurrentCulture = TestResources.German; captured.Should().NotBeNull(); - captured!.OldCulture.Should().Be(TestResources.English); + captured.OldCulture.Should().Be(TestResources.English); captured.NewCulture.Should().Be(TestResources.German); } diff --git a/tests/ResXLocalization.Core.Tests/LocalizerFormatTests.cs b/tests/ResXLocalization.Core.Tests/LocalizerFormatTests.cs index 69a14a7..65515ba 100644 --- a/tests/ResXLocalization.Core.Tests/LocalizerFormatTests.cs +++ b/tests/ResXLocalization.Core.Tests/LocalizerFormatTests.cs @@ -24,7 +24,7 @@ public void InvalidCompositeFormat_FailsLoudly() // 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", []); + var act = () => localizer.Get("ItemsFound", arguments: []); act.Should().Throw(); } diff --git a/tests/ResXLocalization.SourceGenerators.Tests/ResXLocalization.SourceGenerators.Tests.csproj b/tests/ResXLocalization.SourceGenerators.Tests/ResXLocalization.SourceGenerators.Tests.csproj index ed3a54e..a6fb671 100644 --- a/tests/ResXLocalization.SourceGenerators.Tests/ResXLocalization.SourceGenerators.Tests.csproj +++ b/tests/ResXLocalization.SourceGenerators.Tests/ResXLocalization.SourceGenerators.Tests.csproj @@ -1,18 +1,21 @@ - - $(CurrentTargetFramework) - + + $(CurrentTargetFramework) + - - - - + + + + - - - - + + + + diff --git a/tests/package-consumption/AvaloniaConsumer/AvaloniaConsumer.csproj b/tests/package-consumption/AvaloniaConsumer/AvaloniaConsumer.csproj index efc128a..cea5d23 100644 --- a/tests/package-consumption/AvaloniaConsumer/AvaloniaConsumer.csproj +++ b/tests/package-consumption/AvaloniaConsumer/AvaloniaConsumer.csproj @@ -2,8 +2,10 @@ Exe - - net8.0 + + net8.0;net10.0 enable enable en;de diff --git a/tests/package-consumption/AvaloniaConsumer/ConsumerSortOrder.cs b/tests/package-consumption/AvaloniaConsumer/ConsumerSortOrder.cs new file mode 100644 index 0000000..569dc3b --- /dev/null +++ b/tests/package-consumption/AvaloniaConsumer/ConsumerSortOrder.cs @@ -0,0 +1,15 @@ +namespace AvaloniaConsumer; + +/// +/// An enumeration the consumer localizes by convention, to prove that +/// {keyPrefix}{EnumTypeName}_{Value} still resolves once the application is trimmed and +/// compiled ahead of time. The key names in Strings.resx are derived from this type's name. +/// +public enum ConsumerSortOrder +{ + /// Ascending order. + Ascending, + + /// Descending order. + Descending, +} diff --git a/tests/package-consumption/AvaloniaConsumer/LocalizedView.axaml b/tests/package-consumption/AvaloniaConsumer/LocalizedView.axaml index ff44856..bedea44 100644 --- a/tests/package-consumption/AvaloniaConsumer/LocalizedView.axaml +++ b/tests/package-consumption/AvaloniaConsumer/LocalizedView.axaml @@ -3,5 +3,38 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:l="using:RentADeveloper.ResXLocalization.Avalonia" xmlns:resources="using:AvaloniaConsumer.Resources"> - + + + + + + + + + + + + + + + + + + + diff --git a/tests/package-consumption/AvaloniaConsumer/LocalizedView.axaml.cs b/tests/package-consumption/AvaloniaConsumer/LocalizedView.axaml.cs index 8ca606c..73960b1 100644 --- a/tests/package-consumption/AvaloniaConsumer/LocalizedView.axaml.cs +++ b/tests/package-consumption/AvaloniaConsumer/LocalizedView.axaml.cs @@ -1,8 +1,20 @@ using Avalonia.Controls; +using RentADeveloper.ResXLocalization.Avalonia; namespace AvaloniaConsumer; public partial class LocalizedView : UserControl { - public LocalizedView() => this.InitializeComponent(); + 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; + } + + /// 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); } diff --git a/tests/package-consumption/AvaloniaConsumer/Program.cs b/tests/package-consumption/AvaloniaConsumer/Program.cs index 13dd28d..7ea40f0 100644 --- a/tests/package-consumption/AvaloniaConsumer/Program.cs +++ b/tests/package-consumption/AvaloniaConsumer/Program.cs @@ -1,18 +1,23 @@ -// 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 class - the packaged source generator plus the -// buildTransitive MSBuild wiring that feeds it. Exits non-zero on the first failed check. +// 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. +// +// 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. using System.Globalization; using Avalonia.Controls; using Avalonia.Threading; +using AvaloniaConsumer; using AvaloniaConsumer.Resources; using RentADeveloper.ResXLocalization; using RentADeveloper.ResXLocalization.Avalonia; var failures = 0; -void Check(String description, String actual, String expected) +void Check(string description, string actual, string expected) { if (actual == expected) { @@ -25,39 +30,104 @@ 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.CurrentCulture = new CultureInfo("en"); -// Typed key emitted by the packaged source generator (compiles only if the wiring works). +// --- The engine, in English ------------------------------------------------------------------------- + Check("typed key (generated StringsKeys)", Localizer.Current.Get(StringsKeys.Greeting), "Hello from the package!"); Check("search-all lookup", Localizer.Current.Get("Greeting"), "Hello from the package!"); -var localizedView = new AvaloniaConsumer.LocalizedView(); -var localizedText = (TextBlock)localizedView.Content!; -Check("compiled XAML initial value", localizedText.Text ?? "", "Hello from the package!"); +Check( + "scoped lookup into an unregistered resource manager", + Localizer.Current.Get("ScopedOnly", Catalog.ResourceManager), + "Scoped to the catalog" +); +Check("search-all cannot reach the unregistered manager", Localizer.Current.Get("ScopedOnly"), "!ScopedOnly!"); +Check("enum localization by convention", Localizer.Current.Get(ConsumerSortOrder.Ascending), "Ascending"); + +// --- The compiled XAML, in English ------------------------------------------------------------------ + +var localizedView = new LocalizedView(); + +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 composite format, no argument set", + 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(); +Check( + "compiled XAML composite format after an argument change", + localizedView.InvitedText.Text ?? "", + "3 people invited" +); + +// --- The live switch to German ---------------------------------------------------------------------- -// Live switch through the German satellite assembly. Localizer.Current.CurrentCulture = new CultureInfo("de"); Dispatcher.UIThread.RunJobs(); + 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("live switch, enum by convention", Localizer.Current.Get(ConsumerSortOrder.Ascending), "Aufsteigend"); +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, composite format keeps its argument", + localizedView.InvitedText.Text ?? "", + "3 Personen eingeladen" +); +// --- 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 +// an empty string. + +Check( + "typed key falls back to the neutral culture", + Localizer.Current.Get(StringsKeys.NeutralOnly), + "Only in the neutral file" +); +Check( + "scoped key falls back to the neutral culture", + Localizer.Current.Get("ScopedNeutralOnly", Catalog.ResourceManager), + "Catalog value with no German translation" +); +Check("compiled XAML typed fallback", localizedView.FallbackText.Text ?? "", "Only in the neutral file"); +Check( + "compiled XAML scoped fallback", + localizedView.ScopedFallbackText.Text ?? "", + "Catalog value with no German translation" +); +Check("compiled XAML scoped lookup after the switch", localizedView.ScopedText.Text ?? "", "Nur im Katalog"); + +// --- Discovery and the markup extension ------------------------------------------------------------- +// // Culture discovery sees the neutral resources (invariant) and the German satellite. + var cultures = Localizer.Current.GetAvailableCultures(); Check( "culture discovery", - String.Join(",", cultures.Select(culture => culture.Name.Length == 0 ? "" : culture.Name)), + string.Join(",", cultures.Select(culture => culture.Name.Length == 0 ? "" : culture.Name)), ",de" ); -// The Avalonia markup-extension assembly is loadable and functional. var extension = new LocalizeExtension(StringsKeys.Greeting); Check("markup extension instantiation", extension.ResourceKey?.Name ?? "", "Greeting"); 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/AvaloniaConsumer/Resources/Catalog.Designer.cs b/tests/package-consumption/AvaloniaConsumer/Resources/Catalog.Designer.cs new file mode 100644 index 0000000..602226d --- /dev/null +++ b/tests/package-consumption/AvaloniaConsumer/Resources/Catalog.Designer.cs @@ -0,0 +1,20 @@ +// Hand-authored accessor mirroring what PublicResXFileCodeGenerator emits (trimmed to what the +// consumption test needs). The packaged source generator pairs Catalog.resx with this sibling +// file to emit the typed CatalogKeys class. + +#nullable enable + +namespace AvaloniaConsumer.Resources +{ + public class Catalog + { + private static global::System.Resources.ResourceManager? resourceMan; + + internal Catalog() + { + } + + public static global::System.Resources.ResourceManager ResourceManager => + resourceMan ??= new global::System.Resources.ResourceManager("AvaloniaConsumer.Resources.Catalog", typeof(Catalog).Assembly); + } +} diff --git a/tests/package-consumption/AvaloniaConsumer/Resources/Catalog.de.resx b/tests/package-consumption/AvaloniaConsumer/Resources/Catalog.de.resx new file mode 100644 index 0000000..fbf0dd0 --- /dev/null +++ b/tests/package-consumption/AvaloniaConsumer/Resources/Catalog.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 + + + Nur im Katalog + + diff --git a/tests/package-consumption/AvaloniaConsumer/Resources/Catalog.resx b/tests/package-consumption/AvaloniaConsumer/Resources/Catalog.resx new file mode 100644 index 0000000..beed491 --- /dev/null +++ b/tests/package-consumption/AvaloniaConsumer/Resources/Catalog.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 + + + Scoped to the catalog + + + Catalog value with no German translation + + diff --git a/tests/package-consumption/AvaloniaConsumer/Resources/Strings.de.resx b/tests/package-consumption/AvaloniaConsumer/Resources/Strings.de.resx index a847675..4018e4a 100644 --- a/tests/package-consumption/AvaloniaConsumer/Resources/Strings.de.resx +++ b/tests/package-consumption/AvaloniaConsumer/Resources/Strings.de.resx @@ -12,7 +12,16 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Aufsteigend + + + Absteigend + Hallo aus dem Paket! + + {0} Personen eingeladen + diff --git a/tests/package-consumption/AvaloniaConsumer/Resources/Strings.resx b/tests/package-consumption/AvaloniaConsumer/Resources/Strings.resx index 7f8e94e..bfe6b1c 100644 --- a/tests/package-consumption/AvaloniaConsumer/Resources/Strings.resx +++ b/tests/package-consumption/AvaloniaConsumer/Resources/Strings.resx @@ -12,7 +12,19 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Ascending + + + Descending + Hello from the package! + + Only in the neutral file + + + {0} people invited + diff --git a/tests/package-consumption/nuget.config b/tests/package-consumption/nuget.config index b3e2666..21895ef 100644 --- a/tests/package-consumption/nuget.config +++ b/tests/package-consumption/nuget.config @@ -1,8 +1,9 @@ - + @@ -11,4 +12,17 @@ + + + + + + + + + From 9215472c4d043e4a494a6b47ecd9c5cc07dfba72 Mon Sep 17 00:00:00 2001 From: David Liebeherr Date: Mon, 7 Sep 2026 19:25:04 +0200 Subject: [PATCH 5/6] chore: add the mechanical commits to .git-blame-ignore-revs The three style commits rewrote lines across the whole repository without changing what the code does, so blame should point at whoever wrote the logic rather than at the tool that reformatted it. Each SHA was verified with git cat-file and each is an ancestor of HEAD. They only stay valid if this branch is merged with a MERGE COMMIT: a rebase or a squash merge rewrites every one of them, and git skips an entry it cannot resolve without saying so. CI's lint job is what catches that. Co-Authored-By: Claude Opus 5 --- .git-blame-ignore-revs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 74e05b7..c0ad46a 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -12,3 +12,16 @@ # style: renormalize line endings to LF # Line endings only. 3c9a8deec2a886ad7c8e5a269c5f8b60b11cc78b + +# style: apply the C# style, ordering and formatting rules +# dotnet format style, ReSharper member reordering and CSharpier. The visible change is the type +# names: String became string everywhere. +2e78c0b2aeec62ee829f84e972b80962d0c4909c + +# style: reformat the XAML with XamlStyler +# Attribute layout only. No element, setter or attached property moved. +35fd5aa54aa4a0d569ee7b3b7391923b5581faeb + +# style: re-indent the structured configuration to two spaces +# Leading whitespace only; `git diff -w` against its parent is empty. +cd2512bdc6d05ca7ae6e11b91e28cb6a9cfde340 From d1a241d8352406d331990b2c54f6ccc281f84739 Mon Sep 17 00:00:00 2001 From: David Liebeherr Date: Tue, 8 Sep 2026 16:54:23 +0200 Subject: [PATCH 6/6] docs: describe the project at the top of AGENTS.md AGENTS.md opened straight into the project map, so an agent reading it first had the layout and the constraints but never learned what the library does. Add a "What this is" section that names the two packages, the shared engine, the compile-checked keys and the live culture switch, and point at README.md for the user-facing version. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index f0f2f73..aaece46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,27 @@ carries the rationale; this file is the commands, the constraints and the trigge Everything reusable lives in one place — see [`.agents/README.md`](.agents/README.md) for how the AI files fit 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. + +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. +- **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). + +[`README.md`](README.md) is the user-facing version of the same story, with the quick start and the examples. + ## Project map | Path | What it is |