Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 1 addition & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<PropertyGroup>
<ReswPlusGenerateResourceInterfaces>true</ReswPlusGenerateResourceInterfaces>
</PropertyGroup>
```

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

Expand Down
78 changes: 78 additions & 0 deletions docs/Injectable-resource-providers.md
Original file line number Diff line number Diff line change
@@ -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<IResources, ResourcesProvider>();
```

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
<PropertyGroup>
<ReswPlusGenerateResourceInterfaces>false</ReswPlusGenerateResourceInterfaces>
</PropertyGroup>
```

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.
2 changes: 1 addition & 1 deletion nuget/ReswPlus.targets
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<AdditionalFileItemNames>$(AdditionalFileItemNames);PRIResource</AdditionalFileItemNames>
<!-- Read the plural language from the app runtime language list instead of the .NET UI culture -->
<ReswPlusUseApplicationLanguages Condition="'$(ReswPlusUseApplicationLanguages)' == ''">false</ReswPlusUseApplicationLanguages>
<ReswPlusGenerateResourceInterfaces Condition="'$(ReswPlusGenerateResourceInterfaces)' == ''">false</ReswPlusGenerateResourceInterfaces>
<ReswPlusGenerateResourceInterfaces Condition="'$(ReswPlusGenerateResourceInterfaces)' == ''">true</ReswPlusGenerateResourceInterfaces>
<!-- Ask the compiler to include source-generator timings in detailed build output. -->
<ReswPlusReportGeneratorPerformance Condition="'$(ReswPlusReportGeneratorPerformance)' == ''">false</ReswPlusReportGeneratorPerformance>
</PropertyGroup>
Expand Down
14 changes: 12 additions & 2 deletions src/ReswPlus.SourceGenerator/Analysis/ReswResourceAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
21 changes: 15 additions & 6 deletions src/ReswPlus.SourceGenerator/Analysis/ReswResourceRules.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,13 @@ internal static class ReswResourceRules
/// </summary>
/// <param name="reswFiles">The <c>.resw</c> files of the project, with their content.</param>
/// <param name="defaultLanguage">The default language of the project, if it declares one.</param>
/// <param name="generateResourceInterfaces">Whether injectable resource interfaces and providers are generated.</param>
/// <param name="reportDiagnostic">The callback invoked for every problem found.</param>
/// <param name="cancellationToken">The token used to cancel the operation.</param>
public static void Analyze(
IReadOnlyList<(string Path, SourceText Text)> reswFiles,
string? defaultLanguage,
bool generateResourceInterfaces,
Action<Diagnostic> reportDiagnostic,
CancellationToken cancellationToken)
{
Expand All @@ -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)
{
Expand All @@ -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<Diagnostic> reportDiagnostic)
private static void AnalyzeDocument(
ReswResourceModel model,
ReswResourceModel defaultModel,
bool generateResourceInterfaces,
Action<Diagnostic> reportDiagnostic)
{
ReportDuplicateMembers(model, reportDiagnostic);
ReportReservedNames(model, reportDiagnostic);
ReportReservedNames(model, generateResourceInterfaces, reportDiagnostic);
ReportDuplicateFormatParameters(model, reportDiagnostic);
ReportMissingPluralForms(model, defaultModel, reportDiagnostic);
ReportFormattingProblems(model, defaultModel, reportDiagnostic);
Expand All @@ -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.
/// </remarks>
private static void ReportReservedNames(ReswResourceModel model, Action<Diagnostic> reportDiagnostic)
private static void ReportReservedNames(
ReswResourceModel model,
bool generateResourceInterfaces,
Action<Diagnostic> 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
}

Expand All @@ -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<Localization> Items { get; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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)
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
46 changes: 34 additions & 12 deletions src/ReswPlus.SourceGenerator/CodeGenerators/CsharpCodeGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,19 +170,23 @@ public IEnumerable<GeneratedFile> 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.
Expand All @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -495,6 +497,26 @@ private static InterfaceDeclarationSyntax CreateResourceInterface(
.AddMembers(publicMembers.Select(CreateInterfaceMember).ToArray());
}

/// <summary>
/// Creates an injectable adapter that delegates to the existing static resource API.
/// </summary>
private static ClassDeclarationSyntax CreateResourceProvider(
string providerName,
string interfaceName,
string className,
StronglyTypedClass info,
IEnumerable<MemberDeclarationSyntax> publicMembers)
{
return ClassDeclaration(providerName)
.WithAttributeLists(CreateGeneratedTypeAttributes())
.WithModifiers(TokenList(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.SealedKeyword)))
.WithBaseList(BaseList(SingletonSeparatedList<BaseTypeSyntax>(
SimpleBaseType(IdentifierName(interfaceName)))))
.WithLeadingTrivia(CreateDocumentation(
$"Provides injectable access to the strings of the '{info.ResoureFile}' resource file."))
.AddMembers(CreateExplicitInterfaceImplementations(interfaceName, className, publicMembers).ToArray());
}

/// <summary>
/// Removes the implementation and static modifiers from a generated lookup member.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,12 @@ public static string Escape(string name)
/// beside the generated <c>GetString</c> without conflicting with it. Resources whose names only differ by
/// case conflict for a different reason, and are reported by RESWP0009.
/// </remarks>
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;
}

Expand Down
Loading
Loading