diff --git a/BlazorAppTest/BlazorAppTest.csproj b/BlazorAppTest/BlazorAppTest.csproj index 05f954d1e..be476b1f0 100644 --- a/BlazorAppTest/BlazorAppTest.csproj +++ b/BlazorAppTest/BlazorAppTest.csproj @@ -28,4 +28,7 @@ + + + diff --git a/BlazorAppTest/Resources/ResourcesServiceCollectionInstaller.cs b/BlazorAppTest/Resources/ResourcesServiceCollectionInstaller.cs deleted file mode 100644 index c4f6dd4a8..000000000 --- a/BlazorAppTest/Resources/ResourcesServiceCollectionInstaller.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace BlazorAppTest.Resources; - -public static partial class ResourcesServiceCollectionInstaller -{ - -} diff --git a/Directory.Packages.props b/Directory.Packages.props index a6ae3f882..008b4bc54 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,7 +22,9 @@ - + + + diff --git a/Havit.Blazor.sln b/Havit.Blazor.sln index d9eb6000c..f5c43e800 100644 --- a/Havit.Blazor.sln +++ b/Havit.Blazor.sln @@ -78,6 +78,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Havit.Blazor.TestApp", "Hav EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Havit.Blazor.TestApp.Client", "Havit.Blazor.TestApp\Havit.Blazor.TestApp.Client\Havit.Blazor.TestApp.Client.csproj", "{38D87399-13C1-4C86-9343-712EAE6095F0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Havit.SourceGenerators.StrongApiStringLocalizers.Tests", "Havit.SourceGenerators.StrongApiStringLocalizers.Tests\Havit.SourceGenerators.StrongApiStringLocalizers.Tests.csproj", "{9236499E-62FF-4C2F-92A0-408143D67E72}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -180,6 +182,10 @@ Global {38D87399-13C1-4C86-9343-712EAE6095F0}.Debug|Any CPU.Build.0 = Debug|Any CPU {38D87399-13C1-4C86-9343-712EAE6095F0}.Release|Any CPU.ActiveCfg = Release|Any CPU {38D87399-13C1-4C86-9343-712EAE6095F0}.Release|Any CPU.Build.0 = Release|Any CPU + {9236499E-62FF-4C2F-92A0-408143D67E72}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9236499E-62FF-4C2F-92A0-408143D67E72}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9236499E-62FF-4C2F-92A0-408143D67E72}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9236499E-62FF-4C2F-92A0-408143D67E72}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -196,6 +202,7 @@ Global {E64B4752-B697-4B5A-91F0-8A8581B1ABE5} = {79C29E4F-EF98-4F43-9782-B0D58A533C4D} {F6A546C8-60C3-47AC-A58B-66E3513DCC7A} = {79C29E4F-EF98-4F43-9782-B0D58A533C4D} {45BC138B-4F00-43DD-BF5D-D2FC36972857} = {D7B56FC7-6322-4B66-B34F-A972805BF740} + {9236499E-62FF-4C2F-92A0-408143D67E72} = {201D627C-BC35-4971-95D0-5BA656E771F6} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {BA2F0FE2-9DA0-4C29-8772-0802E2E67119} diff --git a/Havit.SourceGenerators.StrongApiStringLocalizers.Tests/Global.resx b/Havit.SourceGenerators.StrongApiStringLocalizers.Tests/Global.resx new file mode 100644 index 000000000..2447f2125 --- /dev/null +++ b/Havit.SourceGenerators.StrongApiStringLocalizers.Tests/Global.resx @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Čeština je <b>skvělá</b>! + + + Hello world!!! + Hello world resource comment. + + \ No newline at end of file diff --git a/Havit.SourceGenerators.StrongApiStringLocalizers.Tests/Havit.SourceGenerators.StrongApiStringLocalizers.Tests.csproj b/Havit.SourceGenerators.StrongApiStringLocalizers.Tests/Havit.SourceGenerators.StrongApiStringLocalizers.Tests.csproj new file mode 100644 index 000000000..0781a123d --- /dev/null +++ b/Havit.SourceGenerators.StrongApiStringLocalizers.Tests/Havit.SourceGenerators.StrongApiStringLocalizers.Tests.csproj @@ -0,0 +1,35 @@ + + + + net9.0 + false + enable + true + Exe + + + + + + + + + PreserveNewest + + + + + + + + + + + + + + + + + + diff --git a/Havit.SourceGenerators.StrongApiStringLocalizers.Tests/StrongApiStringLocalizersGeneratorTests.cs b/Havit.SourceGenerators.StrongApiStringLocalizers.Tests/StrongApiStringLocalizersGeneratorTests.cs new file mode 100644 index 000000000..3580856cd --- /dev/null +++ b/Havit.SourceGenerators.StrongApiStringLocalizers.Tests/StrongApiStringLocalizersGeneratorTests.cs @@ -0,0 +1,124 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Text; +using Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.ObjectModel; + +namespace Havit.SourceGenerators.StrongApiStringLocalizers.Tests; + +[TestClass] +public class StrongApiStringLocalizersGeneratorTests +{ + [TestMethod] + public async Task StrongApiStringLocalizersGenerator_Test() + { + // Arrange + + using var globalResxStream = File.OpenRead("Global.resx"); + + string projectDir = Environment.CurrentDirectory; + + var test = new Microsoft.CodeAnalysis.CSharp.Testing.CSharpSourceGeneratorTest + { + TestState = + { + AnalyzerConfigFiles = + { + ($"/.editorconfig", $@""" + is_global=true + build_property.RootNamespace = MyApp.Resources + build_property.ProjectDir = {projectDir} + """) + } + }, + ReferenceAssemblies = ReferenceAssemblies.Net + .Net90 + .AddPackages(ImmutableArray.Create( + new PackageIdentity("Microsoft.Extensions.Localization", "9.0.1"))) // we are using IStringLocalizer from this package in the generated code + }; + + // resource file + test.TestState.AdditionalFiles.Add((Path.Combine(projectDir, "MyResources", "Global.resx"), SourceText.From(globalResxStream))); + + // EXPECTED OUTPUT + + test.TestState.GeneratedSources.Add((typeof(StrongApiStringLocalizersGenerator), "MyApp.Resources.MyResources.IGlobalLocalizer.g.cs", @"// + +namespace MyApp.Resources.MyResources; + +using System.CodeDom.Compiler; +using Microsoft.Extensions.Localization; + +[GeneratedCode(""Havit.SourceGenerators.StrongApiStringLocalizers.StrongApiStringLocalizersGenerator"", ""2.0.0.0"")] +public interface IGlobalLocalizer : IStringLocalizer +{ + /// + /// Čeština je <b>skvělá</b>! + /// + LocalizedString CzechAndHtml { get; } + + /// + /// Hello world resource comment. + /// + LocalizedString HelloWorld { get; } + +} +")); + + // TestProject - defined by the TestState implementation + test.TestState.GeneratedSources.Add((typeof(StrongApiStringLocalizersGenerator), $"MyApp.Resources.MyResources.GlobalLocalizer.g.cs", @"// + +namespace MyApp.Resources.MyResources; + +using System.CodeDom.Compiler; +using System.Collections.Generic; +using Microsoft.Extensions.Localization; + +[GeneratedCode(""Havit.SourceGenerators.StrongApiStringLocalizers.StrongApiStringLocalizersGenerator"", ""2.0.0.0"")] +public class GlobalLocalizer : IGlobalLocalizer +{ + private readonly IStringLocalizer _localizer; + + public GlobalLocalizer(IStringLocalizerFactory stringLocalizerFactory) + { + _localizer = stringLocalizerFactory.Create(""MyResources.Global"", ""TestProject""); + } + + /// + /// Čeština je <b>skvělá</b>! + /// + public LocalizedString CzechAndHtml => _localizer[""CzechAndHtml""]; + + /// + /// Hello world resource comment. + /// + public LocalizedString HelloWorld => _localizer[""HelloWorld""]; + + LocalizedString IStringLocalizer.this[string name] => _localizer[name]; + LocalizedString IStringLocalizer.this[string name, params object[] arguments] => _localizer[name, arguments]; + IEnumerable IStringLocalizer.GetAllStrings(bool includeParentCultures) => _localizer.GetAllStrings(includeParentCultures); +} +")); + + test.TestState.GeneratedSources.Add((typeof(StrongApiStringLocalizersGenerator), "MyApp.Resources.ServiceCollectionExtensions.g.cs", @"// + +namespace MyApp.Resources; + +using System.CodeDom.Compiler; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +[GeneratedCode(""Havit.SourceGenerators.StrongApiStringLocalizers.StrongApiStringLocalizersGenerator"", ""2.0.0.0"")] +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddGeneratedResourceWrappers(this IServiceCollection services) + { + services.AddTransient(); + return services; + } +} +")); + + // Act + Assert + await test.RunAsync(); + } +} diff --git a/Havit.SourceGenerators.StrongApiStringLocalizers/AnalyzerReleases.Shipped.md b/Havit.SourceGenerators.StrongApiStringLocalizers/AnalyzerReleases.Shipped.md new file mode 100644 index 000000000..60b59dd99 --- /dev/null +++ b/Havit.SourceGenerators.StrongApiStringLocalizers/AnalyzerReleases.Shipped.md @@ -0,0 +1,3 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + diff --git a/Havit.SourceGenerators.StrongApiStringLocalizers/AnalyzerReleases.Unshipped.md b/Havit.SourceGenerators.StrongApiStringLocalizers/AnalyzerReleases.Unshipped.md new file mode 100644 index 000000000..c689753fd --- /dev/null +++ b/Havit.SourceGenerators.StrongApiStringLocalizers/AnalyzerReleases.Unshipped.md @@ -0,0 +1,8 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|-------------------- +HLG1002 | Usage | Warning | Cannot parse RESX file diff --git a/Havit.SourceGenerators.StrongApiStringLocalizers/BuilderExtensions.cs b/Havit.SourceGenerators.StrongApiStringLocalizers/BuilderExtensions.cs deleted file mode 100644 index d6f7f8657..000000000 --- a/Havit.SourceGenerators.StrongApiStringLocalizers/BuilderExtensions.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Reflection; -using System.Text; - -namespace Havit.SourceGenerators.StrongApiStringLocalizers; - -internal static class BuilderExtensions -{ - public static StringBuilder AppendGeneratedCodeAttribute(this StringBuilder builder) - { - builder.Append($"[GeneratedCode(\"{nameof(Havit)}.{nameof(SourceGenerators)}.{nameof(StrongApiStringLocalizers)}.{nameof(LocalizerGenerator)}\", \"{Assembly.GetExecutingAssembly().GetName().Version}\")]"); - - return builder; - } -} diff --git a/Havit.SourceGenerators.StrongApiStringLocalizers/Havit.SourceGenerators.StrongApiStringLocalizers.csproj b/Havit.SourceGenerators.StrongApiStringLocalizers/Havit.SourceGenerators.StrongApiStringLocalizers.csproj index bf749b858..8fe1b5c3a 100644 --- a/Havit.SourceGenerators.StrongApiStringLocalizers/Havit.SourceGenerators.StrongApiStringLocalizers.csproj +++ b/Havit.SourceGenerators.StrongApiStringLocalizers/Havit.SourceGenerators.StrongApiStringLocalizers.csproj @@ -22,6 +22,8 @@ + + diff --git a/Havit.SourceGenerators.StrongApiStringLocalizers/Helpers/HttpUtilityExt.cs b/Havit.SourceGenerators.StrongApiStringLocalizers/Helpers/HttpUtilityExt.cs new file mode 100644 index 000000000..2e301cbb4 --- /dev/null +++ b/Havit.SourceGenerators.StrongApiStringLocalizers/Helpers/HttpUtilityExt.cs @@ -0,0 +1,160 @@ +using System.Text; + +// Copy of HFW HttpUtilityExt. + +namespace Havit.SourceGenerators.StrongApiStringLocalizers.Helpers; + +internal static partial class HttpUtilityExt +{ + public static string HtmlEncode(string unicodeText, HtmlEncodeOptions options) + { + int unicodeValue; + StringBuilder result = new StringBuilder(); + + bool opIgnoreNonASCIICharacters = (options & HtmlEncodeOptions.IgnoreNonASCIICharacters) == HtmlEncodeOptions.IgnoreNonASCIICharacters; + bool opExtendedHtmlEntities = (options & HtmlEncodeOptions.ExtendedHtmlEntities) == HtmlEncodeOptions.ExtendedHtmlEntities; + bool opXmlApostropheEntity = (options & HtmlEncodeOptions.XmlApostropheEntity) == HtmlEncodeOptions.XmlApostropheEntity; + + int length = unicodeText.Length; + for (int i = 0; i < length; i++) + { + unicodeValue = unicodeText[i]; + switch (unicodeValue) + { + case '&': + result.Append("&"); + break; + case '<': + result.Append("<"); + break; + case '>': + result.Append(">"); + break; + case '"': + result.Append("""); + break; + case '\'': + if (opXmlApostropheEntity) + { + result.Append("'"); + break; + } + else + { + goto default; + } + case 0xA0: // no-break space + if (opExtendedHtmlEntities) + { + result.Append(" "); + break; + } + else + { + goto default; + } + case '€': + if (opExtendedHtmlEntities) + { + result.Append("€"); + break; + } + else + { + goto default; + } + case '©': + if (opExtendedHtmlEntities) + { + result.Append("©"); + break; + } + else + { + goto default; + } + case '®': + if (opExtendedHtmlEntities) + { + result.Append("®"); + break; + } + else + { + goto default; + } + case '™': // trade-mark + if (opExtendedHtmlEntities) + { + result.Append("™"); + break; + } + else + { + goto default; + } + default: + if (((unicodeText[i] >= ' ') && (unicodeText[i] <= 0x007E)) + || opIgnoreNonASCIICharacters) + { + result.Append(unicodeText[i]); + } + else + { + result.Append("&#"); + result.Append(unicodeValue.ToString(System.Globalization.NumberFormatInfo.InvariantInfo)); + result.Append(";"); + } + break; + } + } + return result.ToString(); + } + public static string HtmlEncode(string unicodeText) + { + return HtmlEncode(unicodeText, HtmlEncodeOptions.None); + } +} + +[Flags] +public enum HtmlEncodeOptions +{ + /// + /// Označuje, že nemají být nastaveny žádné options, použije se default postup. + /// Default postup převede pouze čtyři základní entity + /// + /// < --- &lt; + /// > --- &gt; + /// & --- &amp; + /// " --- &quot; + /// + /// + None = 0, + + /// + /// Při konverzi budou ignorovány znaky mimo ASCII hodnoty, nebudou tedy tvořeny číselné entity typu &#123;. + /// + IgnoreNonASCIICharacters = 1, + + /// + /// Při konverzi bude použita rozšířená sada HTML-entit, které by se jinak převedly na číselné entity. + /// Např. bude použito &copy;, &nbsp;, &sect;, atp. + /// + ExtendedHtmlEntities = 2, + + /// + /// Při konverzi převede apostrofy na &apos; entitu. + /// POZOR! &apos; není standardní HTML entita a třeba IE ji v HTML režimu nepozná!!! + /// + /// + /// V kombinaci se základním dostaneme sadu pěti built-in XML entit: + /// + /// < --- &lt; + /// > --- &gt; + /// & --- &amp; + /// " --- &quot; + /// ' --- &apos; + /// + /// + XmlApostropheEntity = 4 +} diff --git a/Havit.SourceGenerators.StrongApiStringLocalizers/Helpers/SourceTextReader.cs b/Havit.SourceGenerators.StrongApiStringLocalizers/Helpers/SourceTextReader.cs new file mode 100644 index 000000000..f22b91536 --- /dev/null +++ b/Havit.SourceGenerators.StrongApiStringLocalizers/Helpers/SourceTextReader.cs @@ -0,0 +1,24 @@ +using Microsoft.CodeAnalysis.Text; + +namespace Havit.SourceGenerators.StrongApiStringLocalizers.Helpers; + +// source: https://github.com/dotnet/roslyn-analyzers/blob/8fe7aeb135c64e095f43292c427453858d937184/src/Microsoft.CodeAnalysis.ResxSourceGenerator/Microsoft.CodeAnalysis.ResxSourceGenerator/AbstractResxGenerator.cs#L888 +internal sealed class SourceTextReader : TextReader +{ + private readonly SourceText _text; + private int _position; + + public SourceTextReader(SourceText text) + { + _text = text; + } + + public override int Read(char[] buffer, int index, int count) + { + var remaining = _text.Length - _position; + var charactersToRead = Math.Min(remaining, count); + _text.CopyTo(_position, buffer, index, charactersToRead); + _position += charactersToRead; + return charactersToRead; + } +} diff --git a/Havit.SourceGenerators.StrongApiStringLocalizers/LocalizerBuilder.cs b/Havit.SourceGenerators.StrongApiStringLocalizers/LocalizerBuilder.cs deleted file mode 100644 index 6ea908a02..000000000 --- a/Havit.SourceGenerators.StrongApiStringLocalizers/LocalizerBuilder.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System.Text; -using Havit.SourceGenerators.StrongApiStringLocalizers; - -namespace LocalizerGenerator; - -internal class LocalizerBuilder -{ - public string Name { get; set; } - public string LocalizerClassName => $"{Name}Localizer"; - public string LocalizerInterfaceName => $"I{Name}Localizer"; - public string Namespace { get; set; } - public List Properties { get; } = new List(); - public string IStringLocalizerName => $"IStringLocalizer<{Name}>"; - public string BaseClassName => $"DelegatingStringLocalizer<{Name}>"; - - public string BuildSource() - { - var builder = new StringBuilder(); - BuildNamespace(builder); - return builder.ToString(); - } - - private void BuildNamespace(StringBuilder builder) - { - builder.Append("namespace ").Append(Namespace).AppendLine(); - builder.AppendLine("{"); - BuildUsings(builder); - BuildInterface(builder); - BuildLocalizerClass(builder); - builder.AppendLine("}"); - } - - private void BuildUsings(StringBuilder builder) - { - builder.AppendLine("using System.CodeDom.Compiler;"); - builder.AppendLine("using Microsoft.Extensions.Localization;"); - builder.AppendLine("using Havit.Extensions.Localization;"); - } - - private void BuildInterface(StringBuilder builder) - { - builder.AppendGeneratedCodeAttribute().AppendLine(); - builder.Append("public interface ").Append(LocalizerInterfaceName).Append(" : ").Append(IStringLocalizerName).AppendLine(); - builder.AppendLine("{"); - foreach (var property in Properties) - { - builder.Append("LocalizedString ").Append(property).Append(" { get; }").AppendLine(); - } - builder.AppendLine("}"); - } - - private void BuildLocalizerClass(StringBuilder builder) - { - builder.AppendGeneratedCodeAttribute().AppendLine(); - builder.Append("public class ").Append(LocalizerClassName).Append(" : ").Append(BaseClassName).Append(", ").Append(LocalizerInterfaceName).AppendLine(); - builder.AppendLine("{"); - BuildCtor(builder); - foreach (var property in Properties) - { - builder.Append("public LocalizedString ").Append(property).Append(" => this[\"").Append(property).Append("\"];").AppendLine(); - } - builder.AppendLine("}"); - } - - private void BuildCtor(StringBuilder builder) - { - builder.Append("public ").Append(LocalizerClassName).Append("(").Append(IStringLocalizerName).Append(" innerLocalizer) : base(innerLocalizer)").AppendLine(); - builder.AppendLine("{"); - builder.AppendLine("}"); - } -} diff --git a/Havit.SourceGenerators.StrongApiStringLocalizers/LocalizerGenerator.cs b/Havit.SourceGenerators.StrongApiStringLocalizers/LocalizerGenerator.cs deleted file mode 100644 index c90162401..000000000 --- a/Havit.SourceGenerators.StrongApiStringLocalizers/LocalizerGenerator.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System.Text; -using System.Xml; -using System.Xml.Linq; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Text; - -namespace LocalizerGenerator; - -[Generator] -public class LocalizerGenerator : ISourceGenerator -{ - public const string ServiceCollectionInstallerMarker = "ResourcesServiceCollectionInstaller"; - -#pragma warning disable RS2008 - private static readonly DiagnosticDescriptor s_xmlParseWarning = new DiagnosticDescriptor(id: "LG0001", title: "Cannot parse XML file", messageFormat: "Cannot parse XML file '{0}'", category: nameof(LocalizerGenerator), DiagnosticSeverity.Warning, isEnabledByDefault: true); -#pragma warning restore RS2008 - - - public void Initialize(GeneratorInitializationContext context) - { } - - public void Execute(GeneratorExecutionContext context) - { - foreach (var syntaxTree in context.Compilation.SyntaxTrees) - { - var file = Path.GetFileNameWithoutExtension(syntaxTree.FilePath); - if (!file.Equals(ServiceCollectionInstallerMarker, StringComparison.Ordinal)) - { - continue; - } - - var syntaxRoot = syntaxTree.GetRoot(); - - var namespaceBase = FindNamespaceName(syntaxRoot); - if (namespaceBase == null) - { - continue; - } - - var registrationsBuilder = new RegistrationsBuilder(); - registrationsBuilder.Namespace = namespaceBase; - - var rootDir = Path.GetDirectoryName(syntaxTree.FilePath); -#pragma warning disable RS1035 // Do not use APIs banned for analyzers (Directory) - foreach (var resx in Directory.EnumerateFiles(rootDir, "*.resx", SearchOption.AllDirectories)) - { - var localizerBuilder = new LocalizerBuilder(); - localizerBuilder.Name = Path.GetFileNameWithoutExtension(resx); - if (localizerBuilder.Name.Contains(".")) - { - // language-specific file - continue; - } - var namespaceSuffix = Path.GetDirectoryName(resx).Remove(0, rootDir.Length).Replace(Path.DirectorySeparatorChar, '.'); - localizerBuilder.Namespace = $"{namespaceBase}{namespaceSuffix}"; - var properties = ParseResx(resx); - if (properties == null) - { - context.ReportDiagnostic(Diagnostic.Create(s_xmlParseWarning, Location.None, resx)); - continue; - } - localizerBuilder.Properties.AddRange(properties); - context.AddSource($"{nameof(LocalizerGenerator)}.{localizerBuilder.Namespace}.{localizerBuilder.LocalizerClassName}.generated.cs", SourceText.From(localizerBuilder.BuildSource(), Encoding.UTF8)); - - var markerClassBuilder = new MarkerClassBuilder(); - markerClassBuilder.Namespace = localizerBuilder.Namespace; - markerClassBuilder.Name = localizerBuilder.Name; - context.AddSource($"{nameof(LocalizerGenerator)}.{markerClassBuilder.Namespace}.{markerClassBuilder.Name}.generated.cs", SourceText.From(markerClassBuilder.BuildSource(), Encoding.UTF8)); - - registrationsBuilder.Localizers.Add(localizerBuilder); - } -#pragma warning restore RS1035 // Do not use APIs banned for analyzers - - context.AddSource($"{nameof(LocalizerGenerator)}.{registrationsBuilder.Namespace}.{registrationsBuilder.MethodName}.generated.cs", SourceText.From(registrationsBuilder.BuildSource(), Encoding.UTF8)); - } - } - - private static List ParseResx(string path) - { - try - { - var result = new List(); - var xdoc = XDocument.Load(path); - foreach (var item in xdoc.Root.Elements("data")) - { - var nameAttribute = item.Attribute("name"); - if (nameAttribute == null) - { - continue; - } - - result.Add(nameAttribute.Value); - } - return result; - } - catch (XmlException) - { - return null; - } - } - - private static string FindNamespaceName(SyntaxNode syntaxRoot) - { - var namespaceNode = (BaseNamespaceDeclarationSyntax)syntaxRoot.ChildNodes().Where(x => x.IsKind(SyntaxKind.NamespaceDeclaration)).FirstOrDefault(); - if (namespaceNode != null) - { - return namespaceNode.Name.ToString(); - } - - var fileScopedNamespaceNode = (BaseNamespaceDeclarationSyntax)syntaxRoot.ChildNodes().Where(x => x.IsKind(SyntaxKind.FileScopedNamespaceDeclaration)).FirstOrDefault(); - if (fileScopedNamespaceNode != null) - { - return fileScopedNamespaceNode.Name.ToString(); - } - - return null; - } -} diff --git a/Havit.SourceGenerators.StrongApiStringLocalizers/MarkerClassBuilder.cs b/Havit.SourceGenerators.StrongApiStringLocalizers/MarkerClassBuilder.cs deleted file mode 100644 index e589fc6ac..000000000 --- a/Havit.SourceGenerators.StrongApiStringLocalizers/MarkerClassBuilder.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Text; -using Havit.SourceGenerators.StrongApiStringLocalizers; - -namespace LocalizerGenerator; - -internal class MarkerClassBuilder -{ - public string Name { get; set; } - public string Namespace { get; set; } - - public string BuildSource() - { - var builder = new StringBuilder(); - BuildUsings(builder); - BuildNamespace(builder); - return builder.ToString(); - } - private void BuildUsings(StringBuilder builder) - { - builder.AppendLine("using System.CodeDom.Compiler;"); - builder.AppendLine("using System.ComponentModel;"); - } - - private void BuildNamespace(StringBuilder builder) - { - builder.Append("namespace ").Append(Namespace).AppendLine(); - builder.AppendLine("{"); - BuildMarkerClass(builder); - builder.AppendLine("}"); - } - - private void BuildMarkerClass(StringBuilder builder) - { - builder.AppendGeneratedCodeAttribute().AppendLine(); - builder.Append("[Browsable(false)]").AppendLine(); - builder.Append("[EditorBrowsable(EditorBrowsableState.Never)]").AppendLine(); - builder.Append("public class ").Append(Name).AppendLine(); - builder.AppendLine("{"); - builder.AppendLine("}"); - } -} diff --git a/Havit.SourceGenerators.StrongApiStringLocalizers/Model/BuildConfiguration.cs b/Havit.SourceGenerators.StrongApiStringLocalizers/Model/BuildConfiguration.cs new file mode 100644 index 000000000..c6c97d85e --- /dev/null +++ b/Havit.SourceGenerators.StrongApiStringLocalizers/Model/BuildConfiguration.cs @@ -0,0 +1,8 @@ +namespace Havit.SourceGenerators.StrongApiStringLocalizers.Model; + +internal class BuildConfiguration +{ + public string RootNamespace { get; set; } + public string ProjectDirectory { get; internal set; } + public string AssemblyName { get; internal set; } +} diff --git a/Havit.SourceGenerators.StrongApiStringLocalizers/Model/ResourceData.cs b/Havit.SourceGenerators.StrongApiStringLocalizers/Model/ResourceData.cs new file mode 100644 index 000000000..389a0be25 --- /dev/null +++ b/Havit.SourceGenerators.StrongApiStringLocalizers/Model/ResourceData.cs @@ -0,0 +1,15 @@ +namespace Havit.SourceGenerators.StrongApiStringLocalizers.Model; + +internal class ResourceData +{ + public string AssemblyName { get; set; } + public string ResxFilePath { get; set; } + public string ResourceNamespace { get; set; } + public string ResourceName { get; set; } + + public string TargetLocalizerNamespace { get; set; } + public string LocalizerImplementationClassName => $"{ResourceName}Localizer"; + public string LocalizerInterfaceName => $"I{ResourceName}Localizer"; + + public List Properties { get; set; } +} \ No newline at end of file diff --git a/Havit.SourceGenerators.StrongApiStringLocalizers/Model/ResourcePropertyItem.cs b/Havit.SourceGenerators.StrongApiStringLocalizers/Model/ResourcePropertyItem.cs new file mode 100644 index 000000000..9beb22508 --- /dev/null +++ b/Havit.SourceGenerators.StrongApiStringLocalizers/Model/ResourcePropertyItem.cs @@ -0,0 +1,7 @@ +namespace Havit.SourceGenerators.StrongApiStringLocalizers.Model; + +internal class ResourcePropertyItem +{ + public string Name { get; set; } + public string Comment { get; set; } +} diff --git a/Havit.SourceGenerators.StrongApiStringLocalizers/Model/ServiceCollectionExtensionsData.cs b/Havit.SourceGenerators.StrongApiStringLocalizers/Model/ServiceCollectionExtensionsData.cs new file mode 100644 index 000000000..988db080c --- /dev/null +++ b/Havit.SourceGenerators.StrongApiStringLocalizers/Model/ServiceCollectionExtensionsData.cs @@ -0,0 +1,7 @@ +namespace Havit.SourceGenerators.StrongApiStringLocalizers.Model; + +internal class ServiceCollectionExtensionsData +{ + public string RootNamespace { get; set; } + public List Resources { get; set; } +} \ No newline at end of file diff --git a/Havit.SourceGenerators.StrongApiStringLocalizers/RegistrationsBuilder.cs b/Havit.SourceGenerators.StrongApiStringLocalizers/RegistrationsBuilder.cs deleted file mode 100644 index c16a451d4..000000000 --- a/Havit.SourceGenerators.StrongApiStringLocalizers/RegistrationsBuilder.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Text; -using Havit.SourceGenerators.StrongApiStringLocalizers; - -namespace LocalizerGenerator; - -internal class RegistrationsBuilder -{ - public string Namespace { get; set; } - public List Localizers { get; } = new List(); - public string MethodName => "AddGeneratedResourceWrappers"; - - public string BuildSource() - { - var builder = new StringBuilder(); - BuildNamespace(builder); - return builder.ToString(); - } - - private void BuildNamespace(StringBuilder builder) - { - builder.Append("namespace ").Append(Namespace).AppendLine(); - builder.AppendLine("{"); - BuildUsings(builder); - BuildClass(builder); - builder.AppendLine("}"); - } - - private void BuildUsings(StringBuilder builder) - { - builder.AppendLine("using System.CodeDom.Compiler;"); - builder.AppendLine("using Microsoft.Extensions.DependencyInjection;"); - builder.AppendLine("using Microsoft.Extensions.Localization;"); - foreach (var @namespace in Localizers.Select(x => x.Namespace).Distinct(StringComparer.Ordinal)) - { - builder.Append("using ").Append(@namespace).Append(";").AppendLine(); - } - } - - private void BuildClass(StringBuilder builder) - { - builder.AppendGeneratedCodeAttribute().AppendLine(); - builder.Append("partial class ").Append(LocalizerGenerator.ServiceCollectionInstallerMarker).AppendLine(); - builder.AppendLine("{"); - builder.Append("public static void ").Append(MethodName).Append("(this IServiceCollection services)").AppendLine(); - builder.AppendLine("{"); - BuildRegistrations(builder); - builder.AppendLine("}"); - builder.AppendLine("}"); - } - - private void BuildRegistrations(StringBuilder builder) - { - foreach (var localizer in Localizers) - { - builder.Append("services.AddScoped<").Append(localizer.LocalizerInterfaceName).Append(", ").Append(localizer.LocalizerClassName).Append(">();").AppendLine(); - } - } -} \ No newline at end of file diff --git a/Havit.SourceGenerators.StrongApiStringLocalizers/SourceBuilders/LocalizerImplementationSourceBuilder.cs b/Havit.SourceGenerators.StrongApiStringLocalizers/SourceBuilders/LocalizerImplementationSourceBuilder.cs new file mode 100644 index 000000000..6e22b11ff --- /dev/null +++ b/Havit.SourceGenerators.StrongApiStringLocalizers/SourceBuilders/LocalizerImplementationSourceBuilder.cs @@ -0,0 +1,59 @@ +using System.Text; +using Havit.SourceGenerators.StrongApiStringLocalizers.Model; + +namespace Havit.SourceGenerators.StrongApiStringLocalizers.SourceBuilders; + +/// +/// Generated source code for localizer implementation. +/// +internal class LocalizerImplementationSourceBuilder +{ + private readonly ResourceData _resxBuildData; + + public LocalizerImplementationSourceBuilder(ResourceData resxBuildData) + { + _resxBuildData = resxBuildData; + } + + public string BuildSource() + { + var builder = new StringBuilder(); + builder.AppendAutoGeneratedDocumenationCommentLine(); + builder.AppendLine(); + builder.AppendLine($"namespace {_resxBuildData.TargetLocalizerNamespace};"); + builder.AppendLine(); + builder.AppendLine("using System.CodeDom.Compiler;"); + builder.AppendLine("using System.Collections.Generic;"); + builder.AppendLine("using Microsoft.Extensions.Localization;"); + builder.AppendLine(); + builder.AppendGeneratedCodeAttributeLine(); + builder.AppendLine($"public class {_resxBuildData.LocalizerImplementationClassName} : {_resxBuildData.LocalizerInterfaceName}"); + builder.AppendLine("{"); + + builder.AppendLine("\tprivate readonly IStringLocalizer _localizer;"); + builder.AppendLine(); + + // constructor + builder.AppendLine($"\tpublic {_resxBuildData.LocalizerImplementationClassName}(IStringLocalizerFactory stringLocalizerFactory)"); + builder.AppendLine("\t{"); + builder.AppendLine($"\t\t_localizer = stringLocalizerFactory.Create(\"{(_resxBuildData.ResourceNamespace + "." + _resxBuildData.ResourceName).Trim('.')}\", \"{_resxBuildData.AssemblyName}\");"); + builder.AppendLine("\t}"); + builder.AppendLine(); + + // properties + foreach (var property in _resxBuildData.Properties) + { + builder.AppendSummaryCommentLine(property.Comment); + builder.AppendLine($"\tpublic LocalizedString {property.Name} => _localizer[\"{property.Name}\"];"); + builder.AppendLine(); + } + + // IStringLocalizer + builder.AppendLine("\tLocalizedString IStringLocalizer.this[string name] => _localizer[name];"); + builder.AppendLine("\tLocalizedString IStringLocalizer.this[string name, params object[] arguments] => _localizer[name, arguments];"); + builder.AppendLine("\tIEnumerable IStringLocalizer.GetAllStrings(bool includeParentCultures) => _localizer.GetAllStrings(includeParentCultures);"); + builder.AppendLine("}"); + + return builder.ToString(); + } +} diff --git a/Havit.SourceGenerators.StrongApiStringLocalizers/SourceBuilders/LocalizerInterfaceSourceBuilder.cs b/Havit.SourceGenerators.StrongApiStringLocalizers/SourceBuilders/LocalizerInterfaceSourceBuilder.cs new file mode 100644 index 000000000..900ee151f --- /dev/null +++ b/Havit.SourceGenerators.StrongApiStringLocalizers/SourceBuilders/LocalizerInterfaceSourceBuilder.cs @@ -0,0 +1,41 @@ +using System.Text; +using Havit.SourceGenerators.StrongApiStringLocalizers.Model; + +namespace Havit.SourceGenerators.StrongApiStringLocalizers.SourceBuilders; + +/// +/// Generates source code for the localizer interface. +/// +internal class LocalizerInterfaceSourceBuilder +{ + private readonly ResourceData _resxBuildData; + + public LocalizerInterfaceSourceBuilder(ResourceData resxBuildData) + { + _resxBuildData = resxBuildData; + } + + public string BuildSource() + { + var builder = new StringBuilder(); + builder.AppendAutoGeneratedDocumenationCommentLine(); + builder.AppendLine(); + builder.AppendLine($"namespace {_resxBuildData.TargetLocalizerNamespace};"); + builder.AppendLine(); + builder.AppendLine("using System.CodeDom.Compiler;"); + builder.AppendLine("using Microsoft.Extensions.Localization;"); + builder.AppendLine(); + builder.AppendGeneratedCodeAttributeLine(); + builder.AppendLine($"public interface {_resxBuildData.LocalizerInterfaceName} : IStringLocalizer"); + builder.AppendLine("{"); + foreach (var property in _resxBuildData.Properties) + { + builder.AppendSummaryCommentLine(property.Comment); + builder.AppendLine($"\tLocalizedString {property.Name} {{ get; }}"); + builder.AppendLine(); + } + builder.AppendLine("}"); + + return builder.ToString(); + } +} diff --git a/Havit.SourceGenerators.StrongApiStringLocalizers/SourceBuilders/ServiceRegistrationsSourceBuilder.cs b/Havit.SourceGenerators.StrongApiStringLocalizers/SourceBuilders/ServiceRegistrationsSourceBuilder.cs new file mode 100644 index 000000000..595de4f77 --- /dev/null +++ b/Havit.SourceGenerators.StrongApiStringLocalizers/SourceBuilders/ServiceRegistrationsSourceBuilder.cs @@ -0,0 +1,47 @@ +using System.Text; +using Havit.SourceGenerators.StrongApiStringLocalizers.Model; + +namespace Havit.SourceGenerators.StrongApiStringLocalizers.SourceBuilders; + +/// +/// Generates source code for service registrations. +/// +internal class ServiceRegistrationsSourceBuilder +{ + private readonly ServiceCollectionExtensionsData _serviceCollectionExtensionsData; + + public ServiceRegistrationsSourceBuilder(ServiceCollectionExtensionsData serviceCollectionExtensionsData) + { + _serviceCollectionExtensionsData = serviceCollectionExtensionsData; + } + + public string BuildSource() + { + var builder = new StringBuilder(); + builder.AppendAutoGeneratedDocumenationCommentLine(); + builder.AppendLine(); + + builder.AppendLine($"namespace {_serviceCollectionExtensionsData.RootNamespace};"); + builder.AppendLine(); + + builder.AppendLine("using System.CodeDom.Compiler;"); + builder.AppendLine("using Microsoft.Extensions.DependencyInjection;"); + builder.AppendLine("using Microsoft.Extensions.Localization;"); + builder.AppendLine(); + + builder.AppendGeneratedCodeAttributeLine(); + builder.AppendLine("public static class ServiceCollectionExtensions"); + builder.AppendLine("{"); + builder.AppendLine("\tpublic static IServiceCollection AddGeneratedResourceWrappers(this IServiceCollection services)"); + builder.AppendLine("\t{"); + foreach (var resource in _serviceCollectionExtensionsData.Resources.Where(resource => resource.Properties != null)) + { + builder.AppendLine($"\t\tservices.AddTransient<{resource.TargetLocalizerNamespace}.{resource.LocalizerInterfaceName}, {resource.TargetLocalizerNamespace}.{resource.LocalizerImplementationClassName}>();"); + } + builder.AppendLine("\t\treturn services;"); + builder.AppendLine("\t}"); + builder.AppendLine("}"); + + return builder.ToString(); + } +} \ No newline at end of file diff --git a/Havit.SourceGenerators.StrongApiStringLocalizers/SourceBuilders/StringBuilderExtensions.cs b/Havit.SourceGenerators.StrongApiStringLocalizers/SourceBuilders/StringBuilderExtensions.cs new file mode 100644 index 000000000..444b1ade3 --- /dev/null +++ b/Havit.SourceGenerators.StrongApiStringLocalizers/SourceBuilders/StringBuilderExtensions.cs @@ -0,0 +1,34 @@ +using System.Text; +using System.Text.RegularExpressions; +using Havit.SourceGenerators.StrongApiStringLocalizers.Helpers; + +namespace Havit.SourceGenerators.StrongApiStringLocalizers.SourceBuilders; + +internal static class StringBuilderExtensions +{ + public static StringBuilder AppendAutoGeneratedDocumenationCommentLine(this StringBuilder builder) + { + return builder.AppendLine("// "); + } + + public static StringBuilder AppendGeneratedCodeAttributeLine(this StringBuilder builder) + { + return builder.AppendLine($"[GeneratedCode(\"{nameof(Havit)}.{nameof(SourceGenerators)}.{nameof(StrongApiStringLocalizers)}.{nameof(StrongApiStringLocalizersGenerator)}\", \"{typeof(StringBuilderExtensions).Assembly.GetName().Version}\")]"); + } + + public static StringBuilder AppendSummaryCommentLine(this StringBuilder builder, string comment) + { + if (!String.IsNullOrEmpty(comment)) + { + string[] commentLines = Regex.Split(comment, "\r\n|\r|\n"); + builder.AppendLine("\t/// "); + foreach (var commentLine in commentLines) + { + builder.Append("\t/// ").AppendLine(HttpUtilityExt.HtmlEncode(commentLine, HtmlEncodeOptions.IgnoreNonASCIICharacters)); + } + builder.AppendLine("\t/// "); + } + + return builder; + } +} diff --git a/Havit.SourceGenerators.StrongApiStringLocalizers/StrongApiStringLocalizersGenerator.cs b/Havit.SourceGenerators.StrongApiStringLocalizers/StrongApiStringLocalizersGenerator.cs new file mode 100644 index 000000000..35196cbd6 --- /dev/null +++ b/Havit.SourceGenerators.StrongApiStringLocalizers/StrongApiStringLocalizersGenerator.cs @@ -0,0 +1,147 @@ +using System.Text; +using System.Xml; +using System.Xml.Linq; +using Havit.SourceGenerators.StrongApiStringLocalizers.Helpers; +using Havit.SourceGenerators.StrongApiStringLocalizers.Model; +using Havit.SourceGenerators.StrongApiStringLocalizers.SourceBuilders; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; + +namespace Havit.SourceGenerators.StrongApiStringLocalizers; + +[Generator] +public class StrongApiStringLocalizersGenerator : IIncrementalGenerator +{ + private static readonly DiagnosticDescriptor s_xmlParseWarning = new DiagnosticDescriptor(id: "HLG1002", title: "Cannot parse RESX file", messageFormat: "Cannot parse RESX file '{0}'", category: "Usage", DiagnosticSeverity.Warning, isEnabledByDefault: true); + + public void Initialize(IncrementalGeneratorInitializationContext initializationContext) + { + // read project configuration + IncrementalValueProvider buildConfigurationProvider = initializationContext.AnalyzerConfigOptionsProvider + .Combine(initializationContext.CompilationProvider) + .Select((item, _) => + { + AnalyzerConfigOptionsProvider analyzerConfig = item.Left; + Compilation compilation = item.Right; + analyzerConfig.GlobalOptions.TryGetValue("build_property.RootNamespace", out string rootNamespace); + analyzerConfig.GlobalOptions.TryGetValue("build_property.ProjectDir", out string projectDir); + + return new BuildConfiguration + { + RootNamespace = rootNamespace, + ProjectDirectory = projectDir, + AssemblyName = compilation.AssemblyName + }; + }); + + // get resx files (with all required data to generate localizers one by one) + // (resx files are available in initializationContext.AdditionalTextsProvider only when library references + // nuget package Microsoft.CodeAnalysis) + IncrementalValuesProvider resxDataProvider = initializationContext.AdditionalTextsProvider + .Where(static file => string.Equals(Path.GetExtension(file.Path), ".resx", StringComparison.OrdinalIgnoreCase)) // .resx + .Where(static file => !Path.GetFileNameWithoutExtension(file.Path).Contains(".")) // skip language-specific files - take Resource.resx, skip Resource.cs.resx + .Combine(buildConfigurationProvider) // "join" with build configuration + .Select(static (item, cancellationToken) => + { + AdditionalText additionalText = item.Left; + BuildConfiguration buildConfiguration = item.Right; + return new ResourceData + { + ResxFilePath = additionalText.Path, + AssemblyName = buildConfiguration.AssemblyName, + TargetLocalizerNamespace = GetTargetNamespace(buildConfiguration, additionalText.Path), + ResourceNamespace = GetResourceNamespace(buildConfiguration, additionalText.Path), + ResourceName = Path.GetFileNameWithoutExtension(additionalText.Path), // ie. Homepage, Glossary, ... + Properties = GetResxPropertiesSafe(additionalText, cancellationToken) // ie.. Yes, No, OK, Cancel, ... + }; + }); + + // get list of resx files (with all required data to generate service collection extension) + IncrementalValueProvider serviceCollectionExtensionsDataProvider = resxDataProvider + .Collect() + .Combine(buildConfigurationProvider) + .Select(static (item, cancellationToken) => new ServiceCollectionExtensionsData + { + RootNamespace = item.Right.RootNamespace, + Resources = item.Left.OrderBy(item => item.TargetLocalizerNamespace).ThenBy(item => item.ResourceName).ToList() + }); + + initializationContext.RegisterSourceOutput(resxDataProvider, static (sourceContext, resxBuildData) => + { + if (resxBuildData.Properties == null) + { + // XML could not be parsed + sourceContext.ReportDiagnostic(Diagnostic.Create(s_xmlParseWarning, Location.None, resxBuildData.ResxFilePath)); + } + else + { + // generate localizer interface (ie. Homepage.resx => IHomepageLocalizer) + LocalizerInterfaceSourceBuilder localizerInterfaceSourceBuilder = new LocalizerInterfaceSourceBuilder(resxBuildData); + sourceContext.AddSource($"{resxBuildData.TargetLocalizerNamespace}.I{resxBuildData.ResourceName}Localizer.g.cs", SourceText.From(localizerInterfaceSourceBuilder.BuildSource(), Encoding.UTF8)); + + // generate localizer implementation (ie. Homepage.resx => HomepageLocalizer) + LocalizerImplementationSourceBuilder localizerImplementationSourceBuilder = new LocalizerImplementationSourceBuilder(resxBuildData); + sourceContext.AddSource($"{resxBuildData.TargetLocalizerNamespace}.{resxBuildData.ResourceName}Localizer.g.cs", SourceText.From(localizerImplementationSourceBuilder.BuildSource(), Encoding.UTF8)); + } + }); + + initializationContext.RegisterSourceOutput(serviceCollectionExtensionsDataProvider, static (sourceContext, serviceCollectionExtensionsData) => + { + if (serviceCollectionExtensionsData.Resources.Count > 0) + { + // generate service collection externsion to register all generated localizers + var serviceRegistrationsSourceBuilder = new ServiceRegistrationsSourceBuilder(serviceCollectionExtensionsData); + sourceContext.AddSource($"{serviceCollectionExtensionsData.RootNamespace}.ServiceCollectionExtensions.g.cs", SourceText.From(serviceRegistrationsSourceBuilder.BuildSource(), Encoding.UTF8)); + } + }); + } + + private static string GetTargetNamespace(BuildConfiguration buildConfiguration, string path) + { + return (buildConfiguration.RootNamespace + "." + GetResourceNamespace(buildConfiguration, path)).Trim('.'); + } + + private static string GetResourceNamespace(BuildConfiguration buildConfiguration, string path) + { + string localPath = path.StartsWith(buildConfiguration.ProjectDirectory, StringComparison.InvariantCultureIgnoreCase) + ? path.Substring(buildConfiguration.ProjectDirectory.Length).Trim(Path.DirectorySeparatorChar) + : path; + + return Path.GetDirectoryName(localPath).Replace(Path.DirectorySeparatorChar, '.'); + } + + + private static List GetResxPropertiesSafe(AdditionalText resx, CancellationToken cancellationToken) + { + try + { + SourceText resxContent = resx.GetText(cancellationToken); + var resxXmlDoc = XDocument.Load(new SourceTextReader(resxContent)); + + var result = new List(); + foreach (var item in resxXmlDoc.Root.Elements("data")) + { + var nameAttribute = item.Attribute("name"); + if (nameAttribute == null) + { + continue; + } + + string comment = item.Element("comment")?.Value; + string value = item.Element("value")?.Value; + + result.Add(new ResourcePropertyItem + { + Name = nameAttribute.Value, + Comment = comment ?? value, + }); + } + return result.OrderBy(item => item.Name, StringComparer.InvariantCultureIgnoreCase).ToList(); + } + catch (XmlException) + { + return null; + } + } +}