Browse Source

feat: Add c# translation parser support

Use semantic model to analyze the method to be selected

Support translation comment

Make C# multi-line comments also ignore translation

Add  preprocessor symbols support
molingyu 11 months ago
parent
commit
da8f647fa1

+ 446 - 0
modules/mono/editor/GodotTools/GodotTools/CsTranslationParserPlugin.cs

@@ -0,0 +1,446 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Godot;
+using Godot.Collections;
+using GodotTools.Internals;
+using Microsoft.Build.Evaluation;
+using Microsoft.Build.Execution;
+using Microsoft.Build.Locator;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace GodotTools;
+
+public partial class CsTranslationParserPlugin : EditorTranslationParserPlugin
+{
+
+    private class CommentData
+    {
+        public string Comment = "";
+        public int StartLine;
+        public int EndLine;
+        public bool Newline = true;
+    }
+
+    private List<MetadataReference>? _projectReferences;
+    private Array<string[]> _ret = new Array<string[]>();
+    private List<SyntaxTree> _syntaxTreeCaches = new List<SyntaxTree>();
+
+    private const string TranslationCommentPrefix = "TRANSLATORS:";
+    private const string NoTranslateComment = "NO_TRANSLATE";
+    private const string TranslationStaticClass = "Godot.TranslationServer";
+    private const string TranslationMethod = "Translate";
+    private const string TranslationPluralMethod = "TranslatePlural";
+    private const string TranslationClass = "Godot.GodotObject";
+    private const string TranslationMethodTr = "Tr";
+    private const string TranslationMethodTrN = "TrN";
+    private static readonly string[] _configurations = ["Debug", "Release"];
+    private static readonly string[] _targetPlatforms = ["windows", "linuxbsd", "macos", "android", "ios", "web"];
+
+    public override string[] _GetRecognizedExtensions()
+    {
+        return ["cs"];
+    }
+
+    public override Array<string[]> _ParseFile(string path)
+    {
+        _ret = [];
+
+        if (_projectReferences == null)
+        {
+            _projectReferences = new List<MetadataReference>();
+            foreach (string configuration in _configurations)
+            {
+                foreach (string targetPlatform in _targetPlatforms)
+                {
+                    GetProjectReferences(GodotSharpDirs.ProjectCsProjPath, configuration, targetPlatform).ForEach(reference =>
+                    {
+                        if (!_projectReferences.Contains(reference))
+                        {
+                            _projectReferences.Add(reference);
+                        }
+                    });
+                }
+            }
+            System.AppDomain.CurrentDomain.GetAssemblies()
+                .Where(a => !a.IsDynamic)
+                .Where(a => a.Location != "")
+                .Select(a => MetadataReference.CreateFromFile(a.Location))
+                .Cast<MetadataReference>()
+                .ToList()
+                .ForEach(reference =>
+                {
+                    if (!_projectReferences.Contains(reference))
+                    {
+                        _projectReferences.Add(reference);
+                    }
+                });
+        }
+
+        var res = ResourceLoader.Load<CSharpScript>(path, "Script");
+        var text = res.SourceCode;
+
+        foreach (string configuration in _configurations)
+        {
+            foreach (string targetPlatform in _targetPlatforms)
+            {
+                var symbols = GetProjectDefineConstants(GodotSharpDirs.ProjectCsProjPath, configuration, targetPlatform);
+                ParseCode(text, symbols, _projectReferences);
+            }
+        }
+        _syntaxTreeCaches.Clear();
+        return _ret;
+    }
+
+    private void ParseCode(string code, string[] symbols, List<MetadataReference> references)
+    {
+        var options = new CSharpParseOptions(LanguageVersion.Default, DocumentationMode.Parse, SourceCodeKind.Script, symbols);
+        var tree = CSharpSyntaxTree.ParseText(code, options);
+        if (SyntaxTreeContains(tree) || tree == null)
+        {
+            return;
+        }
+        _syntaxTreeCaches.Add(tree);
+        var compilation = CSharpCompilation.Create("TranslationParser", options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary))
+            .AddReferences(references)
+            .AddSyntaxTrees(tree);
+
+        var semanticModel = compilation.GetSemanticModel(tree);
+        var comments = tree.GetRoot().DescendantNodes()
+            .SelectMany(
+                node => node.GetTrailingTrivia()
+                    .Where(trivia => trivia.IsKind(SyntaxKind.SingleLineCommentTrivia))
+                    .Concat(node.GetLeadingTrivia().Where(trivia => trivia.IsKind(SyntaxKind.SingleLineCommentTrivia)
+                                                                    || trivia.IsKind(SyntaxKind.MultiLineCommentTrivia))))
+            .Select(trivia => new CommentData
+            {
+                Comment = trivia.ToFullString(),
+                StartLine = GetStartLine(trivia.GetLocation()),
+                EndLine = GetEndLine(trivia.GetLocation()),
+                Newline = tree.GetRoot().DescendantNodes()
+                    .FirstOrDefault(node => GetStartLine(node.GetLocation()) == GetStartLine(trivia.GetLocation())) == null
+            })
+            .ToArray();
+
+        foreach (var syntaxNode in tree.GetRoot().DescendantNodes().Where(node => node is InvocationExpressionSyntax))
+        {
+            var invocation = (InvocationExpressionSyntax)syntaxNode;
+            var commentText = "";
+            var skip = false;
+            // Parse inline comment
+            var line = GetStartLine(syntaxNode.GetLocation());
+
+            var commentData = comments.FirstOrDefault(comment => comment.StartLine == line);
+            if (commentData != null)
+            {
+                commentText = commentData.Comment.TrimStart('/').Trim();
+                if (commentText.StartsWith(TranslationCommentPrefix))
+                {
+                    commentText = commentText.TrimPrefix(TranslationCommentPrefix).Trim();
+                }
+                else if (commentText == NoTranslateComment || commentText.StartsWith(NoTranslateComment + ":"))
+                {
+                    skip = true;
+                }
+            }
+            else
+            {
+                // Parse multiline comment
+                for (var index = line - 1; index >= 0; index--)
+                {
+                    var multilineCommentData =
+                        comments.FirstOrDefault(comment => comment.EndLine == index && comment.Newline);
+                    if (multilineCommentData == null)
+                    {
+                        commentText = "";
+                        break;
+                    }
+                    // multiline comment
+                    if (multilineCommentData.StartLine != multilineCommentData.EndLine)
+                    {
+                        var multilineComments = multilineCommentData.Comment.TrimSuffix("*/").Trim().Split("\n")
+                            .Select(lineStr => lineStr.TrimPrefix("/*").Trim().TrimPrefix(TranslationCommentPrefix));
+                        commentText = string.Join("\n", multilineComments);
+                        if (commentText == NoTranslateComment || commentText.StartsWith(NoTranslateComment + ":"))
+                        {
+                            commentText = "";
+                            skip = true;
+                        }
+                        break;
+                    }
+                    // multiline single line comment
+                    var currentComment = multilineCommentData.Comment.TrimStart('/').Trim();
+                    if (currentComment == "")
+                    {
+                        continue;
+                    }
+                    if (commentText == "")
+                    {
+                        commentText = currentComment;
+                    }
+                    else
+                    {
+                        commentText = currentComment + "\n" + commentText;
+                    }
+                    if (currentComment.StartsWith(TranslationCommentPrefix))
+                    {
+                        commentText = commentText.TrimPrefix(TranslationCommentPrefix).Trim();
+                        break;
+                    }
+                    if (currentComment == NoTranslateComment || currentComment.StartsWith(NoTranslateComment + ":"))
+                    {
+                        commentText = "";
+                        skip = true;
+                        break;
+                    }
+                }
+            }
+
+            SymbolInfo? symbolInfo = null;
+            if (invocation.Expression is IdentifierNameSyntax identifierNameSyntax)
+            {
+                symbolInfo = semanticModel.GetSymbolInfo(identifierNameSyntax);
+            }
+            if (invocation.Expression is MemberAccessExpressionSyntax { Name: IdentifierNameSyntax nameSyntax })
+            {
+                symbolInfo = semanticModel.GetSymbolInfo(nameSyntax);
+            }
+
+            var methodSymbol = symbolInfo?.Symbol as IMethodSymbol;
+            if (methodSymbol == null)
+            {
+                continue;
+            }
+            if (methodSymbol.Name == TranslationMethod &&
+                methodSymbol.ContainingType.ToDisplayString() == TranslationStaticClass)
+            {
+                if (skip)
+                {
+                    continue;
+                }
+                AddMsg(invocation.ArgumentList.Arguments, semanticModel, commentText);
+            }
+
+            if (methodSymbol.Name == TranslationPluralMethod &&
+                methodSymbol.ContainingType.ToDisplayString() == TranslationStaticClass)
+            {
+                if (skip)
+                {
+                    continue;
+                }
+                AddPluralMsg(invocation.ArgumentList.Arguments, semanticModel, commentText);
+            }
+
+            if (methodSymbol.Name is TranslationMethodTr or TranslationMethodTrN
+                && methodSymbol.MethodKind == MethodKind.Ordinary)
+            {
+                var receiverType = methodSymbol.ReceiverType ?? methodSymbol.ContainingType;
+
+                if (receiverType != null && InheritsFromGodotObject(receiverType))
+                {
+                    if (skip)
+                    {
+                        continue;
+                    }
+                    if (methodSymbol.Name == TranslationMethodTr)
+                    {
+                        AddMsg(invocation.ArgumentList.Arguments, semanticModel, commentText);
+                    }
+                    else
+                    {
+                        AddPluralMsg(invocation.ArgumentList.Arguments, semanticModel, commentText);
+                    }
+                }
+            }
+        }
+    }
+
+    private bool SyntaxTreeContains(SyntaxTree otherTree)
+    {
+        return _syntaxTreeCaches.Any(syntaxTree => syntaxTree.GetRoot().IsEquivalentTo(otherTree.GetRoot()));
+    }
+    private int GetStartLine(Location location)
+    {
+        return location.GetLineSpan().StartLinePosition.Line;
+    }
+
+    private int GetEndLine(Location location)
+    {
+        return location.GetLineSpan().EndLinePosition.Line;
+    }
+
+    private bool InheritsFromGodotObject(ITypeSymbol typeSymbol)
+    {
+        while (typeSymbol != null)
+        {
+            if (typeSymbol.ToDisplayString() == TranslationClass)
+            {
+                return true;
+            }
+#pragma warning disable CS8600
+            typeSymbol = typeSymbol.BaseType;
+#pragma warning restore CS8600
+        }
+        return false;
+    }
+
+    private void AddMsg(SeparatedSyntaxList<ArgumentSyntax> arguments, SemanticModel semanticModel, string comment)
+    {
+        switch (arguments.Count)
+        {
+            case 1:
+            {
+                var argExpr = arguments[0].Expression;
+                var constantValue = semanticModel.GetConstantValue(argExpr);
+
+                if (constantValue is { HasValue: true, Value: string message })
+                {
+                    _ret.Add([message, "", "", comment]);
+                }
+
+                break;
+            }
+            case 2:
+            {
+                var msgExpr = arguments[0].Expression;
+                var ctxExpr = arguments[1].Expression;
+
+                var msgValue = semanticModel.GetConstantValue(msgExpr);
+                var ctxValue = semanticModel.GetConstantValue(ctxExpr);
+
+                if (msgValue is { HasValue: true, Value: string message } &&
+                    ctxValue is { HasValue: true, Value: string context })
+                {
+                    _ret.Add([message, context, "", comment]);
+                }
+
+                break;
+            }
+        }
+    }
+
+    private void AddPluralMsg(SeparatedSyntaxList<ArgumentSyntax> arguments, SemanticModel semanticModel, string comment)
+    {
+        var singularExpr = arguments[0].Expression;
+        var pluralExpr = arguments[1].Expression;
+
+        var singularValue = semanticModel.GetConstantValue(singularExpr);
+        var pluralValue = semanticModel.GetConstantValue(pluralExpr);
+
+        if (!singularValue.HasValue || singularValue.Value is not string singular ||
+            !pluralValue.HasValue || pluralValue.Value is not string plural)
+        {
+            return;
+        }
+
+        var context = "";
+        if (arguments.Count == 4)
+        {
+            var ctxExpr = arguments[3].Expression;
+            var ctxValue = semanticModel.GetConstantValue(ctxExpr);
+            if (ctxValue is { HasValue: true, Value: string ctx })
+            {
+                context = ctx;
+            }
+        }
+        _ret.Add([singular, context, plural, comment]);
+    }
+
+    private List<MetadataReference> GetProjectReferences(string projectPath, string configuration = "Debug", string? targetPlatform = null)
+    {
+        if (!MSBuildLocator.IsRegistered)
+        {
+            MSBuildLocator.RegisterDefaults();
+        }
+
+        var referencePaths = GetProjectReferencePaths(projectPath, configuration, targetPlatform ?? OS.GetName());
+
+        var metadataReferences = new List<MetadataReference>();
+        foreach (var dllPath in referencePaths)
+        {
+            if (File.Exists(dllPath))
+            {
+                var metadataReference = MetadataReference.CreateFromFile(dllPath);
+                metadataReferences.Add(metadataReference);
+            }
+        }
+
+        return metadataReferences;
+    }
+
+    private List<string> GetProjectReferencePaths(string projectPath, string configuration, string targetPlatform)
+    {
+        var referencePaths = new List<string>();
+
+        var projectCollection = new ProjectCollection();
+        var project = projectCollection.LoadProject(projectPath);
+
+        project.SetProperty("Configuration", configuration);
+        project.SetProperty("Platform", "Any CPU");
+        project.SetProperty("GodotTargetPlatform", targetPlatform);
+
+        var buildParameters = new BuildParameters(projectCollection);
+        var buildRequest = new BuildRequestData(project.FullPath, project.GlobalProperties, null, ["GetTargetPath"], null);
+        var buildResult = BuildManager.DefaultBuildManager.Build(buildParameters, buildRequest);
+
+        if (buildResult.OverallResult == BuildResultCode.Success)
+        {
+            referencePaths.AddRange(buildResult.ResultsByTarget["GetTargetPath"].Items.Select(item => item.ItemSpec));
+        }
+
+        projectCollection.UnloadAllProjects();
+        projectCollection.Dispose();
+
+        return referencePaths;
+    }
+
+    private string[] GetProjectDefineConstants(string projectPath, string configuration = "Debug", string? targetPlatform = null)
+    {
+        if (!MSBuildLocator.IsRegistered)
+        {
+            MSBuildLocator.RegisterDefaults();
+        }
+        string[] defineConstants = [];
+
+        var projectCollection = new ProjectCollection();
+        var project = projectCollection.LoadProject(projectPath);
+
+        project.SetProperty("Configuration", configuration);
+        project.SetProperty("Platform", "Any CPU");
+        project.SetProperty("GodotTargetPlatform", targetPlatform ?? OS.GetName());
+
+        var target = project.Xml.AddTarget("GetDefineConstants");
+        var propertyGroup = target.AddPropertyGroup();
+        propertyGroup.AddProperty("DefineConstantsValue", "$(DefineConstants)");
+        var itemGroup = target.AddItemGroup();
+        itemGroup.AddItem("DefineConstantsItem", "$(DefineConstantsValue)");
+        var task = target.AddTask("WriteLinesToFile");
+        var tempFilePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+        task.SetParameter("File", tempFilePath);
+        task.SetParameter("Lines", "@(DefineConstantsItem)");
+        task.SetParameter("Overwrite", "true");
+
+        var buildParameters = new BuildParameters(projectCollection);
+        var buildRequest = new BuildRequestData(project.FullPath, project.GlobalProperties, null, ["GetDefineConstants"], null);
+        var buildResult = BuildManager.DefaultBuildManager.Build(buildParameters, buildRequest);
+
+        if (buildResult.OverallResult == BuildResultCode.Success)
+        {
+            var defineConstantsOutput = File.ReadAllText(tempFilePath);
+            if (string.IsNullOrEmpty(defineConstantsOutput))
+            {
+                defineConstants = defineConstantsOutput.Split('\n')
+                    .Select(symbol => symbol.Trim('\r').Trim('\n'))
+                    .Where(defineConstant => defineConstant != "").ToArray();
+            }
+            File.Delete(tempFilePath);
+        }
+
+        projectCollection.UnloadAllProjects();
+        projectCollection.Dispose();
+
+        return defineConstants;
+    }
+}

+ 3 - 0
modules/mono/editor/GodotTools/GodotTools/GodotSharpEditor.cs

@@ -643,6 +643,9 @@ namespace GodotTools
             AddInspectorPlugin(inspectorPlugin);
             AddInspectorPlugin(inspectorPlugin);
             _inspectorPluginWeak = WeakRef(inspectorPlugin);
             _inspectorPluginWeak = WeakRef(inspectorPlugin);
 
 
+            // TranslationParser Plugin
+            AddTranslationParserPlugin(new CsTranslationParserPlugin());
+
             BuildManager.Initialize();
             BuildManager.Initialize();
 
 
             GodotIdeManager = new GodotIdeManager();
             GodotIdeManager = new GodotIdeManager();

+ 1 - 0
modules/mono/editor/GodotTools/GodotTools/GodotTools.csproj

@@ -34,6 +34,7 @@
 
 
   <ItemGroup>
   <ItemGroup>
     <PackageReference Include="JetBrains.Annotations" Version="2019.1.3.0" ExcludeAssets="runtime" PrivateAssets="all" />
     <PackageReference Include="JetBrains.Annotations" Version="2019.1.3.0" ExcludeAssets="runtime" PrivateAssets="all" />
+    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.11.0" />
     <PackageReference Include="JetBrains.Rider.PathLocator" Version="1.0.12" />
     <PackageReference Include="JetBrains.Rider.PathLocator" Version="1.0.12" />
     <PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.0" PrivateAssets="All" />
     <PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.0" PrivateAssets="All" />
     <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
     <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />