diff --git a/Aigamo.ResXGenerator.Tests/Aigamo.ResXGenerator.Tests.csproj b/Aigamo.ResXGenerator.Tests/Aigamo.ResXGenerator.Tests.csproj index 6e39235..cd6c75e 100644 --- a/Aigamo.ResXGenerator.Tests/Aigamo.ResXGenerator.Tests.csproj +++ b/Aigamo.ResXGenerator.Tests/Aigamo.ResXGenerator.Tests.csproj @@ -1,11 +1,12 @@  - net8.0 + net8.0-windows false latest enable enable + true @@ -19,6 +20,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -42,6 +44,9 @@ true + + MyCustomResourceFileName.resources + StringLocalizer diff --git a/Aigamo.ResXGenerator.Tests/IntegrationTests/BinaryFile.bin b/Aigamo.ResXGenerator.Tests/IntegrationTests/BinaryFile.bin new file mode 100644 index 0000000..df93f5f Binary files /dev/null and b/Aigamo.ResXGenerator.Tests/IntegrationTests/BinaryFile.bin differ diff --git a/Aigamo.ResXGenerator.Tests/IntegrationTests/Test1.da-dk.resx b/Aigamo.ResXGenerator.Tests/IntegrationTests/Test1.da-dk.resx index 0de448d..5a064c8 100644 --- a/Aigamo.ResXGenerator.Tests/IntegrationTests/Test1.da-dk.resx +++ b/Aigamo.ResXGenerator.Tests/IntegrationTests/Test1.da-dk.resx @@ -1,107 +1,126 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 1.3 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - OldestDaDK - - - NewestDaDK - - + mimetype: application/x-microsoft.net.object.bytearray.base64 + value : The object must be serialized into a byte array + : using a System.ComponentModel.TypeConverter + : and then encoded with base64 encoding. + --> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + OldestDaDK + + + NewestDaDK + + \ No newline at end of file diff --git a/Aigamo.ResXGenerator.Tests/IntegrationTests/Test1.da.resx b/Aigamo.ResXGenerator.Tests/IntegrationTests/Test1.da.resx index 7d7fd5a..b6f5e7e 100644 --- a/Aigamo.ResXGenerator.Tests/IntegrationTests/Test1.da.resx +++ b/Aigamo.ResXGenerator.Tests/IntegrationTests/Test1.da.resx @@ -1,107 +1,126 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 1.3 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - OldestDa - - - NewestDa - - + mimetype: application/x-microsoft.net.object.bytearray.base64 + value : The object must be serialized into a byte array + : using a System.ComponentModel.TypeConverter + : and then encoded with base64 encoding. + --> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + OldestDa + + + NewestDa + + \ No newline at end of file diff --git a/Aigamo.ResXGenerator.Tests/IntegrationTests/Test1.en-us.resx b/Aigamo.ResXGenerator.Tests/IntegrationTests/Test1.en-us.resx index 63413e6..c621e79 100644 --- a/Aigamo.ResXGenerator.Tests/IntegrationTests/Test1.en-us.resx +++ b/Aigamo.ResXGenerator.Tests/IntegrationTests/Test1.en-us.resx @@ -1,107 +1,126 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 1.3 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - OldestEnUs - - - NewestEnUs - - + mimetype: application/x-microsoft.net.object.bytearray.base64 + value : The object must be serialized into a byte array + : using a System.ComponentModel.TypeConverter + : and then encoded with base64 encoding. + --> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + OldestEnUs + + + NewestEnUs + + \ No newline at end of file diff --git a/Aigamo.ResXGenerator.Tests/IntegrationTests/Test1.resx b/Aigamo.ResXGenerator.Tests/IntegrationTests/Test1.resx index 6d5b07a..b060987 100644 --- a/Aigamo.ResXGenerator.Tests/IntegrationTests/Test1.resx +++ b/Aigamo.ResXGenerator.Tests/IntegrationTests/Test1.resx @@ -1,107 +1,145 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 1.3 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - Oldest - - - Newest - + mimetype: application/x-microsoft.net.object.bytearray.base64 + value : The object must be serialized into a byte array + : using a System.ComponentModel.TypeConverter + : and then encoded with base64 encoding. + --> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + BinaryFile.bin;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Oldest + + + Newest + + + TestIcon.ico;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + TestIcon.ico;System.Drawing.Icon, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + TestImage.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + TestImage.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + TextFile.txt;System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089;utf-8 + diff --git a/Aigamo.ResXGenerator.Tests/IntegrationTests/Test5.resx b/Aigamo.ResXGenerator.Tests/IntegrationTests/Test5.resx new file mode 100644 index 0000000..ceb62be --- /dev/null +++ b/Aigamo.ResXGenerator.Tests/IntegrationTests/Test5.resx @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + Oldest + + + Newest + + \ No newline at end of file diff --git a/Aigamo.ResXGenerator.Tests/IntegrationTests/TestIcon.ico b/Aigamo.ResXGenerator.Tests/IntegrationTests/TestIcon.ico new file mode 100644 index 0000000..ac3a8c6 Binary files /dev/null and b/Aigamo.ResXGenerator.Tests/IntegrationTests/TestIcon.ico differ diff --git a/Aigamo.ResXGenerator.Tests/IntegrationTests/TestImage.png b/Aigamo.ResXGenerator.Tests/IntegrationTests/TestImage.png new file mode 100644 index 0000000..e22aa6d Binary files /dev/null and b/Aigamo.ResXGenerator.Tests/IntegrationTests/TestImage.png differ diff --git a/Aigamo.ResXGenerator.Tests/IntegrationTests/TestResxFiles.cs b/Aigamo.ResXGenerator.Tests/IntegrationTests/TestResxFiles.cs index 7d189f3..4048123 100644 --- a/Aigamo.ResXGenerator.Tests/IntegrationTests/TestResxFiles.cs +++ b/Aigamo.ResXGenerator.Tests/IntegrationTests/TestResxFiles.cs @@ -1,5 +1,6 @@ using System.Globalization; using FluentAssertions; +using Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.Serialization; using Xunit; namespace Aigamo.ResXGenerator.Tests.IntegrationTests; @@ -19,6 +20,14 @@ public void TestNormalResourceGen() Test1.CreateDate.Should().Be("OldestEnUs"); Thread.CurrentThread.CurrentUICulture = new CultureInfo("da-DK"); Test1.CreateDate.Should().Be("OldestDaDK"); + + // Test embedded files are as expected + Test1.TextFile.Should().Be("This is a test.\r\n"); + Test1.BinaryFile.Should().BeEquivalentTo(new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }); + Test1.TestIconAsBytes.Should().NotBeNull(); + Test1.TestIconAsDrawingIcon.Should().NotBeNull().And.BeOfType().Which.Size.Should().Be(new Size(32, 32)); + Test1.TestImageAsBytes.Should().NotBeNull(); + Test1.TestImageAsDrawingBitmap.Should().NotBeNull().And.BeOfType().Which.Size.Should().Be(new Size(32, 32)); } [Fact] public void TestCodeGenResourceGen() @@ -39,4 +48,11 @@ public void TestCodeGenResourceGen() public void TestSkipFile_DoesNotGenerate() => GetType().Assembly.GetTypes().Should() .NotContain(t => t.Name == "Test3"); + + [Fact] + public void TestLogicalNameMetadata() + { + // Simply loading a resource should prove that the LogicalName was interpreted correctly + Test5.CreateDate.Should().Be("Oldest"); + } } diff --git a/Aigamo.ResXGenerator.Tests/IntegrationTests/TextFile.txt b/Aigamo.ResXGenerator.Tests/IntegrationTests/TextFile.txt new file mode 100644 index 0000000..484ba93 --- /dev/null +++ b/Aigamo.ResXGenerator.Tests/IntegrationTests/TextFile.txt @@ -0,0 +1 @@ +This is a test. diff --git a/Aigamo.ResXGenerator.Tests/SettingsTests.cs b/Aigamo.ResXGenerator.Tests/SettingsTests.cs index a2f7e43..29790f5 100644 --- a/Aigamo.ResXGenerator.Tests/SettingsTests.cs +++ b/Aigamo.ResXGenerator.Tests/SettingsTests.cs @@ -399,7 +399,7 @@ private class AnalyzerConfigOptionsStub : AnalyzerConfigOptions // ReSharper restore InconsistentNaming - public override bool TryGetValue(string key, [NotNullWhen(true)] out string? value) + public override bool TryGetValue(string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? value) { string? GetVal() { diff --git a/Aigamo.ResXGenerator.Tests/TypeNameParserTests.cs b/Aigamo.ResXGenerator.Tests/TypeNameParserTests.cs new file mode 100644 index 0000000..f87f49d --- /dev/null +++ b/Aigamo.ResXGenerator.Tests/TypeNameParserTests.cs @@ -0,0 +1,485 @@ +using Aigamo.ResXGenerator.Tools; +using FluentAssertions; +using Xunit; + +namespace Aigamo.ResXGenerator.Tests; + +public class TypeNameParserTests +{ + private readonly TypeNameParser _parser = new(); + + #region Parser Build Diagnostics + + [Fact] + public void BuildParser_ShouldSucceed() + { + var parser = new TypeNameParser(); + var buildResult = parser.BuildParser(); + + // Output any errors for debugging + if (buildResult.IsError) + { + var errors = string.Join("\n", buildResult.Errors.Select(e => e.Message)); + buildResult.IsError.Should().BeFalse($"Parser build failed with errors:\n{errors}"); + } + + buildResult.IsError.Should().BeFalse(); + buildResult.Result.Should().NotBeNull(); + } + + #endregion + + #region Simple Type Names + + [Theory] + [InlineData("String", null, "String")] + [InlineData("Int32", null, "Int32")] + [InlineData("MyClass", null, "MyClass")] + [InlineData("_PrivateClass", null, "_PrivateClass")] + [InlineData("Class123", null, "Class123")] + public void Parse_SimpleTypeName_ShouldSucceed(string typeName, string? expectedNamespace, string expectedSimpleName) + { + var result = _parser.TryParse(typeName, out var parsed); + + result.Should().BeTrue(); + parsed.Should().NotBeNull(); + parsed!.Namespace.Should().Be(expectedNamespace); + parsed.SimpleName.Should().Be(expectedSimpleName); + parsed.TypeNames.Should().HaveCount(1); + } + + #endregion + + #region Namespaced Type Names + + [Theory] + [InlineData("System.String", "System", "String")] + [InlineData("System.Int32", "System", "Int32")] + [InlineData("System.Collections.ArrayList", "System.Collections", "ArrayList")] + [InlineData("System.Collections.Generic.List", "System.Collections.Generic", "List")] + [InlineData("MyCompany.MyProduct.MyClass", "MyCompany.MyProduct", "MyClass")] + public void Parse_NamespacedTypeName_ShouldSucceed(string typeName, string expectedNamespace, string expectedSimpleName) + { + var result = _parser.TryParse(typeName, out var parsed); + + result.Should().BeTrue(); + parsed.Should().NotBeNull(); + parsed!.Namespace.Should().Be(expectedNamespace); + parsed.SimpleName.Should().Be(expectedSimpleName); + } + + #endregion + + #region Nested Types + + [Theory] + [InlineData("OuterClass+InnerClass", null, new[] { "OuterClass", "InnerClass" })] + [InlineData("Outer+Middle+Inner", null, new[] { "Outer", "Middle", "Inner" })] + public void Parse_NestedTypeName_ShouldSucceed(string typeName, string? expectedNamespace, string[] expectedTypeNames) + { + var result = _parser.TryParse(typeName, out var parsed); + + result.Should().BeTrue(); + parsed.Should().NotBeNull(); + parsed!.Namespace.Should().Be(expectedNamespace); + parsed.TypeNames.Should().BeEquivalentTo(expectedTypeNames); + parsed.SimpleName.Should().Be(expectedTypeNames[^1]); + } + + [Theory] + [InlineData("System.Environment+SpecialFolder", "System", new[] { "Environment", "SpecialFolder" })] + [InlineData("MyNamespace.Outer+Inner", "MyNamespace", new[] { "Outer", "Inner" })] + public void Parse_NamespacedNestedTypeName_ShouldSucceed(string typeName, string expectedNamespace, string[] expectedTypeNames) + { + var result = _parser.TryParse(typeName, out var parsed); + + result.Should().BeTrue(); + parsed.Should().NotBeNull(); + parsed!.Namespace.Should().Be(expectedNamespace); + parsed.TypeNames.Should().BeEquivalentTo(expectedTypeNames); + } + + #endregion + + #region Generic Types + + [Fact] + public void Parse_OpenGenericType_ShouldSucceed() + { + // List`1 - open generic with 1 type parameter + // Note: Open generics without type arguments may not be supported in all grammar variations + // This tests the basic generic arity recognition + var typeName = "System.Collections.Generic.List`1"; + typeName.Should().Be(typeof(List<>).FullName); + + var result = _parser.TryParse(typeName, out var parsed); + + result.Should().BeTrue(); + parsed.Should().NotBeNull(); + parsed!.Namespace.Should().Be("System.Collections.Generic"); + parsed.SimpleName.Should().Be("List"); + parsed.GenericArity.Should().Be(1); + parsed.GenericArguments.Should().HaveCount(0); + parsed.FullName.Should().Be(typeName); + } + + [Fact] + public void Parse_ClosedGenericType_SingleArg_ShouldSucceed() + { + var typeName = "System.Collections.Generic.List`1[[System.Int32]]"; + //typeName.Should().Be(typeof(List).FullName); // This won't match because Type.FullName uses assembly-qualified names for generic args + + var result = _parser.TryParse(typeName, out var parsed); + + result.Should().BeTrue(); + parsed.Should().NotBeNull(); + parsed!.GenericArity.Should().Be(1); + parsed.GenericArguments.Should().HaveCount(1); + parsed.GenericArguments[0].Namespace.Should().Be("System"); + parsed.GenericArguments[0].SimpleName.Should().Be("Int32"); + parsed.FullName.Should().Be(typeName); + } + + [Fact] + public void Parse_ClosedGenericType_MultipleArgs_ShouldSucceed() + { + var typeName = "System.Collections.Generic.Dictionary`2[[System.String],[System.Int32]]"; + //typeName.Should().Be(typeof(IDictionary).FullName); // This won't match because Type.FullName uses assembly-qualified names for generic args + + var result = _parser.TryParse(typeName, out var parsed); + + result.Should().BeTrue(); + parsed.Should().NotBeNull(); + parsed!.Namespace.Should().Be("System.Collections.Generic"); + parsed.SimpleName.Should().Be("Dictionary"); + parsed.GenericArity.Should().Be(2); + parsed.GenericArguments.Should().HaveCount(2); + parsed.GenericArguments[0].SimpleName.Should().Be("String"); + parsed.GenericArguments[1].SimpleName.Should().Be("Int32"); + parsed.FullName.Should().Be(typeName); + } + + [Fact] + public void Parse_NestedGenericType_ShouldSucceed() + { + // List of List of String + var typeName = "System.Collections.Generic.List`1[[System.Collections.Generic.List`1[[System.String]]]]"; + //typeName.Should().Be(typeof(List>).FullName); // This won't match because Type.FullName uses assembly-qualified names for generic args + + var result = _parser.TryParse(typeName, out var parsed); + + result.Should().BeTrue(); + parsed.Should().NotBeNull(); + parsed!.GenericArity.Should().Be(1); + parsed.GenericArguments.Should().HaveCount(1); + + var innerList = parsed.GenericArguments[0]; + innerList.SimpleName.Should().Be("List"); + innerList.GenericArity.Should().Be(1); + innerList.GenericArguments.Should().HaveCount(1); + innerList.GenericArguments[0].SimpleName.Should().Be("String"); + // FullName doesn't include the generic argument brackets - it's just the type name with arity marker + parsed.FullName.Should().Be(typeName); + parsed.ToCSharp().Should().Be("System.Collections.Generic.List>"); + } + + #endregion + + #region Array Types + + [Theory] + [InlineData("System.Int32[]", "System", "Int32", 1)] + [InlineData("System.String[]", "System", "String", 1)] + [InlineData("MyClass[]", null, "MyClass", 1)] + public void Parse_SingleDimensionArray_ShouldSucceed(string typeName, string? expectedNamespace, string expectedSimpleName, int expectedRank) + { + var result = _parser.TryParse(typeName, out var parsed); + + result.Should().BeTrue(); + parsed.Should().NotBeNull(); + parsed!.Namespace.Should().Be(expectedNamespace); + parsed.SimpleName.Should().Be(expectedSimpleName); + parsed.ArrayRanks.Should().HaveCount(1); + parsed.ArrayRanks[0].Rank.Should().Be(expectedRank); + parsed.FullName.Should().Be(typeName); + } + + [Theory] + [InlineData("System.Int32[,]", 2)] + [InlineData("System.Int32[,,]", 3)] + [InlineData("System.Int32[,,,]", 4)] + public void Parse_MultiDimensionArray_ShouldSucceed(string typeName, int expectedRank) + { + var result = _parser.TryParse(typeName, out var parsed); + + result.Should().BeTrue(); + parsed.Should().NotBeNull(); + parsed!.ArrayRanks.Should().HaveCount(1); + parsed.ArrayRanks[0].Rank.Should().Be(expectedRank); + parsed.FullName.Should().Be(typeName); + } + + [Fact] + public void Parse_JaggedArray_ShouldSucceed() + { + var typeName = "System.Int32[][]"; + + var result = _parser.TryParse(typeName, out var parsed); + + result.Should().BeTrue(); + parsed.Should().NotBeNull(); + parsed!.ArrayRanks.Should().HaveCount(2); + parsed.ArrayRanks[0].Rank.Should().Be(1); + parsed.ArrayRanks[1].Rank.Should().Be(1); + parsed.FullName.Should().Be(typeName); + } + + [Fact] + public void Parse_ArrayWithUnknownLowerBound_ShouldSucceed() + { + var typeName = "System.Int32[*]"; + + var result = _parser.TryParse(typeName, out var parsed); + + result.Should().BeTrue(); + parsed.Should().NotBeNull(); + parsed!.ArrayRanks.Should().HaveCount(1); + parsed.FullName.Should().Be(typeName); + } + + #endregion + + #region Pointer Types + + [Theory] + [InlineData("System.Int32*", 1)] + [InlineData("System.Int32**", 2)] + [InlineData("System.Void*", 1)] + public void Parse_PointerType_ShouldSucceed(string typeName, int expectedPointerDepth) + { + var result = _parser.TryParse(typeName, out var parsed); + + result.Should().BeTrue(); + parsed.Should().NotBeNull(); + parsed!.PointerDepth.Should().Be(expectedPointerDepth); + parsed.FullName.Should().Be(typeName); + } + + #endregion + + #region Reference Types + + [Theory] + [InlineData("System.Int32&")] + [InlineData("MyClass&")] + public void Parse_ReferenceType_ShouldSucceed(string typeName) + { + var result = _parser.TryParse(typeName, out var parsed); + + result.Should().BeTrue(); + parsed.Should().NotBeNull(); + parsed!.IsReference.Should().BeTrue(); + parsed.FullName.Should().Be(typeName); + } + + #endregion + + #region Assembly-Qualified Names + + [Fact] + public void Parse_SimpleAssemblyQualifiedName_ShouldSucceed() + { + var typeName = "MyNamespace.MyClass, MyAssembly"; + + var result = _parser.TryParse(typeName, out var parsed); + + result.Should().BeTrue(); + parsed.Should().NotBeNull(); + parsed!.Namespace.Should().Be("MyNamespace"); + parsed.SimpleName.Should().Be("MyClass"); + parsed.AssemblyName.Should().Be("MyAssembly"); + } + + [Fact] + public void Parse_SimpleAssemblyQualifiedNameWithSpecialChars_ShouldSucceed() + { + var typeName = "MyNamespace.MyClass, My.Assembly-That_Has.Weird_Name"; + + var result = _parser.TryParse(typeName, out var parsed); + + result.Should().BeTrue(); + parsed.Should().NotBeNull(); + parsed!.Namespace.Should().Be("MyNamespace"); + parsed.SimpleName.Should().Be("MyClass"); + parsed.AssemblyName.Should().Be("My.Assembly-That_Has.Weird_Name"); + } + + [Fact] + public void Parse_FullyQualifiedAssemblyName_ShouldSucceed() + { + var typeName = "System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"; + + var result = _parser.TryParse(typeName, out var parsed); + + result.Should().BeTrue(); + parsed.Should().NotBeNull(); + parsed!.Namespace.Should().Be("System"); + parsed.SimpleName.Should().Be("String"); + parsed.AssemblyName.Should().Be("mscorlib"); + parsed.AssemblyProperties.Should().NotBeNull(); + parsed.AssemblyProperties.Should().ContainKey("Version"); + parsed.AssemblyProperties!["Version"].Should().Be("4.0.0.0"); + parsed.AssemblyProperties.Should().ContainKey("Culture"); + parsed.AssemblyProperties["Culture"].Should().Be("neutral"); + parsed.AssemblyProperties.Should().ContainKey("PublicKeyToken"); + parsed.AssemblyProperties["PublicKeyToken"].Should().Be("b77a5c561934e089"); + } + + [Fact] + public void Parse_AssemblyWithCulture_ShouldSucceed() + { + // Note: This uses a proper type name format with assembly and culture + var typeName = "MyNamespace.MyClass, MyAssembly, Culture=en"; + + var result = _parser.TryParse(typeName, out var parsed); + + result.Should().BeTrue(); + parsed.Should().NotBeNull(); + parsed!.Namespace.Should().Be("MyNamespace"); + parsed.SimpleName.Should().Be("MyClass"); + parsed.AssemblyName.Should().Be("MyAssembly"); + parsed.AssemblyProperties.Should().ContainKey("Culture"); + parsed.AssemblyProperties!["Culture"].Should().Be("en"); + } + + #endregion + + #region Combined Complex Types + + [Fact] + public void Parse_GenericArrayType_ShouldSucceed() + { + var typeName = "System.Collections.Generic.List`1[[System.Int32]][]"; + + var result = _parser.TryParse(typeName, out var parsed); + + result.Should().BeTrue(); + parsed.Should().NotBeNull(); + parsed!.SimpleName.Should().Be("List"); + parsed.GenericArity.Should().Be(1); + parsed.ArrayRanks.Should().HaveCount(1); + parsed.GenericArguments.Count.Should().Be(1); + parsed.GenericArguments[0].FullName.Should().Be("System.Int32"); + } + + [Fact] + public void Parse_ArrayOfPointers_ShouldSucceed() + { + var typeName = "System.Int32*[]"; + + var result = _parser.TryParse(typeName, out var parsed); + + result.Should().BeTrue(); + parsed.Should().NotBeNull(); + parsed!.PointerDepth.Should().Be(1); + parsed.ArrayRanks.Should().HaveCount(1); + parsed.FullName.Should().Be(typeName); + } + + #endregion + + #region FullName Property + + [Theory] + [InlineData("String", "String")] + [InlineData("System.String", "System.String")] + [InlineData("Outer+Inner", "Outer+Inner")] + [InlineData("System.Outer+Inner", "System.Outer+Inner")] + public void FullName_ShouldReturnCorrectValue(string typeName, string expectedFullName) + { + var result = _parser.TryParse(typeName, out var parsed); + + result.Should().BeTrue(); + parsed.Should().NotBeNull(); + parsed!.FullName.Should().Be(expectedFullName); + } + + [Fact] + public void FullName_GenericType_ShouldIncludeArity() + { + var typeName = "System.Collections.Generic.List`1[[System.String]]"; + //typeName.Should().Be(typeof(List).FullName); // This won't match because Type.FullName uses assembly-qualified names for generic args + + var result = _parser.TryParse(typeName, out var parsed); + + result.Should().BeTrue(); + parsed.Should().NotBeNull(); + parsed!.FullName.Should().Be(typeName); + } + + #endregion + + #region Error Cases + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Parse_EmptyOrWhitespace_ShouldFail(string typeName) + { + var result = _parser.TryParse(typeName, out var parsed); + + result.Should().BeFalse(); + } + + [Theory] + [InlineData("123InvalidStart")] + [InlineData("Invalid-Name")] + public void Parse_InvalidIdentifier_ShouldFail(string typeName) + { + var result = _parser.TryParse(typeName, out var parsed); + + result.Should().BeFalse(); + } + + #endregion + + #region Parser Reuse + + [Fact] + public void Parse_MultipleCalls_ShouldReuseParser() + { + // First parse + var result1 = _parser.TryParse("System.String", out var parsed1); + result1.Should().BeTrue(); + parsed1.Should().NotBeNull(); + + // Second parse with different type + var result2 = _parser.TryParse("System.Int32", out var parsed2); + result2.Should().BeTrue(); + parsed2.Should().NotBeNull(); + + // Verify both results are correct + parsed1!.SimpleName.Should().Be("String"); + parsed2!.SimpleName.Should().Be("Int32"); + } + + #endregion + + #region ResX File Ref Types + + [Theory] + [InlineData("System.Resources.ResXFileRef, System.Windows.Forms", "System.Resources", "ResXFileRef", "System.Windows.Forms")] + [InlineData("System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089", "System", "Byte", "mscorlib")] + public void Parse_ResXFileRefType_ShouldSucceed(string typeName, string expectedNamespace, string expectedSimpleName, string expectedAssembly) + { + var result = _parser.TryParse(typeName, out var parsed); + + result.Should().BeTrue($"Failed to parse: {typeName}"); + parsed.Should().NotBeNull(); + parsed!.Namespace.Should().Be(expectedNamespace); + parsed.SimpleName.Should().Be(expectedSimpleName); + parsed.AssemblyName.Should().Be(expectedAssembly); + } + + #endregion +} diff --git a/Aigamo.ResXGenerator/Aigamo.ResXGenerator.csproj b/Aigamo.ResXGenerator/Aigamo.ResXGenerator.csproj index 14cb67d..b7d3edc 100644 --- a/Aigamo.ResXGenerator/Aigamo.ResXGenerator.csproj +++ b/Aigamo.ResXGenerator/Aigamo.ResXGenerator.csproj @@ -1,4 +1,4 @@ - + netstandard2.0 @@ -21,6 +21,10 @@ true + + + + true @@ -35,9 +39,11 @@ + + @@ -51,4 +57,50 @@ + + $(GetTargetPathDependsOn);GetDependencyTargetPaths + + + + + + + + + + + + + + + + + + + <_PackagesToPack Remove="@(_PackagesToPack)" Condition="%(NuGetPackageId) == 'NETStandard.Library'" /> + <_PackagesToPack Remove="@(_PackagesToPack)" Condition="%(_PackagesToPack.IncludeInPackage) != 'true'" /> + + + + + + + + + + + + + + diff --git a/Aigamo.ResXGenerator/Analyser.cs b/Aigamo.ResXGenerator/Analyser.cs index 658ed8d..00debd5 100644 --- a/Aigamo.ResXGenerator/Analyser.cs +++ b/Aigamo.ResXGenerator/Analyser.cs @@ -79,6 +79,36 @@ public override void Initialize(AnalysisContext context) isEnabledByDefault: true ); + public static readonly DiagnosticDescriptor TypeNameParseError = new( + id: "AigamoResXGenerator007", + title: "Invalid type name", + messageFormat: + "The type name specified for member {0} is invalid", + category: "ResXGenerator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor ResXFileRefParseError = new( + id: "AigamoResXGenerator008", + title: "ResXFileRef", + messageFormat: + "The ResXFileRef resource value specified for member {0} is invalid", + category: "ResXGenerator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor TypeNotSupportedForCodeGen = new( + id: "AigamoResXGenerator009", + title: "The type of the resource is not compatible with code gen mode", + messageFormat: + "When using StringLocalizer, only string resources are supported. Set GenerationType to ResourceManager.", + category: "ResXGenerator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + public static DiagnosticDescriptor FatalError => new( id: "AigamoResXGenerator999", title: "Fatal Error generated", diff --git a/Aigamo.ResXGenerator/AnalyzerReleases.Unshipped.md b/Aigamo.ResXGenerator/AnalyzerReleases.Unshipped.md index 4bfc7eb..dc24885 100644 --- a/Aigamo.ResXGenerator/AnalyzerReleases.Unshipped.md +++ b/Aigamo.ResXGenerator/AnalyzerReleases.Unshipped.md @@ -5,4 +5,7 @@ Rule ID | Category | Severity | Notes AigamoResXGenerator004 | ResXGenerator | Warning | LocalizerGenerator AigamoResXGenerator005 | ResXGenerator | Warning | LocalizerGenerator AigamoResXGenerator006 | ResXGenerator | Warning | LocalizerGenerator +AigamoResXGenerator007 | ResXGenerator | Error | SourceGenerator +AigamoResXGenerator008 | ResXGenerator | Error | SourceGenerator +AigamoResXGenerator009 | ResXGenerator | Error | SourceGenerator AigamoResXGenerator999 | ResXGenerator | Error | SourceGenerator diff --git a/Aigamo.ResXGenerator/Generators/CodeGenerator.cs b/Aigamo.ResXGenerator/Generators/CodeGenerator.cs index 6c95164..ccbf898 100644 --- a/Aigamo.ResXGenerator/Generators/CodeGenerator.cs +++ b/Aigamo.ResXGenerator/Generators/CodeGenerator.cs @@ -61,7 +61,10 @@ private void GenerateCode(CancellationToken cancellationToken) fallback.ForEach(fbi => { cancellationToken.ThrowIfCancellationRequested(); - if (Helper.GenerateMember(fbi, Options, Validator) is not { valid: true }) return; + var memberResult = Helper.GenerateMember(fbi, Options, Validator); + if (memberResult is not { valid: true }) return; + + if (!Validator.ValidateTypeForCodeGen(memberResult.typeName, fbi, Options)) return; Helper.Append(" => GetString_"); Helper.AppendLanguages(definedLanguages); diff --git a/Aigamo.ResXGenerator/Generators/GeneratorBase.cs b/Aigamo.ResXGenerator/Generators/GeneratorBase.cs index 03cd162..06a718d 100644 --- a/Aigamo.ResXGenerator/Generators/GeneratorBase.cs +++ b/Aigamo.ResXGenerator/Generators/GeneratorBase.cs @@ -30,7 +30,7 @@ protected static IEnumerable ReadResxFile(SourceText content) return element .Descendants() .Where(static data => data.Name == "data") - .Select(static data => new FallBackItem(data.Attribute("name")!.Value, data.Descendants("value").First().Value, data.Attribute("name")!)); + .Select(static data => new FallBackItem(data.Attribute("name")!.Value, data.Descendants("value").First().Value, data.Attribute("type")?.Value, data.Attribute("name")!)); return []; } diff --git a/Aigamo.ResXGenerator/Generators/ResourceManagerGenerator.cs b/Aigamo.ResXGenerator/Generators/ResourceManagerGenerator.cs index f7a9041..54bb6e7 100644 --- a/Aigamo.ResXGenerator/Generators/ResourceManagerGenerator.cs +++ b/Aigamo.ResXGenerator/Generators/ResourceManagerGenerator.cs @@ -51,19 +51,43 @@ private void CreateMember(FallBackItem fallbackItem) { if (Helper.GenerateMember(fallbackItem, Options, Validator) is not { valid: true } output) return; - var (_, resourceAccessByName) = output; + var (_, resourceAccessByName, typeName) = output; - if (resourceAccessByName) + switch (typeName) { - Helper.Append(" => ResourceManager.GetString(nameof("); - Helper.Append(fallbackItem.Key); - Helper.Append("), "); - } - else - { - Helper.Append(@" => ResourceManager.GetString("""); - Helper.Append(fallbackItem.Key.Replace(@"""", @"\""")); - Helper.Append(@""", "); + case null: + case { FullName: "System.String" }: + if (resourceAccessByName) + { + Helper.Append(" => ResourceManager.GetString(nameof("); + Helper.Append(fallbackItem.Key); + Helper.Append("), "); + } + else + { + Helper.Append(@" => ResourceManager.GetString("""); + Helper.Append(fallbackItem.Key.Replace(@"""", @"\""")); + Helper.Append(@""", "); + } + break; + default: + Helper.Append(" => ("); + Helper.Append(typeName.ToCSharp()); + Helper.Append(")"); + + if (resourceAccessByName) + { + Helper.Append("ResourceManager.GetObject(nameof("); + Helper.Append(fallbackItem.Key); + Helper.Append("), "); + } + else + { + Helper.Append(@"ResourceManager.GetObject("""); + Helper.Append(fallbackItem.Key.Replace(@"""", @"\""")); + Helper.Append(@""", "); + } + break; } Helper.Append(Constants.CultureInfoVariable); diff --git a/Aigamo.ResXGenerator/Models/FallBackItem.cs b/Aigamo.ResXGenerator/Models/FallBackItem.cs index 69dd62b..a41b01a 100644 --- a/Aigamo.ResXGenerator/Models/FallBackItem.cs +++ b/Aigamo.ResXGenerator/Models/FallBackItem.cs @@ -2,4 +2,4 @@ namespace Aigamo.ResXGenerator.Models; -public record FallBackItem(string Key, string Value, IXmlLineInfo Line); +public record FallBackItem(string Key, string Value, string? Type, IXmlLineInfo Line); diff --git a/Aigamo.ResXGenerator/Tools/GenFileOptions.cs b/Aigamo.ResXGenerator/Tools/GenFileOptions.cs index 8dced47..dad2874 100644 --- a/Aigamo.ResXGenerator/Tools/GenFileOptions.cs +++ b/Aigamo.ResXGenerator/Tools/GenFileOptions.cs @@ -43,7 +43,20 @@ GlobalOptions globalOptions globalOptions.ProjectName, globalOptions.RootNamespace); - EmbeddedFilename = string.IsNullOrEmpty(detectedNamespace) ? classNameFromFileName : $"{detectedNamespace}.{classNameFromFileName}"; + // LogicalName metadata takes precedence over all other embedded resource naming schemes, but it should end in ".resources" which we don't pass to ResourceManager + // See https://learn.microsoft.com/en-us/dotnet/core/resources/manifest-file-names + var logicalNameMetadata = options.TryGetValue("build_metadata.EmbeddedResource.LogicalName", out var logicalName) && + logicalName is { Length: > 0 } && logicalName.EndsWith(".resources", StringComparison.OrdinalIgnoreCase) + ? logicalName.Substring(0, logicalName.Length - ".resources".Length) + : null; + + // ManifestResourceName metadata takes second precedence over default embedded resource naming scheme + var manifestResourceNameMetadata = options.TryGetValue("build_metadata.EmbeddedResource.ManifestResourceName", out var manifestResourceName) && + manifestResourceName is { Length: > 0 } + ? manifestResourceName + : null; + + EmbeddedFilename = logicalNameMetadata ?? manifestResourceNameMetadata ?? (string.IsNullOrEmpty(detectedNamespace) ? classNameFromFileName : $"{detectedNamespace}.{classNameFromFileName}"); LocalNamespace = options.TryGetValue("build_metadata.EmbeddedResource.TargetPath", out var targetPath) && diff --git a/Aigamo.ResXGenerator/Tools/IntegrityValidator.cs b/Aigamo.ResXGenerator/Tools/IntegrityValidator.cs index ac52baa..f1acc94 100644 --- a/Aigamo.ResXGenerator/Tools/IntegrityValidator.cs +++ b/Aigamo.ResXGenerator/Tools/IntegrityValidator.cs @@ -8,6 +8,7 @@ namespace Aigamo.ResXGenerator.Tools; public class IntegrityValidator { private readonly HashSet _alreadyAddedMembers = []; + private TypeNameParser _typeNameParser = new(); public List ErrorsAndWarnings { get; } = []; public bool ValidateMember(FallBackItem fallBackItem, GenFileOptions options) => ValidateMember(fallBackItem, options, options.ClassName); @@ -27,15 +28,16 @@ public bool ValidateMember(FallBackItem fallBackItem, GenFileOptions options, st valid = false; } - if (fallBackItem.Key != className) return valid; - - ErrorsAndWarnings.Add(Diagnostic.Create( - descriptor: Analyser.MemberSameAsClassWarning, - location: Utilities.LocateMember(fallBackItem, options), - fallBackItem.Key - )); + if (fallBackItem.Key == className) + { + ErrorsAndWarnings.Add(Diagnostic.Create( + descriptor: Analyser.MemberSameAsClassWarning, + location: Utilities.LocateMember(fallBackItem, options), + fallBackItem.Key + )); - valid = false; + valid = false; + } return valid; } @@ -112,4 +114,111 @@ public bool ValidateInconsistentNameSpace(GenFileOptions options) return false; } + + internal (bool valid, TypeNameParser.ParsedTypeName? typeName) ValidateTypeName(FallBackItem fallbackItem, GenFileOptions options) + { + // If no type is specified, this is a string value + if (string.IsNullOrEmpty(fallbackItem.Type)) + { + return (true, null); + } + + return ValidateTypeName(fallbackItem, options, fallbackItem.Type!); + } + + internal (bool valid, TypeNameParser.ParsedTypeName? typeName) ValidateTypeName(FallBackItem fallbackItem, GenFileOptions options, string inputType) + { + // Ensure the type name parses successfully + if (!_typeNameParser.TryParse(inputType, out var parsedTypeName)) + { + ErrorsAndWarnings.Add(Diagnostic.Create( + descriptor: Analyser.TypeNameParseError, + location: Utilities.LocateMember(fallbackItem, options), + fallbackItem.Key + )); + return (false, null); + } + + // The type name cannot be a pointer or reference + if (parsedTypeName!.IsReference || + parsedTypeName.PointerDepth != 0 || + parsedTypeName.ArrayRanks.Count > 1 || + (parsedTypeName.ArrayRanks.Count == 1 && parsedTypeName.ArrayRanks[0].Rank != 1)) + { + // These are expected to be exceedingly rare, so we can just issue a generic type name error + ErrorsAndWarnings.Add(Diagnostic.Create( + descriptor: Analyser.TypeNameParseError, + location: Utilities.LocateMember(fallbackItem, options), + fallbackItem.Key + )); + return (false, null); + } + + return (true, parsedTypeName); + } + + internal (bool valid, TypeNameParser.ParsedTypeName? typeName) ValidateResXFileRefValue(FallBackItem fallbackItem, GenFileOptions options) + { + // ResXFileRef indicates that the string is of the form ";[; fallbackItem.Value.Length || fallbackItem.Value[nextQuote + 1] != ';') + { + // Invalid ResXFileRef format + ErrorsAndWarnings.Add(Diagnostic.Create( + descriptor: Analyser.ResXFileRefParseError, + location: Utilities.LocateMember(fallbackItem, options), + fallbackItem.Key + )); + return (false, null); + } + valueTypeName = fallbackItem.Value.Substring(nextQuote + 2); + } + else + { + var firstSemicolon = fallbackItem.Value.IndexOf(';'); + if (firstSemicolon < 0 || firstSemicolon + 1 >= fallbackItem.Value.Length) + { + // Invalid ResXFileRef format + ErrorsAndWarnings.Add(Diagnostic.Create( + descriptor: Analyser.ResXFileRefParseError, + location: Utilities.LocateMember(fallbackItem, options), + fallbackItem.Key + )); + return (false, null); + } + valueTypeName = fallbackItem.Value.Substring(firstSemicolon + 1); + } + + var nextSemicolon = valueTypeName.IndexOf(";"); + if (nextSemicolon >= 0) + { + valueTypeName = valueTypeName.Substring(0, nextSemicolon); + } + + return ValidateTypeName(fallbackItem, options, valueTypeName); + } + + internal bool ValidateTypeForCodeGen(TypeNameParser.ParsedTypeName? typeName, FallBackItem fallbackItem, GenFileOptions options) + { + // For code generation scenarios only pure strings are allowed right now. Null is treated as string by default. + + if (typeName is not null && typeName is not { FullName: "System.String" }) + { + ErrorsAndWarnings.Add(Diagnostic.Create( + descriptor: Analyser.TypeNotSupportedForCodeGen, + location: Utilities.LocateMember(fallbackItem, options), + fallbackItem.Key + )); + return false; + } + return true; + } + } diff --git a/Aigamo.ResXGenerator/Tools/StringBuilderGeneratorHelper.cs b/Aigamo.ResXGenerator/Tools/StringBuilderGeneratorHelper.cs index 6128e7d..c2c0b48 100644 --- a/Aigamo.ResXGenerator/Tools/StringBuilderGeneratorHelper.cs +++ b/Aigamo.ResXGenerator/Tools/StringBuilderGeneratorHelper.cs @@ -3,6 +3,7 @@ using System.Text; using Aigamo.ResXGenerator.Extensions; using Aigamo.ResXGenerator.Models; +using Microsoft.CodeAnalysis; namespace Aigamo.ResXGenerator.Tools; @@ -86,7 +87,7 @@ public void AppendClassFooter(GenFileOptions options) Builder.AppendLineLF("}"); } - public (bool valid, bool resourceAccessByName) GenerateMember(FallBackItem fallbackItem, GenFileOptions options, IntegrityValidator validator) + internal (bool valid, bool resourceAccessByName, TypeNameParser.ParsedTypeName? typeName) GenerateMember(FallBackItem fallbackItem, GenFileOptions options, IntegrityValidator validator) { string memberName; bool resourceAccessByName; @@ -103,8 +104,41 @@ public void AppendClassFooter(GenFileOptions options) } - if (!validator.ValidateMember(fallbackItem, options, ContainerClassName)) return (false, resourceAccessByName); + if (!validator.ValidateMember(fallbackItem, options, ContainerClassName)) return (false, resourceAccessByName, null); + var typeNameResult = validator.ValidateTypeName(fallbackItem, options); + if (!typeNameResult.valid) return (false, resourceAccessByName, null); + + switch (typeNameResult.typeName) + { + case null: + case { FullName: "System.String"}: + GenerateValidatedMemberAsString(fallbackItem, options, memberName); + break; + case { FullName: "System.Resources.ResXFileRef" }: + // ReXFileRef indicates that the string is of the form ";[;"); + + Builder.Append(Indent); + Builder.Append("/// Looks up a localized resource of type "); + Builder.Append(typeName.FullName.ToXmlCommentSafe(Indent)); + Builder.AppendLineLF("."); + + Builder.Append(Indent); + Builder.AppendLineLF("/// "); + + Builder.Append(Indent); + Builder.Append("public "); + Builder.Append(options.StaticMembers ? "static " : string.Empty); + Builder.Append(typeName.ToCSharp()); + Builder.Append(options.NullForgivingOperators ? null : "?"); + Builder.Append(" "); + Builder.Append(memberName); } public void AppendResourceManagerUsings() diff --git a/Aigamo.ResXGenerator/Tools/TypeNameParser.cs b/Aigamo.ResXGenerator/Tools/TypeNameParser.cs new file mode 100644 index 0000000..8a8e71a --- /dev/null +++ b/Aigamo.ResXGenerator/Tools/TypeNameParser.cs @@ -0,0 +1,727 @@ +using System; +using System.Collections.Generic; +using System.Text; +using sly.buildresult; +using sly.lexer; +using sly.parser; +using sly.parser.generator; + +namespace Aigamo.ResXGenerator.Tools; + +/// +/// Parser for .NET fully qualified type names using the sly parser generator. +/// Grammar based on: https://learn.microsoft.com/en-us/dotnet/fundamentals/reflection/specifying-fully-qualified-type-names +/// +internal class TypeNameParser +{ + public enum TypeNameToken + { + [Lexeme(@"[a-zA-Z_][a-zA-Z0-9_]*")] + Identifier = 1, + + [Lexeme(@"\.")] + Dot = 2, + + [Lexeme(@",")] + Comma = 3, + + [Lexeme(@"`[0-9]+")] + GenericArity = 4, + + [Lexeme(@"\[")] + OpenBracket = 5, + + [Lexeme(@"\]")] + CloseBracket = 6, + + [Lexeme(@"\+")] + Plus = 7, + + [Lexeme(@"\*")] + Asterisk = 8, + + [Lexeme(@"&")] + Ampersand = 9, + + [Lexeme(@"=")] + Equals = 10, + + [Lexeme(@"[0-9]+")] + Number = 11, + + [Lexeme(@"-")] + Dash = 12, + + [Lexeme(@"[0-9][0-9a-zA-Z_]+")] + NumberPrefixedIdentifier = 13, + + [Lexeme(@"[ \t]+", isSkippable: true)] + Whitespace = 100, + } + + [ParserRoot("typeSpec")] + public class TypeNameParserDefinition + { + // ========================================== + // Top-level: typeSpec + // ========================================== + + [Production("typeSpec: simpleTypeSpec Comma assemblySpec Ampersand")] + public ParsedTypeName ReferenceTypeWithAssembly(ParsedTypeName simpleType, Token comma, ParsedTypeName assembly, Token ampersand) + { + simpleType.AssemblyName = assembly.AssemblyName; + simpleType.AssemblyProperties = assembly.AssemblyProperties; + simpleType.IsReference = true; + return simpleType; + } + + [Production("typeSpec: simpleTypeSpec Ampersand")] + public ParsedTypeName ReferenceType(ParsedTypeName simpleType, Token ampersand) + { + simpleType.IsReference = true; + return simpleType; + } + + [Production("typeSpec: simpleTypeSpec Comma assemblySpec")] + public ParsedTypeName TypeSpecWithAssembly(ParsedTypeName simpleType, Token comma, ParsedTypeName assembly) + { + simpleType.AssemblyName = assembly.AssemblyName; + simpleType.AssemblyProperties = assembly.AssemblyProperties; + return simpleType; + } + + [Production("typeSpec: simpleTypeSpec")] + public ParsedTypeName TypeSpecSimple(ParsedTypeName simpleType) + { + return simpleType; + } + + // ========================================== + // simpleTypeSpec: base type with suffixes (pointers and/or arrays) + // ========================================== + + [Production("simpleTypeSpec: baseType pointerSuffix arraySuffix")] + public ParsedTypeName SimpleTypeSpecWithPointerAndArray(ParsedTypeName baseType, ParsedTypeName pointerSuffix, ParsedTypeName arraySuffix) + { + baseType.PointerDepth = pointerSuffix.PointerDepth; + baseType.ArrayRanks.AddRange(arraySuffix.ArrayRanks); + return baseType; + } + + [Production("simpleTypeSpec: baseType pointerSuffix")] + public ParsedTypeName SimpleTypeSpecWithPointer(ParsedTypeName baseType, ParsedTypeName suffix) + { + baseType.PointerDepth = suffix.PointerDepth; + return baseType; + } + + [Production("simpleTypeSpec: baseType arraySuffix")] + public ParsedTypeName SimpleTypeSpecWithArray(ParsedTypeName baseType, ParsedTypeName suffix) + { + baseType.ArrayRanks.AddRange(suffix.ArrayRanks); + return baseType; + } + + [Production("simpleTypeSpec: baseType")] + public ParsedTypeName SimpleTypeSpecBase(ParsedTypeName baseType) + { + return baseType; + } + + // ========================================== + // pointerSuffix: one or more asterisks + // ========================================== + + [Production("pointerSuffix: Asterisk pointerSuffix")] + public ParsedTypeName PointerSuffixCons(Token asterisk, ParsedTypeName rest) + { + rest.PointerDepth++; + return rest; + } + + [Production("pointerSuffix: Asterisk")] + public ParsedTypeName PointerSuffixSingle(Token asterisk) + { + return new ParsedTypeName { PointerDepth = 1 }; + } + + // ========================================== + // arraySuffix: one or more array specs + // ========================================== + + [Production("arraySuffix: arraySpec arraySuffix")] + public ParsedTypeName ArraySuffixCons(ParsedTypeName spec, ParsedTypeName rest) + { + var result = new ParsedTypeName(); + result.ArrayRanks.AddRange(spec.ArrayRanks); + result.ArrayRanks.AddRange(rest.ArrayRanks); + return result; + } + + [Production("arraySuffix: arraySpec")] + public ParsedTypeName ArraySuffixSingle(ParsedTypeName spec) + { + return spec; + } + + // ========================================== + // arraySpec: single array dimension specification + // ========================================== + + [Production("arraySpec: OpenBracket commas CloseBracket")] + public ParsedTypeName ArraySpecMultiDim(Token open, ParsedTypeName commas, Token close) + { + var result = new ParsedTypeName(); + result.ArrayRanks.Add(new ArrayRank() { Rank = commas.GenericArity }); + return result; + } + + [Production("arraySpec: OpenBracket Asterisk CloseBracket")] + public ParsedTypeName ArraySpecUnknownBound(Token open, Token asterisk, Token close) + { + var result = new ParsedTypeName(); + result.ArrayRanks.Add(new ArrayRank() { Rank = 1, IsUnknownBound = true }); + return result; + } + + [Production("arraySpec: OpenBracket CloseBracket")] + public ParsedTypeName ArraySpecSingleDim(Token open, Token close) + { + var result = new ParsedTypeName(); + result.ArrayRanks.Add(new ArrayRank() { Rank = 1 }); + return result; + } + + // ========================================== + // commas: count commas for multi-dimensional arrays + // ========================================== + + [Production("commas: Comma commas")] + public ParsedTypeName CommasCons(Token comma, ParsedTypeName rest) + { + rest.GenericArity++; + return rest; + } + + [Production("commas: Comma")] + public ParsedTypeName CommasSingle(Token comma) + { + return new ParsedTypeName { GenericArity = 2 }; // One comma = 2 dimensions + } + + // ========================================== + // baseType: type with optional generics and assembly + // ========================================== + + [Production("baseType: qualifiedName GenericArity OpenBracket genericArgs CloseBracket Comma assemblySpec")] + public ParsedTypeName BaseTypeGenericWithAssembly( + ParsedTypeName qualifiedName, + Token arity, + Token open, + ParsedTypeName args, + Token close, + Token comma, + ParsedTypeName assembly) + { + var arityStr = arity.Value.Substring(1); + if (int.TryParse(arityStr, out var arityValue)) + { + qualifiedName.GenericArity = arityValue; + } + qualifiedName.GenericArguments.AddRange(args.GenericArguments); + qualifiedName.AssemblyName = assembly.AssemblyName; + qualifiedName.AssemblyProperties = assembly.AssemblyProperties; + return qualifiedName; + } + + [Production("baseType: qualifiedName GenericArity OpenBracket genericArgs CloseBracket")] + public ParsedTypeName BaseTypeGeneric( + ParsedTypeName qualifiedName, + Token arity, + Token open, + ParsedTypeName args, + Token close) + { + var arityStr = arity.Value.Substring(1); + if (int.TryParse(arityStr, out var arityValue)) + { + qualifiedName.GenericArity = arityValue; + } + qualifiedName.GenericArguments.AddRange(args.GenericArguments); + return qualifiedName; + } + + [Production("baseType: qualifiedName GenericArity")] + public ParsedTypeName BaseTypeGenericNoParams( + ParsedTypeName qualifiedName, + Token arity) + { + var arityStr = arity.Value.Substring(1); + if (int.TryParse(arityStr, out var arityValue)) + { + qualifiedName.GenericArity = arityValue; + } + return qualifiedName; + } + + [Production("baseType: qualifiedName")] + public ParsedTypeName BaseTypeSimple(ParsedTypeName qualifiedName) + { + return qualifiedName; + } + + // ========================================== + // genericArgs: generic type arguments + // ========================================== + + [Production("genericArgs: genericArg Comma genericArgs")] + public ParsedTypeName GenericArgsCons(ParsedTypeName first, Token comma, ParsedTypeName rest) + { + var result = new ParsedTypeName(); + result.GenericArguments.Add(first); + result.GenericArguments.AddRange(rest.GenericArguments); + return result; + } + + [Production("genericArgs: genericArg")] + public ParsedTypeName GenericArgsSingle(ParsedTypeName arg) + { + var result = new ParsedTypeName(); + result.GenericArguments.Add(arg); + return result; + } + + [Production("genericArg: OpenBracket typeSpec CloseBracket")] + public ParsedTypeName GenericArgBracketed(Token open, ParsedTypeName type, Token close) + { + return type; + } + + [Production("genericArg: baseType")] + public ParsedTypeName GenericArgSimple(ParsedTypeName type) + { + return type; + } + + // ========================================== + // assemblySpec: assembly name with optional properties + // ========================================== + + [Production("assemblySpec: assemblyName Comma assemblyProps")] + public ParsedTypeName AssemblySpecWithProps(ParsedTypeName name, Token comma, ParsedTypeName props) + { + var result = new ParsedTypeName(); + result.AssemblyName = name.AssemblyName; + result.AssemblyProperties = props.AssemblyProperties; + return result; + } + + [Production("assemblySpec: assemblyName")] + public ParsedTypeName AssemblySpecName(ParsedTypeName name) + { + return new ParsedTypeName { AssemblyName = name.AssemblyName }; + } + + // ========================================== + // assemblyName: assembly name parts joined by dots/dashes + // ========================================== + + [Production("assemblyName: Identifier Dot assemblyName")] + public ParsedTypeName AssemblyNameDot(Token first, Token dot, ParsedTypeName rest) + { + return new ParsedTypeName { AssemblyName = first.Value + "." + rest.AssemblyName }; + } + + [Production("assemblyName: Identifier Dash assemblyName")] + public ParsedTypeName AssemblyNameDash(Token first, Token dash, ParsedTypeName rest) + { + return new ParsedTypeName { AssemblyName = first.Value + "-" + rest.AssemblyName }; + } + + [Production("assemblyName: Number Dot assemblyName")] + public ParsedTypeName AssemblyNameNumberDot(Token first, Token dot, ParsedTypeName rest) + { + return new ParsedTypeName { AssemblyName = first.Value + "." + rest.AssemblyName }; + } + + [Production("assemblyName: Number Dash assemblyName")] + public ParsedTypeName AssemblyNameNumberDash(Token first, Token dash, ParsedTypeName rest) + { + return new ParsedTypeName { AssemblyName = first.Value + "-" + rest.AssemblyName }; + } + + [Production("assemblyName: Identifier")] + public ParsedTypeName AssemblyNameIdentifier(Token id) + { + return new ParsedTypeName { AssemblyName = id.Value }; + } + + [Production("assemblyName: Number")] + public ParsedTypeName AssemblyNameNumber(Token num) + { + return new ParsedTypeName { AssemblyName = num.Value }; + } + + // ========================================== + // assemblyProps: assembly properties + // ========================================== + + [Production("assemblyProps: assemblyProp Comma assemblyProps")] + public ParsedTypeName AssemblyPropsCons(ParsedTypeName prop, Token comma, ParsedTypeName rest) + { + var result = new ParsedTypeName(); + result.AssemblyProperties = rest.AssemblyProperties ?? new Dictionary(); + if (prop.AssemblyProperties != null) + { + foreach (var kv in prop.AssemblyProperties) + { + result.AssemblyProperties[kv.Key] = kv.Value; + } + } + return result; + } + + [Production("assemblyProps: assemblyProp")] + public ParsedTypeName AssemblyPropsSingle(ParsedTypeName prop) + { + return prop; + } + + [Production("assemblyProp: Identifier Equals propValue")] + public ParsedTypeName AssemblyProp(Token name, Token equals, ParsedTypeName value) + { + var result = new ParsedTypeName(); + result.AssemblyProperties = new Dictionary + { + { name.Value, value.Namespace ?? string.Empty } + }; + return result; + } + + // ========================================== + // propValue: property value (identifier/number with dots) + // ========================================== + + [Production("propValue: Identifier Dot propValue")] + public ParsedTypeName PropValueIdentifierDot(Token first, Token dot, ParsedTypeName rest) + { + return new ParsedTypeName { Namespace = first.Value + "." + rest.Namespace }; + } + + [Production("propValue: Number Dot propValue")] + public ParsedTypeName PropValueNumberDot(Token first, Token dot, ParsedTypeName rest) + { + return new ParsedTypeName { Namespace = first.Value + "." + rest.Namespace }; + } + + [Production("propValue: Number Identifier")] + public ParsedTypeName PropValueNumberIdentifier(Token first, Token id) + { + return new ParsedTypeName { Namespace = first.Value + id.Value }; + } + + [Production("propValue: NumberPrefixedIdentifier")] + public ParsedTypeName PropValueNumberPrefixedIdentifier(Token id) + { + return new ParsedTypeName { Namespace = id.Value }; + } + + [Production("propValue: Identifier")] + public ParsedTypeName PropValueIdentifier(Token id) + { + return new ParsedTypeName { Namespace = id.Value }; + } + + [Production("propValue: Number")] + public ParsedTypeName PropValueNumber(Token num) + { + return new ParsedTypeName { Namespace = num.Value }; + } + + // ========================================== + // qualifiedName: namespace.type+nested + // ========================================== + + [Production("qualifiedName: Identifier Dot qualifiedName")] + public ParsedTypeName QualifiedNameDot(Token first, Token dot, ParsedTypeName rest) + { + var result = new ParsedTypeName(); + + if (rest.TypeNames.Count > 0) + { + // rest has type names, first is namespace part + if (!string.IsNullOrEmpty(rest.Namespace)) + { + result.Namespace = first.Value + "." + rest.Namespace; + } + else + { + result.Namespace = first.Value; + } + result.TypeNames.AddRange(rest.TypeNames); + } + else + { + // This shouldn't happen with proper grammar + result.TypeNames.Add(first.Value); + } + + return result; + } + + [Production("qualifiedName: Identifier Plus nestedTypes")] + public ParsedTypeName QualifiedNameNested(Token first, Token plus, ParsedTypeName nested) + { + var result = new ParsedTypeName(); + result.TypeNames.Add(first.Value); + result.TypeNames.AddRange(nested.TypeNames); + return result; + } + + [Production("qualifiedName: Identifier")] + public ParsedTypeName QualifiedNameSingle(Token id) + { + var result = new ParsedTypeName(); + result.TypeNames.Add(id.Value); + return result; + } + + // ========================================== + // nestedTypes: nested type chain + // ========================================== + + [Production("nestedTypes: Identifier Plus nestedTypes")] + public ParsedTypeName NestedTypesCons(Token id, Token plus, ParsedTypeName rest) + { + var result = new ParsedTypeName(); + result.TypeNames.Add(id.Value); + result.TypeNames.AddRange(rest.TypeNames); + return result; + } + + [Production("nestedTypes: Identifier")] + public ParsedTypeName NestedTypesSingle(Token id) + { + var result = new ParsedTypeName(); + result.TypeNames.Add(id.Value); + return result; + } + } + + public struct ArrayRank + { + public int Rank; + public bool IsUnknownBound; + } + + /// + /// Result of parsing a .NET type name + /// + public class ParsedTypeName + { + /// + /// The namespace of the type (e.g., "System.Collections.Generic") + /// + public string? Namespace { get; set; } + + /// + /// The type names (for nested types, contains multiple entries) + /// First is the outer type, last is the innermost nested type + /// + public List TypeNames { get; } = new(); + + /// + /// The generic arity (number of generic type parameters) + /// + public int GenericArity { get; set; } + + /// + /// The generic type arguments (for closed generic types) + /// + public List GenericArguments { get; } = new(); + + /// + /// Pointer depth (e.g., 2 for "int**") + /// + public int PointerDepth { get; set; } + + /// + /// Array ranks (e.g., [1, 2] for "int[][,]" - jagged array of 2D arrays) + /// + public List ArrayRanks { get; } = new(); + + /// + /// Whether this is a reference type (ends with &) + /// + public bool IsReference { get; set; } + + /// + /// Assembly name (if specified) + /// + public string? AssemblyName { get; set; } + + /// + /// Assembly properties (Version, Culture, PublicKeyToken, etc.) + /// + public Dictionary? AssemblyProperties { get; set; } + + /// + /// Gets the simple type name (innermost type for nested types) + /// + public string SimpleName => TypeNames.Count > 0 ? TypeNames[TypeNames.Count - 1] : string.Empty; + + /// + /// Gets the full type name without assembly qualification + /// + public string FullName + { + get + { + var sb = new StringBuilder(); + if (!string.IsNullOrEmpty(Namespace)) + { + sb.Append(Namespace); + sb.Append('.'); + } + for (int i = 0; i < TypeNames.Count; i++) + { + if (i > 0) + { + sb.Append('+'); + } + sb.Append(TypeNames[i]); + } + if (GenericArity > 0) + { + sb.Append('`'); + sb.Append(GenericArity); + } + if (GenericArguments.Count > 0) + { + sb.Append('['); + for (int i = 0; i < GenericArguments.Count; i++) + { + sb.Append('['); + sb.Append(GenericArguments[i].FullName); + sb.Append(']'); + if (i < GenericArguments.Count - 1) + { + sb.Append(','); + } + } + sb.Append(']'); + } + sb.Append('*', PointerDepth); + for (int i = 0; i < ArrayRanks.Count; i++) + { + if (ArrayRanks[i].Rank == 1 && ArrayRanks[i].IsUnknownBound) + sb.Append("[*]"); + else + sb.Append('[').Append(',', ArrayRanks[i].Rank - 1).Append(']'); + } + if (IsReference) + { + sb.Append('&'); + } + + return sb.ToString(); + } + } + + public override string ToString() => FullName; + + public string ToCSharp() + { + var sb = new StringBuilder(); + if (IsReference) + { + sb.Append("ref "); + } + if (!string.IsNullOrEmpty(Namespace)) + { + sb.Append(Namespace); + sb.Append('.'); + } + for (int i = 0; i < TypeNames.Count; i++) + { + if (i > 0) + { + sb.Append('.'); + } + sb.Append(TypeNames[i]); + } + if (GenericArity != 0) + { + sb.Append('<'); + for (int i = 0; i < GenericArity; i++) + { + if (GenericArguments.Count > i) + { + sb.Append(GenericArguments[i].ToCSharp()); + } + if (i + 1 < GenericArity) + { + sb.Append(','); + } + } + sb.Append('>'); + } + sb.Append('*', PointerDepth); + for (int i = 0; i < ArrayRanks.Count; i++) + { + sb.Append('[').Append(',', ArrayRanks[i].Rank - 1).Append(']'); + } + return sb.ToString(); + } + } + + private Parser? _parser; + + /// + /// Builds the parser instance + /// + public BuildResult> BuildParser() + { + var parserInstance = new TypeNameParserDefinition(); + var builder = new ParserBuilder(); + return builder.BuildParser(parserInstance, ParserType.LL_RECURSIVE_DESCENT, "typeSpec"); + } + + /// + /// Parses a .NET type name string + /// + /// The type name to parse + /// The parsed result or null if parsing failed + public ParseResult? Parse(string typeName) + { + if (_parser == null) + { + var buildResult = BuildParser(); + if (buildResult.IsError) + { + return null; + } + _parser = buildResult.Result; + } + + return _parser.Parse(typeName); + } + + /// + /// Tries to parse a .NET type name string + /// + /// The type name to parse + /// The parsed result if successful + /// True if parsing succeeded + public bool TryParse(string typeName, [NotNullWhen(true)] out ParsedTypeName? result) + { + result = null; + var parseResult = Parse(typeName); + if (parseResult == null || parseResult.IsError) + { + return false; + } + result = parseResult.Result; + return true; + } +} diff --git a/Aigamo.ResXGenerator/build/Aigamo.ResXGenerator.props b/Aigamo.ResXGenerator/build/Aigamo.ResXGenerator.props index 729bf1d..febd9bf 100644 --- a/Aigamo.ResXGenerator/build/Aigamo.ResXGenerator.props +++ b/Aigamo.ResXGenerator/build/Aigamo.ResXGenerator.props @@ -34,6 +34,8 @@ + +