diff --git a/README.md b/README.md index 13109c8..9a7eb15 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ 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) +* `LocalizedString` or `LocalizedHtmlString` instances created directly with a string literal name, e.g., `new LocalizedString("Text", "Text")` or `new LocalizedHtmlString("Text", "Text")`, are extracted using the name as the message ID. The single-argument form, e.g., `new LocalizedString("Text")` or `new LocalizedHtmlString("Text")`, is also supported +* `LocalizedString.Create("Text")` and `LocalizedHtmlString.Create("Text")` factory calls (and the `LocalizedStringExtensions.Create("Text")` and `LocalizedHtmlStringExtensions.Create("Text")` forms) are extracted using the first argument as the message ID. Any other arguments, such as format arguments, e.g., `LocalizedString.Create("Hello {0}", name)`, are ignored * 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 diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet.CS/CSharpProjectProcessor.cs b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/CSharpProjectProcessor.cs index 72e9a0c..22b3731 100644 --- a/src/OrchardCoreContrib.PoExtractor.DotNet.CS/CSharpProjectProcessor.cs +++ b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/CSharpProjectProcessor.cs @@ -33,6 +33,7 @@ public virtual void Process(string path, string basePath, LocalizableStringColle [ new SingularStringExtractor(csharpMetadataProvider), new PluralStringExtractor(csharpMetadataProvider), + new LocalizedStringExtractor(csharpMetadataProvider), new ErrorMessageAnnotationStringExtractor(csharpMetadataProvider), new DisplayAttributeDescriptionStringExtractor(csharpMetadataProvider), new DisplayAttributeNameStringExtractor(csharpMetadataProvider), diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet.CS/ExpressionSyntaxExtensions.cs b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/ExpressionSyntaxExtensions.cs new file mode 100644 index 0000000..1df85cc --- /dev/null +++ b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/ExpressionSyntaxExtensions.cs @@ -0,0 +1,37 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace OrchardCoreContrib.PoExtractor.DotNet.CS; + +internal static class ExpressionSyntaxExtensions +{ + /// + /// Gets the value of a string literal, or of string literals joined with the + operator. + /// + /// The . + /// The string value. + public static bool TryGetString(this ExpressionSyntax expression, out string value) + { + if (expression is LiteralExpressionSyntax literal && literal.IsKind(SyntaxKind.StringLiteralExpression)) + { + value = literal.Token.ValueText; + + return true; + } + + if (expression is BinaryExpressionSyntax binary && + binary.IsKind(SyntaxKind.AddExpression) && + binary.Left.TryGetString(out var left) && + binary.Right.TryGetString(out var right)) + { + value = left + right; + + return true; + } + + value = null; + + return false; + } +} diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet.CS/LocalizedStringExtractor.cs b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/LocalizedStringExtractor.cs new file mode 100644 index 0000000..2fbce7d --- /dev/null +++ b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/LocalizedStringExtractor.cs @@ -0,0 +1,69 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System; +using System.Linq; + +namespace OrchardCoreContrib.PoExtractor.DotNet.CS; + +/// +/// Extracts with the singular text from the C# AST node +/// +/// +/// The localizable string is identified by the constructor call - new LocalizedString("TEXT TO TRANSLATE", "TEXT TO TRANSLATE") +/// or new LocalizedHtmlString("TEXT TO TRANSLATE", "TEXT TO TRANSLATE"). The single argument form +/// new LocalizedString("TEXT TO TRANSLATE") or new LocalizedHtmlString("TEXT TO TRANSLATE") is also supported. +/// The factory method call - LocalizedString.Create("TEXT TO TRANSLATE") or LocalizedHtmlString.Create("TEXT TO TRANSLATE") +/// is also supported, including the LocalizedStringExtensions.Create("TEXT TO TRANSLATE") and +/// LocalizedHtmlStringExtensions.Create("TEXT TO TRANSLATE") forms. +/// +/// +/// Creates a new instance of a . +/// +/// The . +public class LocalizedStringExtractor(IMetadataProvider metadataProvider) : LocalizableStringExtractor(metadataProvider) +{ + /// + public override bool TryExtract(SyntaxNode node, out LocalizableStringOccurence result) + { + ArgumentNullException.ThrowIfNull(node); + + result = null; + + var argumentList = node switch + { + ObjectCreationExpressionSyntax creation when IsTypeName(creation.Type, LocalizedStringTypes.TypeNames) => creation.ArgumentList, + InvocationExpressionSyntax invocation when IsFactoryMethod(invocation.Expression) => invocation.ArgumentList, + _ => null + }; + + if (argumentList != null && + argumentList.Arguments.Count > 0 && + argumentList.Arguments[0].Expression.TryGetString(out var value)) + { + result = CreateLocalizedString(value, null, node); + + return true; + } + + return false; + } + + private static bool IsFactoryMethod(ExpressionSyntax expression) + => expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name.Identifier.Text == LocalizedStringTypes.FactoryMethodName && + IsTypeName(memberAccess.Expression, LocalizedStringTypes.FactoryTypeNames); + + private static bool IsTypeName(ExpressionSyntax expression, string[] typeNames) + { + var name = expression switch + { + IdentifierNameSyntax identifierName => identifierName.Identifier.Text, + QualifiedNameSyntax qualifiedName => qualifiedName.Right.Identifier.Text, + AliasQualifiedNameSyntax aliasQualifiedName => aliasQualifiedName.Name.Identifier.Text, + MemberAccessExpressionSyntax memberAccess => memberAccess.Name.Identifier.Text, + _ => null + }; + + return name != null && typeNames.Contains(name); + } +} diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet.CS/SingularStringExtractor.cs b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/SingularStringExtractor.cs index 43e1432..cb85e1e 100644 --- a/src/OrchardCoreContrib.PoExtractor.DotNet.CS/SingularStringExtractor.cs +++ b/src/OrchardCoreContrib.PoExtractor.DotNet.CS/SingularStringExtractor.cs @@ -1,5 +1,4 @@ using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using System; using System.Linq; @@ -33,7 +32,7 @@ accessor.Expression is IdentifierNameSyntax identifierName && { var argument = accessor.ArgumentList.Arguments.FirstOrDefault(); - if (argument != null && TryGetString(argument.Expression, out var value)) + if (argument != null && argument.Expression.TryGetString(out var value)) { result = CreateLocalizedString(value, null, node); return true; @@ -42,28 +41,4 @@ accessor.Expression is IdentifierNameSyntax identifierName && return false; } - - private static bool TryGetString(ExpressionSyntax expression, out string value) - { - if (expression is LiteralExpressionSyntax literal && literal.IsKind(SyntaxKind.StringLiteralExpression)) - { - value = literal.Token.ValueText; - - return true; - } - - if (expression is BinaryExpressionSyntax binary && - binary.IsKind(SyntaxKind.AddExpression) && - TryGetString(binary.Left, out var left) && - TryGetString(binary.Right, out var right)) - { - value = left + right; - - return true; - } - - value = null; - - return false; - } } diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet.VB/LocalizedStringExtractor.cs b/src/OrchardCoreContrib.PoExtractor.DotNet.VB/LocalizedStringExtractor.cs new file mode 100644 index 0000000..f026581 --- /dev/null +++ b/src/OrchardCoreContrib.PoExtractor.DotNet.VB/LocalizedStringExtractor.cs @@ -0,0 +1,74 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.VisualBasic; +using Microsoft.CodeAnalysis.VisualBasic.Syntax; +using System; +using System.Linq; + +namespace OrchardCoreContrib.PoExtractor.DotNet.VB; + +/// +/// Extracts with the singular text from the VB AST node +/// +/// +/// The localizable string is identified by the constructor call - New LocalizedString("TEXT TO TRANSLATE", "TEXT TO TRANSLATE") +/// or New LocalizedHtmlString("TEXT TO TRANSLATE", "TEXT TO TRANSLATE"). The single argument form +/// New LocalizedString("TEXT TO TRANSLATE") or New LocalizedHtmlString("TEXT TO TRANSLATE") is also supported. +/// The factory method call - LocalizedString.Create("TEXT TO TRANSLATE") or LocalizedHtmlString.Create("TEXT TO TRANSLATE") +/// is also supported, including the LocalizedStringExtensions.Create("TEXT TO TRANSLATE") and +/// LocalizedHtmlStringExtensions.Create("TEXT TO TRANSLATE") forms. +/// +/// +/// Creates a new instance of a . +/// +/// The . +public class LocalizedStringExtractor(IMetadataProvider metadataProvider) : LocalizableStringExtractor(metadataProvider) +{ + /// + public override bool TryExtract(SyntaxNode node, out LocalizableStringOccurence result) + { + ArgumentNullException.ThrowIfNull(node); + + result = null; + + var argumentList = node switch + { + ObjectCreationExpressionSyntax creation when IsTypeName(creation.Type, LocalizedStringTypes.TypeNames) => creation.ArgumentList, + InvocationExpressionSyntax invocation when IsFactoryMethod(invocation.Expression) => invocation.ArgumentList, + _ => null + }; + + if (argumentList != null && + argumentList.Arguments.Count > 0 && + argumentList.Arguments[0].GetExpression() is LiteralExpressionSyntax literal && + literal.IsKind(SyntaxKind.StringLiteralExpression)) + { + result = CreateLocalizedString(literal.Token.ValueText, null, node); + + return true; + } + + return false; + } + + private static bool IsFactoryMethod(ExpressionSyntax expression) + => expression is MemberAccessExpressionSyntax memberAccess && + IsName(memberAccess.Name.Identifier.ValueText, LocalizedStringTypes.FactoryMethodName) && + IsTypeName(memberAccess.Expression, LocalizedStringTypes.FactoryTypeNames); + + private static bool IsTypeName(ExpressionSyntax expression, string[] typeNames) + { + var name = expression switch + { + IdentifierNameSyntax identifierName => identifierName.Identifier.ValueText, + QualifiedNameSyntax qualifiedName => qualifiedName.Right.Identifier.ValueText, + MemberAccessExpressionSyntax memberAccess => memberAccess.Name.Identifier.ValueText, + _ => null + }; + + return name != null && typeNames.Any(typeName => IsName(name, typeName)); + } + + // Visual Basic identifiers are case-insensitive. + private static bool IsName(string name, string expected) + => string.Equals(name, expected, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet.VB/VisualBasicProjectProcessor.cs b/src/OrchardCoreContrib.PoExtractor.DotNet.VB/VisualBasicProjectProcessor.cs index dc7c97f..e353933 100644 --- a/src/OrchardCoreContrib.PoExtractor.DotNet.VB/VisualBasicProjectProcessor.cs +++ b/src/OrchardCoreContrib.PoExtractor.DotNet.VB/VisualBasicProjectProcessor.cs @@ -33,6 +33,7 @@ public void Process(string path, string basePath, LocalizableStringCollection lo [ new SingularStringExtractor(visualBasicMetadataProvider), new PluralStringExtractor(visualBasicMetadataProvider), + new LocalizedStringExtractor(visualBasicMetadataProvider), new ErrorMessageAnnotationStringExtractor(visualBasicMetadataProvider), new DisplayAttributeDescriptionStringExtractor(visualBasicMetadataProvider), new DisplayAttributeNameStringExtractor(visualBasicMetadataProvider), diff --git a/src/OrchardCoreContrib.PoExtractor.DotNet/LocalizedStringTypes.cs b/src/OrchardCoreContrib.PoExtractor.DotNet/LocalizedStringTypes.cs new file mode 100644 index 0000000..27eefb3 --- /dev/null +++ b/src/OrchardCoreContrib.PoExtractor.DotNet/LocalizedStringTypes.cs @@ -0,0 +1,52 @@ +namespace OrchardCoreContrib.PoExtractor.DotNet; + +/// +/// Represents a class that contains the names of the localized string types and their factory methods. +/// +public static class LocalizedStringTypes +{ + /// + /// Gets the name of the type that represents a localized string. + /// + public static readonly string LocalizedStringTypeName = "LocalizedString"; + + /// + /// Gets the name of the type that represents a localized HTML string. + /// + public static readonly string LocalizedHtmlStringTypeName = "LocalizedHtmlString"; + + /// + /// Gets the name of the class that contains the LocalizedString factory method. + /// + public static readonly string LocalizedStringExtensionsTypeName = "LocalizedStringExtensions"; + + /// + /// Gets the name of the class that contains the LocalizedHtmlString factory method. + /// + public static readonly string LocalizedHtmlStringExtensionsTypeName = "LocalizedHtmlStringExtensions"; + + /// + /// Gets the name of the factory method that creates a localized string from its name. + /// + public static readonly string FactoryMethodName = "Create"; + + /// + /// Gets the names of the localized string types that can be created with a constructor. + /// + public static readonly string[] TypeNames = + [ + LocalizedStringTypeName, + LocalizedHtmlStringTypeName + ]; + + /// + /// Gets the names of the types that contain the factory method. + /// + public static readonly string[] FactoryTypeNames = + [ + LocalizedStringTypeName, + LocalizedHtmlStringTypeName, + LocalizedStringExtensionsTypeName, + LocalizedHtmlStringExtensionsTypeName + ]; +} diff --git a/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/LocalizedStringExtractorTests.cs b/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/LocalizedStringExtractorTests.cs new file mode 100644 index 0000000..788ba08 --- /dev/null +++ b/test/OrchardCoreContrib.PoExtractor.DotNet.CS.Tests/LocalizedStringExtractorTests.cs @@ -0,0 +1,92 @@ +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using OrchardCoreContrib.PoExtractor.DotNet.CS.MetadataProviders; + +namespace OrchardCoreContrib.PoExtractor.DotNet.CS.Tests; + +public class LocalizedStringExtractorTests +{ + [Theory] + [InlineData("""new LocalizedString("Thing", "Thing");""", "Thing")] + [InlineData("""new LocalizedString("Thing");""", "Thing")] + [InlineData("""new LocalizedHtmlString("Thing");""", "Thing")] + [InlineData("""new LocalizedString("Thing", "Other thing");""", "Thing")] + [InlineData("""new LocalizedString("Thing", "Thing", true);""", "Thing")] + [InlineData("""new Microsoft.Extensions.Localization.LocalizedString("Thing", "Thing");""", "Thing")] + [InlineData("""new global::Microsoft.Extensions.Localization.LocalizedString("Thing", "Thing");""", "Thing")] + [InlineData("""new LocalizedString("my " + "text", "my text");""", "my text")] + [InlineData("""new LocalizedHtmlString("Thing", "Thing");""", "Thing")] + [InlineData("""new LocalizedHtmlString("Thing {0}", "Thing {0}", false, 1);""", "Thing {0}")] + [InlineData("""new Microsoft.AspNetCore.Mvc.Localization.LocalizedHtmlString("Thing", "Thing");""", "Thing")] + [InlineData("""LocalizedString.Create("Thing");""", "Thing")] + [InlineData("""LocalizedHtmlString.Create("Thing");""", "Thing")] + [InlineData("""LocalizedString.Create("my " + "text");""", "my text")] + [InlineData("""Microsoft.Extensions.Localization.LocalizedString.Create("Thing");""", "Thing")] + [InlineData("""global::Microsoft.AspNetCore.Mvc.Localization.LocalizedHtmlString.Create("Thing");""", "Thing")] + [InlineData("""LocalizedStringExtensions.Create("Thing");""", "Thing")] + [InlineData("""LocalizedHtmlStringExtensions.Create("Thing");""", "Thing")] + [InlineData("""LocalizedString.Create("Hello {0}", name);""", "Hello {0}")] + [InlineData("""LocalizedHtmlString.Create("{0} and {1}", first, second);""", "{0} and {1}")] + [InlineData("""LocalizedString.Create("Manage things", typeof(Permissions));""", "Manage things")] + [InlineData( + """ + new LocalizedString(@"This is a multi-line + string.", "value"); + """, + """ + This is a multi-line + string. + """)] + public void ExtractString(string source, string expected) + { + // Arrange + var metadataProvider = new CSharpMetadataProvider("DummyBasePath"); + var extractor = new LocalizedStringExtractor(metadataProvider); + + var node = GetNode(source); + + // Act + var extracted = extractor.TryExtract(node, out var result); + + // Assert + Assert.True(extracted); + Assert.Equal(expected, result.Text); + Assert.Null(result.TextPlural); + } + + [Theory] + [InlineData("""new LocalizedString(nameof(Thing), "Thing");""")] + [InlineData("""new LocalizedString(name, "Thing");""")] + [InlineData("""new LocalizedString();""")] + [InlineData("""new LocalizedHtmlString();""")] + [InlineData("""new LocalizedHtmlString(nameof(Thing), "Thing");""")] + [InlineData("""new Thing("Thing", "Thing");""")] + [InlineData("""new LocalizedStringExtensions("Thing");""")] + [InlineData("""LocalizedString.Create(nameof(Thing));""")] + [InlineData("""LocalizedString.Create(name);""")] + [InlineData("""LocalizedString.Create();""")] + [InlineData("""LocalizedString.Parse("Thing");""")] + [InlineData("""Thing.Create("Thing");""")] + public void ExtractString_NotLocalizedStringWithLiteralName_ReturnsFalse(string source) + { + // Arrange + var metadataProvider = new CSharpMetadataProvider("DummyBasePath"); + var extractor = new LocalizedStringExtractor(metadataProvider); + + var node = GetNode(source); + + // Act + var extracted = extractor.TryExtract(node, out var result); + + // Assert + Assert.False(extracted); + Assert.Null(result); + } + + private static ExpressionSyntax GetNode(string source) + => CSharpSyntaxTree.ParseText(source, path: "DummyPath") + .GetRoot() + .DescendantNodes() + .OfType() + .First(node => node is ObjectCreationExpressionSyntax or InvocationExpressionSyntax); +} diff --git a/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/LocalizedStringExtractorTests.cs b/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/LocalizedStringExtractorTests.cs new file mode 100644 index 0000000..2f6429d --- /dev/null +++ b/test/OrchardCoreContrib.PoExtractor.DotNet.VB.Tests/LocalizedStringExtractorTests.cs @@ -0,0 +1,77 @@ +using Microsoft.CodeAnalysis.VisualBasic; +using Microsoft.CodeAnalysis.VisualBasic.Syntax; +using OrchardCoreContrib.PoExtractor.DotNet.VB.MetadataProviders; + +namespace OrchardCoreContrib.PoExtractor.DotNet.VB.Tests; + +public class LocalizedStringExtractorTests +{ + [Theory] + [InlineData("""New LocalizedString("Thing", "Thing")""", "Thing")] + [InlineData("""New LocalizedString("Thing")""", "Thing")] + [InlineData("""New LocalizedHtmlString("Thing")""", "Thing")] + [InlineData("""New LocalizedString("Thing", "Other thing")""", "Thing")] + [InlineData("""New localizedstring("Thing", "Thing")""", "Thing")] + [InlineData("""New Microsoft.Extensions.Localization.LocalizedString("Thing", "Thing")""", "Thing")] + [InlineData("""New LocalizedHtmlString("Thing", "Thing")""", "Thing")] + [InlineData("""New Microsoft.AspNetCore.Mvc.Localization.LocalizedHtmlString("Thing", "Thing")""", "Thing")] + [InlineData("""LocalizedString.Create("Thing")""", "Thing")] + [InlineData("""LocalizedHtmlString.Create("Thing")""", "Thing")] + [InlineData("""localizedstring.create("Thing")""", "Thing")] + [InlineData("""Microsoft.Extensions.Localization.LocalizedString.Create("Thing")""", "Thing")] + [InlineData("""LocalizedStringExtensions.Create("Thing")""", "Thing")] + [InlineData("""LocalizedHtmlStringExtensions.Create("Thing")""", "Thing")] + [InlineData("""LocalizedStringExtensions.Create("Hello {0}", name)""", "Hello {0}")] + [InlineData("""LocalizedStringExtensions.Create("Manage things", GetType(Permissions))""", "Manage things")] + public void ExtractString(string source, string expected) + { + // Arrange + var metadataProvider = new VisualBasicMetadataProvider("DummyBasePath"); + var extractor = new LocalizedStringExtractor(metadataProvider); + + var node = GetNode(source); + + // Act + var extracted = extractor.TryExtract(node, out var result); + + // Assert + Assert.True(extracted); + Assert.Equal(expected, result.Text); + Assert.Null(result.TextPlural); + } + + [Theory] + [InlineData("""New LocalizedString(NameOf(Thing), "Thing")""")] + [InlineData("""New LocalizedString(name, "Thing")""")] + [InlineData("""New LocalizedString()""")] + [InlineData("""New LocalizedString""")] + [InlineData("""New LocalizedHtmlString(NameOf(Thing), "Thing")""")] + [InlineData("""New Thing("Thing", "Thing")""")] + [InlineData("""LocalizedString.Create(NameOf(Thing))""")] + [InlineData("""LocalizedString.Create(name)""")] + [InlineData("""LocalizedString.Create()""")] + [InlineData("""LocalizedString.Parse("Thing")""")] + [InlineData("""Thing.Create("Thing")""")] + public void ExtractString_NotLocalizedStringWithLiteralName_ReturnsFalse(string source) + { + // Arrange + var metadataProvider = new VisualBasicMetadataProvider("DummyBasePath"); + var extractor = new LocalizedStringExtractor(metadataProvider); + + var node = GetNode(source); + + // Act + var extracted = extractor.TryExtract(node, out var result); + + // Assert + Assert.False(extracted); + Assert.Null(result); + } + + private static ExpressionSyntax GetNode(string source) + => VisualBasicSyntaxTree.ParseText($"Dim value = {source}", path: "DummyPath") + .GetRoot() + .DescendantNodes() + .OfType() + .First(node => node is ObjectCreationExpressionSyntax or InvocationExpressionSyntax); +}