From ffeb8d8c2335219e3fdc264b372c29b017dfe9b1 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Tue, 28 Apr 2026 15:40:59 -0700 Subject: [PATCH] Add Static Extractor Support --- README.md | 65 ++++++++++++++- .../LocalizerAccessorSyntax.cs | 17 ++++ .../PluralStringExtractor.cs | 8 +- .../SingularStringExtractor.cs | 15 +++- .../LocalizerAccessorSyntax.cs | 17 ++++ .../PluralStringExtractor.cs | 3 +- .../SingularStringExtractor.cs | 3 +- .../LocalizerAccessors.cs | 8 +- .../PluralStringExtractorTests.cs | 80 ++++++++++++++++++- .../SingularStringExtractorTests.cs | 73 ++++++++++++++++- .../PluralStringExtractorTests.cs | 80 ++++++++++++++++++- .../SingularStringExtractorTests.cs | 77 +++++++++++++++++- 12 files changed, 411 insertions(+), 35 deletions(-) create mode 100644 src/OrchardCoreContrib.PoExtractor.DotNet.CS/LocalizerAccessorSyntax.cs create mode 100644 src/OrchardCoreContrib.PoExtractor.DotNet.VB/LocalizerAccessorSyntax.cs diff --git a/README.md b/README.md index 13109c8..9947677 100644 --- a/README.md +++ b/README.md @@ -70,14 +70,71 @@ dotnet tool uninstall --global OrchardCoreContrib.PoExtractor OrchardCoreContrib.PoExtractor assumes, the code follows several conventions: -* `IStringLocalizer` or a derived class is accessed via a field named `S` (This is a convention used in Orchard Core) -* `IHtmlLocalizer` or a derived class is accessed via a field named `H` (This is a convention used in Orchard Core) -* `IStringLocalizer` or `IHtmlLocalizer` is accessed via a field named `T` (This is a older convention used in Orchard Core) +* `IStringLocalizer` or a derived class is accessed via a field, property, or helper named `S` (This is a convention used in Orchard Core) +* `IHtmlLocalizer` or a derived class is accessed via a field, property, or helper named `H` (This is a convention used in Orchard Core) +* `IStringLocalizer` or `IHtmlLocalizer` is accessed via a field, property, or helper named `T` (This is a older convention used in Orchard Core) * Liquid templates use the filter named `t` (This is a convention used in Fluid) * context of the localizable string is the full name (with namespace) of the containing class for C# or VB code * context of the localizable string is the dot-delimited relative path the to view for Razor templates * context of the localizable string is the dot-delimited relative path the to template for Liquid templates - +* code extraction supports Orchard-style accessors such as `S["Text"]` and `T["Text"]`, instance/helper calls such as `S("Text")` and `T("Text")`, and typed static helper calls such as `S("Text")`, `T("Text")`, and `.Plural(...)` + +## Static localization helpers + +The extractor can recognize typed static helper calls such as `S("Text")`, `T("Text")`, `S.Plural(...)`, and `T.Plural(...)`, but this tool doesn't provide those runtime helpers for you. If you want to use them in your project then you need to define them in the consuming application. + +Use a typed helper instead of a single global untyped localizer. This keeps runtime localization aligned with the class-based extraction context written into the POT file. + +Example C# setup: + +```csharp +using Microsoft.Extensions.Localization; + +public static class StaticLocalizers +{ + private static IStringLocalizerFactory _stringLocalizerFactory; + + public static void Configure(IStringLocalizerFactory stringLocalizerFactory) => + _stringLocalizerFactory = stringLocalizerFactory; + + public static TypedStringLocalizer S() => new(_stringLocalizerFactory.Create(typeof(T))); + + public static TypedStringLocalizer T() => new(_stringLocalizerFactory.Create(typeof(T))); +} + +public readonly struct TypedStringLocalizer(IStringLocalizer localizer) +{ + public LocalizedString this[string name] => localizer[name]; + + public LocalizedString this[string name, params object[] arguments] => localizer[name, arguments]; + + public LocalizedString Invoke(string name) => localizer[name]; + + public LocalizedString Invoke(string name, params object[] arguments) => localizer[name, arguments]; + + public LocalizedString Plural(int count, string singular, string plural) => + localizer[count == 1 ? singular : plural, count]; +} +``` + +Register the factory once during startup: + +```csharp +StaticLocalizers.Configure(app.Services.GetRequiredService()); +``` + +Then create project-level aliases matching the extractor-supported shapes: + +```csharp +using static StaticLocalizers; + +var title = S()["Title"]; +var message = T().Invoke("Hello"); +var itemCount = T().Plural(totalItems, "1 item", "{0} items"); +``` + +If you want call sites like `S("Text")` or `T.Plural(...)`, add your own thin wrapper methods or types with those exact names. The important part is that the helper remains typed by the containing class, not backed by one shared untyped localizer. + ## Example C# code: diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet.CS/LocalizerAccessorSyntax.cs b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/LocalizerAccessorSyntax.cs new file mode 100644 index 0000000..36feb3d --- /dev/null +++ b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/LocalizerAccessorSyntax.cs @@ -0,0 +1,17 @@ +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace OrchardCoreContrib.PoExtractor.DotNet.CS; + +internal static class LocalizerAccessorSyntax +{ + public static bool IsLocalizerAccessor(ExpressionSyntax expression) => + GetLocalizerIdentifier(expression) is { } identifier && + LocalizerAccessors.IsLocalizerIdentifier(identifier); + + private static string GetLocalizerIdentifier(ExpressionSyntax expression) => expression switch + { + IdentifierNameSyntax identifier => identifier.Identifier.Text, + GenericNameSyntax genericName => genericName.Identifier.Text, + _ => null + }; +} diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet.CS/PluralStringExtractor.cs b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/PluralStringExtractor.cs index 17bf3ca..11ca265 100644 --- a/src/OrchardCoreContrib.PoExtractor.DotNet.CS/PluralStringExtractor.cs +++ b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/PluralStringExtractor.cs @@ -28,11 +28,9 @@ public override bool TryExtract(SyntaxNode node, out LocalizableStringOccurence if (node is InvocationExpressionSyntax invocation && invocation.Expression is MemberAccessExpressionSyntax accessor && - accessor.Expression is IdentifierNameSyntax identifierName && - LocalizerAccessors.LocalizerIdentifiers.Contains(identifierName.Identifier.Text) && + LocalizerAccessorSyntax.IsLocalizerAccessor(accessor.Expression) && accessor.Name.Identifier.Text == "Plural") { - var arguments = invocation.ArgumentList.Arguments; if (arguments.Count >= 2 && arguments[1].Expression is ArrayCreationExpressionSyntax array) @@ -43,9 +41,7 @@ accessor.Expression is IdentifierNameSyntax identifierName && array.Initializer.Expressions[0] is LiteralExpressionSyntax singularLiteral && singularLiteral.IsKind(SyntaxKind.StringLiteralExpression) && array.Initializer.Expressions[1] is LiteralExpressionSyntax pluralLiteral && pluralLiteral.IsKind(SyntaxKind.StringLiteralExpression)) { - result = CreateLocalizedString(singularLiteral.Token.ValueText, pluralLiteral.Token.ValueText, node); - return true; } } @@ -55,9 +51,7 @@ accessor.Expression is IdentifierNameSyntax identifierName && arguments[1].Expression is LiteralExpressionSyntax singularLiteral && singularLiteral.IsKind(SyntaxKind.StringLiteralExpression) && arguments[2].Expression is LiteralExpressionSyntax pluralLiteral && pluralLiteral.IsKind(SyntaxKind.StringLiteralExpression)) { - result = CreateLocalizedString(singularLiteral.Token.ValueText, pluralLiteral.Token.ValueText, node); - return true; } } diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet.CS/SingularStringExtractor.cs b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/SingularStringExtractor.cs index 1653d3c..6c01ae3 100644 --- a/src/OrchardCoreContrib.PoExtractor.DotNet.CS/SingularStringExtractor.cs +++ b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/SingularStringExtractor.cs @@ -27,11 +27,9 @@ public override bool TryExtract(SyntaxNode node, out LocalizableStringOccurence result = null; if (node is ElementAccessExpressionSyntax accessor && - accessor.Expression is IdentifierNameSyntax identifierName && - LocalizerAccessors.LocalizerIdentifiers.Contains(identifierName.Identifier.Text) && + LocalizerAccessorSyntax.IsLocalizerAccessor(accessor.Expression) && accessor.ArgumentList != null) { - var argument = accessor.ArgumentList.Arguments.FirstOrDefault(); if (argument != null && argument.Expression is LiteralExpressionSyntax literal && literal.IsKind(SyntaxKind.StringLiteralExpression)) { @@ -40,6 +38,17 @@ accessor.Expression is IdentifierNameSyntax identifierName && } } + if (node is InvocationExpressionSyntax invocation && + LocalizerAccessorSyntax.IsLocalizerAccessor(invocation.Expression)) + { + var argument = invocation.ArgumentList.Arguments.FirstOrDefault(); + if (argument != null && argument.Expression is LiteralExpressionSyntax literal && literal.IsKind(SyntaxKind.StringLiteralExpression)) + { + result = CreateLocalizedString(literal.Token.ValueText, null, node); + return true; + } + } + return false; } } diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet.VB/LocalizerAccessorSyntax.cs b/src/OrchardCoreContrib.PoExtractor.DotNet.VB/LocalizerAccessorSyntax.cs new file mode 100644 index 0000000..5511fa0 --- /dev/null +++ b/src/OrchardCoreContrib.PoExtractor.DotNet.VB/LocalizerAccessorSyntax.cs @@ -0,0 +1,17 @@ +using Microsoft.CodeAnalysis.VisualBasic.Syntax; + +namespace OrchardCoreContrib.PoExtractor.DotNet.VB; + +internal static class LocalizerAccessorSyntax +{ + public static bool IsLocalizerAccessor(ExpressionSyntax expression) => + GetLocalizerIdentifier(expression) is { } identifier && + LocalizerAccessors.IsLocalizerIdentifier(identifier); + + private static string GetLocalizerIdentifier(ExpressionSyntax expression) => expression switch + { + IdentifierNameSyntax identifier => identifier.Identifier.Text, + GenericNameSyntax genericName => genericName.Identifier.Text, + _ => null + }; +} diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet.VB/PluralStringExtractor.cs b/src/OrchardCoreContrib.PoExtractor.DotNet.VB/PluralStringExtractor.cs index b07a039..3f2b63e 100644 --- a/src/OrchardCoreContrib.PoExtractor.DotNet.VB/PluralStringExtractor.cs +++ b/src/OrchardCoreContrib.PoExtractor.DotNet.VB/PluralStringExtractor.cs @@ -28,8 +28,7 @@ public override bool TryExtract(SyntaxNode node, out LocalizableStringOccurence if (node is InvocationExpressionSyntax invocation && invocation.Expression is MemberAccessExpressionSyntax accessor && - accessor.Expression is IdentifierNameSyntax identifierName && - LocalizerAccessors.LocalizerIdentifiers.Contains(identifierName.Identifier.Text) && + LocalizerAccessorSyntax.IsLocalizerAccessor(accessor.Expression) && accessor.Name.Identifier.Text == "Plural") { var arguments = invocation.ArgumentList.Arguments; diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet.VB/SingularStringExtractor.cs b/src/OrchardCoreContrib.PoExtractor.DotNet.VB/SingularStringExtractor.cs index d0961e7..d8b40a2 100644 --- a/src/OrchardCoreContrib.PoExtractor.DotNet.VB/SingularStringExtractor.cs +++ b/src/OrchardCoreContrib.PoExtractor.DotNet.VB/SingularStringExtractor.cs @@ -26,8 +26,7 @@ public override bool TryExtract(SyntaxNode node, out LocalizableStringOccurence result = null; if (node is InvocationExpressionSyntax accessor && - accessor.Expression is IdentifierNameSyntax identifierName && - LocalizerAccessors.LocalizerIdentifiers.Contains(identifierName.Identifier.Text) && + LocalizerAccessorSyntax.IsLocalizerAccessor(accessor.Expression) && accessor.ArgumentList != null) { var argument = accessor.ArgumentList.Arguments.FirstOrDefault(); diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet/LocalizerAccessors.cs b/src/OrchardCoreContrib.PoExtractor.DotNet/LocalizerAccessors.cs index f64d841..b0e46f8 100644 --- a/src/OrchardCoreContrib.PoExtractor.DotNet/LocalizerAccessors.cs +++ b/src/OrchardCoreContrib.PoExtractor.DotNet/LocalizerAccessors.cs @@ -27,6 +27,12 @@ public static class LocalizerAccessors [ DefaultLocalizerIdentifier, StringLocalizerIdentifier, - HtmlLocalizerIdentifier + HtmlLocalizerIdentifier, ]; + + /// + /// Determines whether the identifier matches a supported localizer accessor name. + /// + public static bool IsLocalizerIdentifier(string identifier) => + LocalizerIdentifiers.Contains(identifier, StringComparer.Ordinal); } diff --git a/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/PluralStringExtractorTests.cs b/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/PluralStringExtractorTests.cs index 2d17b1b..1e46e4c 100644 --- a/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/PluralStringExtractorTests.cs +++ b/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/PluralStringExtractorTests.cs @@ -1,12 +1,15 @@ using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; using OrchardCoreContrib.PoExtractor.DotNet.CS.MetadataProviders; namespace OrchardCoreContrib.PoExtractor.DotNet.CS.Tests; public class PluralStringExtractorTests { - [Fact] - public void ShouldExtractValidString() + [Theory] + [InlineData("S")] + [InlineData("T")] + public void ShouldExtractValidString(string localizer) { // Arrange var text = "{0} thing"; @@ -15,7 +18,7 @@ public void ShouldExtractValidString() var extractor = new PluralStringExtractor(metadataProvider); var syntaxTree = CSharpSyntaxTree - .ParseText($"S.Plural(1, \"{text}\", \"{pluralText}\");", path: "DummyPath"); + .ParseText($@"{localizer}.Plural(1, ""{text}"", ""{pluralText}"");", path: "DummyPath"); var node = syntaxTree .GetRoot() @@ -30,4 +33,73 @@ public void ShouldExtractValidString() Assert.Equal(text, result.Text); Assert.Equal(pluralText, result.TextPlural); } -} \ No newline at end of file + + [Theory] + [InlineData("S.Plural")] + [InlineData("T.Plural")] + public void ShouldExtractTypedStaticPluralString(string localizer) + { + // Arrange + var text = "{0} thing"; + var pluralText = "{0} things"; + var metadataProvider = new CSharpMetadataProvider("DummyBasePath"); + var extractor = new PluralStringExtractor(metadataProvider); + + var syntaxTree = CSharpSyntaxTree.ParseText($@" +namespace OrchardCore.Tests; + +public class ContainingType +{{ + public void Run() + {{ + {localizer}(1, ""{text}"", ""{pluralText}""); + }} +}} +", path: "DummyPath"); + + var node = syntaxTree + .GetRoot() + .DescendantNodes() + .OfType() + .Single(); + + // Act + var extracted = extractor.TryExtract(node, out var result); + + // Assert + Assert.True(extracted); + Assert.Equal(text, result.Text); + Assert.Equal(pluralText, result.TextPlural); + Assert.Equal("OrchardCore.Tests.ContainingType", result.Context); + } + + [Fact] + public void ShouldNotExtractUntypedStaticPluralString() + { + // Arrange + var text = "{0} thing"; + var pluralText = "{0} things"; + var metadataProvider = new CSharpMetadataProvider("DummyBasePath"); + var extractor = new PluralStringExtractor(metadataProvider); + + var syntaxTree = CSharpSyntaxTree.ParseText($@" +namespace OrchardCore.Tests; + +public class ContainingType +{{ + public void Run() + {{ + SharedLocalizer.S.Plural(1, ""{text}"", ""{pluralText}""); + }} +}} +", path: "DummyPath"); + + var node = syntaxTree.GetRoot().DescendantNodes().OfType().Single(); + + // Act + var extracted = extractor.TryExtract(node, out _); + + // Assert + Assert.False(extracted); + } +} diff --git a/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/SingularStringExtractorTests.cs b/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/SingularStringExtractorTests.cs index 272a1d7..cc50528 100644 --- a/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/SingularStringExtractorTests.cs +++ b/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/SingularStringExtractorTests.cs @@ -1,19 +1,23 @@ +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; using OrchardCoreContrib.PoExtractor.DotNet.CS.MetadataProviders; namespace OrchardCoreContrib.PoExtractor.DotNet.CS.Tests; public class SingularStringExtractorTests { - [Fact] - public void ExtractString() + [Theory] + [InlineData("S")] + [InlineData("T")] + public void ExtractIndexerString(string localizer) { // Arrange var text = "Thing"; var metadataProvider = new CSharpMetadataProvider("DummyBasePath"); var extractor = new SingularStringExtractor(metadataProvider); - var syntaxTree = CSharpSyntaxTree.ParseText($"S[\"{text}\"];", path: "DummyPath"); + var syntaxTree = CSharpSyntaxTree.ParseText($@"{localizer}[""{text}""];", path: "DummyPath"); var node = syntaxTree .GetRoot() @@ -27,4 +31,65 @@ public void ExtractString() Assert.True(extracted); Assert.Equal(text, result.Text); } -} \ No newline at end of file + + [Theory] + [InlineData("S(\"Thing\");")] + [InlineData("T(\"Thing\");")] + public void ExtractTypedStaticLocalizerString(string code) + { + // Arrange + var metadataProvider = new CSharpMetadataProvider("DummyBasePath"); + var extractor = new SingularStringExtractor(metadataProvider); + + var syntaxTree = CSharpSyntaxTree.ParseText($@" +namespace OrchardCore.Tests; + +public class ContainingType +{{ + public void Run() + {{ + {code} + }} +}} +", path: "DummyPath"); + + var root = syntaxTree.GetRoot(); + SyntaxNode node = root.DescendantNodes().OfType().Single(); + + // Act + var extracted = extractor.TryExtract(node, out var result); + + // Assert + Assert.True(extracted); + Assert.Equal("Thing", result.Text); + Assert.Equal("OrchardCore.Tests.ContainingType", result.Context); + } + + [Fact] + public void DoesNotExtractUntypedStaticLocalizerString() + { + // Arrange + var metadataProvider = new CSharpMetadataProvider("DummyBasePath"); + var extractor = new SingularStringExtractor(metadataProvider); + + var syntaxTree = CSharpSyntaxTree.ParseText($@" +namespace OrchardCore.Tests; + +public class ContainingType +{{ + public void Run() + {{ + SharedLocalizer.S(""Thing""); + }} +}} +", path: "DummyPath"); + + var node = syntaxTree.GetRoot().DescendantNodes().OfType().Single(); + + // Act + var extracted = extractor.TryExtract(node, out _); + + // Assert + Assert.False(extracted); + } +} diff --git a/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/PluralStringExtractorTests.cs b/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/PluralStringExtractorTests.cs index 1423c45..e2fcc19 100644 --- a/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/PluralStringExtractorTests.cs +++ b/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/PluralStringExtractorTests.cs @@ -1,12 +1,15 @@ using Microsoft.CodeAnalysis.VisualBasic; +using Microsoft.CodeAnalysis.VisualBasic.Syntax; using OrchardCoreContrib.PoExtractor.DotNet.VB.MetadataProviders; namespace OrchardCoreContrib.PoExtractor.DotNet.VB.Tests; public class PluralStringExtractorTests { - [Fact] - public void ShouldExtractValidString() + [Theory] + [InlineData("S")] + [InlineData("T")] + public void ShouldExtractValidString(string localizer) { // Arrange var text = "{0} thing"; @@ -15,7 +18,7 @@ public void ShouldExtractValidString() var extractor = new PluralStringExtractor(metadataProvider); var syntaxTree = VisualBasicSyntaxTree - .ParseText($"S.Plural(1, \"{text}\", \"{pluralText}\")", path: "DummyPath"); + .ParseText($@"{localizer}.Plural(1, ""{text}"", ""{pluralText}"")", path: "DummyPath"); var node = syntaxTree .GetRoot() @@ -30,4 +33,73 @@ public void ShouldExtractValidString() Assert.Equal(text, result.Text); Assert.Equal(pluralText, result.TextPlural); } -} \ No newline at end of file + + [Theory] + [InlineData("S(Of ContainingType).Plural")] + [InlineData("T(Of ContainingType).Plural")] + public void ShouldExtractTypedStaticPluralString(string localizer) + { + // Arrange + var text = "{0} thing"; + var pluralText = "{0} things"; + var metadataProvider = new VisualBasicMetadataProvider("DummyBasePath"); + var extractor = new PluralStringExtractor(metadataProvider); + + var syntaxTree = VisualBasicSyntaxTree.ParseText($""" + Namespace OrchardCore.Tests + Public Class ContainingType + Public Sub Run() + {localizer}(1, "{text}", "{pluralText}") + End Sub + End Class + End Namespace + """, path: "DummyPath"); + + var node = syntaxTree + .GetRoot() + .DescendantNodes() + .OfType() + .Single(); + + // Act + var extracted = extractor.TryExtract(node, out var result); + + // Assert + Assert.True(extracted); + Assert.Equal(text, result.Text); + Assert.Equal(pluralText, result.TextPlural); + Assert.Equal("OrchardCore.Tests.ContainingType", result.Context); + } + + [Fact] + public void ShouldNotExtractUntypedStaticPluralString() + { + // Arrange + var text = "{0} thing"; + var pluralText = "{0} things"; + var metadataProvider = new VisualBasicMetadataProvider("DummyBasePath"); + var extractor = new PluralStringExtractor(metadataProvider); + + var syntaxTree = VisualBasicSyntaxTree.ParseText($""" + Namespace OrchardCore.Tests + Public Class ContainingType + Public Sub Run() + SharedLocalizer.S.Plural(1, "{text}", "{pluralText}") + End Sub + End Class + End Namespace + """, path: "DummyPath"); + + var node = syntaxTree + .GetRoot() + .DescendantNodes() + .OfType() + .Single(); + + // Act + var extracted = extractor.TryExtract(node, out _); + + // Assert + Assert.False(extracted); + } +} diff --git a/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/SingularStringExtractorTests.cs b/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/SingularStringExtractorTests.cs index b8d0717..3613a76 100644 --- a/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/SingularStringExtractorTests.cs +++ b/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/SingularStringExtractorTests.cs @@ -1,19 +1,22 @@ using Microsoft.CodeAnalysis.VisualBasic; +using Microsoft.CodeAnalysis.VisualBasic.Syntax; using OrchardCoreContrib.PoExtractor.DotNet.VB.MetadataProviders; namespace OrchardCoreContrib.PoExtractor.DotNet.VB.Tests; public class SingularStringExtractorTests { - [Fact] - public void ExtractString() + [Theory] + [InlineData("S")] + [InlineData("T")] + public void ExtractString(string localizer) { // Arrange var text = "Thing"; var metadataProvider = new VisualBasicMetadataProvider("DummyBasePath"); var extractor = new SingularStringExtractor(metadataProvider); - var syntaxTree = VisualBasicSyntaxTree.ParseText($"S(\"{text}\")", path: "DummyPath"); + var syntaxTree = VisualBasicSyntaxTree.ParseText($@"{localizer}(""{text}"")", path: "DummyPath"); var node = syntaxTree .GetRoot() @@ -27,4 +30,70 @@ public void ExtractString() Assert.True(extracted); Assert.Equal(text, result.Text); } -} \ No newline at end of file + + [Theory] + [InlineData("S(Of ContainingType)(\"Thing\")")] + [InlineData("T(Of ContainingType)(\"Thing\")")] + public void ExtractTypedStaticString(string code) + { + // Arrange + var text = "Thing"; + var metadataProvider = new VisualBasicMetadataProvider("DummyBasePath"); + var extractor = new SingularStringExtractor(metadataProvider); + + var syntaxTree = VisualBasicSyntaxTree.ParseText($""" + Namespace OrchardCore.Tests + Public Class ContainingType + Public Sub Run() + {code} + End Sub + End Class + End Namespace + """, path: "DummyPath"); + + var node = syntaxTree + .GetRoot() + .DescendantNodes() + .OfType() + .Single(); + + // Act + var extracted = extractor.TryExtract(node, out var result); + + // Assert + Assert.True(extracted); + Assert.Equal(text, result.Text); + Assert.Equal("OrchardCore.Tests.ContainingType", result.Context); + } + + [Fact] + public void DoesNotExtractUntypedStaticString() + { + // Arrange + var text = "Thing"; + var metadataProvider = new VisualBasicMetadataProvider("DummyBasePath"); + var extractor = new SingularStringExtractor(metadataProvider); + + var syntaxTree = VisualBasicSyntaxTree.ParseText($""" + Namespace OrchardCore.Tests + Public Class ContainingType + Public Sub Run() + SharedLocalizer.S("{text}") + End Sub + End Class + End Namespace + """, path: "DummyPath"); + + var node = syntaxTree + .GetRoot() + .DescendantNodes() + .OfType() + .Single(); + + // Act + var extracted = extractor.TryExtract(node, out _); + + // Assert + Assert.False(extracted); + } +}