1166 lines
50 KiB
C#
1166 lines
50 KiB
C#
/*
|
|
Yarn Spinner is licensed to you under the terms found in the file LICENSE.md.
|
|
*/
|
|
|
|
using Microsoft.CodeAnalysis;
|
|
using Microsoft.CodeAnalysis.CSharp;
|
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Xml.Linq;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
|
|
#nullable enable
|
|
|
|
namespace Yarn.Unity.ActionAnalyser
|
|
{
|
|
static class EnumerableExtensions
|
|
{
|
|
private struct Comparer<TItem, TKey> : IEqualityComparer<TItem>
|
|
{
|
|
Func<TItem, TKey> KeyFunc;
|
|
public Comparer(Func<TItem, TKey> keyFunc) => this.KeyFunc = keyFunc;
|
|
|
|
public readonly bool Equals(TItem x, TItem y)
|
|
{
|
|
var xKey = KeyFunc(x);
|
|
var yKey = KeyFunc(y);
|
|
return (xKey == null && yKey == null) || (xKey != null && xKey.Equals(yKey));
|
|
}
|
|
|
|
public readonly int GetHashCode(TItem obj) => KeyFunc(obj)?.GetHashCode() ?? 0;
|
|
}
|
|
public static IEnumerable<TItem> DistinctBy<TItem, TKey>(this IEnumerable<TItem> enumerable, Func<TItem, TKey> key) => Enumerable.Distinct(enumerable, new Comparer<TItem, TKey>(key));
|
|
}
|
|
|
|
public class Analyser
|
|
{
|
|
const string toolName = "YarnActionAnalyzer";
|
|
const string toolVersion = "1.0.0.0";
|
|
const string registrationMethodName = "RegisterActions";
|
|
const string targetParameterName = "target";
|
|
const string registrationTypeParameterName = "registrationType";
|
|
const string initialisationMethodName = "AddRegisterFunction";
|
|
|
|
/// <summary>
|
|
/// The name of a scripting define symbol that, if set, indicates that
|
|
/// Yarn actions specific to unit tests should be generated.
|
|
/// </summary>
|
|
public const string GenerateTestActionRegistrationSymbol = "YARN_GENERATE_TEST_ACTION_REGISTRATIONS";
|
|
|
|
public Analyser(string sourcePath)
|
|
{
|
|
this.SourcePath = sourcePath;
|
|
}
|
|
|
|
public IEnumerable<string> SourceFiles => GetSourceFiles(SourcePath);
|
|
public string SourcePath { get; set; }
|
|
|
|
public IEnumerable<Action> GetActions(IEnumerable<string>? assemblyPaths = null, ILogger? logger = null)
|
|
{
|
|
var trees = SourceFiles
|
|
.Select(path => CSharpSyntaxTree.ParseText(File.ReadAllText(path), path: path))
|
|
.ToList();
|
|
|
|
var systemAssemblyPath = Path.GetDirectoryName(typeof(object).Assembly.Location);
|
|
|
|
if (string.IsNullOrEmpty(systemAssemblyPath))
|
|
{
|
|
throw new AnalyserException("Unable to find an assembly that defines System.Object");
|
|
}
|
|
|
|
static string GetLocationOfAssemblyWithType(string typeName)
|
|
{
|
|
return GetTypeByName(typeName)?.Assembly.Location ?? throw new AnalyserException($"Failed to find an assembly for type " + typeName);
|
|
}
|
|
|
|
var runtiem = System.Runtime.InteropServices.RuntimeEnvironment.GetRuntimeDirectory();
|
|
|
|
var netstandard = AppDomain.CurrentDomain.GetAssemblies().Single(a => a.GetName().Name == "netstandard");
|
|
var systemCore = AppDomain.CurrentDomain.GetAssemblies().Single(a => a.GetName().Name == "System.Core");
|
|
|
|
var references = new List<MetadataReference> {
|
|
MetadataReference.CreateFromFile(netstandard.Location),
|
|
MetadataReference.CreateFromFile(systemCore.Location),
|
|
MetadataReference.CreateFromFile(
|
|
typeof(object).Assembly.Location),
|
|
MetadataReference.CreateFromFile(
|
|
GetLocationOfAssemblyWithType("Yarn.Unity.DialogueRunner")),
|
|
MetadataReference.CreateFromFile(
|
|
GetLocationOfAssemblyWithType("Yarn.Unity.YarnCommandAttribute")),
|
|
MetadataReference.CreateFromFile(
|
|
GetLocationOfAssemblyWithType("UnityEngine.MonoBehaviour")),
|
|
};
|
|
|
|
if (assemblyPaths != null)
|
|
{
|
|
references.AddRange(assemblyPaths.Select(p => MetadataReference.CreateFromFile(p)));
|
|
}
|
|
|
|
var compilation = CSharpCompilation.Create("YarnActionAnalysis")
|
|
.AddReferences(references)
|
|
.AddSyntaxTrees(trees);
|
|
|
|
var output = new List<Action>();
|
|
|
|
var diagnostics = compilation.GetDiagnostics();
|
|
|
|
try
|
|
{
|
|
foreach (var tree in trees)
|
|
{
|
|
output.AddRange(GetActions(compilation, tree, logger));
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
throw new AnalyserException(e.Message, e, diagnostics);
|
|
}
|
|
|
|
foreach (var action in output)
|
|
{
|
|
if (action.Validate(compilation, logger).Any(d => d.Severity == DiagnosticSeverity.Warning || d.Severity == DiagnosticSeverity.Error))
|
|
{
|
|
action.ContainsErrors = true;
|
|
}
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
public static string GenerateRegistrationFileSource(IEnumerable<Action> actions, string @namespace = "Yarn.Unity.Generated", string className = "ActionRegistration")
|
|
{
|
|
var namespaceDecl = SyntaxFactory.NamespaceDeclaration(SyntaxFactory.ParseName(@namespace));
|
|
|
|
var classDeclaration = SyntaxFactory.ClassDeclaration(className);
|
|
classDeclaration = classDeclaration.AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword));
|
|
classDeclaration = classDeclaration.AddAttributeLists(GeneratedCodeAttributeList);
|
|
|
|
MethodDeclarationSyntax registrationMethod = GenerateRegistrationMethod(actions);
|
|
MethodDeclarationSyntax initializationMethod = GenerateInitialisationMethod();
|
|
|
|
classDeclaration = classDeclaration.AddMembers(
|
|
initializationMethod,
|
|
registrationMethod
|
|
);
|
|
|
|
|
|
namespaceDecl = namespaceDecl.AddMembers(classDeclaration);
|
|
|
|
return namespaceDecl.NormalizeWhitespace().ToFullString();
|
|
}
|
|
|
|
private static MethodDeclarationSyntax GenerateInitialisationMethod()
|
|
{
|
|
return SyntaxFactory.MethodDeclaration(
|
|
SyntaxFactory.PredefinedType(
|
|
SyntaxFactory.Token(SyntaxKind.VoidKeyword)
|
|
),
|
|
SyntaxFactory.Identifier(initialisationMethodName)
|
|
)
|
|
.WithAttributeLists(
|
|
SyntaxFactory.List(
|
|
new AttributeListSyntax[]{
|
|
SyntaxFactory.AttributeList(
|
|
SyntaxFactory.SingletonSeparatedList(
|
|
SyntaxFactory.Attribute(
|
|
SyntaxFactory.QualifiedName(
|
|
SyntaxFactory.AliasQualifiedName(
|
|
SyntaxFactory.IdentifierName(
|
|
SyntaxFactory.Token(SyntaxKind.GlobalKeyword)
|
|
),
|
|
SyntaxFactory.IdentifierName("UnityEditor")
|
|
),
|
|
SyntaxFactory.IdentifierName("InitializeOnLoadMethod")
|
|
)
|
|
)
|
|
)
|
|
)
|
|
.WithOpenBracketToken(
|
|
SyntaxFactory.Token(
|
|
SyntaxFactory.TriviaList(
|
|
SyntaxFactory.Trivia(
|
|
SyntaxFactory.IfDirectiveTrivia(
|
|
SyntaxFactory.IdentifierName("UNITY_EDITOR"),
|
|
true,
|
|
true,
|
|
true
|
|
)
|
|
)
|
|
),
|
|
SyntaxKind.OpenBracketToken,
|
|
SyntaxFactory.TriviaList()
|
|
)
|
|
),
|
|
SyntaxFactory.AttributeList(
|
|
SyntaxFactory.SingletonSeparatedList(
|
|
SyntaxFactory.Attribute(
|
|
SyntaxFactory.QualifiedName(
|
|
SyntaxFactory.AliasQualifiedName(
|
|
SyntaxFactory.IdentifierName(
|
|
SyntaxFactory.Token(SyntaxKind.GlobalKeyword)
|
|
),
|
|
SyntaxFactory.IdentifierName("UnityEngine")
|
|
),
|
|
SyntaxFactory.IdentifierName("RuntimeInitializeOnLoadMethod")
|
|
)
|
|
)
|
|
.WithArgumentList(
|
|
SyntaxFactory.AttributeArgumentList(
|
|
SyntaxFactory.SingletonSeparatedList(
|
|
SyntaxFactory.AttributeArgument(
|
|
SyntaxFactory.MemberAccessExpression(
|
|
SyntaxKind.SimpleMemberAccessExpression,
|
|
SyntaxFactory.MemberAccessExpression(
|
|
SyntaxKind.SimpleMemberAccessExpression,
|
|
SyntaxFactory.AliasQualifiedName(
|
|
SyntaxFactory.IdentifierName(
|
|
SyntaxFactory.Token(SyntaxKind.GlobalKeyword)
|
|
),
|
|
SyntaxFactory.IdentifierName("UnityEngine")
|
|
),
|
|
SyntaxFactory.IdentifierName("RuntimeInitializeLoadType")
|
|
),
|
|
SyntaxFactory.IdentifierName("BeforeSceneLoad")
|
|
)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
.WithOpenBracketToken(
|
|
SyntaxFactory.Token(
|
|
SyntaxFactory.TriviaList(
|
|
SyntaxFactory.Trivia(
|
|
SyntaxFactory.EndIfDirectiveTrivia(
|
|
true
|
|
)
|
|
)
|
|
),
|
|
SyntaxKind.OpenBracketToken,
|
|
SyntaxFactory.TriviaList()
|
|
)
|
|
)
|
|
}
|
|
)
|
|
)
|
|
.WithModifiers(
|
|
SyntaxFactory.TokenList(
|
|
new[]{
|
|
SyntaxFactory.Token(SyntaxKind.PublicKeyword),
|
|
SyntaxFactory.Token(SyntaxKind.StaticKeyword)
|
|
}
|
|
)
|
|
)
|
|
.WithBody(
|
|
SyntaxFactory.Block(
|
|
SyntaxFactory.SingletonList<StatementSyntax>(
|
|
SyntaxFactory.ExpressionStatement(
|
|
SyntaxFactory.InvocationExpression(
|
|
SyntaxFactory.MemberAccessExpression(
|
|
SyntaxKind.SimpleMemberAccessExpression,
|
|
SyntaxFactory.IdentifierName("Actions"),
|
|
SyntaxFactory.IdentifierName("AddRegistrationMethod")
|
|
)
|
|
)
|
|
.WithArgumentList(
|
|
SyntaxFactory.ArgumentList(
|
|
SyntaxFactory.SingletonSeparatedList(
|
|
SyntaxFactory.Argument(
|
|
SyntaxFactory.IdentifierName(registrationMethodName)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
.NormalizeWhitespace();
|
|
}
|
|
|
|
public static MethodDeclarationSyntax GenerateRegistrationMethod(IEnumerable<Action> actions)
|
|
{
|
|
var actionGroups = actions.GroupBy(a => a.SourceFileName);
|
|
|
|
// Create registrations for all attribute registrations
|
|
// ([YarnCommand], [YarnFunction]). These registration are always
|
|
// invoked. Separately, create registrations for all runtime
|
|
// registrations (AddCommandHandler() invocations). These are only
|
|
// invoked when the registration methods 'registrationType'
|
|
// parameter equals Yarn.Unity.RegistrationType, which is true when
|
|
// Yarn Spinner needs to list all commands and functions from
|
|
// everywhere.
|
|
//
|
|
// We should only register runtime-registered commands when
|
|
// explicitly requested, because otherwise if we register them here
|
|
// AND during gameplay, they'll overlap and cause problems
|
|
|
|
var attributeRegistrationStatements = actionGroups.SelectMany(group =>
|
|
{
|
|
|
|
var attributeRegistrations = group
|
|
.Where(a => a.DeclarationType != DeclarationType.DirectRegistration);
|
|
return GetRegistrationStatements(attributeRegistrations);
|
|
});
|
|
|
|
var runtimeRegistrationStatements = actionGroups.SelectMany(group =>
|
|
{
|
|
var runtimeRegistrations = group
|
|
.Where(a => a.DeclarationType == DeclarationType.DirectRegistration);
|
|
return GetRegistrationStatements(runtimeRegistrations);
|
|
});
|
|
|
|
var functionDeclarations = actionGroups.SelectMany(group =>
|
|
{
|
|
var functions = group
|
|
.Where(a => a.Type == ActionType.Function);
|
|
return GetFunctionDeclarationStatements(functions);
|
|
});
|
|
|
|
// Create the method body that combines the attribute-registered and
|
|
// (possibly) runtime-registered action registrations.
|
|
var registrationMethodBody = SyntaxFactory.Block().WithStatements(SyntaxFactory.List<StatementSyntax>(
|
|
attributeRegistrationStatements.Concat(functionDeclarations)
|
|
));
|
|
|
|
// Create the list of attributes to attach to this method.
|
|
var attributes = SyntaxFactory.List(new[] { GeneratedCodeAttributeList });
|
|
|
|
var methodSyntax = SyntaxFactory.MethodDeclaration(
|
|
attributes, // attribute list
|
|
SyntaxFactory.TokenList(
|
|
SyntaxFactory.Token(SyntaxKind.PublicKeyword),
|
|
SyntaxFactory.Token(SyntaxKind.StaticKeyword)
|
|
), // modifiers
|
|
SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.VoidKeyword)), // return type
|
|
null, // explicit interface identifier
|
|
SyntaxFactory.Identifier(registrationMethodName), // method name
|
|
null, // type parameter list
|
|
SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(
|
|
new[] {
|
|
SyntaxFactory.Parameter(SyntaxFactory.Identifier(targetParameterName)).WithType(SyntaxFactory.ParseTypeName("global::Yarn.Unity.IActionRegistration")),
|
|
SyntaxFactory.Parameter(SyntaxFactory.Identifier(registrationTypeParameterName)).WithType(SyntaxFactory.ParseTypeName("Yarn.Unity.RegistrationType")),
|
|
}
|
|
)), // parameters
|
|
SyntaxFactory.List<TypeParameterConstraintClauseSyntax>(), // type parameter constraints
|
|
registrationMethodBody, // body
|
|
null, // arrow expression clause
|
|
SyntaxFactory.Token(SyntaxKind.None) // semicolon token
|
|
);
|
|
|
|
return methodSyntax.NormalizeWhitespace();
|
|
|
|
IEnumerable<StatementSyntax> GetRegistrationStatements(IEnumerable<Action> registerableCommands)
|
|
{
|
|
return registerableCommands
|
|
.Where(a => a.MethodSymbol?.MethodKind != MethodKind.AnonymousFunction)
|
|
.Select((a, i) =>
|
|
{
|
|
var registrationStatement = a.GetRegistrationSyntax(targetParameterName);
|
|
if (i == 0)
|
|
{
|
|
// Add a comment above the first registration that indicates where these actions came from
|
|
return registrationStatement.WithLeadingTrivia(
|
|
SyntaxFactory.TriviaList(
|
|
SyntaxFactory.Comment($"// Actions from file:"),
|
|
SyntaxFactory.Comment($"// {a.SourceFileName}")
|
|
));
|
|
}
|
|
else
|
|
{
|
|
return registrationStatement;
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
IEnumerable<StatementSyntax> GetFunctionDeclarationStatements(IEnumerable<Action> functions)
|
|
{
|
|
return functions
|
|
.Select((a, i) =>
|
|
{
|
|
var declarationStatement = a.GetFunctionDeclarationSyntax(targetParameterName);
|
|
if (i == 0)
|
|
{
|
|
// Add a comment above the first registration that indicates where these actions came from
|
|
return declarationStatement.WithLeadingTrivia(
|
|
SyntaxFactory.TriviaList(
|
|
SyntaxFactory.Comment($"// Function declarations from file:"),
|
|
SyntaxFactory.Comment($"// {a.SourceFileName}")
|
|
));
|
|
}
|
|
else
|
|
{
|
|
return declarationStatement;
|
|
}
|
|
}
|
|
);
|
|
|
|
}
|
|
}
|
|
|
|
private static AttributeListSyntax GeneratedCodeAttributeList
|
|
{
|
|
get
|
|
{
|
|
// [System.CodeDom.Compiler.GeneratedCode(<toolName>, <toolVersion>)]
|
|
|
|
var toolNameArgument = SyntaxFactory.AttributeArgument(
|
|
SyntaxFactory.LiteralExpression(
|
|
SyntaxKind.StringLiteralExpression,
|
|
SyntaxFactory.Literal(toolName)
|
|
)
|
|
);
|
|
|
|
var toolVersionArgument = SyntaxFactory.AttributeArgument(
|
|
SyntaxFactory.LiteralExpression(
|
|
SyntaxKind.StringLiteralExpression,
|
|
SyntaxFactory.Literal(toolVersion)
|
|
)
|
|
);
|
|
|
|
return SyntaxFactory.AttributeList(
|
|
SyntaxFactory.SeparatedList(
|
|
new[] {
|
|
SyntaxFactory.Attribute(
|
|
SyntaxFactory.ParseName("System.CodeDom.Compiler.GeneratedCode"),
|
|
SyntaxFactory.AttributeArgumentList(
|
|
SyntaxFactory.SeparatedList(
|
|
new[] {
|
|
toolNameArgument,
|
|
toolVersionArgument,
|
|
}
|
|
)
|
|
)
|
|
)
|
|
}
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
public static IEnumerable<Action> GetActions(CSharpCompilation compilation, SyntaxTree tree, ILogger? yLogger = null)
|
|
{
|
|
var logger = yLogger;
|
|
if (logger == null)
|
|
{
|
|
logger = new NullLogger();
|
|
}
|
|
|
|
var root = tree.GetCompilationUnitRoot();
|
|
|
|
SemanticModel? model = null;
|
|
|
|
if (compilation != null)
|
|
{
|
|
model = compilation.GetSemanticModel(tree, true);
|
|
}
|
|
|
|
if (model == null)
|
|
{
|
|
return Array.Empty<Action>();
|
|
}
|
|
|
|
return GetAttributeActions(root, model, logger).Concat(GetRuntimeDefinedActions(root, model, logger));
|
|
}
|
|
|
|
private static IEnumerable<Action> GetRuntimeDefinedActions(CompilationUnitSyntax root, SemanticModel model, ILogger? logger)
|
|
{
|
|
var classes = root.DescendantNodes().OfType<ClassDeclarationSyntax>();
|
|
classes = classes.Where(c =>
|
|
{
|
|
var classAttributes = GetAttributes(c, model);
|
|
|
|
bool hasGeneratedCodeAttribute = classAttributes.Any(attr =>
|
|
{
|
|
var syntax = attr.Item1;
|
|
var data = attr.Item2;
|
|
|
|
// Check to see if this attribute is the [GeneratedCode]
|
|
// attribute
|
|
return (data?.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) == "global::System.CodeDom.Compiler.GeneratedCodeAttribute";
|
|
});
|
|
|
|
// Do not visit this class if it is generated code
|
|
if (hasGeneratedCodeAttribute)
|
|
{
|
|
return false;
|
|
}
|
|
else
|
|
{
|
|
return true;
|
|
}
|
|
});
|
|
|
|
var methodInvocations = classes
|
|
.SelectMany(classDecl => classDecl.DescendantNodes())
|
|
.OfType<InvocationExpressionSyntax>()
|
|
.Select(i =>
|
|
{
|
|
// Get the symbol represening the method that's being called
|
|
var symbolInfo = model.GetSymbolInfo(i.Expression);
|
|
|
|
ISymbol? symbol = symbolInfo.Symbol;
|
|
|
|
if (symbol == null && symbolInfo.CandidateReason == CandidateReason.OverloadResolutionFailure)
|
|
{
|
|
// We weren't able to determine what specific method
|
|
// this was. Pick the first one - we don't actually need
|
|
// to know about what specific overload was used, since
|
|
// they all have a similar signature.
|
|
symbol = symbolInfo.CandidateSymbols.First();
|
|
}
|
|
var methodSymbol = symbol as IMethodSymbol;
|
|
return (Syntax: i, Symbol: methodSymbol);
|
|
})
|
|
.Where(i => i.Symbol != null)
|
|
.DistinctBy(i => i.Syntax)
|
|
.ToList();
|
|
|
|
var dialogueRunnerCalls = methodInvocations
|
|
.Where(info => info.Symbol?.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::Yarn.Unity.DialogueRunner").ToList();
|
|
|
|
var addCommandCalls = methodInvocations.Where(
|
|
info => info.Symbol?.Name == "AddCommandHandler"
|
|
).Select(c => (Call: c, Type: ActionType.Command));
|
|
|
|
var addFunctionCalls = methodInvocations.Where(
|
|
info => info.Symbol?.Name == "AddFunction"
|
|
).Select(c => (Call: c, Type: ActionType.Function));
|
|
|
|
var methodCalls = addCommandCalls.Concat(addFunctionCalls);
|
|
|
|
foreach (var methodCall in methodCalls)
|
|
{
|
|
var (Syntax, Symbol) = methodCall.Call;
|
|
|
|
var methodNameSyntax = Syntax.ArgumentList.Arguments.ElementAtOrDefault(0);
|
|
var targetSyntax = Syntax.ArgumentList.Arguments.ElementAtOrDefault(1);
|
|
if (methodNameSyntax == null || targetSyntax == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!(model.GetConstantValue(methodNameSyntax.Expression).Value is string name))
|
|
{
|
|
// TODO: handle case of 'we couldn't figure out the constant value here'
|
|
continue;
|
|
}
|
|
|
|
SymbolInfo targetSymbolInfo = model.GetSymbolInfo(targetSyntax.Expression);
|
|
IMethodSymbol? targetSymbol = targetSymbolInfo.Symbol as IMethodSymbol;
|
|
if (targetSymbol == null && targetSymbolInfo.CandidateReason == CandidateReason.OverloadResolutionFailure)
|
|
{
|
|
// We couldn't figure out exactly which of the targets to
|
|
// use. Choose one.
|
|
targetSymbol = targetSymbolInfo.CandidateSymbols.FirstOrDefault() as IMethodSymbol;
|
|
}
|
|
if (targetSymbol == null)
|
|
{
|
|
// TODO: handle case of 'we couldn't figure out target method's
|
|
// symbol'
|
|
continue;
|
|
}
|
|
|
|
var declaringSyntax = targetSymbol.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax();
|
|
|
|
string? ReturnDescription = null;
|
|
if (TryGetDocumentation(targetSymbol, logger, out XElement? documentationXML, out string? summary))
|
|
{
|
|
var returnNode = documentationXML?.Element("returns");
|
|
if (returnNode != null)
|
|
{
|
|
ReturnDescription = string.Join("", returnNode.DescendantNodes().OfType<XText>().Select(n => n.ToString())).Trim();
|
|
logger?.WriteLine($"\tFound a return: {ReturnDescription}");
|
|
}
|
|
}
|
|
|
|
yield return new Action(name, methodCall.Type, targetSymbol)
|
|
{
|
|
SemanticModel = model,
|
|
MethodName = targetSymbol.Name,
|
|
MethodDeclarationSyntax = declaringSyntax,
|
|
Declaration = declaringSyntax,
|
|
Description = summary,
|
|
Parameters = GetParams(targetSymbol, documentationXML, logger),
|
|
SourceFileName = root.SyntaxTree.FilePath,
|
|
DeclarationType = DeclarationType.DirectRegistration,
|
|
ReturnDescription = ReturnDescription,
|
|
};
|
|
}
|
|
}
|
|
|
|
private static bool TryGetDocumentation(IMethodSymbol targetSymbol, ILogger? logger, out XElement? documentationXML, out string? summary)
|
|
{
|
|
documentationXML = null;
|
|
summary = null;
|
|
|
|
var documentationComments = targetSymbol.GetDocumentationCommentXml();
|
|
if (string.IsNullOrEmpty(documentationComments))
|
|
{
|
|
documentationComments = null;
|
|
logger?.WriteLine($"Unable to find any xml documentation for {targetSymbol.Name}, attempting to load it syntactically instead.");
|
|
|
|
foreach (var reference in targetSymbol.DeclaringSyntaxReferences)
|
|
{
|
|
var method = reference.GetSyntax() as MethodDeclarationSyntax;
|
|
if (method != null)
|
|
{
|
|
var comment = GetActionTrivia(method, logger);
|
|
if (!string.IsNullOrEmpty(comment))
|
|
{
|
|
documentationComments = comment;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// at this point we still don't have a doc string
|
|
// going to have to just give up
|
|
if (documentationComments == null || string.IsNullOrWhiteSpace(documentationComments))
|
|
{
|
|
logger?.WriteLine($"Unable to find any xml documentation for {targetSymbol.Name}, syntactically either.");
|
|
return false;
|
|
}
|
|
logger?.WriteLine($"Found a potential documentation candidate:\"{documentationComments}\"");
|
|
|
|
// there are three different situations:
|
|
// 1. This is a correctly structured docs string that has come from GetDocumentationCommentXml
|
|
if (TryGetXMLFromDocumentString(documentationComments, out documentationXML, logger))
|
|
{
|
|
var summaryNode = documentationXML?.Element("summary");
|
|
if (summaryNode != null)
|
|
{
|
|
summary = string.Join("", summaryNode.DescendantNodes().OfType<XText>().Select(n => n.ToString())).Trim();
|
|
logger?.WriteLine("Found the GetDocumentationCommentXml comments and parsed it successfully");
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// 2. This is a syntactically determined string that happens to also be XML, but it will be missing the synthesised member root
|
|
// so we add the missing root node on and try again
|
|
if (TryGetXMLFromDocumentString($"<member name=\"M:{targetSymbol.ToString()}\">{documentationComments}</member>", out documentationXML, logger))
|
|
{
|
|
// so we wrap this node and try again
|
|
var summaryNode = documentationXML?.Element("summary");
|
|
if (summaryNode != null)
|
|
{
|
|
summary = string.Join("", summaryNode.DescendantNodes().OfType<XText>().Select(n => n.ToString())).Trim();
|
|
logger?.WriteLine("Found the unrooted XML comments and parsed it successfully");
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// 3. This is not doc XML and just happens to be a comment above a command/function
|
|
summary = documentationComments;
|
|
documentationXML = null;
|
|
logger?.WriteLine("Unable to determine XML, returning the comment as is");
|
|
return true;
|
|
}
|
|
|
|
private static bool TryGetXMLFromDocumentString(string comment, out XElement? element, ILogger? logger)
|
|
{
|
|
try
|
|
{
|
|
element = XElement.Parse(comment);
|
|
return true;
|
|
}
|
|
catch (System.Xml.XmlException ex)
|
|
{
|
|
logger?.WriteLine("Failed to parse comments as XML");
|
|
logger?.WriteException(ex);
|
|
element = null;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static IEnumerable<Action> GetAttributeActions(CompilationUnitSyntax root, SemanticModel model, ILogger logger)
|
|
{
|
|
var methodInfos = root
|
|
.DescendantNodes()
|
|
.OfType<MethodDeclarationSyntax>()
|
|
.Where(decl => decl.Parent is ClassDeclarationSyntax);
|
|
|
|
var methodsAndSymbols = methodInfos
|
|
.Select(decl =>
|
|
{
|
|
return (MethodDeclaration: decl, Symbol: model.GetDeclaredSymbol(decl));
|
|
})
|
|
.Where(pair => pair.Symbol != null);
|
|
|
|
var actionMethods = methodsAndSymbols
|
|
.Select(pair =>
|
|
{
|
|
var actionType = GetActionType(model, pair.MethodDeclaration, out var attr);
|
|
|
|
return (pair.MethodDeclaration, pair.Symbol, ActionType: actionType, ActionAttribute: attr);
|
|
})
|
|
.Where(info => info.ActionType != ActionType.NotAnAction);
|
|
|
|
foreach (var methodInfo in actionMethods)
|
|
{
|
|
var attr = methodInfo.ActionAttribute;
|
|
|
|
if (attr == null)
|
|
{
|
|
// Not a valid action; skip
|
|
continue;
|
|
}
|
|
|
|
// working on an assumption that most people just use the method name
|
|
string actionName = methodInfo.MethodDeclaration.Identifier.ToString();
|
|
|
|
// handling the situation where they have provided arguments
|
|
// if we have an argument list
|
|
if (attr.ArgumentList != null)
|
|
{
|
|
// we resolve the value of first item in that list
|
|
// and if it's a string we use that as the action name
|
|
var constantValue = model.GetConstantValue(attr.ArgumentList.Arguments.First().Expression);
|
|
if (constantValue.HasValue)
|
|
{
|
|
if (constantValue.Value is string constantString)
|
|
{
|
|
logger.WriteLine($"resolved constant expression value for the action name: {constantValue.Value.ToString()}");
|
|
actionName = constantString;
|
|
}
|
|
else
|
|
{
|
|
// Otherwise just logging the incorrect type and moving on with our life
|
|
logger.WriteLine($"resolved constant expression value for the action name, but it is not a string, skipping: {constantValue.Value}");
|
|
}
|
|
}
|
|
}
|
|
|
|
var position = methodInfo.MethodDeclaration.GetLocation();
|
|
var lineIndex = position.GetLineSpan().StartLinePosition.Line + 1;
|
|
|
|
var methodSymbol = methodInfo.Symbol;
|
|
if (methodSymbol == null)
|
|
{
|
|
logger.WriteLine($"Failed to get a symbol for " + methodInfo.MethodDeclaration.Identifier);
|
|
continue;
|
|
}
|
|
|
|
if (!(methodSymbol.ContainingSymbol is ITypeSymbol container))
|
|
{
|
|
logger.WriteLine($"Failed to get a containing symbol for " + methodInfo.MethodDeclaration.Identifier);
|
|
continue;
|
|
}
|
|
|
|
string? ReturnDescription = null;
|
|
if (TryGetDocumentation(methodSymbol, logger, out XElement? documentationXML, out string? summary))
|
|
{
|
|
var returnNode = documentationXML?.Element("returns");
|
|
if (returnNode != null)
|
|
{
|
|
ReturnDescription = string.Join("", returnNode.DescendantNodes().OfType<XText>().Select(n => n.ToString())).Trim();
|
|
logger.WriteLine($"\tFound a return: {ReturnDescription}");
|
|
}
|
|
}
|
|
|
|
var containerName = container?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) ?? "<unknown>";
|
|
|
|
yield return new Action(actionName, methodInfo.ActionType, methodSymbol)
|
|
{
|
|
Name = actionName,
|
|
Type = methodInfo.ActionType,
|
|
MethodName = $"{containerName}.{methodInfo.MethodDeclaration.Identifier}",
|
|
MethodIdentifierName = methodInfo.MethodDeclaration.Identifier.ToString(),
|
|
MethodSymbol = methodSymbol,
|
|
MethodDeclarationSyntax = methodInfo.MethodDeclaration,
|
|
IsStatic = methodSymbol.IsStatic,
|
|
Declaration = methodInfo.MethodDeclaration,
|
|
Parameters = GetParams(methodSymbol, documentationXML, logger),
|
|
AsyncType = GetAsyncType(methodSymbol),
|
|
SemanticModel = model,
|
|
Description = summary,
|
|
SourceFileName = root.SyntaxTree.FilePath,
|
|
DeclarationType = DeclarationType.Attribute,
|
|
ReturnDescription = ReturnDescription,
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns a value indicating the Unity async type for this action.
|
|
/// </summary>
|
|
/// <param name="symbol">The method symbol to test.</param>
|
|
/// <returns></returns>
|
|
private static AsyncType GetAsyncType(IMethodSymbol symbol)
|
|
{
|
|
var returnType = symbol.ReturnType;
|
|
|
|
if (returnType.SpecialType == SpecialType.System_Void)
|
|
{
|
|
return AsyncType.Sync;
|
|
}
|
|
|
|
// If the method returns IEnumerator, it is a coroutine, and therefore async.
|
|
if (returnType.SpecialType == SpecialType.System_Collections_IEnumerator)
|
|
{
|
|
return AsyncType.AsyncCoroutine;
|
|
}
|
|
|
|
// If the method returns a Coroutine, then it is potentially async
|
|
// (because if it returns null, it's sync, and if it returns non-null,
|
|
// it's async)
|
|
if (returnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::UnityEngine.Coroutine")
|
|
{
|
|
return AsyncType.MaybeAsyncCoroutine;
|
|
}
|
|
|
|
// now checking for the various different awaiter types
|
|
// later on it might be worth seeing if there is a good way to check if the return type is something that can be awaited
|
|
// but we only have four types so it's probably fine this way
|
|
switch (returnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))
|
|
{
|
|
case "global::Yarn.Unity.YarnTask":
|
|
case "global::System.Threading.Tasks.Task":
|
|
case "global::Cysharp.Threading.Tasks.UniTask":
|
|
case "global::UnityEngine.Awaitable":
|
|
return AsyncType.AsyncTask;
|
|
default:
|
|
return default;
|
|
};
|
|
}
|
|
|
|
private static List<Parameter> GetParams(IMethodSymbol symbol, XElement? documentationXML, ILogger? logger)
|
|
{
|
|
List<Parameter> parameters = new List<Parameter>();
|
|
|
|
// if this is an instance command registered via the yarn attribute we need to do an extra step
|
|
// we will need to create and add in a new parameter to the front of the parameters list
|
|
// to represent the game object that we will do a lookup for
|
|
var isAttributeRegistered = symbol.GetAttributes().Where(a => a.AttributeClass?.Name == "YarnCommandAttribute").Count() > 0;
|
|
if (isAttributeRegistered && !symbol.IsStatic)
|
|
{
|
|
logger?.WriteLine("Command has been registered via the attribute, will be adding a target parameter.");
|
|
var p = new Parameter
|
|
{
|
|
Name = "target",
|
|
IsOptional = false,
|
|
Type = symbol.ContainingType,
|
|
Description = "The name of the Game Object the runner will search for to run this command upon. This will be done through a normal GameObject.Find Unity call.",
|
|
IsParamsArray = false,
|
|
};
|
|
parameters.Insert(0, p);
|
|
}
|
|
|
|
if (symbol.Parameters.Count() > 0)
|
|
{
|
|
logger?.WriteLine($"Processing {symbol.Name} parameters");
|
|
}
|
|
else
|
|
{
|
|
logger?.WriteLine($"{symbol.Name} has no parameters");
|
|
return parameters;
|
|
}
|
|
|
|
var parameterDocumentation = new Dictionary<string, string>();
|
|
|
|
if (documentationXML != null)
|
|
{
|
|
var parameterNodes = documentationXML.Elements("param");
|
|
foreach (var parameterNode in parameterNodes)
|
|
{
|
|
var name = parameterNode.Attribute("name");
|
|
if (name == null) { continue; }
|
|
var text = string.Join(
|
|
"",
|
|
parameterNode.DescendantNodes().OfType<XText>().Select(v => v.Value)
|
|
).Trim();
|
|
|
|
if (!parameterDocumentation.ContainsKey(name.Value))
|
|
{
|
|
parameterDocumentation.Add(name.Value, text);
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (var param in symbol.Parameters)
|
|
{
|
|
logger?.WriteLine($"\t{param.Name} is a {param.Type.ToDisplayString()}");
|
|
|
|
List<AttributeData> attributes = new List<AttributeData>();
|
|
foreach (var attribute in param.GetAttributes())
|
|
{
|
|
if (attribute.AttributeClass?.BaseType?.Name == "YarnParameterAttribute")
|
|
{
|
|
logger?.WriteLine($"\t\tattribute: {attribute.AttributeClass?.Name}");
|
|
attributes.Add(attribute);
|
|
}
|
|
}
|
|
|
|
// ok here need to make some changes
|
|
// if p is variadic it will be array<T> and I need to get just the T
|
|
ITypeSymbol parameterType = param.Type;
|
|
if (param.IsParams)
|
|
{
|
|
if (param.Type is IArrayTypeSymbol arrayTypeSymbol)
|
|
{
|
|
parameterType = arrayTypeSymbol.ElementType;
|
|
}
|
|
else
|
|
{
|
|
logger?.WriteLine($"\t{param.Name} is a variadic parameter but isn't an array");
|
|
}
|
|
}
|
|
|
|
parameterDocumentation.TryGetValue(param.Name, out var paramDoc);
|
|
var p = new Parameter
|
|
{
|
|
Name = param.Name,
|
|
IsOptional = param.IsOptional,
|
|
Type = parameterType,
|
|
Description = paramDoc,
|
|
IsParamsArray = param.IsParams,
|
|
Attributes = attributes.Count() == 0 ? null : attributes.ToArray(),
|
|
DefaultValueString = param.HasExplicitDefaultValue ? param.ExplicitDefaultValue?.ToString() : null,
|
|
};
|
|
parameters.Add(p);
|
|
}
|
|
|
|
return parameters;
|
|
}
|
|
|
|
internal static bool IsAttributeYarnCommand(AttributeData attribute)
|
|
{
|
|
return GetActionType(attribute) != ActionType.NotAnAction;
|
|
}
|
|
|
|
internal static ActionType GetActionType(SemanticModel model, MethodDeclarationSyntax decl, out AttributeSyntax? actionAttribute)
|
|
{
|
|
var attributes = GetAttributes(decl, model);
|
|
|
|
var actionTypes = attributes
|
|
.Select(attr => (Syntax: attr.Item1, Data: attr.Item2, Type: GetActionType(attr.Item2)))
|
|
.Where(info => info.Type != ActionType.NotAnAction);
|
|
|
|
if (actionTypes.Count() != 1)
|
|
{
|
|
// Not an action, because you can only have one YarnCommand or
|
|
// YarnFunction attribute
|
|
actionAttribute = null;
|
|
return ActionType.Invalid;
|
|
}
|
|
|
|
var actionType = actionTypes.Single();
|
|
actionAttribute = actionType.Syntax;
|
|
return actionType.Type;
|
|
}
|
|
|
|
internal static ActionType GetActionType(AttributeData attr)
|
|
{
|
|
INamedTypeSymbol? attributeClass = attr.AttributeClass;
|
|
|
|
switch (attributeClass?.Name)
|
|
{
|
|
case "YarnCommandAttribute": return ActionType.Command;
|
|
case "YarnFunctionAttribute": return ActionType.Function;
|
|
default: return ActionType.NotAnAction;
|
|
}
|
|
}
|
|
|
|
public static IEnumerable<(AttributeSyntax, AttributeData)> GetAttributes(ClassDeclarationSyntax classDecl, SemanticModel model)
|
|
{
|
|
INamedTypeSymbol? classSymbol = model.GetDeclaredSymbol(classDecl);
|
|
|
|
var methodAttributes = classSymbol?.GetAttributes();
|
|
|
|
if (methodAttributes == null)
|
|
{
|
|
yield break;
|
|
}
|
|
|
|
foreach (var attribute in methodAttributes)
|
|
{
|
|
if (attribute.ApplicationSyntaxReference?.GetSyntax() is AttributeSyntax syntax)
|
|
{
|
|
yield return (syntax, attribute);
|
|
}
|
|
}
|
|
}
|
|
|
|
public static IEnumerable<(AttributeSyntax, AttributeData)> GetAttributes(MethodDeclarationSyntax method, SemanticModel model)
|
|
{
|
|
IMethodSymbol? methodSymbol = model.GetDeclaredSymbol(method);
|
|
|
|
var methodAttributes = methodSymbol?.GetAttributes();
|
|
|
|
if (methodAttributes == null)
|
|
{
|
|
yield break;
|
|
}
|
|
|
|
foreach (var attribute in methodAttributes)
|
|
{
|
|
if (attribute.ApplicationSyntaxReference?.GetSyntax() is AttributeSyntax syntax)
|
|
{
|
|
yield return (syntax, attribute);
|
|
}
|
|
}
|
|
}
|
|
|
|
internal static IEnumerable<string> GetSourceFiles(string sourcePath)
|
|
{
|
|
if (Directory.Exists(sourcePath))
|
|
{
|
|
return System.IO.Directory.EnumerateFiles(sourcePath, "*.cs", SearchOption.AllDirectories);
|
|
}
|
|
if (File.Exists(sourcePath))
|
|
{
|
|
return new[] { sourcePath };
|
|
}
|
|
throw new FileNotFoundException($"No file or directory at {sourcePath} was found.", sourcePath);
|
|
}
|
|
|
|
public static Type? GetTypeByName(string name)
|
|
{
|
|
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
|
{
|
|
var tt = assembly.GetType(name);
|
|
if (tt != null)
|
|
{
|
|
return tt;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// these are basically just ripped straight from the LSP
|
|
// should maybe look at making these more accessible, for now the code dupe is fine IMO
|
|
public static string? GetActionTrivia(MethodDeclarationSyntax method, ILogger? logger)
|
|
{
|
|
// The main string to use as the function's documentation.
|
|
if (method.HasLeadingTrivia)
|
|
{
|
|
var trivias = method.GetLeadingTrivia();
|
|
var structuredTrivia = trivias.LastOrDefault(t => t.HasStructure);
|
|
if (structuredTrivia.Kind() != SyntaxKind.None)
|
|
{
|
|
// The method contains structured trivia. Extract the
|
|
// documentation for it.
|
|
logger?.WriteLine($"trivia for {method.Identifier} is structured");
|
|
return GetDocumentationFromStructuredTrivia(structuredTrivia);
|
|
}
|
|
else
|
|
{
|
|
// There isn't any structured trivia, but perhaps there's a
|
|
// comment above the method, which we can use as our
|
|
// documentation.
|
|
logger?.WriteLine($"trivia for {method.Identifier} is unstructured");
|
|
return GetDocumentationFromUnstructuredTrivia(trivias, logger);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
logger?.WriteLine($"{method.Identifier} has no trivia");
|
|
return null;
|
|
}
|
|
}
|
|
private static string GetDocumentationFromUnstructuredTrivia(SyntaxTriviaList trivias, ILogger? logger)
|
|
{
|
|
string documentation;
|
|
bool emptyLineFlag = false;
|
|
var documentationParts = Enumerable.Empty<string>();
|
|
|
|
// loop in reverse order until hit something that doesn't look like it's related
|
|
foreach (var trivia in trivias.Reverse())
|
|
{
|
|
var doneWithTrivia = false;
|
|
switch (trivia.Kind())
|
|
{
|
|
case SyntaxKind.EndOfLineTrivia:
|
|
// if we hit two lines in a row without a comment/attribute inbetween, we're done collecting trivia
|
|
if (emptyLineFlag == true)
|
|
{
|
|
logger?.WriteLine("have hit two empty lines in a row, done collecting unstructured trivia");
|
|
doneWithTrivia = true;
|
|
}
|
|
emptyLineFlag = true;
|
|
break;
|
|
case SyntaxKind.WhitespaceTrivia:
|
|
break;
|
|
case SyntaxKind.Attribute:
|
|
emptyLineFlag = false;
|
|
break;
|
|
case SyntaxKind.SingleLineCommentTrivia:
|
|
case SyntaxKind.MultiLineCommentTrivia:
|
|
documentationParts = documentationParts.Prepend(trivia.ToString().Trim('/', ' '));
|
|
emptyLineFlag = false;
|
|
break;
|
|
default:
|
|
doneWithTrivia = true;
|
|
break;
|
|
}
|
|
|
|
if (doneWithTrivia)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
documentation = string.Join(" ", documentationParts);
|
|
return documentation;
|
|
}
|
|
private static string? GetDocumentationFromStructuredTrivia(SyntaxTrivia structuredTrivia)
|
|
{
|
|
string documentation;
|
|
var triviaStructure = structuredTrivia.GetStructure();
|
|
if (triviaStructure == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
string? ExtractStructuredTrivia(string tagName)
|
|
{
|
|
// Find the tag that matches the requested name.
|
|
var triviaMatch = triviaStructure
|
|
.ChildNodes()
|
|
.OfType<XmlElementSyntax>()
|
|
.FirstOrDefault(x =>
|
|
x.StartTag.Name.ToString() == tagName
|
|
);
|
|
|
|
if (triviaMatch != null
|
|
&& triviaMatch.Kind() != SyntaxKind.None
|
|
&& triviaMatch.Content.Any())
|
|
{
|
|
// Get all content from this element that isn't a newline, and
|
|
// join it up into a single string.
|
|
var v = triviaMatch
|
|
.Content[0]
|
|
.ChildTokens()
|
|
.Where(ct => ct.Kind() != SyntaxKind.XmlTextLiteralNewLineToken)
|
|
.Select(ct => ct.ValueText.Trim());
|
|
|
|
return string.Join(" ", v).Trim();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
var summary = ExtractStructuredTrivia("summary");
|
|
var remarks = ExtractStructuredTrivia("remarks");
|
|
|
|
documentation = summary ?? triviaStructure.ToString();
|
|
|
|
if (remarks != null)
|
|
{
|
|
documentation += "\n\n" + remarks;
|
|
}
|
|
|
|
return documentation;
|
|
}
|
|
}
|
|
}
|