diff --git a/README.md b/README.md index 6ec1d62..fc22aef 100644 --- a/README.md +++ b/README.md @@ -50,22 +50,7 @@ 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`: - -```csharp -IResources resources = new Resources(); -var title = resources.WelcomeTitle; -``` - -The interface includes `GetString`, regular resource properties, and all generated formatting, plural, and variant overloads. +🗨 [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. diff --git a/nuget/ReswPlus.targets b/nuget/ReswPlus.targets index 8299c9e..bf19306 100644 --- a/nuget/ReswPlus.targets +++ b/nuget/ReswPlus.targets @@ -14,7 +14,7 @@ $(AdditionalFileItemNames);PRIResource false - 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 742cf6b..4ad190d 100644 --- a/src/ReswPlus.SourceGenerator/ClassGenerators/Models/StronglyTypedClass.cs +++ b/src/ReswPlus.SourceGenerator/ClassGenerators/Models/StronglyTypedClass.cs @@ -10,14 +10,14 @@ public StronglyTypedClass( string resoureFile, string className, AppType appType, - bool generateResourceInterface) + bool generateResourceInterfaces) { IsAdvanced = isAdvanced; Namespaces = namespaces; ResoureFile = resoureFile; ClassName = className; AppType = appType; - GenerateResourceInterface = generateResourceInterface; + GenerateResourceInterfaces = generateResourceInterfaces; Items = []; } @@ -26,7 +26,7 @@ public StronglyTypedClass( public string ResoureFile { get; } public string ClassName { get; } public AppType AppType { get; } - public bool GenerateResourceInterface { 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 49da1ac..dfe2817 100644 --- a/src/ReswPlus.SourceGenerator/ClassGenerators/ReswClassGenerator.cs +++ b/src/ReswPlus.SourceGenerator/ClassGenerators/ReswClassGenerator.cs @@ -91,7 +91,7 @@ private StronglyTypedClass Parse( string defaultNamespace, bool isAdvanced, AppType appType, - bool generateResourceInterface) + bool generateResourceInterfaces) { var namespacesToUse = ExtractNamespace(defaultNamespace); var resourceFileName = Path.GetFileName(_resourceFileInfo.Path); @@ -110,14 +110,14 @@ private StronglyTypedClass Parse( resourceLoaderName, className, appType, - generateResourceInterface + 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, generateResourceInterface)) + .Where(i => !GeneratedIdentifier.ConflictsWithGeneratedMember(i.Key, className, generateResourceInterfaces)) .ToArray(); if (isAdvanced) @@ -132,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, generateResourceInterface)) + if (GeneratedIdentifier.ConflictsWithGeneratedMember(itemKey, className, generateResourceInterfaces)) { continue; } @@ -220,9 +220,9 @@ internal static bool IsValidPropertyName(string propertyName) string defaultNamespace, bool isAdvanced, AppType appType, - bool generateResourceInterface) + bool generateResourceInterfaces) { - var stronglyTypedClassInfo = Parse(content, defaultNamespace, isAdvanced, appType, generateResourceInterface); + 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 9aa2fcc..4f8478b 100644 --- a/src/ReswPlus.SourceGenerator/CodeGenerators/CsharpCodeGenerator.cs +++ b/src/ReswPlus.SourceGenerator/CodeGenerators/CsharpCodeGenerator.cs @@ -170,19 +170,23 @@ public IEnumerable GetGeneratedFiles(string? baseFilename, Strong strongClassDecl = strongClassDecl.AddMembers(formatMembers.ToArray()); InterfaceDeclarationSyntax? resourceInterfaceDecl = null; - if (info.GenerateResourceInterface) + 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); - strongClassDecl = strongClassDecl - .AddBaseListTypes(SimpleBaseType(IdentifierName(interfaceName))) - .AddMembers(CreateExplicitInterfaceImplementations(interfaceName, info.ClassName, publicMembers).ToArray()); + resourceProviderDecl = CreateResourceProvider( + providerName, + interfaceName, + info.ClassName, + info, + publicMembers); } // Create the markup extension class that allows resource keys to be used in XAML. @@ -193,17 +197,17 @@ member is MethodDeclarationSyntax or PropertyDeclarationSyntax { var nsName = string.Join(".", info.Namespaces); var namespaceDecl = NamespaceDeclaration(ParseName(nsName)); - namespaceDecl = resourceInterfaceDecl is null + namespaceDecl = resourceInterfaceDecl is null || resourceProviderDecl is null ? namespaceDecl.AddMembers(strongClassDecl, markupExtensionDecl) - : namespaceDecl.AddMembers(resourceInterfaceDecl, strongClassDecl, markupExtensionDecl); + : namespaceDecl.AddMembers(resourceInterfaceDecl, resourceProviderDecl, strongClassDecl, markupExtensionDecl); compilationUnit = compilationUnit.AddMembers(namespaceDecl); } else { // Otherwise, add the classes at the root level. - compilationUnit = resourceInterfaceDecl is null + compilationUnit = resourceInterfaceDecl is null || resourceProviderDecl is null ? compilationUnit.AddMembers(strongClassDecl, markupExtensionDecl) - : compilationUnit.AddMembers(resourceInterfaceDecl, strongClassDecl, markupExtensionDecl); + : compilationUnit.AddMembers(resourceInterfaceDecl, resourceProviderDecl, strongClassDecl, markupExtensionDecl); } // Normalize the whitespace (formatting) and return the generated source code. @@ -442,9 +446,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 +497,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..85f4ad9 100644 --- a/src/ReswPlus.SourceGenerator/CodeGenerators/GeneratedIdentifier.cs +++ b/src/ReswPlus.SourceGenerator/CodeGenerators/GeneratedIdentifier.cs @@ -57,10 +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, bool hasResourceInterface = false) + public static bool ConflictsWithGeneratedMember(string name, string className, bool generateResourceInterfaces) { return string.Equals(name, className, StringComparison.Ordinal) - || (hasResourceInterface && string.Equals(name, "I" + className, 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 57ee499..4f27908 100644 --- a/src/ReswPlus.SourceGenerator/Pipeline/ReswBuildOptions.cs +++ b/src/ReswPlus.SourceGenerator/Pipeline/ReswBuildOptions.cs @@ -68,7 +68,7 @@ private ReswBuildOptions( public bool UseUwp { get; } /// - /// Gets whether the project opted into generated injectable resource interfaces. + /// Gets whether injectable resource interfaces and providers should be generated. /// public bool GenerateResourceInterfaces { get; } @@ -88,7 +88,7 @@ public static ReswBuildOptions Read(AnalyzerConfigOptions globalOptions) 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.ReswPlusGenerateResourceInterfaces"), out var parsedInterfaces) || parsedInterfaces); string? Get(string key) => globalOptions.TryGetValue(key, out var value) ? value : null; } @@ -137,7 +137,6 @@ public override int GetHashCode() hash = (hash * 31) + UseApplicationLanguages.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 1d39226..e4ae65a 100644 --- a/src/ReswPlus.SourceGenerator/Pipeline/ReswProject.cs +++ b/src/ReswPlus.SourceGenerator/Pipeline/ReswProject.cs @@ -139,7 +139,7 @@ public static ReswProject Create(CompilationInfo compilationInfo, ReswBuildOptio 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.GenerateResourceInterfaces, options.DefaultLanguage, new EquatableArray(problems)); } /// diff --git a/src/ReswPlus.SourceGenerator/ReswGenerator.cs b/src/ReswPlus.SourceGenerator/ReswGenerator.cs index fd94c84..8597bfc 100644 --- a/src/ReswPlus.SourceGenerator/ReswGenerator.cs +++ b/src/ReswPlus.SourceGenerator/ReswGenerator.cs @@ -184,7 +184,7 @@ private static void ReportSetupProblems(SourceProductionContext spc, ReswProject defaultNamespace: project.GetNamespace(file.Path), isAdvanced: true, appType: project.AppType, - generateResourceInterface: project.GenerateResourceInterfaces); + generateResourceInterfaces: project.GenerateResourceInterfaces); 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..8fbd2fe 100644 --- a/tests/ReswPlusUnitTests/GeneratorEndToEnd.cs +++ b/tests/ReswPlusUnitTests/GeneratorEndToEnd.cs @@ -48,36 +48,51 @@ 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() + public void ResourceInterfacesAndProvidersCanBeDisabled() { - var options = new System.Collections.Generic.Dictionary(AnalyzerConfigOptions.KeyComparer) - { - ["build_property.ReswPlusGenerateResourceInterfaces"] = "true", - }; + 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 buildOptions = ReswBuildOptions.Read(new TestAnalyzerConfigOptionsProvider(options).GlobalOptions); + var generated = run.Source("Resources.resw"); - Assert.True(buildOptions.GenerateResourceInterfaces); + 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() { 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 +102,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 +132,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 +142,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..f595b1f 100644 --- a/tests/ReswPlusUnitTests/GeneratorHarness.cs +++ b/tests/ReswPlusUnitTests/GeneratorHarness.cs @@ -64,7 +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. + /// 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. diff --git a/tests/ReswPlusUnitTests/ReswTestHelpers.cs b/tests/ReswPlusUnitTests/ReswTestHelpers.cs index 389dab8..5f81f12 100644 --- a/tests/ReswPlusUnitTests/ReswTestHelpers.cs +++ b/tests/ReswPlusUnitTests/ReswTestHelpers.cs @@ -55,9 +55,9 @@ public static string CreateResw(params (string Key, string Value, string? Commen public static string GenerateCode( string reswContent, AppType appType = AppType.WindowsAppSDK, - bool generateResourceInterface = false) + bool generateResourceInterfaces = true) { - return GenerateFile(reswContent, appType, generateResourceInterface).Content; + return GenerateFile(reswContent, appType, generateResourceInterfaces).Content; } /// @@ -69,7 +69,7 @@ public static string GenerateCode( public static GeneratedFile GenerateFile( string reswContent, AppType appType = AppType.WindowsAppSDK, - bool generateResourceInterface = false) + 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); @@ -82,7 +82,7 @@ public static GeneratedFile GenerateFile( defaultNamespace: "TestProject.Strings", isAdvanced: true, appType: appType, - generateResourceInterface: generateResourceInterface); + generateResourceInterfaces: generateResourceInterfaces); Assert.NotNull(result); @@ -96,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))) @@ -103,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; }