From 64d841a114222974042f547f112d53badc3febc6 Mon Sep 17 00:00:00 2001 From: Rudy Huyn Date: Fri, 21 Aug 2026 22:33:31 -0700 Subject: [PATCH 1/4] Generate injectable resource providers by default - Keep generated resource classes static and preserve their API shape - Always emit injectable interfaces and sealed provider adapters - Remove the obsolete opt-in property and cover provider collisions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 12 +--- nuget/ReswPlus.targets | 2 - .../Models/StronglyTypedClass.cs | 5 +- .../ClassGenerators/ReswClassGenerator.cs | 15 ++-- .../CodeGenerators/CsharpCodeGenerator.cs | 69 ++++++++++++------- .../CodeGenerators/GeneratedIdentifier.cs | 5 +- .../Pipeline/ReswBuildOptions.cs | 19 ++--- .../Pipeline/ReswProject.cs | 9 +-- src/ReswPlus.SourceGenerator/ReswGenerator.cs | 3 +- .../ReswPlusUnitTests/GeneratedCodeHygiene.cs | 13 +++- tests/ReswPlusUnitTests/GeneratorEndToEnd.cs | 36 ++++------ tests/ReswPlusUnitTests/GeneratorHarness.cs | 3 - tests/ReswPlusUnitTests/ReswTestHelpers.cs | 11 ++- 13 files changed, 91 insertions(+), 111 deletions(-) diff --git a/README.md b/README.md index 6ec1d62..2ef4da2 100644 --- a/README.md +++ b/README.md @@ -50,18 +50,10 @@ Without it, such a project is reported as `RESWP0005` and no code is generated f ### Injectable resource interfaces -Resource classes are static by default. Projects that inject localization into view models or services can opt into an interface for every generated resource class: - -```xml - - true - -``` - -For `Resources.resw`, ReswPlus then generates `IResources` and makes `Resources` a sealed, instantiable implementation while retaining its existing static members. Existing calls such as `Resources.WelcomeTitle` keep working. The instance members are explicit interface implementations, so dependency-injected code accesses them through `IResources`: +For `Resources.resw`, ReswPlus generates the static `Resources` class as before, plus an `IResources` interface and a sealed `ResourcesProvider` adapter. The provider delegates to the static API, so existing calls such as `Resources.WelcomeTitle` remain unchanged while view models and services can receive an injectable resource dependency: ```csharp -IResources resources = new Resources(); +IResources resources = new ResourcesProvider(); var title = resources.WelcomeTitle; ``` diff --git a/nuget/ReswPlus.targets b/nuget/ReswPlus.targets index 8299c9e..0596f25 100644 --- a/nuget/ReswPlus.targets +++ b/nuget/ReswPlus.targets @@ -8,13 +8,11 @@ - $(AdditionalFileItemNames);PRIResource false - false false diff --git a/src/ReswPlus.SourceGenerator/ClassGenerators/Models/StronglyTypedClass.cs b/src/ReswPlus.SourceGenerator/ClassGenerators/Models/StronglyTypedClass.cs index 742cf6b..6052831 100644 --- a/src/ReswPlus.SourceGenerator/ClassGenerators/Models/StronglyTypedClass.cs +++ b/src/ReswPlus.SourceGenerator/ClassGenerators/Models/StronglyTypedClass.cs @@ -9,15 +9,13 @@ public StronglyTypedClass( string[] namespaces, string resoureFile, string className, - AppType appType, - bool generateResourceInterface) + AppType appType) { IsAdvanced = isAdvanced; Namespaces = namespaces; ResoureFile = resoureFile; ClassName = className; AppType = appType; - GenerateResourceInterface = generateResourceInterface; Items = []; } @@ -26,7 +24,6 @@ public StronglyTypedClass( public string ResoureFile { get; } public string ClassName { get; } public AppType AppType { get; } - public bool GenerateResourceInterface { get; } public List Items { get; } } diff --git a/src/ReswPlus.SourceGenerator/ClassGenerators/ReswClassGenerator.cs b/src/ReswPlus.SourceGenerator/ClassGenerators/ReswClassGenerator.cs index 49da1ac..055f3c9 100644 --- a/src/ReswPlus.SourceGenerator/ClassGenerators/ReswClassGenerator.cs +++ b/src/ReswPlus.SourceGenerator/ClassGenerators/ReswClassGenerator.cs @@ -90,8 +90,7 @@ private StronglyTypedClass Parse( string content, string defaultNamespace, bool isAdvanced, - AppType appType, - bool generateResourceInterface) + AppType appType) { var namespacesToUse = ExtractNamespace(defaultNamespace); var resourceFileName = Path.GetFileName(_resourceFileInfo.Path); @@ -109,15 +108,14 @@ private StronglyTypedClass Parse( namespacesToUse, resourceLoaderName, className, - appType, - generateResourceInterface + appType ); // Only use items with valid keys, that do not carry the ignore tag, and whose name the generated types // don't already declare. var stringItems = reswInfo.Items .Where(i => IsValidPropertyName(i.Key) && !(i.Comment?.Contains(TagIgnore) ?? false)) - .Where(i => !GeneratedIdentifier.ConflictsWithGeneratedMember(i.Key, className, generateResourceInterface)) + .Where(i => !GeneratedIdentifier.ConflictsWithGeneratedMember(i.Key, className)) .ToArray(); if (isAdvanced) @@ -132,7 +130,7 @@ private StronglyTypedClass Parse( // The forms of the resource are already out of the plain items, so a conflicting group is // dropped here rather than declined into members the generated types already declare. - if (GeneratedIdentifier.ConflictsWithGeneratedMember(itemKey, className, generateResourceInterface)) + if (GeneratedIdentifier.ConflictsWithGeneratedMember(itemKey, className)) { continue; } @@ -219,10 +217,9 @@ internal static bool IsValidPropertyName(string propertyName) string content, string defaultNamespace, bool isAdvanced, - AppType appType, - bool generateResourceInterface) + AppType appType) { - var stronglyTypedClassInfo = Parse(content, defaultNamespace, isAdvanced, appType, generateResourceInterface); + var stronglyTypedClassInfo = Parse(content, defaultNamespace, isAdvanced, appType); if (stronglyTypedClassInfo is null) { return null; diff --git a/src/ReswPlus.SourceGenerator/CodeGenerators/CsharpCodeGenerator.cs b/src/ReswPlus.SourceGenerator/CodeGenerators/CsharpCodeGenerator.cs index 9aa2fcc..e61e6c4 100644 --- a/src/ReswPlus.SourceGenerator/CodeGenerators/CsharpCodeGenerator.cs +++ b/src/ReswPlus.SourceGenerator/CodeGenerators/CsharpCodeGenerator.cs @@ -169,21 +169,20 @@ public IEnumerable GetGeneratedFiles(string? baseFilename, Strong // Add the generated members (format methods/properties) to the strongly-typed class. strongClassDecl = strongClassDecl.AddMembers(formatMembers.ToArray()); - InterfaceDeclarationSyntax? resourceInterfaceDecl = null; - if (info.GenerateResourceInterface) - { - var interfaceName = "I" + info.ClassName; - var publicMembers = strongClassDecl.Members - .Where(member => - member is MethodDeclarationSyntax or PropertyDeclarationSyntax - && member.Modifiers.Any(SyntaxKind.PublicKeyword)) - .ToArray(); - - resourceInterfaceDecl = CreateResourceInterface(interfaceName, info, publicMembers); - strongClassDecl = strongClassDecl - .AddBaseListTypes(SimpleBaseType(IdentifierName(interfaceName))) - .AddMembers(CreateExplicitInterfaceImplementations(interfaceName, info.ClassName, publicMembers).ToArray()); - } + var interfaceName = "I" + info.ClassName; + var providerName = info.ClassName + "Provider"; + var publicMembers = strongClassDecl.Members + .Where(member => + member is MethodDeclarationSyntax or PropertyDeclarationSyntax + && member.Modifiers.Any(SyntaxKind.PublicKeyword)) + .ToArray(); + var resourceInterfaceDecl = CreateResourceInterface(interfaceName, info, publicMembers); + var resourceProviderDecl = CreateResourceProvider( + providerName, + interfaceName, + info.ClassName, + info, + publicMembers); // Create the markup extension class that allows resource keys to be used in XAML. var markupExtensionDecl = CreateMarkupExtensionSyntax(info.ResoureFile, info.ClassName + "Extension", info.Items.Select(x => x.Key), info.AppType); @@ -193,17 +192,21 @@ member is MethodDeclarationSyntax or PropertyDeclarationSyntax { var nsName = string.Join(".", info.Namespaces); var namespaceDecl = NamespaceDeclaration(ParseName(nsName)); - namespaceDecl = resourceInterfaceDecl is null - ? namespaceDecl.AddMembers(strongClassDecl, markupExtensionDecl) - : namespaceDecl.AddMembers(resourceInterfaceDecl, strongClassDecl, markupExtensionDecl); + namespaceDecl = namespaceDecl.AddMembers( + resourceInterfaceDecl, + resourceProviderDecl, + strongClassDecl, + markupExtensionDecl); compilationUnit = compilationUnit.AddMembers(namespaceDecl); } else { // Otherwise, add the classes at the root level. - compilationUnit = resourceInterfaceDecl is null - ? compilationUnit.AddMembers(strongClassDecl, markupExtensionDecl) - : compilationUnit.AddMembers(resourceInterfaceDecl, strongClassDecl, markupExtensionDecl); + compilationUnit = compilationUnit.AddMembers( + resourceInterfaceDecl, + resourceProviderDecl, + strongClassDecl, + markupExtensionDecl); } // Normalize the whitespace (formatting) and return the generated source code. @@ -442,9 +445,7 @@ private ClassDeclarationSyntax CreateStronglyTypedClass(StronglyTypedClass info) var classDecl = ClassDeclaration(info.ClassName) .WithAttributeLists(attributes) .WithLeadingTrivia(CreateDocumentation($"Provides strongly-typed access to the strings of the '{info.ResoureFile}' resource file.")) - .WithModifiers(info.GenerateResourceInterface - ? TokenList(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.SealedKeyword)) - : TokenList(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword))) + .WithModifiers(TokenList(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword))) .AddMembers(resourceField, staticCtor, getStringMethod); return classDecl; @@ -495,6 +496,26 @@ private static InterfaceDeclarationSyntax CreateResourceInterface( .AddMembers(publicMembers.Select(CreateInterfaceMember).ToArray()); } + /// + /// Creates an injectable adapter that delegates to the existing static resource API. + /// + private static ClassDeclarationSyntax CreateResourceProvider( + string providerName, + string interfaceName, + string className, + StronglyTypedClass info, + IEnumerable publicMembers) + { + return ClassDeclaration(providerName) + .WithAttributeLists(CreateGeneratedTypeAttributes()) + .WithModifiers(TokenList(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.SealedKeyword))) + .WithBaseList(BaseList(SingletonSeparatedList( + SimpleBaseType(IdentifierName(interfaceName))))) + .WithLeadingTrivia(CreateDocumentation( + $"Provides injectable access to the strings of the '{info.ResoureFile}' resource file.")) + .AddMembers(CreateExplicitInterfaceImplementations(interfaceName, className, publicMembers).ToArray()); + } + /// /// Removes the implementation and static modifiers from a generated lookup member. /// diff --git a/src/ReswPlus.SourceGenerator/CodeGenerators/GeneratedIdentifier.cs b/src/ReswPlus.SourceGenerator/CodeGenerators/GeneratedIdentifier.cs index adf7f3b..90ea302 100644 --- a/src/ReswPlus.SourceGenerator/CodeGenerators/GeneratedIdentifier.cs +++ b/src/ReswPlus.SourceGenerator/CodeGenerators/GeneratedIdentifier.cs @@ -57,10 +57,11 @@ public static string Escape(string name) /// beside the generated GetString without conflicting with it. Resources whose names only differ by /// case conflict for a different reason, and are reported by RESWP0009. /// - public static bool ConflictsWithGeneratedMember(string name, string className, bool hasResourceInterface = false) + public static bool ConflictsWithGeneratedMember(string name, string className) { return string.Equals(name, className, StringComparison.Ordinal) - || (hasResourceInterface && string.Equals(name, "I" + className, StringComparison.Ordinal)) + || string.Equals(name, "I" + className, StringComparison.Ordinal) + || string.Equals(name, className + "Provider", StringComparison.Ordinal) || Array.IndexOf(GeneratedMemberNames, name) >= 0; } diff --git a/src/ReswPlus.SourceGenerator/Pipeline/ReswBuildOptions.cs b/src/ReswPlus.SourceGenerator/Pipeline/ReswBuildOptions.cs index 57ee499..0149475 100644 --- a/src/ReswPlus.SourceGenerator/Pipeline/ReswBuildOptions.cs +++ b/src/ReswPlus.SourceGenerator/Pipeline/ReswBuildOptions.cs @@ -24,8 +24,7 @@ private ReswBuildOptions( string? defaultLanguage, string? rootNamespace, bool useApplicationLanguages, - bool useUwp, - bool generateResourceInterfaces) + bool useUwp) { ProjectDir = projectDir; MSBuildProjectFullPath = msBuildProjectFullPath; @@ -35,7 +34,6 @@ private ReswBuildOptions( RootNamespace = rootNamespace; UseApplicationLanguages = useApplicationLanguages; UseUwp = useUwp; - GenerateResourceInterfaces = generateResourceInterfaces; } public string? ProjectDir { get; } @@ -67,11 +65,6 @@ private ReswBuildOptions( /// public bool UseUwp { get; } - /// - /// Gets whether the project opted into generated injectable resource interfaces. - /// - public bool GenerateResourceInterfaces { get; } - /// /// Reads the properties of a project. /// @@ -87,8 +80,7 @@ public static ReswBuildOptions Read(AnalyzerConfigOptions globalOptions) Get("build_property.DefaultLanguage"), Get("build_property.RootNamespace"), bool.TryParse(Get("build_property.ReswPlusUseApplicationLanguages"), out var parsed) && parsed, - bool.TryParse(Get("build_property.UseUwp"), out var parsedUseUwp) && parsedUseUwp, - bool.TryParse(Get("build_property.ReswPlusGenerateResourceInterfaces"), out var parsedInterfaces) && parsedInterfaces); + bool.TryParse(Get("build_property.UseUwp"), out var parsedUseUwp) && parsedUseUwp); string? Get(string key) => globalOptions.TryGetValue(key, out var value) ? value : null; } @@ -118,8 +110,7 @@ public bool Equals(ReswBuildOptions? other) && DefaultLanguage == other.DefaultLanguage && RootNamespace == other.RootNamespace && UseApplicationLanguages == other.UseApplicationLanguages - && UseUwp == other.UseUwp - && GenerateResourceInterfaces == other.GenerateResourceInterfaces; + && UseUwp == other.UseUwp; } /// @@ -136,8 +127,6 @@ public override int GetHashCode() } hash = (hash * 31) + UseApplicationLanguages.GetHashCode(); - hash = (hash * 31) + UseUwp.GetHashCode(); - - return (hash * 31) + GenerateResourceInterfaces.GetHashCode(); + return (hash * 31) + UseUwp.GetHashCode(); } } diff --git a/src/ReswPlus.SourceGenerator/Pipeline/ReswProject.cs b/src/ReswPlus.SourceGenerator/Pipeline/ReswProject.cs index 1d39226..e0f1c93 100644 --- a/src/ReswPlus.SourceGenerator/Pipeline/ReswProject.cs +++ b/src/ReswPlus.SourceGenerator/Pipeline/ReswProject.cs @@ -19,7 +19,6 @@ private ReswProject( string rootNamespace, bool isLibrary, bool useApplicationLanguages, - bool generateResourceInterfaces, string? defaultLanguage, EquatableArray setupProblems) { @@ -31,7 +30,6 @@ private ReswProject( RootNamespace = rootNamespace; IsLibrary = isLibrary; UseApplicationLanguages = useApplicationLanguages; - GenerateResourceInterfaces = generateResourceInterfaces; SetupProblems = setupProblems; } @@ -52,8 +50,6 @@ private ReswProject( public bool UseApplicationLanguages { get; } - public bool GenerateResourceInterfaces { get; } - /// /// Gets the default language of the project, which picks the resource file the code is generated from. /// @@ -134,12 +130,11 @@ public static ReswProject Create(CompilationInfo compilationInfo, ReswBuildOptio options.RootNamespace!, isLibrary, options.UseApplicationLanguages, - options.GenerateResourceInterfaces, options.DefaultLanguage, new EquatableArray(problems)); ReswProject Unsupported(params string[] problems) => - new(false, AppType.Unknown, "", "", "", false, false, false, options.DefaultLanguage, new EquatableArray(problems)); + new(false, AppType.Unknown, "", "", "", false, false, options.DefaultLanguage, new EquatableArray(problems)); } /// @@ -214,7 +209,6 @@ public bool Equals(ReswProject? other) && RootNamespace == other.RootNamespace && IsLibrary == other.IsLibrary && UseApplicationLanguages == other.UseApplicationLanguages - && GenerateResourceInterfaces == other.GenerateResourceInterfaces && DefaultLanguage == other.DefaultLanguage && SetupProblems.Equals(other.SetupProblems); } @@ -234,7 +228,6 @@ public override int GetHashCode() hash = (hash * 31) + RootNamespace.GetHashCode(); hash = (hash * 31) + IsLibrary.GetHashCode(); hash = (hash * 31) + UseApplicationLanguages.GetHashCode(); - hash = (hash * 31) + GenerateResourceInterfaces.GetHashCode(); hash = (hash * 31) + (DefaultLanguage?.GetHashCode() ?? 0); return (hash * 31) + SetupProblems.GetHashCode(); diff --git a/src/ReswPlus.SourceGenerator/ReswGenerator.cs b/src/ReswPlus.SourceGenerator/ReswGenerator.cs index fd94c84..89ee691 100644 --- a/src/ReswPlus.SourceGenerator/ReswGenerator.cs +++ b/src/ReswPlus.SourceGenerator/ReswGenerator.cs @@ -183,8 +183,7 @@ private static void ReportSetupProblems(SourceProductionContext spc, ReswProject content: content, defaultNamespace: project.GetNamespace(file.Path), isAdvanced: true, - appType: project.AppType, - generateResourceInterface: project.GenerateResourceInterfaces); + appType: project.AppType); if (generated?.Files.FirstOrDefault() is not { } generatedFile) { diff --git a/tests/ReswPlusUnitTests/GeneratedCodeHygiene.cs b/tests/ReswPlusUnitTests/GeneratedCodeHygiene.cs index 106722c..759dc7b 100644 --- a/tests/ReswPlusUnitTests/GeneratedCodeHygiene.cs +++ b/tests/ReswPlusUnitTests/GeneratedCodeHygiene.cs @@ -80,7 +80,9 @@ public void EveryVisibleMemberIsDocumented() .OfType() .Where(member => member is EnumMemberDeclarationSyntax || member.Modifiers.Any(SyntaxKind.PublicKeyword) || - member.Modifiers.Any(SyntaxKind.ProtectedKeyword)) + member.Modifiers.Any(SyntaxKind.ProtectedKeyword) || + member.Parent is InterfaceDeclarationSyntax interfaceDeclaration && + interfaceDeclaration.Modifiers.Any(SyntaxKind.PublicKeyword)) .ToArray(); Assert.NotEmpty(visibleMembers); @@ -100,7 +102,14 @@ public void EveryParameterOfADocumentedMethodIsDocumented() { var root = CSharpSyntaxTree.ParseText(GenerateSample()).GetRoot(); - var methods = root.DescendantNodes().OfType().ToArray(); + var methods = root.DescendantNodes() + .OfType() + .Where(method => + method.Modifiers.Any(SyntaxKind.PublicKeyword) || + method.Modifiers.Any(SyntaxKind.ProtectedKeyword) || + method.Parent is InterfaceDeclarationSyntax interfaceDeclaration && + interfaceDeclaration.Modifiers.Any(SyntaxKind.PublicKeyword)) + .ToArray(); Assert.NotEmpty(methods); diff --git a/tests/ReswPlusUnitTests/GeneratorEndToEnd.cs b/tests/ReswPlusUnitTests/GeneratorEndToEnd.cs index 7432d03..e5a96d9 100644 --- a/tests/ReswPlusUnitTests/GeneratorEndToEnd.cs +++ b/tests/ReswPlusUnitTests/GeneratorEndToEnd.cs @@ -48,36 +48,24 @@ public void TheGeneratedCodeCompiles(ReswPlus.SourceGenerator.AppType appType) } [Fact] - public void ResourceInterfacesAreNotGeneratedByDefault() + public void ResourceInterfacesAndProvidersAreGeneratedByDefault() { var run = ReswGeneratorHarness.Run( [ReswGeneratorHarness.File("en-US", ReswTestHelpers.CreateResw(("Plain", "A plain string", null)))]); - Assert.DoesNotContain("interface IResources", run.Source("Resources.resw")); + Assert.Contains("public interface IResources", run.Source("Resources.resw")); + Assert.Contains("public sealed class ResourcesProvider : IResources", run.Source("Resources.resw")); Assert.Contains("public static class Resources", run.Source("Resources.resw")); } - [Fact] - public void ResourceInterfaceOptionIsReadFromTheBuild() - { - var options = new System.Collections.Generic.Dictionary(AnalyzerConfigOptions.KeyComparer) - { - ["build_property.ReswPlusGenerateResourceInterfaces"] = "true", - }; - - var buildOptions = ReswBuildOptions.Read(new TestAnalyzerConfigOptionsProvider(options).GlobalOptions); - - Assert.True(buildOptions.GenerateResourceInterfaces); - } - [Fact] public void ResourceInterfaceCanBeGeneratedDirectly() { var generated = ReswTestHelpers.GenerateCode( - ReswTestHelpers.CreateResw(("Plain", "A plain string", null)), - generateResourceInterface: true); + ReswTestHelpers.CreateResw(("Plain", "A plain string", null))); Assert.Contains("public interface IResources", generated); + Assert.Contains("public sealed class ResourcesProvider : IResources", generated); } [Theory] @@ -87,14 +75,14 @@ public void ResourceInterfacesCanBeInjectedWithoutChangingTheStaticApi(ReswPlus. { var run = ReswGeneratorHarness.Run( [ReswGeneratorHarness.File("en-US", EveryFeature)], - appType, - generateResourceInterfaces: true); + appType); var generated = run.Source("Resources.resw"); Assert.Contains("public interface IResources", generated); Assert.Contains("GeneratedCodeAttribute(\"ReswPlus\"", generated); - Assert.Contains("public sealed class Resources : IResources", generated); + Assert.Contains("public sealed class ResourcesProvider : IResources", generated); + Assert.Contains("public static class Resources", generated); Assert.Contains("string IResources.Plain => Resources.Plain;", generated); Assert.Contains("string IResources.Formatted(string name, int age) => Resources.Formatted(name, age);", generated); @@ -117,7 +105,7 @@ public ViewModel(global::TestProject.Strings.IResources resources) public static class Composition { public static ViewModel Create() => - new ViewModel(new global::TestProject.Strings.Resources()); + new ViewModel(new global::TestProject.Strings.ResourcesProvider()); } } """); @@ -127,10 +115,12 @@ public static ViewModel Create() => public void AResourceCannotCollideWithItsGeneratedInterface() { var run = ReswGeneratorHarness.Run( - [ReswGeneratorHarness.File("en-US", ReswTestHelpers.CreateResw(("IResources", "A collision", null)))], - generateResourceInterfaces: true); + [ReswGeneratorHarness.File("en-US", ReswTestHelpers.CreateResw( + ("IResources", "An interface collision", null), + ("ResourcesProvider", "A provider collision", null)))]); Assert.DoesNotContain("string IResources {", run.Source("Resources.resw")); + Assert.DoesNotContain("string ResourcesProvider {", run.Source("Resources.resw")); run.AssertCompiles(); } diff --git a/tests/ReswPlusUnitTests/GeneratorHarness.cs b/tests/ReswPlusUnitTests/GeneratorHarness.cs index d109e8d..d4196f7 100644 --- a/tests/ReswPlusUnitTests/GeneratorHarness.cs +++ b/tests/ReswPlusUnitTests/GeneratorHarness.cs @@ -64,7 +64,6 @@ public static ReswFile File(string language, string content, string baseName = " /// The DefaultLanguage of the project, which picks the language the code is generated from. /// The ReswPlusUseApplicationLanguages of the project, or to leave it undeclared. /// The UseUwp of the project, which is how a UWP project built for Native AOT says what it is, or to leave it undeclared. - /// Whether to generate injectable resource interfaces. /// The name of the assembly being compiled. /// Whether the compilation should look like a UWP project built for Native AOT, which has the UWP types but no recognizable API contract reference. /// Files to pass to the compiler on top of , to cover what the generator does with the ones it doesn't own. @@ -80,7 +79,6 @@ public static ReswGeneratorRun Run( string? defaultLanguage = null, bool? useApplicationLanguages = null, bool? useUwp = null, - bool? generateResourceInterfaces = null, string assemblyName = "TestProject", bool nativeAotUwp = false, IEnumerable? additionalFiles = null) @@ -102,7 +100,6 @@ public static ReswGeneratorRun Run( Declare("build_property.RootNamespace", rootNamespace); Declare("build_property.ReswPlusUseApplicationLanguages", useApplicationLanguages?.ToString().ToLowerInvariant()); Declare("build_property.UseUwp", useUwp?.ToString().ToLowerInvariant()); - Declare("build_property.ReswPlusGenerateResourceInterfaces", generateResourceInterfaces?.ToString().ToLowerInvariant()); var compilation = CSharpCompilation.Create( assemblyName, diff --git a/tests/ReswPlusUnitTests/ReswTestHelpers.cs b/tests/ReswPlusUnitTests/ReswTestHelpers.cs index 389dab8..47e74ac 100644 --- a/tests/ReswPlusUnitTests/ReswTestHelpers.cs +++ b/tests/ReswPlusUnitTests/ReswTestHelpers.cs @@ -54,10 +54,9 @@ public static string CreateResw(params (string Key, string Value, string? Commen /// The generated C# code. public static string GenerateCode( string reswContent, - AppType appType = AppType.WindowsAppSDK, - bool generateResourceInterface = false) + AppType appType = AppType.WindowsAppSDK) { - return GenerateFile(reswContent, appType, generateResourceInterface).Content; + return GenerateFile(reswContent, appType).Content; } /// @@ -68,8 +67,7 @@ public static string GenerateCode( /// The generated file. public static GeneratedFile GenerateFile( string reswContent, - AppType appType = AppType.WindowsAppSDK, - bool generateResourceInterface = false) + AppType appType = AppType.WindowsAppSDK) { var resourceFileInfo = new ResourceFileInfo(@"C:\Project\Strings\en-US\Resources.resw", new Project("TestProject", isLibrary: false)); var generator = ReswClassGenerator.CreateGenerator(resourceFileInfo, logger: null); @@ -81,8 +79,7 @@ public static GeneratedFile GenerateFile( content: reswContent, defaultNamespace: "TestProject.Strings", isAdvanced: true, - appType: appType, - generateResourceInterface: generateResourceInterface); + appType: appType); Assert.NotNull(result); From dc628a35522838d2178543ed06c42a162577dddb Mon Sep 17 00:00:00 2001 From: Rudy Huyn Date: Fri, 21 Aug 2026 23:10:39 -0700 Subject: [PATCH 2/4] Allow generated resource providers to be disabled - Default ReswPlusGenerateResourceInterfaces to true - Suppress interfaces, providers, and related collision diagnostics when false - Verify reserved adapter names remain usable when generation is disabled Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 8 +++ nuget/ReswPlus.targets | 2 + .../Analysis/ReswResourceAnalyzer.cs | 14 +++++- .../Analysis/ReswResourceRules.cs | 21 +++++--- .../Models/StronglyTypedClass.cs | 5 +- .../ClassGenerators/ReswClassGenerator.cs | 15 +++--- .../CodeGenerators/CsharpCodeGenerator.cs | 49 ++++++++++--------- .../CodeGenerators/GeneratedIdentifier.cs | 7 +-- .../Pipeline/ReswBuildOptions.cs | 18 +++++-- .../Pipeline/ReswProject.cs | 9 +++- src/ReswPlus.SourceGenerator/ReswGenerator.cs | 3 +- tests/ReswPlusUnitTests/GeneratorEndToEnd.cs | 27 ++++++++++ tests/ReswPlusUnitTests/GeneratorHarness.cs | 3 ++ tests/ReswPlusUnitTests/ReswTestHelpers.cs | 29 +++++++++-- 14 files changed, 157 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 2ef4da2..045113c 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,14 @@ var title = resources.WelcomeTitle; The interface includes `GetString`, regular resource properties, and all generated formatting, plural, and variant overloads. +Generation is enabled by default. Projects that do not use dependency injection can disable the additional types: + +```xml + + false + +``` + ### Generator performance diagnostics To include compiler-measured source-generator timings in detailed build output, enable: diff --git a/nuget/ReswPlus.targets b/nuget/ReswPlus.targets index 0596f25..bf19306 100644 --- a/nuget/ReswPlus.targets +++ b/nuget/ReswPlus.targets @@ -8,11 +8,13 @@ + $(AdditionalFileItemNames);PRIResource false + true false diff --git a/src/ReswPlus.SourceGenerator/Analysis/ReswResourceAnalyzer.cs b/src/ReswPlus.SourceGenerator/Analysis/ReswResourceAnalyzer.cs index 3c043bb..5885557 100644 --- a/src/ReswPlus.SourceGenerator/Analysis/ReswResourceAnalyzer.cs +++ b/src/ReswPlus.SourceGenerator/Analysis/ReswResourceAnalyzer.cs @@ -66,10 +66,20 @@ private static void AnalyzeCompilation(CompilationAnalysisContext context) return; } - var defaultLanguage = context.Options.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue("build_property.DefaultLanguage", out var value) + var globalOptions = context.Options.AnalyzerConfigOptionsProvider.GlobalOptions; + var defaultLanguage = globalOptions.TryGetValue("build_property.DefaultLanguage", out var value) ? value : null; + var generateResourceInterfaces = + !globalOptions.TryGetValue("build_property.ReswPlusGenerateResourceInterfaces", out var interfaceOption) + || !bool.TryParse(interfaceOption, out var parsedInterfaces) + || parsedInterfaces; - ReswResourceRules.Analyze(reswFiles, defaultLanguage, context.ReportDiagnostic, context.CancellationToken); + ReswResourceRules.Analyze( + reswFiles, + defaultLanguage, + generateResourceInterfaces, + context.ReportDiagnostic, + context.CancellationToken); } } diff --git a/src/ReswPlus.SourceGenerator/Analysis/ReswResourceRules.cs b/src/ReswPlus.SourceGenerator/Analysis/ReswResourceRules.cs index 6efd891..7a26770 100644 --- a/src/ReswPlus.SourceGenerator/Analysis/ReswResourceRules.cs +++ b/src/ReswPlus.SourceGenerator/Analysis/ReswResourceRules.cs @@ -35,11 +35,13 @@ internal static class ReswResourceRules /// /// The .resw files of the project, with their content. /// The default language of the project, if it declares one. + /// Whether injectable resource interfaces and providers are generated. /// The callback invoked for every problem found. /// The token used to cancel the operation. public static void Analyze( IReadOnlyList<(string Path, SourceText Text)> reswFiles, string? defaultLanguage, + bool generateResourceInterfaces, Action reportDiagnostic, CancellationToken cancellationToken) { @@ -63,7 +65,7 @@ public static void Analyze( var defaultModel = ReswResourceModel.Create(defaultDocument); - AnalyzeDocument(defaultModel, defaultModel, reportDiagnostic); + AnalyzeDocument(defaultModel, defaultModel, generateResourceInterfaces, reportDiagnostic); foreach (var path in group) { @@ -74,15 +76,19 @@ public static void Analyze( continue; } - AnalyzeDocument(ReswResourceModel.Create(document), defaultModel, reportDiagnostic); + AnalyzeDocument(ReswResourceModel.Create(document), defaultModel, generateResourceInterfaces, reportDiagnostic); } } } - private static void AnalyzeDocument(ReswResourceModel model, ReswResourceModel defaultModel, Action reportDiagnostic) + private static void AnalyzeDocument( + ReswResourceModel model, + ReswResourceModel defaultModel, + bool generateResourceInterfaces, + Action reportDiagnostic) { ReportDuplicateMembers(model, reportDiagnostic); - ReportReservedNames(model, reportDiagnostic); + ReportReservedNames(model, generateResourceInterfaces, reportDiagnostic); ReportDuplicateFormatParameters(model, reportDiagnostic); ReportMissingPluralForms(model, defaultModel, reportDiagnostic); ReportFormattingProblems(model, defaultModel, reportDiagnostic); @@ -95,13 +101,16 @@ private static void AnalyzeDocument(ReswResourceModel model, ReswResourceModel d /// The generator skips these resources rather than emitting a member that would not compile, which makes /// them silently absent from the generated class. This is what says so. /// - private static void ReportReservedNames(ReswResourceModel model, Action reportDiagnostic) + private static void ReportReservedNames( + ReswResourceModel model, + bool generateResourceInterfaces, + Action reportDiagnostic) { var className = Path.GetFileNameWithoutExtension(model.Document.Path); foreach (var member in model.Members) { - if (!GeneratedIdentifier.ConflictsWithGeneratedMember(member.Name, className)) + if (!GeneratedIdentifier.ConflictsWithGeneratedMember(member.Name, className, generateResourceInterfaces)) { continue; } diff --git a/src/ReswPlus.SourceGenerator/ClassGenerators/Models/StronglyTypedClass.cs b/src/ReswPlus.SourceGenerator/ClassGenerators/Models/StronglyTypedClass.cs index 6052831..4ad190d 100644 --- a/src/ReswPlus.SourceGenerator/ClassGenerators/Models/StronglyTypedClass.cs +++ b/src/ReswPlus.SourceGenerator/ClassGenerators/Models/StronglyTypedClass.cs @@ -9,13 +9,15 @@ public StronglyTypedClass( string[] namespaces, string resoureFile, string className, - AppType appType) + AppType appType, + bool generateResourceInterfaces) { IsAdvanced = isAdvanced; Namespaces = namespaces; ResoureFile = resoureFile; ClassName = className; AppType = appType; + GenerateResourceInterfaces = generateResourceInterfaces; Items = []; } @@ -24,6 +26,7 @@ public StronglyTypedClass( public string ResoureFile { get; } public string ClassName { get; } public AppType AppType { get; } + public bool GenerateResourceInterfaces { get; } public List Items { get; } } diff --git a/src/ReswPlus.SourceGenerator/ClassGenerators/ReswClassGenerator.cs b/src/ReswPlus.SourceGenerator/ClassGenerators/ReswClassGenerator.cs index 055f3c9..dfe2817 100644 --- a/src/ReswPlus.SourceGenerator/ClassGenerators/ReswClassGenerator.cs +++ b/src/ReswPlus.SourceGenerator/ClassGenerators/ReswClassGenerator.cs @@ -90,7 +90,8 @@ private StronglyTypedClass Parse( string content, string defaultNamespace, bool isAdvanced, - AppType appType) + AppType appType, + bool generateResourceInterfaces) { var namespacesToUse = ExtractNamespace(defaultNamespace); var resourceFileName = Path.GetFileName(_resourceFileInfo.Path); @@ -108,14 +109,15 @@ private StronglyTypedClass Parse( namespacesToUse, resourceLoaderName, className, - appType + appType, + generateResourceInterfaces ); // Only use items with valid keys, that do not carry the ignore tag, and whose name the generated types // don't already declare. var stringItems = reswInfo.Items .Where(i => IsValidPropertyName(i.Key) && !(i.Comment?.Contains(TagIgnore) ?? false)) - .Where(i => !GeneratedIdentifier.ConflictsWithGeneratedMember(i.Key, className)) + .Where(i => !GeneratedIdentifier.ConflictsWithGeneratedMember(i.Key, className, generateResourceInterfaces)) .ToArray(); if (isAdvanced) @@ -130,7 +132,7 @@ private StronglyTypedClass Parse( // The forms of the resource are already out of the plain items, so a conflicting group is // dropped here rather than declined into members the generated types already declare. - if (GeneratedIdentifier.ConflictsWithGeneratedMember(itemKey, className)) + if (GeneratedIdentifier.ConflictsWithGeneratedMember(itemKey, className, generateResourceInterfaces)) { continue; } @@ -217,9 +219,10 @@ internal static bool IsValidPropertyName(string propertyName) string content, string defaultNamespace, bool isAdvanced, - AppType appType) + AppType appType, + bool generateResourceInterfaces) { - var stronglyTypedClassInfo = Parse(content, defaultNamespace, isAdvanced, appType); + var stronglyTypedClassInfo = Parse(content, defaultNamespace, isAdvanced, appType, generateResourceInterfaces); if (stronglyTypedClassInfo is null) { return null; diff --git a/src/ReswPlus.SourceGenerator/CodeGenerators/CsharpCodeGenerator.cs b/src/ReswPlus.SourceGenerator/CodeGenerators/CsharpCodeGenerator.cs index e61e6c4..4f8478b 100644 --- a/src/ReswPlus.SourceGenerator/CodeGenerators/CsharpCodeGenerator.cs +++ b/src/ReswPlus.SourceGenerator/CodeGenerators/CsharpCodeGenerator.cs @@ -169,20 +169,25 @@ public IEnumerable GetGeneratedFiles(string? baseFilename, Strong // Add the generated members (format methods/properties) to the strongly-typed class. strongClassDecl = strongClassDecl.AddMembers(formatMembers.ToArray()); - var interfaceName = "I" + info.ClassName; - var providerName = info.ClassName + "Provider"; - var publicMembers = strongClassDecl.Members - .Where(member => - member is MethodDeclarationSyntax or PropertyDeclarationSyntax - && member.Modifiers.Any(SyntaxKind.PublicKeyword)) - .ToArray(); - var resourceInterfaceDecl = CreateResourceInterface(interfaceName, info, publicMembers); - var resourceProviderDecl = CreateResourceProvider( - providerName, - interfaceName, - info.ClassName, - info, - publicMembers); + InterfaceDeclarationSyntax? resourceInterfaceDecl = null; + ClassDeclarationSyntax? resourceProviderDecl = null; + if (info.GenerateResourceInterfaces) + { + var interfaceName = "I" + info.ClassName; + var providerName = info.ClassName + "Provider"; + var publicMembers = strongClassDecl.Members + .Where(member => + member is MethodDeclarationSyntax or PropertyDeclarationSyntax + && member.Modifiers.Any(SyntaxKind.PublicKeyword)) + .ToArray(); + resourceInterfaceDecl = CreateResourceInterface(interfaceName, info, publicMembers); + resourceProviderDecl = CreateResourceProvider( + providerName, + interfaceName, + info.ClassName, + info, + publicMembers); + } // Create the markup extension class that allows resource keys to be used in XAML. var markupExtensionDecl = CreateMarkupExtensionSyntax(info.ResoureFile, info.ClassName + "Extension", info.Items.Select(x => x.Key), info.AppType); @@ -192,21 +197,17 @@ member is MethodDeclarationSyntax or PropertyDeclarationSyntax { var nsName = string.Join(".", info.Namespaces); var namespaceDecl = NamespaceDeclaration(ParseName(nsName)); - namespaceDecl = namespaceDecl.AddMembers( - resourceInterfaceDecl, - resourceProviderDecl, - strongClassDecl, - markupExtensionDecl); + namespaceDecl = resourceInterfaceDecl is null || resourceProviderDecl is null + ? namespaceDecl.AddMembers(strongClassDecl, markupExtensionDecl) + : namespaceDecl.AddMembers(resourceInterfaceDecl, resourceProviderDecl, strongClassDecl, markupExtensionDecl); compilationUnit = compilationUnit.AddMembers(namespaceDecl); } else { // Otherwise, add the classes at the root level. - compilationUnit = compilationUnit.AddMembers( - resourceInterfaceDecl, - resourceProviderDecl, - strongClassDecl, - markupExtensionDecl); + compilationUnit = resourceInterfaceDecl is null || resourceProviderDecl is null + ? compilationUnit.AddMembers(strongClassDecl, markupExtensionDecl) + : compilationUnit.AddMembers(resourceInterfaceDecl, resourceProviderDecl, strongClassDecl, markupExtensionDecl); } // Normalize the whitespace (formatting) and return the generated source code. diff --git a/src/ReswPlus.SourceGenerator/CodeGenerators/GeneratedIdentifier.cs b/src/ReswPlus.SourceGenerator/CodeGenerators/GeneratedIdentifier.cs index 90ea302..85f4ad9 100644 --- a/src/ReswPlus.SourceGenerator/CodeGenerators/GeneratedIdentifier.cs +++ b/src/ReswPlus.SourceGenerator/CodeGenerators/GeneratedIdentifier.cs @@ -57,11 +57,12 @@ public static string Escape(string name) /// beside the generated GetString without conflicting with it. Resources whose names only differ by /// case conflict for a different reason, and are reported by RESWP0009. /// - public static bool ConflictsWithGeneratedMember(string name, string className) + public static bool ConflictsWithGeneratedMember(string name, string className, bool generateResourceInterfaces) { return string.Equals(name, className, StringComparison.Ordinal) - || string.Equals(name, "I" + className, StringComparison.Ordinal) - || string.Equals(name, className + "Provider", StringComparison.Ordinal) + || generateResourceInterfaces && ( + string.Equals(name, "I" + className, StringComparison.Ordinal) + || string.Equals(name, className + "Provider", StringComparison.Ordinal)) || Array.IndexOf(GeneratedMemberNames, name) >= 0; } diff --git a/src/ReswPlus.SourceGenerator/Pipeline/ReswBuildOptions.cs b/src/ReswPlus.SourceGenerator/Pipeline/ReswBuildOptions.cs index 0149475..4f27908 100644 --- a/src/ReswPlus.SourceGenerator/Pipeline/ReswBuildOptions.cs +++ b/src/ReswPlus.SourceGenerator/Pipeline/ReswBuildOptions.cs @@ -24,7 +24,8 @@ private ReswBuildOptions( string? defaultLanguage, string? rootNamespace, bool useApplicationLanguages, - bool useUwp) + bool useUwp, + bool generateResourceInterfaces) { ProjectDir = projectDir; MSBuildProjectFullPath = msBuildProjectFullPath; @@ -34,6 +35,7 @@ private ReswBuildOptions( RootNamespace = rootNamespace; UseApplicationLanguages = useApplicationLanguages; UseUwp = useUwp; + GenerateResourceInterfaces = generateResourceInterfaces; } public string? ProjectDir { get; } @@ -65,6 +67,11 @@ private ReswBuildOptions( /// public bool UseUwp { get; } + /// + /// Gets whether injectable resource interfaces and providers should be generated. + /// + public bool GenerateResourceInterfaces { get; } + /// /// Reads the properties of a project. /// @@ -80,7 +87,8 @@ public static ReswBuildOptions Read(AnalyzerConfigOptions globalOptions) Get("build_property.DefaultLanguage"), Get("build_property.RootNamespace"), bool.TryParse(Get("build_property.ReswPlusUseApplicationLanguages"), out var parsed) && parsed, - bool.TryParse(Get("build_property.UseUwp"), out var parsedUseUwp) && parsedUseUwp); + bool.TryParse(Get("build_property.UseUwp"), out var parsedUseUwp) && parsedUseUwp, + !bool.TryParse(Get("build_property.ReswPlusGenerateResourceInterfaces"), out var parsedInterfaces) || parsedInterfaces); string? Get(string key) => globalOptions.TryGetValue(key, out var value) ? value : null; } @@ -110,7 +118,8 @@ public bool Equals(ReswBuildOptions? other) && DefaultLanguage == other.DefaultLanguage && RootNamespace == other.RootNamespace && UseApplicationLanguages == other.UseApplicationLanguages - && UseUwp == other.UseUwp; + && UseUwp == other.UseUwp + && GenerateResourceInterfaces == other.GenerateResourceInterfaces; } /// @@ -127,6 +136,7 @@ public override int GetHashCode() } hash = (hash * 31) + UseApplicationLanguages.GetHashCode(); - return (hash * 31) + UseUwp.GetHashCode(); + hash = (hash * 31) + UseUwp.GetHashCode(); + return (hash * 31) + GenerateResourceInterfaces.GetHashCode(); } } diff --git a/src/ReswPlus.SourceGenerator/Pipeline/ReswProject.cs b/src/ReswPlus.SourceGenerator/Pipeline/ReswProject.cs index e0f1c93..e4ae65a 100644 --- a/src/ReswPlus.SourceGenerator/Pipeline/ReswProject.cs +++ b/src/ReswPlus.SourceGenerator/Pipeline/ReswProject.cs @@ -19,6 +19,7 @@ private ReswProject( string rootNamespace, bool isLibrary, bool useApplicationLanguages, + bool generateResourceInterfaces, string? defaultLanguage, EquatableArray setupProblems) { @@ -30,6 +31,7 @@ private ReswProject( RootNamespace = rootNamespace; IsLibrary = isLibrary; UseApplicationLanguages = useApplicationLanguages; + GenerateResourceInterfaces = generateResourceInterfaces; SetupProblems = setupProblems; } @@ -50,6 +52,8 @@ private ReswProject( public bool UseApplicationLanguages { get; } + public bool GenerateResourceInterfaces { get; } + /// /// Gets the default language of the project, which picks the resource file the code is generated from. /// @@ -130,11 +134,12 @@ public static ReswProject Create(CompilationInfo compilationInfo, ReswBuildOptio options.RootNamespace!, isLibrary, options.UseApplicationLanguages, + options.GenerateResourceInterfaces, options.DefaultLanguage, new EquatableArray(problems)); ReswProject Unsupported(params string[] problems) => - new(false, AppType.Unknown, "", "", "", false, false, options.DefaultLanguage, new EquatableArray(problems)); + new(false, AppType.Unknown, "", "", "", false, false, options.GenerateResourceInterfaces, options.DefaultLanguage, new EquatableArray(problems)); } /// @@ -209,6 +214,7 @@ public bool Equals(ReswProject? other) && RootNamespace == other.RootNamespace && IsLibrary == other.IsLibrary && UseApplicationLanguages == other.UseApplicationLanguages + && GenerateResourceInterfaces == other.GenerateResourceInterfaces && DefaultLanguage == other.DefaultLanguage && SetupProblems.Equals(other.SetupProblems); } @@ -228,6 +234,7 @@ public override int GetHashCode() hash = (hash * 31) + RootNamespace.GetHashCode(); hash = (hash * 31) + IsLibrary.GetHashCode(); hash = (hash * 31) + UseApplicationLanguages.GetHashCode(); + hash = (hash * 31) + GenerateResourceInterfaces.GetHashCode(); hash = (hash * 31) + (DefaultLanguage?.GetHashCode() ?? 0); return (hash * 31) + SetupProblems.GetHashCode(); diff --git a/src/ReswPlus.SourceGenerator/ReswGenerator.cs b/src/ReswPlus.SourceGenerator/ReswGenerator.cs index 89ee691..8597bfc 100644 --- a/src/ReswPlus.SourceGenerator/ReswGenerator.cs +++ b/src/ReswPlus.SourceGenerator/ReswGenerator.cs @@ -183,7 +183,8 @@ private static void ReportSetupProblems(SourceProductionContext spc, ReswProject content: content, defaultNamespace: project.GetNamespace(file.Path), isAdvanced: true, - appType: project.AppType); + appType: project.AppType, + generateResourceInterfaces: project.GenerateResourceInterfaces); if (generated?.Files.FirstOrDefault() is not { } generatedFile) { diff --git a/tests/ReswPlusUnitTests/GeneratorEndToEnd.cs b/tests/ReswPlusUnitTests/GeneratorEndToEnd.cs index e5a96d9..8fbd2fe 100644 --- a/tests/ReswPlusUnitTests/GeneratorEndToEnd.cs +++ b/tests/ReswPlusUnitTests/GeneratorEndToEnd.cs @@ -58,6 +58,33 @@ public void ResourceInterfacesAndProvidersAreGeneratedByDefault() Assert.Contains("public static class Resources", run.Source("Resources.resw")); } + [Fact] + public void ResourceInterfacesAndProvidersCanBeDisabled() + { + var run = ReswGeneratorHarness.Run( + [ReswGeneratorHarness.File("en-US", ReswTestHelpers.CreateResw( + ("Plain", "A plain string", null), + ("IResources", "An interface-shaped resource", null), + ("ResourcesProvider", "A provider-shaped resource", null)))], + generateResourceInterfaces: false); + + var generated = run.Source("Resources.resw"); + + Assert.DoesNotContain("interface IResources", generated); + Assert.DoesNotContain("class ResourcesProvider", generated); + Assert.Contains("public static class Resources", generated); + Assert.Contains("public static string IResources", generated); + Assert.Contains("public static string ResourcesProvider", generated); + run.AssertCompiles(); + + Assert.Empty(ReswTestHelpers.Analyze( + defaultLanguage: null, + generateResourceInterfaces: false, + ("en-US", ReswTestHelpers.CreateResw( + ("IResources", "An interface-shaped resource", null), + ("ResourcesProvider", "A provider-shaped resource", null))))); + } + [Fact] public void ResourceInterfaceCanBeGeneratedDirectly() { diff --git a/tests/ReswPlusUnitTests/GeneratorHarness.cs b/tests/ReswPlusUnitTests/GeneratorHarness.cs index d4196f7..f595b1f 100644 --- a/tests/ReswPlusUnitTests/GeneratorHarness.cs +++ b/tests/ReswPlusUnitTests/GeneratorHarness.cs @@ -64,6 +64,7 @@ public static ReswFile File(string language, string content, string baseName = " /// The DefaultLanguage of the project, which picks the language the code is generated from. /// The ReswPlusUseApplicationLanguages of the project, or to leave it undeclared. /// The UseUwp of the project, which is how a UWP project built for Native AOT says what it is, or to leave it undeclared. + /// Whether to generate injectable resource interfaces and providers. /// The name of the assembly being compiled. /// Whether the compilation should look like a UWP project built for Native AOT, which has the UWP types but no recognizable API contract reference. /// Files to pass to the compiler on top of , to cover what the generator does with the ones it doesn't own. @@ -79,6 +80,7 @@ public static ReswGeneratorRun Run( string? defaultLanguage = null, bool? useApplicationLanguages = null, bool? useUwp = null, + bool? generateResourceInterfaces = null, string assemblyName = "TestProject", bool nativeAotUwp = false, IEnumerable? additionalFiles = null) @@ -100,6 +102,7 @@ public static ReswGeneratorRun Run( Declare("build_property.RootNamespace", rootNamespace); Declare("build_property.ReswPlusUseApplicationLanguages", useApplicationLanguages?.ToString().ToLowerInvariant()); Declare("build_property.UseUwp", useUwp?.ToString().ToLowerInvariant()); + Declare("build_property.ReswPlusGenerateResourceInterfaces", generateResourceInterfaces?.ToString().ToLowerInvariant()); var compilation = CSharpCompilation.Create( assemblyName, diff --git a/tests/ReswPlusUnitTests/ReswTestHelpers.cs b/tests/ReswPlusUnitTests/ReswTestHelpers.cs index 47e74ac..5f81f12 100644 --- a/tests/ReswPlusUnitTests/ReswTestHelpers.cs +++ b/tests/ReswPlusUnitTests/ReswTestHelpers.cs @@ -54,9 +54,10 @@ public static string CreateResw(params (string Key, string Value, string? Commen /// The generated C# code. public static string GenerateCode( string reswContent, - AppType appType = AppType.WindowsAppSDK) + AppType appType = AppType.WindowsAppSDK, + bool generateResourceInterfaces = true) { - return GenerateFile(reswContent, appType).Content; + return GenerateFile(reswContent, appType, generateResourceInterfaces).Content; } /// @@ -67,7 +68,8 @@ public static string GenerateCode( /// The generated file. public static GeneratedFile GenerateFile( string reswContent, - AppType appType = AppType.WindowsAppSDK) + AppType appType = AppType.WindowsAppSDK, + bool generateResourceInterfaces = true) { var resourceFileInfo = new ResourceFileInfo(@"C:\Project\Strings\en-US\Resources.resw", new Project("TestProject", isLibrary: false)); var generator = ReswClassGenerator.CreateGenerator(resourceFileInfo, logger: null); @@ -79,7 +81,8 @@ public static GeneratedFile GenerateFile( content: reswContent, defaultNamespace: "TestProject.Strings", isAdvanced: true, - appType: appType); + appType: appType, + generateResourceInterfaces: generateResourceInterfaces); Assert.NotNull(result); @@ -93,6 +96,17 @@ public static GeneratedFile GenerateFile( /// The files of the project, as language folder name and content pairs. /// The diagnostics reported for those files. public static IReadOnlyList Analyze(string? defaultLanguage, params (string Language, string Content)[] files) + { + return Analyze(defaultLanguage, generateResourceInterfaces: true, files); + } + + /// + /// Runs the resource analysis with the configured resource-interface behavior. + /// + public static IReadOnlyList Analyze( + string? defaultLanguage, + bool generateResourceInterfaces, + params (string Language, string Content)[] files) { var documents = files .Select(file => (GetPath(file.Language), SourceText.From(file.Content))) @@ -100,7 +114,12 @@ public static IReadOnlyList Analyze(string? defaultLanguage, params var diagnostics = new List(); - ReswResourceRules.Analyze(documents, defaultLanguage, diagnostics.Add, CancellationToken.None); + ReswResourceRules.Analyze( + documents, + defaultLanguage, + generateResourceInterfaces, + diagnostics.Add, + CancellationToken.None); return diagnostics; } From 547f448c7230d14451ce46bf8845d1d901ca4b3e Mon Sep 17 00:00:00 2001 From: Rudy Huyn Date: Fri, 21 Aug 2026 23:17:46 -0700 Subject: [PATCH 3/4] Document injectable resource providers - Describe per-resource generated type names - Show constructor injection and container registration - Clarify explicit implementation and opt-out behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 045113c..225f571 100644 --- a/README.md +++ b/README.md @@ -50,14 +50,36 @@ Without it, such a project is reported as `RESWP0005` and no code is generated f ### Injectable resource interfaces -For `Resources.resw`, ReswPlus generates the static `Resources` class as before, plus an `IResources` interface and a sealed `ResourcesProvider` adapter. The provider delegates to the static API, so existing calls such as `Resources.WelcomeTitle` remain unchanged while view models and services can receive an injectable resource dependency: +For every resource file, ReswPlus generates an interface and provider alongside the existing static resource class: + +| Resource file | Static API | Injectable API | Default implementation | +| --- | --- | --- | --- | +| `Resources.resw` | `Resources` | `IResources` | `ResourcesProvider` | +| `Errors.resw` | `Errors` | `IErrors` | `ErrorsProvider` | + +The provider delegates to the static API, so existing calls such as `Resources.WelcomeTitle` remain unchanged while view models and services can receive an injectable resource dependency: + +```csharp +public sealed class WelcomeViewModel +{ + private readonly IResources _resources; + + public WelcomeViewModel(IResources resources) + { + _resources = resources; + } + + public string Title => _resources.WelcomeTitle; +} +``` + +Register the generated provider with the dependency-injection container used by the application: ```csharp -IResources resources = new ResourcesProvider(); -var title = resources.WelcomeTitle; +services.AddSingleton(); ``` -The interface includes `GetString`, regular resource properties, and all generated formatting, plural, and variant overloads. +The interface includes `GetString`, regular resource properties, and every generated formatting, plural, and variant overload. The provider implements these members explicitly, so application code should consume it through the generated interface. Generation is enabled by default. Projects that do not use dependency injection can disable the additional types: From e8c80137b2b28c7daac131ddb50da690399d60d7 Mon Sep 17 00:00:00 2001 From: Rudy Huyn Date: Fri, 21 Aug 2026 23:22:33 -0700 Subject: [PATCH 4/4] Move resource provider guidance to a documentation page - Add a dedicated guide for generated types, DI registration, compatibility, and opt-out behavior - Keep the README section as a concise link to the guide Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 39 +------------- docs/Injectable-resource-providers.md | 78 +++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 38 deletions(-) create mode 100644 docs/Injectable-resource-providers.md diff --git a/README.md b/README.md index 225f571..fc22aef 100644 --- a/README.md +++ b/README.md @@ -50,44 +50,7 @@ Without it, such a project is reported as `RESWP0005` and no code is generated f ### Injectable resource interfaces -For every resource file, ReswPlus generates an interface and provider alongside the existing static resource class: - -| Resource file | Static API | Injectable API | Default implementation | -| --- | --- | --- | --- | -| `Resources.resw` | `Resources` | `IResources` | `ResourcesProvider` | -| `Errors.resw` | `Errors` | `IErrors` | `ErrorsProvider` | - -The provider delegates to the static API, so existing calls such as `Resources.WelcomeTitle` remain unchanged while view models and services can receive an injectable resource dependency: - -```csharp -public sealed class WelcomeViewModel -{ - private readonly IResources _resources; - - public WelcomeViewModel(IResources resources) - { - _resources = resources; - } - - public string Title => _resources.WelcomeTitle; -} -``` - -Register the generated provider with the dependency-injection container used by the application: - -```csharp -services.AddSingleton(); -``` - -The interface includes `GetString`, regular resource properties, and every generated formatting, plural, and variant overload. The provider implements these members explicitly, so application code should consume it through the generated interface. - -Generation is enabled by default. Projects that do not use dependency injection can disable the additional types: - -```xml - - false - -``` +🗨 [How to inject generated resources](docs/Injectable-resource-providers.md) ### Generator performance diagnostics diff --git a/docs/Injectable-resource-providers.md b/docs/Injectable-resource-providers.md new file mode 100644 index 0000000..43290cf --- /dev/null +++ b/docs/Injectable-resource-providers.md @@ -0,0 +1,78 @@ +# Injectable resource providers + +ReswPlus generates an interface and provider for each resource file so that view models and services can +receive localized resources through dependency injection. The existing static resource API remains unchanged. + +## Generated types + +The generated type names follow the resource file name: + +| Resource file | Static API | Injectable API | Default implementation | +| --- | --- | --- | --- | +| `Resources.resw` | `Resources` | `IResources` | `ResourcesProvider` | +| `Errors.resw` | `Errors` | `IErrors` | `ErrorsProvider` | + +The interface includes: + +- `GetString` +- regular resource properties +- formatted resource methods +- plural methods +- variant and plural-variant overloads + +The provider delegates every interface member to the existing static API. It does not create a separate +resource-loading path or cache. + +## Use constructor injection + +Receive the generated interface instead of referring to the static class: + +```csharp +public sealed class WelcomeViewModel +{ + private readonly IResources _resources; + + public WelcomeViewModel(IResources resources) + { + _resources = resources; + } + + public string Title => _resources.WelcomeTitle; +} +``` + +Register the generated provider with the dependency-injection container used by the application. For +`Microsoft.Extensions.DependencyInjection`, register it as follows: + +```csharp +services.AddSingleton(); +``` + +The provider implements the interface explicitly, so consume a `ResourcesProvider` through `IResources`. +Tests can supply a stub, fake, or mock implementation of the same interface. + +## Compatibility with the static API + +The generated resource class remains static: + +```csharp +var title = Resources.WelcomeTitle; +``` + +Adding the injectable types therefore does not change existing call sites, construction behavior, or the +shape of the static resource class. + +## Disable generation + +Interfaces and providers are generated by default. A project that does not want the additional public types +can disable them: + +```xml + + false + +``` + +With the option set to `false`, ReswPlus emits only the existing static resource class and markup extension. +Resource keys such as `IResources` and `ResourcesProvider`, which would otherwise conflict with generated +members, are available again.