virtualx-engine/modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators/ScriptPathAttributeGenerator.cs
Raul Santos fe280ef9ae
C#: Various fixes to generic scripts
- Report a diagnostic when there are multiple classes that match the script file name in the same script since that will result in a duplicate path key in the bimap and it's not allowed.
- Fix InspectorPlugin to handle empty paths in case the project was built with a previous version of Godot that used empty paths for generic scripts.
- Add tests for the new diagnostic GD0003.
2024-02-19 06:33:13 +01:00

204 lines
8 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
namespace Godot.SourceGenerators
{
[Generator]
public class ScriptPathAttributeGenerator : ISourceGenerator
{
public void Execute(GeneratorExecutionContext context)
{
if (context.IsGodotSourceGeneratorDisabled("ScriptPathAttribute"))
return;
if (context.IsGodotToolsProject())
return;
// NOTE: NotNullWhen diagnostics don't work on projects targeting .NET Standard 2.0
// ReSharper disable once ReplaceWithStringIsNullOrEmpty
if (!context.TryGetGlobalAnalyzerProperty("GodotProjectDirBase64", out string? godotProjectDir) || godotProjectDir!.Length == 0)
{
if (!context.TryGetGlobalAnalyzerProperty("GodotProjectDir", out godotProjectDir) || godotProjectDir!.Length == 0)
{
throw new InvalidOperationException("Property 'GodotProjectDir' is null or empty.");
}
}
else
{
// Workaround for https://github.com/dotnet/roslyn/issues/51692
godotProjectDir = Encoding.UTF8.GetString(Convert.FromBase64String(godotProjectDir));
}
Dictionary<INamedTypeSymbol, IEnumerable<ClassDeclarationSyntax>> godotClasses = context
.Compilation.SyntaxTrees
.SelectMany(tree =>
tree.GetRoot().DescendantNodes()
.OfType<ClassDeclarationSyntax>()
// Ignore inner classes
.Where(cds => !cds.IsNested())
.SelectGodotScriptClasses(context.Compilation)
// Report and skip non-partial classes
.Where(x =>
{
if (x.cds.IsPartial())
return true;
Common.ReportNonPartialGodotScriptClass(context, x.cds, x.symbol);
return false;
})
)
.Where(x =>
// Ignore classes whose name is not the same as the file name
Path.GetFileNameWithoutExtension(x.cds.SyntaxTree.FilePath) == x.symbol.Name)
.GroupBy<(ClassDeclarationSyntax cds, INamedTypeSymbol symbol), INamedTypeSymbol>(x => x.symbol, SymbolEqualityComparer.Default)
.ToDictionary<IGrouping<INamedTypeSymbol, (ClassDeclarationSyntax cds, INamedTypeSymbol symbol)>, INamedTypeSymbol, IEnumerable<ClassDeclarationSyntax>>(g => g.Key, g => g.Select(x => x.cds), SymbolEqualityComparer.Default);
var usedPaths = new HashSet<string>();
foreach (var godotClass in godotClasses)
{
VisitGodotScriptClass(context, godotProjectDir, usedPaths,
symbol: godotClass.Key,
classDeclarations: godotClass.Value);
}
if (godotClasses.Count <= 0)
return;
AddScriptTypesAssemblyAttr(context, godotClasses);
}
private static void VisitGodotScriptClass(
GeneratorExecutionContext context,
string godotProjectDir,
HashSet<string> usedPaths,
INamedTypeSymbol symbol,
IEnumerable<ClassDeclarationSyntax> classDeclarations
)
{
var attributes = new StringBuilder();
// Remember syntax trees for which we already added an attribute, to prevent unnecessary duplicates.
var attributedTrees = new List<SyntaxTree>();
foreach (var cds in classDeclarations)
{
if (attributedTrees.Contains(cds.SyntaxTree))
continue;
attributedTrees.Add(cds.SyntaxTree);
if (attributes.Length != 0)
attributes.Append("\n");
string scriptPath = RelativeToDir(cds.SyntaxTree.FilePath, godotProjectDir);
if (!usedPaths.Add(scriptPath))
{
context.ReportDiagnostic(Diagnostic.Create(
Common.MultipleClassesInGodotScriptRule,
cds.Identifier.GetLocation(),
symbol.Name
));
return;
}
attributes.Append(@"[ScriptPathAttribute(""res://");
attributes.Append(scriptPath);
attributes.Append(@""")]");
}
INamespaceSymbol namespaceSymbol = symbol.ContainingNamespace;
string classNs = namespaceSymbol != null && !namespaceSymbol.IsGlobalNamespace ?
namespaceSymbol.FullQualifiedNameOmitGlobal() :
string.Empty;
bool hasNamespace = classNs.Length != 0;
string uniqueHint = symbol.FullQualifiedNameOmitGlobal().SanitizeQualifiedNameForUniqueHint()
+ "_ScriptPath.generated";
var source = new StringBuilder();
// using Godot;
// namespace {classNs} {
// {attributesBuilder}
// partial class {className} { }
// }
source.Append("using Godot;\n");
if (hasNamespace)
{
source.Append("namespace ");
source.Append(classNs);
source.Append(" {\n\n");
}
source.Append(attributes);
source.Append("\npartial class ");
source.Append(symbol.NameWithTypeParameters());
source.Append("\n{\n}\n");
if (hasNamespace)
{
source.Append("\n}\n");
}
context.AddSource(uniqueHint, SourceText.From(source.ToString(), Encoding.UTF8));
}
private static void AddScriptTypesAssemblyAttr(GeneratorExecutionContext context,
Dictionary<INamedTypeSymbol, IEnumerable<ClassDeclarationSyntax>> godotClasses)
{
var sourceBuilder = new StringBuilder();
sourceBuilder.Append("[assembly:");
sourceBuilder.Append(GodotClasses.AssemblyHasScriptsAttr);
sourceBuilder.Append("(new System.Type[] {");
bool first = true;
foreach (var godotClass in godotClasses)
{
var qualifiedName = godotClass.Key.ToDisplayString(
NullableFlowState.NotNull, SymbolDisplayFormat.FullyQualifiedFormat
.WithGenericsOptions(SymbolDisplayGenericsOptions.None));
if (!first)
sourceBuilder.Append(", ");
first = false;
sourceBuilder.Append("typeof(");
sourceBuilder.Append(qualifiedName);
if (godotClass.Key.IsGenericType)
sourceBuilder.Append($"<{new string(',', godotClass.Key.TypeParameters.Count() - 1)}>");
sourceBuilder.Append(")");
}
sourceBuilder.Append("})]\n");
context.AddSource("AssemblyScriptTypes.generated",
SourceText.From(sourceBuilder.ToString(), Encoding.UTF8));
}
public void Initialize(GeneratorInitializationContext context)
{
}
private static string RelativeToDir(string path, string dir)
{
// Make sure the directory ends with a path separator
dir = Path.Combine(dir, " ").TrimEnd();
if (Path.DirectorySeparatorChar == '\\')
dir = dir.Replace("/", "\\") + "\\";
var fullPath = new Uri(Path.GetFullPath(path), UriKind.Absolute);
var relRoot = new Uri(Path.GetFullPath(dir), UriKind.Absolute);
// MakeRelativeUri converts spaces to %20, hence why we need UnescapeDataString
return Uri.UnescapeDataString(relRoot.MakeRelativeUri(fullPath).ToString());
}
}
}