From e3d42491b15d64c6ed35f650d3dfb61c058ba37f Mon Sep 17 00:00:00 2001 From: Lowie Delneste Date: Fri, 10 Jul 2026 08:34:14 +0200 Subject: [PATCH 1/4] Add support for format methods in LocalizerGenerator --- .../GeneratorLocalizerTests.cs | 76 +++++++++++++++++++ .../Generators/LocalizerGenerator.cs | 46 +++++++++-- Aigamo.ResXGenerator/Tools/GenFileOptions.cs | 7 ++ Aigamo.ResXGenerator/Tools/GlobalOptions.cs | 6 ++ 4 files changed, 129 insertions(+), 6 deletions(-) diff --git a/Aigamo.ResXGenerator.Tests/GeneratorLocalizerTests.cs b/Aigamo.ResXGenerator.Tests/GeneratorLocalizerTests.cs index 21266c8..913f8a9 100644 --- a/Aigamo.ResXGenerator.Tests/GeneratorLocalizerTests.cs +++ b/Aigamo.ResXGenerator.Tests/GeneratorLocalizerTests.cs @@ -85,6 +85,82 @@ public void Generate_Localizer_NullForgivingOperators() Generate(generator, nullForgivingOperators: true); } + [Fact] + public void Generate_Localizer_FormattableValue_WithoutOption_DoesNotGenerateFormatMethods() + { + const string text = + """ + + + + Hello {0}! + + + """; + + var generator = new LocalizerGenerator(); + var result = generator.Generate( + options: new GenFileOptions + { + LocalNamespace = "VocaDb.Web.App_GlobalResources", + EmbeddedFilename = "VocaDb.Web.App_GlobalResources.CommonMessages", + GroupedFile = new GroupedAdditionalFile( + mainFile: new AdditionalTextWithHash(new AdditionalTextStub("", text), Guid.NewGuid()), + subFiles: [] + ), + GenerationType = GenerationType.StringLocalizer, + ClassName = "CommonMessages" + } + ); + + result.ErrorsAndWarnings.Should().BeNullOrEmpty(); + result.SourceCode.ReplaceLineEndings().Should().Contain("public string Greeting => stringLocalizer[\"Greeting\"];"); + result.SourceCode.ReplaceLineEndings().Should().NotContain("public string Greeting(object arg0)"); + } + + [Fact] + public void Generate_Localizer_FormattableValue_GeneratesMethodWithParameters_WhenEnabled() + { + const string text = + """ + + + + Hello {0}! + + + Hi {1} {0}! + + + """; + + var generator = new LocalizerGenerator(); + var result = generator.Generate( + options: new GenFileOptions + { + LocalNamespace = "VocaDb.Web.App_GlobalResources", + EmbeddedFilename = "VocaDb.Web.App_GlobalResources.CommonMessages", + GroupedFile = new GroupedAdditionalFile( + mainFile: new AdditionalTextWithHash(new AdditionalTextStub("", text), Guid.NewGuid()), + subFiles: [] + ), + GenerationType = GenerationType.StringLocalizer, + ClassName = "CommonMessages", + GenerateFormatMethods = true + } + ); + + result.ErrorsAndWarnings.Should().BeNullOrEmpty(); + result.SourceCode.ReplaceLineEndings().Should().Contain("public string Greeting => stringLocalizer[\"Greeting\"];"); + result.SourceCode.ReplaceLineEndings().Should().Contain("public string Greeting(object arg0) => stringLocalizer[\"Greeting\", arg0];"); + result.SourceCode.ReplaceLineEndings().Should().Contain("public string MultiGreeting => stringLocalizer[\"MultiGreeting\"];"); + result.SourceCode.ReplaceLineEndings().Should().Contain("public string MultiGreeting(object arg0, object arg1) => stringLocalizer[\"MultiGreeting\", arg0, arg1];"); + result.SourceCode.ReplaceLineEndings().Should().Contain("string Greeting {get;}"); + result.SourceCode.ReplaceLineEndings().Should().Contain("string Greeting(object arg0);"); + result.SourceCode.ReplaceLineEndings().Should().Contain("string MultiGreeting {get;}"); + result.SourceCode.ReplaceLineEndings().Should().Contain("string MultiGreeting(object arg0, object arg1);"); + } + [Fact] public void Generate_Localizer_NewLine() { diff --git a/Aigamo.ResXGenerator/Generators/LocalizerGenerator.cs b/Aigamo.ResXGenerator/Generators/LocalizerGenerator.cs index 75493db..b0e54f8 100644 --- a/Aigamo.ResXGenerator/Generators/LocalizerGenerator.cs +++ b/Aigamo.ResXGenerator/Generators/LocalizerGenerator.cs @@ -1,4 +1,5 @@ -using Aigamo.ResXGenerator.Extensions; +using System.Text.RegularExpressions; +using Aigamo.ResXGenerator.Extensions; using Aigamo.ResXGenerator.Models; using Aigamo.ResXGenerator.Tools; @@ -50,15 +51,48 @@ public interface I{{Options.ClassName}} """; } - private string GenerateMembers(FallBackItem fallbackItem) => !Validator.ValidateMember(fallbackItem, Options) ? - $"// Skipped invalid member name: {fallbackItem.Key}" : - $"public string {fallbackItem.Key} => stringLocalizer[\"{fallbackItem.Key}\"];"; + private string GenerateMembers(FallBackItem fallbackItem) + { + if (!Validator.ValidateMember(fallbackItem, Options)) + return $"// Skipped invalid member name: {fallbackItem.Key}"; + + var parameters = GetFormatParameters(fallbackItem.Value); + if (parameters.Length == 0 || !Options.GenerateFormatMethods) + return $"public string {fallbackItem.Key} => stringLocalizer[\"{fallbackItem.Key}\"];"; + + return $$""" + public string {{fallbackItem.Key}} => stringLocalizer["{{fallbackItem.Key}}"]; + public string {{fallbackItem.Key}}({{string.Join(", ", parameters.Select(p => $"object {p}"))}}) => stringLocalizer["{{fallbackItem.Key}}", {{string.Join(", ", parameters)}}]; + """; + } - private static string GenerateInterfaceMembers(FallBackItem fallbackItem) => - $$""" + private string GenerateInterfaceMembers(FallBackItem fallbackItem) + { + var parameters = GetFormatParameters(fallbackItem.Value); + if (parameters.Length == 0 || !Options.GenerateFormatMethods) + return $$""" /// /// Looks up a localized string similar to {{fallbackItem.Value.ToXmlCommentSafe()}}. /// string {{fallbackItem.Key}} {get;} """; + + return $$""" + /// d + /// Looks up a localized string similar to {{fallbackItem.Value.ToXmlCommentSafe()}}. + /// + string {{fallbackItem.Key}} {get;} + string {{fallbackItem.Key}}({{string.Join(", ", parameters.Select(p => $"object {p}"))}}); + """; + } + + private static string[] GetFormatParameters(string value) + { + var placeholders = Regex.Matches(value, "\\{(\\d+)\\}"); + return [.. placeholders.Cast() + .Select(match => int.Parse(match.Groups[1].Value)) + .Distinct() + .OrderBy(index => index) + .Select(index => $"arg{index}")]; + } } diff --git a/Aigamo.ResXGenerator/Tools/GenFileOptions.cs b/Aigamo.ResXGenerator/Tools/GenFileOptions.cs index dad2874..4e82a9a 100644 --- a/Aigamo.ResXGenerator/Tools/GenFileOptions.cs +++ b/Aigamo.ResXGenerator/Tools/GenFileOptions.cs @@ -13,6 +13,7 @@ public readonly record struct GenFileOptions public bool StaticClass { get; init; } public bool NullForgivingOperators { get; init; } public bool PublicClass { get; init; } + public bool GenerateFormatMethods { get; init; } public string ClassName { get; init; } public string? CustomToolNamespace { get; init; } public string LocalNamespace { get; init; } @@ -96,6 +97,12 @@ GlobalOptions globalOptions ? !perFileStaticClassSwitch.Equals("false", StringComparison.OrdinalIgnoreCase) : globalOptions.StaticClass; + GenerateFormatMethods = + options.TryGetValue("build_metadata.EmbeddedResource.GenerateFormatMethods", out var generateFormatMethodsSwitch) && + generateFormatMethodsSwitch is { Length: > 0 } + ? generateFormatMethodsSwitch.Equals("true", StringComparison.OrdinalIgnoreCase) + : globalOptions.GenerateFormatMethods; + StaticMembers = options.TryGetValue("build_metadata.EmbeddedResource.StaticMembers", out var staticMembersSwitch) && staticMembersSwitch is { Length: > 0 } diff --git a/Aigamo.ResXGenerator/Tools/GlobalOptions.cs b/Aigamo.ResXGenerator/Tools/GlobalOptions.cs index ee8c300..a37f6dc 100644 --- a/Aigamo.ResXGenerator/Tools/GlobalOptions.cs +++ b/Aigamo.ResXGenerator/Tools/GlobalOptions.cs @@ -16,6 +16,7 @@ public sealed record GlobalOptions // this must be a record or implement IEquata public bool StaticClass { get; } public bool NullForgivingOperators { get; } public bool PublicClass { get; } + public bool GenerateFormatMethods { get; } public string ClassNamePostfix { get; } public bool GenerateCode { get; } public GenerationType GenerationType { get; } @@ -68,6 +69,11 @@ public GlobalOptions(AnalyzerConfigOptions options) staticMembersSwitch.Equals("false", StringComparison.OrdinalIgnoreCase) ); + GenerateFormatMethods = + options.TryGetValue("build_property.ResXGenerator_GenerateFormatMethods", out var generateFormatMethodsSwitch) && + generateFormatMethodsSwitch is { Length: > 0 } && + generateFormatMethodsSwitch.Equals("true", StringComparison.OrdinalIgnoreCase); + PartialClass = options.TryGetValue("build_property.ResXGenerator_PartialClass", out var partialClassSwitch) && partialClassSwitch is { Length: > 0 } && From e46c4f64690498cb71f89636f6062b670c6942b9 Mon Sep 17 00:00:00 2001 From: Lowie Delneste Date: Fri, 10 Jul 2026 08:34:31 +0200 Subject: [PATCH 2/4] Update readme --- README.md | 464 +++++++++++++++++++++++++++++------------------------- 1 file changed, 250 insertions(+), 214 deletions(-) diff --git a/README.md b/README.md index 30e67ec..bb64d02 100644 --- a/README.md +++ b/README.md @@ -16,61 +16,61 @@ Generated source from [ActivityEntrySortRuleNames.resx](https://github.com/VocaD ```cs // ------------------------------------------------------------------------------ -// -// This code was generated by a tool. +// +// This code was generated by a tool. // -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// // ------------------------------------------------------------------------------ #nullable enable namespace Resources { - using System.Globalization; - using System.Resources; - - public static class ActivityEntrySortRuleNames - { - private static ResourceManager? s_resourceManager; - public static ResourceManager ResourceManager => s_resourceManager ??= new ResourceManager("VocaDb.Web.App_GlobalResources.ActivityEntrySortRuleNames", typeof(ActivityEntrySortRuleNames).Assembly); - public static CultureInfo? CultureInfo { get; set; } - - /// - /// Looks up a localized string similar to Oldest. - /// - public static string? CreateDate => ResourceManager.GetString(nameof(CreateDate), CultureInfo); - - /// - /// Looks up a localized string similar to Newest. - /// - public static string? CreateDateDescending => ResourceManager.GetString(nameof(CreateDateDescending), CultureInfo); - } + using System.Globalization; + using System.Resources; + + public static class ActivityEntrySortRuleNames + { + private static ResourceManager? s_resourceManager; + public static ResourceManager ResourceManager => s_resourceManager ??= new ResourceManager("VocaDb.Web.App_GlobalResources.ActivityEntrySortRuleNames", typeof(ActivityEntrySortRuleNames).Assembly); + public static CultureInfo? CultureInfo { get; set; } + + /// + /// Looks up a localized string similar to Oldest. + /// + public static string? CreateDate => ResourceManager.GetString(nameof(CreateDate), CultureInfo); + + /// + /// Looks up a localized string similar to Newest. + /// + public static string? CreateDateDescending => ResourceManager.GetString(nameof(CreateDateDescending), CultureInfo); + } } ``` ## New in version 3 -- The generator now utilizes the IIncrementalGenerator API to instantly update the generated code, thus giving you instant intellisense. +- The generator now utilizes the IIncrementalGenerator API to instantly update the generated code, thus giving you instant intellisense. -- Added error handling for multiple members of same name, and members that have same name as class. These are clickable in visual studio to lead you to the source of the error, unlike before where they resulted in broken builds and you had to figure out why. +- Added error handling for multiple members of same name, and members that have same name as class. These are clickable in visual studio to lead you to the source of the error, unlike before where they resulted in broken builds and you had to figure out why. -- Namespace naming fixed for resx files in the top level folder. +- Namespace naming fixed for resx files in the top level folder. -- Resx files can now be named with multiple extensions, e.g. myresources.cshtml.resx and will result in class being called myresources. +- Resx files can now be named with multiple extensions, e.g. myresources.cshtml.resx and will result in class being called myresources. -- Added the ability to generate inner classes, partial outer classes and non-static members. Very useful if you want to ensure that only a particular class can use those resources instead of being spread around the codebase. +- Added the ability to generate inner classes, partial outer classes and non-static members. Very useful if you want to ensure that only a particular class can use those resources instead of being spread around the codebase. -- Use same 'Link' setting as msbuild uses to determine embedded file name. +- Use same 'Link' setting as msbuild uses to determine embedded file name. -- Can set a class postfix name +- Can set a class postfix name ## New in version 3.1 -- The generator can now generate code to lookup translations instead of using the 20 year old System.Resources.ResourceManager +- The generator can now generate code to lookup translations instead of using the 20 year old System.Resources.ResourceManager ## New in version 4.3 -- The generator now includes support for `IStringLocalizer` and static methods to register resources in `IServiceCollection` (no more need for "magic strings"). +- The generator now includes support for `IStringLocalizer` and static methods to register resources in `IServiceCollection` (no more need for "magic strings"). ```c# var local = provider.GetService(); @@ -84,13 +84,13 @@ var myViewModel = provider.GetService(); public class MyViewModel(IArtistCategoriesNames resources) : IMyViewModel { - public string Value => resources.Actor // return also the value string + public string Value => resources.Actor // return also the value string } ``` ## New in version 4.4 -- Resources are no longer limited to strings. A resource with a `type` (for example `System.Drawing.Bitmap`, `System.Drawing.Icon`, or `System.Byte[]`), including [`ResXFileRef`](https://learn.microsoft.com/en-us/dotnet/api/system.resources.resxfileref) file references, now generates a strongly-typed member backed by `ResourceManager.GetObject`. String resources are unchanged. +- Resources are no longer limited to strings. A resource with a `type` (for example `System.Drawing.Bitmap`, `System.Drawing.Icon`, or `System.Byte[]`), including [`ResXFileRef`](https://learn.microsoft.com/en-us/dotnet/api/system.resources.resxfileref) file references, now generates a strongly-typed member backed by `ResourceManager.GetObject`. String resources are unchanged. ```cs /// @@ -101,33 +101,37 @@ public static System.Drawing.Bitmap? Logo => (System.Drawing.Bitmap)ResourceMana Non-string resources are only supported with the `ResourceManager` generation type. `StringLocalizer` and `CodeGeneration` remain string-only, and a non-string resource used with those modes reports a build diagnostic. -- Added support for the `LogicalName` and `ManifestResourceName` metadata on `EmbeddedResource` items, so resources whose manifest name is set explicitly are resolved with the correct name. +- Added support for the `LogicalName` and `ManifestResourceName` metadata on `EmbeddedResource` items, so resources whose manifest name is set explicitly are resolved with the correct name. ## Options + ### GenerationType (per file or globally) -Use cases: https://github.com/ycanardeau/ResXGenerator/issues/6. + +Use cases: . Some frameworks, like Blazor, use `IStringLocalizer` to get resource strings via dependency injection. To support this while keeping existing functionality, it is now possible to choose the generation type by setting the `GenerationType` parameter. This parameter is optional. The default value is `ResourceManager` to preserve the existing behavior. AllowedValues : -- ResourceManager - - When this option is chosen, the generator will use the classic [System.Resources.ResourceManager](https://learn.microsoft.com/en-us/dotnet/api/system.resources.resourcemanager?view=net-9.0) to get resource strings. -- CodeGeneration (You can chose this option to replace the GenerateCode option) - - When this option is chosen, the generator will generate code to access resource strings. See [Generate Code](#Generate-Code) for more details. -- StringLocalizer - - When this option is chosen, the generator will create interfaces and classes compatible with [Microsoft.Extensions.Localization](https://docs.microsoft.com/en-us/dotnet/core/extensions/localization) `IStringLocalizer`. + +- ResourceManager + - When this option is chosen, the generator will use the classic [System.Resources.ResourceManager](https://learn.microsoft.com/en-us/dotnet/api/system.resources.resourcemanager?view=net-9.0) to get resource strings. +- CodeGeneration (You can chose this option to replace the GenerateCode option) + - When this option is chosen, the generator will generate code to access resource strings. See [Generate Code](#Generate-Code) for more details. +- StringLocalizer + - When this option is chosen, the generator will create interfaces and classes compatible with [Microsoft.Extensions.Localization](https://docs.microsoft.com/en-us/dotnet/core/extensions/localization) `IStringLocalizer`. See [Using IStringLocalizer](#IStringLocalizer) for usage examples. -- SameAsOuter - - When this option is chosen, the generator will use the same generation type as the outer class, if any. If no outer class exists, it falls back to `ResourceManager`. +- SameAsOuter + - When this option is chosen, the generator will use the same generation type as the outer class, if any. If no outer class exists, it falls back to `ResourceManager`. + ```xml - - StringLocalizer - + + StringLocalizer + ``` @@ -135,7 +139,7 @@ or ```xml - + ``` @@ -143,21 +147,21 @@ If you want to apply this globally, use ```xml - StringLocalizer + StringLocalizer ``` ### PublicClass (per file or globally) -Use cases: https://github.com/VocaDB/ResXFileCodeGenerator/issues/2. +Use cases: . Since version 2.0.0, ResXGenerator generates internal classes by default. You can change this behavior by setting `PublicClass` to `true`. ```xml - - true - + + true + ``` @@ -165,7 +169,7 @@ or ```xml - + ``` @@ -173,17 +177,17 @@ If you want to apply this globally, use ```xml - true + true ``` ### NullForgivingOperators (globally) -Use cases: https://github.com/VocaDB/ResXFileCodeGenerator/issues/1. +Use cases: . ```xml - true + true ``` @@ -205,9 +209,9 @@ To use generated resources with [Microsoft.Extensions.Localization](https://docs ```xml - - false - + + false + ``` @@ -215,7 +219,7 @@ or globally ```xml - false + false ``` @@ -227,9 +231,9 @@ To extend an existing class, you can make your classes partial. *Not suitable fo ```xml - - true - + + true + ``` @@ -237,7 +241,7 @@ or globally ```xml - true + true ``` @@ -247,9 +251,9 @@ In some rare cases it might be useful for the members to be non-static. *Not sui ```xml - - false - + + false + ``` @@ -257,7 +261,7 @@ or globally ```xml - false + false ``` @@ -270,17 +274,17 @@ This example configuration allows you to use Resources.MyResource in your model, ```xml - - Model - false - false - true - true - public - false - Resources - _Resources - + + Model + false + false + true + true + public + false + Resources + _Resources + ``` @@ -288,7 +292,7 @@ or just the postfix globally ```xml - Model + Model ``` @@ -298,18 +302,18 @@ If your resx files are organized along with code files, it can be quite useful t ```xml - - $([System.String]::Copy('%(FileName).cs')) - MyResources - private - EveryoneLikeMyNaming - false - false - true - - - $([System.IO.Path]::GetFileNameWithoutExtension('%(FileName)')).resx - + + $([System.String]::Copy('%(FileName).cs')) + MyResources + private + EveryoneLikeMyNaming + false + false + true + + + $([System.IO.Path]::GetFileNameWithoutExtension('%(FileName)')).resx + ``` @@ -317,12 +321,12 @@ or globally ```xml - MyResources - private - EveryoneLikeMyNaming - false - false - true + MyResources + private + EveryoneLikeMyNaming + false + false + true ``` @@ -331,39 +335,39 @@ This example would generate files like this: ```cs // ------------------------------------------------------------------------------ // -// This code was generated by a tool. +// This code was generated by a tool. // -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. // // ------------------------------------------------------------------------------ #nullable enable namespace Resources { - using System.Globalization; - using System.Resources; - - public partial class ActivityEntryModel - { - public MyResources EveryoneLikeMyNaming { get; } = new(); - - private class MyResources - { - private static ResourceManager? s_resourceManager; - public static ResourceManager ResourceManager => s_resourceManager ??= new ResourceManager("VocaDb.Web.App_GlobalResources.ActivityEntryModel", typeof(ActivityEntryModel).Assembly); - public CultureInfo? CultureInfo { get; set; } - - /// - /// Looks up a localized string similar to Oldest. - /// - public string? CreateDate => ResourceManager.GetString(nameof(CreateDate), CultureInfo); - - /// - /// Looks up a localized string similar to Newest. - /// - public string? CreateDateDescending => ResourceManager.GetString(nameof(CreateDateDescending), CultureInfo); - } - } + using System.Globalization; + using System.Resources; + + public partial class ActivityEntryModel + { + public MyResources EveryoneLikeMyNaming { get; } = new(); + + private class MyResources + { + private static ResourceManager? s_resourceManager; + public static ResourceManager ResourceManager => s_resourceManager ??= new ResourceManager("VocaDb.Web.App_GlobalResources.ActivityEntryModel", typeof(ActivityEntryModel).Assembly); + public CultureInfo? CultureInfo { get; set; } + + /// + /// Looks up a localized string similar to Oldest. + /// + public string? CreateDate => ResourceManager.GetString(nameof(CreateDate), CultureInfo); + + /// + /// Looks up a localized string similar to Newest. + /// + public string? CreateDateDescending => ResourceManager.GetString(nameof(CreateDateDescending), CultureInfo); + } + } } ``` @@ -371,11 +375,11 @@ namespace Resources By default inner classes are not generated, unless this setting is one of the following: -- Public -- Internal -- Private -- Protected -- SameAsOuter +- Public +- Internal +- Private +- Protected +- SameAsOuter Case is ignored, so you could use "private". @@ -383,9 +387,9 @@ It is also possible to use "NotGenerated" to override on a file if the global se ```xml - - private - + + private + ``` @@ -393,7 +397,7 @@ or globally ```xml - private + private ``` @@ -403,9 +407,9 @@ By default the inner class is named "Resources", which can be overridden with th ```xml - - MyResources - + + MyResources + ``` @@ -413,7 +417,7 @@ or globally ```xml - MyResources + MyResources ``` @@ -423,9 +427,9 @@ By default no instance is available of the class, but that can be made available ```xml - - EveryoneLikeMyNaming - + + EveryoneLikeMyNaming + ``` @@ -433,57 +437,58 @@ or globally ```xml - EveryoneLikeMyNaming + EveryoneLikeMyNaming ``` For brevity, settings to make everything non-static is omitted. + ### Generate Code (per file or globally) By default the ancient `System.Resources.ResourceManager` is used. Benefits of using `System.Resources.ResourceManager`: -- Supports custom `CultureInfo` -- Languages are only loaded the first time a language is referenced -- Only use memory for the languages used -- Can ship satellite dlls separately +- Supports custom `CultureInfo` +- Languages are only loaded the first time a language is referenced +- Only use memory for the languages used +- Can ship satellite dlls separately Disadvantages of using `System.Resources.ResourceManager` -- The satellite dlls are always lazy loaded, so cold start penalty is high -- Satellite dlls requires that you can scan the dir for which files are available, which can cause issues in some project types -- Loading a satellite dll takes way more memory than just loading the respective strings -- Build time for .resources -> satellite dll can be quite slow (~150msec per file) -- Linker optimization doesn't work, since it cannot know which resources are referenced +- The satellite dlls are always lazy loaded, so cold start penalty is high +- Satellite dlls requires that you can scan the dir for which files are available, which can cause issues in some project types +- Loading a satellite dll takes way more memory than just loading the respective strings +- Build time for .resources -> satellite dll can be quite slow (~150msec per file) +- Linker optimization doesn't work, since it cannot know which resources are referenced Benefits of using `GenerateCode` code generation: -- All languages are placed in the main dll, no more satellite dlls -- Lookup speed is ~600% faster (5ns vs 33ns) -- Zero allocations -- Very small code footprint (about 10 bytes per language, instead of including the entire `System.Resources`) -- Very fast build time -- Because all code is referencing the strings directly, the linker can see which strings are actually used and which are not. -- No cold start penalty -- Smaller combined size of dll (up to 50%, since it doesn't need to store the keys for every single language) +- All languages are placed in the main dll, no more satellite dlls +- Lookup speed is ~600% faster (5ns vs 33ns) +- Zero allocations +- Very small code footprint (about 10 bytes per language, instead of including the entire `System.Resources`) +- Very fast build time +- Because all code is referencing the strings directly, the linker can see which strings are actually used and which are not. +- No cold start penalty +- Smaller combined size of dll (up to 50%, since it doesn't need to store the keys for every single language) Disadvantages of using `GenerateCode` code generation -- Since `CultureInfo` are pre-computed, custom `CultureInfo` are not supported (or rather, they always return the default language) -- Cannot lookup "all" keys (unless using reflection) -- Main dll size increased since it contains all language strings (sometimes, the compiler can pack code strings much better than resource strings and it doesn't need to store the keys) +- Since `CultureInfo` are pre-computed, custom `CultureInfo` are not supported (or rather, they always return the default language) +- Cannot lookup "all" keys (unless using reflection) +- Main dll size increased since it contains all language strings (sometimes, the compiler can pack code strings much better than resource strings and it doesn't need to store the keys) Notice, it is required to set `GenerateResource` to false for all resx files to prevent the built-in resgen.exe from running. ```xml - - true - false - + + true + false + ``` @@ -491,12 +496,12 @@ or globally ```xml - true + true - - false - + + false + ``` @@ -504,9 +509,9 @@ If you get build error MSB3030, add this to your csproj to prevent it from tryin ```xml - - - + + + ``` @@ -518,14 +523,14 @@ Use-case: Linking `.resx` files from outside source (e.g. generated in a localiz ```xml - - Resources\%(FileName)%(Extension) - true - false - - - $([System.IO.Path]::GetFilenameWithoutExtension([System.String]::Copy('%(FileName)'))).resx - + + Resources\%(FileName)%(Extension) + true + false + + + $([System.IO.Path]::GetFilenameWithoutExtension([System.String]::Copy('%(FileName)'))).resx + ``` @@ -533,14 +538,14 @@ You can also use the `TargetPath` to just overwrite the namespace ```xml - - Resources\%(FileName)%(Extension) - true - false - - - $([System.IO.Path]::GetFilenameWithoutExtension([System.String]::Copy('%(FileName)'))).resx - + + Resources\%(FileName)%(Extension) + true + false + + + $([System.IO.Path]::GetFilenameWithoutExtension([System.String]::Copy('%(FileName)'))).resx + ``` @@ -548,9 +553,9 @@ It is also possible to set the namespace using the `CustomToolNamespace` setting ```xml - - MyNamespace.AllMyResourcesAreBelongToYouNamespace - + + MyNamespace.AllMyResourcesAreBelongToYouNamespace + ``` @@ -560,9 +565,9 @@ Individual resx files can also be excluded from being processed by setting the ` ```xml - - true - + + true + ``` @@ -570,14 +575,15 @@ Alternatively it can be set with the attribute `SkipFile="true"`. ```xml - + ``` + ## Using IStringLocalizer -To enable the generation of interfaces and classes for your resources, you need to set the `GenerationType` to [StringLocalizer](#GenerationType). +To enable the generation of interfaces and classes for your resources, you need to set the `GenerationType` to [StringLocalizer](#GenerationType). Note: to use this, you must ensure that your project references the following NuGet packages: - [Microsoft.Extensions.DependencyInjection](https://www.nuget.org/packages/Microsoft.Extensions.DependencyInjection) @@ -587,13 +593,14 @@ Note: to use this, you must ensure that your project references the following Nu You can now register singletons for resources using extension methods. Examples for a file ArtistCategoriesNames.resx : + ```c# using ResXGenerator.Registration; builder.Services - .AddLogging(); - .AddLocalization() - .UsingResXGenerator(); //This will register all resx class in one line of code + .AddLogging(); + .AddLocalization() + .UsingResXGenerator(); //This will register all resx class in one line of code ``` you can also (if you prefer to load resources by namespace using individual registrations). A class of registration is created by namespace. @@ -602,22 +609,51 @@ you can also (if you prefer to load resources by namespace using individual regi using MyResourceAssembly; builder.Services - .AddLogging(); - .AddLocalization() - .UsingArtistCategoriesNamesResX(); //This will register all resx class of the namespace in one line of code + .AddLogging(); + .AddLocalization() + .UsingArtistCategoriesNamesResX(); //This will register all resx class of the namespace in one line of code ``` You can now use dependency injection to get your resource classes. All interfaces are in the same namespace as their corresponding resource file (or the configured namespace). +For resources that contain format placeholders such as `{0}` or `{1}`, you can optionally generate both the existing property-style accessor and a parameterized overload. Enable this with the `ResXGenerator_GenerateFormatMethods` MSBuild property or the per-file `GenerateFormatMethods` metadata. + +```xml + + true + +``` + +```xml + + + +``` + +With this enabled, a resource like: + +```xml + + Hi, {0}! + +``` + +will generate both of these members: + +```csharp +public string HiUser => stringLocalizer["HiUser"]; +public string HiUser(object arg0) => stringLocalizer["HiUser", arg0]; +``` + ## References -- [Introducing C# Source Generators | .NET Blog](https://devblogs.microsoft.com/dotnet/introducing-c-source-generators/) -- [microsoft/CsWin32: A source generator to add a user-defined set of Win32 P/Invoke methods and supporting types to a C# project.](https://github.com/microsoft/cswin32) -- [kenkendk/mdresxfilecodegenerator: Resx Designer Generator](https://github.com/kenkendk/mdresxfilecodegenerator) -- [dotnet/ResXResourceManager: Manage localization of all ResX-Based resources in one central place.](https://github.com/dotnet/ResXResourceManager) -- [roslyn/source-generators.cookbook.md at master · dotnet/roslyn](https://github.com/dotnet/roslyn/blob/master/docs/features/source-generators.cookbook.md) -- [roslyn/Using Additional Files.md at master · dotnet/roslyn](https://github.com/dotnet/roslyn/blob/master/docs/analyzers/Using%20Additional%20Files.md) -- [ufcpp - YouTube](https://www.youtube.com/channel/UCY-z_9mau6X-Vr4gk2aWtMQ) -- [amis92/csharp-source-generators: A list of C# Source Generators (not necessarily awesome) and associated resources: articles, talks, demos.](https://github.com/amis92/csharp-source-generators) -- [A NuGet package workflow using GitHub Actions | by Andrew Craven | Medium](https://acraven.medium.com/a-nuget-package-workflow-using-github-actions-7da8c6557863) +- [Introducing C# Source Generators | .NET Blog](https://devblogs.microsoft.com/dotnet/introducing-c-source-generators/) +- [microsoft/CsWin32: A source generator to add a user-defined set of Win32 P/Invoke methods and supporting types to a C# project.](https://github.com/microsoft/cswin32) +- [kenkendk/mdresxfilecodegenerator: Resx Designer Generator](https://github.com/kenkendk/mdresxfilecodegenerator) +- [dotnet/ResXResourceManager: Manage localization of all ResX-Based resources in one central place.](https://github.com/dotnet/ResXResourceManager) +- [roslyn/source-generators.cookbook.md at master · dotnet/roslyn](https://github.com/dotnet/roslyn/blob/master/docs/features/source-generators.cookbook.md) +- [roslyn/Using Additional Files.md at master · dotnet/roslyn](https://github.com/dotnet/roslyn/blob/master/docs/analyzers/Using%20Additional%20Files.md) +- [ufcpp - YouTube](https://www.youtube.com/channel/UCY-z_9mau6X-Vr4gk2aWtMQ) +- [amis92/csharp-source-generators: A list of C# Source Generators (not necessarily awesome) and associated resources: articles, talks, demos.](https://github.com/amis92/csharp-source-generators) +- [A NuGet package workflow using GitHub Actions | by Andrew Craven | Medium](https://acraven.medium.com/a-nuget-package-workflow-using-github-actions-7da8c6557863) From 025d2461f8658754bfc4adbb55e068105021c170 Mon Sep 17 00:00:00 2001 From: Lowie Delneste Date: Fri, 10 Jul 2026 08:45:30 +0200 Subject: [PATCH 3/4] Fix line ending comparison in HelperGeneratorTests and TestResxFiles --- Aigamo.ResXGenerator.Tests/HelperGeneratorTests.cs | 4 ++-- Aigamo.ResXGenerator.Tests/IntegrationTests/TestResxFiles.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Aigamo.ResXGenerator.Tests/HelperGeneratorTests.cs b/Aigamo.ResXGenerator.Tests/HelperGeneratorTests.cs index 1dc87fc..588cf80 100644 --- a/Aigamo.ResXGenerator.Tests/HelperGeneratorTests.cs +++ b/Aigamo.ResXGenerator.Tests/HelperGeneratorTests.cs @@ -46,7 +46,7 @@ internal static partial class Helpers """; result.ErrorsAndWarnings.Should().BeNullOrEmpty(); result.FileName.Should().Be("Aigamo.ResXGenerator.1030_6.g.cs"); - result.SourceCode.Should().Be(expected); + result.SourceCode.ReplaceLineEndings().Should().Be(expected.ReplaceLineEndings()); } [Fact] public void CanGenerateEmptyCombo() @@ -75,6 +75,6 @@ internal static partial class Helpers """; result.ErrorsAndWarnings.Should().BeNullOrEmpty(); result.FileName.Should().Be("Aigamo.ResXGenerator..g.cs"); - result.SourceCode.Should().Be(expected); + result.SourceCode.ReplaceLineEndings().Should().Be(expected.ReplaceLineEndings()); } } diff --git a/Aigamo.ResXGenerator.Tests/IntegrationTests/TestResxFiles.cs b/Aigamo.ResXGenerator.Tests/IntegrationTests/TestResxFiles.cs index f77a532..3cba226 100644 --- a/Aigamo.ResXGenerator.Tests/IntegrationTests/TestResxFiles.cs +++ b/Aigamo.ResXGenerator.Tests/IntegrationTests/TestResxFiles.cs @@ -21,7 +21,7 @@ public void TestNormalResourceGen() Test1.CreateDate.Should().Be("OldestDaDK"); // Test embedded files are as expected - Test1.TextFile.Should().Be("This is a test.\n"); + Test1.TextFile.ReplaceLineEndings().Should().Be("This is a test.\n".ReplaceLineEndings()); Test1.BinaryFile.Should().BeEquivalentTo(new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }); Test1.TestIconAsBytes.Should().NotBeNull(); Test1.TestImageAsBytes.Should().NotBeNull(); From 0e1601a302cb1748adc836afe42d167bf6ce84d3 Mon Sep 17 00:00:00 2001 From: Lowie Delneste Date: Fri, 10 Jul 2026 10:50:08 +0200 Subject: [PATCH 4/4] Update readme --- .../GeneratorLocalizerTests.cs | 8 ++-- .../Generators/LocalizerGenerator.cs | 38 ++++++++++--------- Aigamo.ResXGenerator/QUICKSTART.md | 1 + .../build/Aigamo.ResXGenerator.props | 2 + README.md | 11 +++++- 5 files changed, 36 insertions(+), 24 deletions(-) diff --git a/Aigamo.ResXGenerator.Tests/GeneratorLocalizerTests.cs b/Aigamo.ResXGenerator.Tests/GeneratorLocalizerTests.cs index 913f8a9..e438d5c 100644 --- a/Aigamo.ResXGenerator.Tests/GeneratorLocalizerTests.cs +++ b/Aigamo.ResXGenerator.Tests/GeneratorLocalizerTests.cs @@ -151,13 +151,13 @@ public void Generate_Localizer_FormattableValue_GeneratesMethodWithParameters_Wh ); result.ErrorsAndWarnings.Should().BeNullOrEmpty(); - result.SourceCode.ReplaceLineEndings().Should().Contain("public string Greeting => stringLocalizer[\"Greeting\"];"); + result.SourceCode.ReplaceLineEndings().Should().NotContain("public string Greeting => stringLocalizer[\"Greeting\"];"); result.SourceCode.ReplaceLineEndings().Should().Contain("public string Greeting(object arg0) => stringLocalizer[\"Greeting\", arg0];"); - result.SourceCode.ReplaceLineEndings().Should().Contain("public string MultiGreeting => stringLocalizer[\"MultiGreeting\"];"); + result.SourceCode.ReplaceLineEndings().Should().NotContain("public string MultiGreeting => stringLocalizer[\"MultiGreeting\"];"); result.SourceCode.ReplaceLineEndings().Should().Contain("public string MultiGreeting(object arg0, object arg1) => stringLocalizer[\"MultiGreeting\", arg0, arg1];"); - result.SourceCode.ReplaceLineEndings().Should().Contain("string Greeting {get;}"); + result.SourceCode.ReplaceLineEndings().Should().NotContain("string Greeting {get;}"); result.SourceCode.ReplaceLineEndings().Should().Contain("string Greeting(object arg0);"); - result.SourceCode.ReplaceLineEndings().Should().Contain("string MultiGreeting {get;}"); + result.SourceCode.ReplaceLineEndings().Should().NotContain("string MultiGreeting {get;}"); result.SourceCode.ReplaceLineEndings().Should().Contain("string MultiGreeting(object arg0, object arg1);"); } diff --git a/Aigamo.ResXGenerator/Generators/LocalizerGenerator.cs b/Aigamo.ResXGenerator/Generators/LocalizerGenerator.cs index b0e54f8..8b92294 100644 --- a/Aigamo.ResXGenerator/Generators/LocalizerGenerator.cs +++ b/Aigamo.ResXGenerator/Generators/LocalizerGenerator.cs @@ -57,33 +57,35 @@ private string GenerateMembers(FallBackItem fallbackItem) return $"// Skipped invalid member name: {fallbackItem.Key}"; var parameters = GetFormatParameters(fallbackItem.Value); - if (parameters.Length == 0 || !Options.GenerateFormatMethods) - return $"public string {fallbackItem.Key} => stringLocalizer[\"{fallbackItem.Key}\"];"; + if (parameters.Length > 0 && Options.GenerateFormatMethods) + { + return $$""" + public string {{fallbackItem.Key}}({{string.Join(", ", parameters.Select(p => $"object {p}"))}}) => stringLocalizer["{{fallbackItem.Key}}", {{string.Join(", ", parameters)}}]; + """; + } - return $$""" - public string {{fallbackItem.Key}} => stringLocalizer["{{fallbackItem.Key}}"]; - public string {{fallbackItem.Key}}({{string.Join(", ", parameters.Select(p => $"object {p}"))}}) => stringLocalizer["{{fallbackItem.Key}}", {{string.Join(", ", parameters)}}]; - """; + return $"public string {fallbackItem.Key} => stringLocalizer[\"{fallbackItem.Key}\"];"; } private string GenerateInterfaceMembers(FallBackItem fallbackItem) { var parameters = GetFormatParameters(fallbackItem.Value); - if (parameters.Length == 0 || !Options.GenerateFormatMethods) + if (parameters.Length > 0 && Options.GenerateFormatMethods) + { return $$""" - /// - /// Looks up a localized string similar to {{fallbackItem.Value.ToXmlCommentSafe()}}. - /// - string {{fallbackItem.Key}} {get;} - """; + /// + /// Looks up a localized string similar to {{fallbackItem.Value.ToXmlCommentSafe()}}. + /// + string {{fallbackItem.Key}}({{string.Join(", ", parameters.Select(p => $"object {p}"))}}); + """; + } return $$""" - /// d - /// Looks up a localized string similar to {{fallbackItem.Value.ToXmlCommentSafe()}}. - /// - string {{fallbackItem.Key}} {get;} - string {{fallbackItem.Key}}({{string.Join(", ", parameters.Select(p => $"object {p}"))}}); - """; + /// + /// Looks up a localized string similar to {{fallbackItem.Value.ToXmlCommentSafe()}}. + /// + string {{fallbackItem.Key}} {get;} + """; } private static string[] GetFormatParameters(string value) diff --git a/Aigamo.ResXGenerator/QUICKSTART.md b/Aigamo.ResXGenerator/QUICKSTART.md index 7036b05..4390ba6 100644 --- a/Aigamo.ResXGenerator/QUICKSTART.md +++ b/Aigamo.ResXGenerator/QUICKSTART.md @@ -15,5 +15,6 @@ |GenerateCode*|ResXGenerator_GenerateCode|- true
- **false**|All except StringLocalizer| |CustomToolNamespace|ResXGenerator_CustomToolNamespace|Any valid C# namespace|All except StringLocalizer| |SkipFile| |- true
- **false**|All| +|GenerateFormatMethods|ResXGenerator_GenerateFormatMethods|- true
- **false**|StringLocalizer| \* *Could be replaced by the option GenerationType with the value CodeGeneration* diff --git a/Aigamo.ResXGenerator/build/Aigamo.ResXGenerator.props b/Aigamo.ResXGenerator/build/Aigamo.ResXGenerator.props index febd9bf..c505c13 100644 --- a/Aigamo.ResXGenerator/build/Aigamo.ResXGenerator.props +++ b/Aigamo.ResXGenerator/build/Aigamo.ResXGenerator.props @@ -20,6 +20,7 @@ + @@ -36,6 +37,7 @@ + diff --git a/README.md b/README.md index bb64d02..3c763a2 100644 --- a/README.md +++ b/README.md @@ -631,6 +631,14 @@ For resources that contain format placeholders such as `{0}` or `{1}`, you can o ``` +```xml + + + true + + +``` + With this enabled, a resource like: ```xml @@ -639,10 +647,9 @@ With this enabled, a resource like: ``` -will generate both of these members: +will generate: ```csharp -public string HiUser => stringLocalizer["HiUser"]; public string HiUser(object arg0) => stringLocalizer["HiUser", arg0]; ```