Updated EllieBot.Generators
This commit is contained in:
parent
c69f7951a7
commit
3252829969
8 changed files with 841 additions and 0 deletions
|
@ -23,6 +23,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot.Tests", "src\Ellie
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EllieBot.Coordinator", "src\EllieBot.Coordinator\EllieBot.Coordinator.csproj", "{A631DDF0-3AD1-4CB9-8458-314B1320868A}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EllieBot.Coordinator", "src\EllieBot.Coordinator\EllieBot.Coordinator.csproj", "{A631DDF0-3AD1-4CB9-8458-314B1320868A}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EllieBot.Generators", "src\EllieBot.Generators\EllieBot.Generators.csproj", "{CB1A5307-DD85-4795-8A8A-A25D36DADC51}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
@ -45,6 +47,10 @@ Global
|
||||||
{A631DDF0-3AD1-4CB9-8458-314B1320868A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{A631DDF0-3AD1-4CB9-8458-314B1320868A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{A631DDF0-3AD1-4CB9-8458-314B1320868A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{A631DDF0-3AD1-4CB9-8458-314B1320868A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{A631DDF0-3AD1-4CB9-8458-314B1320868A}.Release|Any CPU.Build.0 = Release|Any CPU
|
{A631DDF0-3AD1-4CB9-8458-314B1320868A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{CB1A5307-DD85-4795-8A8A-A25D36DADC51}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{CB1A5307-DD85-4795-8A8A-A25D36DADC51}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{CB1A5307-DD85-4795-8A8A-A25D36DADC51}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{CB1A5307-DD85-4795-8A8A-A25D36DADC51}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
@ -55,6 +61,7 @@ Global
|
||||||
{5AD2EFFB-7774-49B2-A791-3BAC4DAEE067} = {872A4C63-833C-4AE0-91AB-3CE348D3E6F8}
|
{5AD2EFFB-7774-49B2-A791-3BAC4DAEE067} = {872A4C63-833C-4AE0-91AB-3CE348D3E6F8}
|
||||||
{179DF3B3-AD32-4335-8231-9818338DF3A2} = {B28FB883-9688-41EB-BF5A-945F4A4EB628}
|
{179DF3B3-AD32-4335-8231-9818338DF3A2} = {B28FB883-9688-41EB-BF5A-945F4A4EB628}
|
||||||
{A631DDF0-3AD1-4CB9-8458-314B1320868A} = {B28FB883-9688-41EB-BF5A-945F4A4EB628}
|
{A631DDF0-3AD1-4CB9-8458-314B1320868A} = {B28FB883-9688-41EB-BF5A-945F4A4EB628}
|
||||||
|
{CB1A5307-DD85-4795-8A8A-A25D36DADC51} = {B28FB883-9688-41EB-BF5A-945F4A4EB628}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {79F61C2C-CDBB-4361-A234-91A0B334CFE4}
|
SolutionGuid = {79F61C2C-CDBB-4361-A234-91A0B334CFE4}
|
||||||
|
|
254
src/EllieBot.Generators/Cloneable/CloneableGenerator.cs
Normal file
254
src/EllieBot.Generators/Cloneable/CloneableGenerator.cs
Normal file
|
@ -0,0 +1,254 @@
|
||||||
|
// Code temporarily yeeted from
|
||||||
|
// https://github.com/mostmand/Cloneable/blob/master/Cloneable/CloneableGenerator.cs
|
||||||
|
// because of NRT issue
|
||||||
|
#nullable enable
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
using Microsoft.CodeAnalysis.Text;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Cloneable
|
||||||
|
{
|
||||||
|
[Generator]
|
||||||
|
public class CloneableGenerator : ISourceGenerator
|
||||||
|
{
|
||||||
|
private const string PREVENT_DEEP_COPY_KEY_STRING = "PreventDeepCopy";
|
||||||
|
private const string EXPLICIT_DECLARATION_KEY_STRING = "ExplicitDeclaration";
|
||||||
|
|
||||||
|
private const string CLONEABLE_NAMESPACE = "Cloneable";
|
||||||
|
private const string CLONEABLE_ATTRIBUTE_STRING = "CloneableAttribute";
|
||||||
|
private const string CLONE_ATTRIBUTE_STRING = "CloneAttribute";
|
||||||
|
private const string IGNORE_CLONE_ATTRIBUTE_STRING = "IgnoreCloneAttribute";
|
||||||
|
|
||||||
|
private const string CLONEABLE_ATTRIBUTE_TEXT = @"// <AutoGenerated/>
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace " + CLONEABLE_NAMESPACE + @"
|
||||||
|
{
|
||||||
|
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = true, AllowMultiple = false)]
|
||||||
|
public sealed class " + CLONEABLE_ATTRIBUTE_STRING + @" : Attribute
|
||||||
|
{
|
||||||
|
public " + CLONEABLE_ATTRIBUTE_STRING + @"()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool " + EXPLICIT_DECLARATION_KEY_STRING + @" { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
|
private const string CLONE_PROPERTY_ATTRIBUTE_TEXT = @"// <AutoGenerated/>
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace " + CLONEABLE_NAMESPACE + @"
|
||||||
|
{
|
||||||
|
[AttributeUsage(AttributeTargets.Property, Inherited = true, AllowMultiple = false)]
|
||||||
|
public sealed class " + CLONE_ATTRIBUTE_STRING + @" : Attribute
|
||||||
|
{
|
||||||
|
public " + CLONE_ATTRIBUTE_STRING + @"()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool " + PREVENT_DEEP_COPY_KEY_STRING + @" { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
|
private const string IGNORE_CLONE_PROPERTY_ATTRIBUTE_TEXT = @"// <AutoGenerated/>
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace " + CLONEABLE_NAMESPACE + @"
|
||||||
|
{
|
||||||
|
[AttributeUsage(AttributeTargets.Property, Inherited = true, AllowMultiple = false)]
|
||||||
|
public sealed class " + IGNORE_CLONE_ATTRIBUTE_STRING + @" : Attribute
|
||||||
|
{
|
||||||
|
public " + IGNORE_CLONE_ATTRIBUTE_STRING + @"()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
|
private INamedTypeSymbol? _cloneableAttribute;
|
||||||
|
private INamedTypeSymbol? _ignoreCloneAttribute;
|
||||||
|
private INamedTypeSymbol? _cloneAttribute;
|
||||||
|
|
||||||
|
public void Initialize(GeneratorInitializationContext context)
|
||||||
|
=> context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());
|
||||||
|
|
||||||
|
public void Execute(GeneratorExecutionContext context)
|
||||||
|
{
|
||||||
|
InjectCloneableAttributes(context);
|
||||||
|
GenerateCloneMethods(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void GenerateCloneMethods(GeneratorExecutionContext context)
|
||||||
|
{
|
||||||
|
if (context.SyntaxReceiver is not SyntaxReceiver receiver)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Compilation compilation = GetCompilation(context);
|
||||||
|
|
||||||
|
InitAttributes(compilation);
|
||||||
|
|
||||||
|
var classSymbols = GetClassSymbols(compilation, receiver);
|
||||||
|
foreach (var classSymbol in classSymbols)
|
||||||
|
{
|
||||||
|
if (!classSymbol.TryGetAttribute(_cloneableAttribute!, out var attributes))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var attribute = attributes.Single();
|
||||||
|
var isExplicit = (bool?)attribute.NamedArguments.FirstOrDefault(e => e.Key.Equals(EXPLICIT_DECLARATION_KEY_STRING)).Value.Value ?? false;
|
||||||
|
context.AddSource($"{classSymbol.Name}_cloneable.g.cs", SourceText.From(CreateCloneableCode(classSymbol, isExplicit), Encoding.UTF8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitAttributes(Compilation compilation)
|
||||||
|
{
|
||||||
|
_cloneableAttribute = compilation.GetTypeByMetadataName($"{CLONEABLE_NAMESPACE}.{CLONEABLE_ATTRIBUTE_STRING}")!;
|
||||||
|
_cloneAttribute = compilation.GetTypeByMetadataName($"{CLONEABLE_NAMESPACE}.{CLONE_ATTRIBUTE_STRING}")!;
|
||||||
|
_ignoreCloneAttribute = compilation.GetTypeByMetadataName($"{CLONEABLE_NAMESPACE}.{IGNORE_CLONE_ATTRIBUTE_STRING}")!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Compilation GetCompilation(GeneratorExecutionContext context)
|
||||||
|
{
|
||||||
|
var options = context.Compilation.SyntaxTrees.First().Options as CSharpParseOptions;
|
||||||
|
|
||||||
|
var compilation = context.Compilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(SourceText.From(CLONEABLE_ATTRIBUTE_TEXT, Encoding.UTF8), options)).
|
||||||
|
AddSyntaxTrees(CSharpSyntaxTree.ParseText(SourceText.From(CLONE_PROPERTY_ATTRIBUTE_TEXT, Encoding.UTF8), options)).
|
||||||
|
AddSyntaxTrees(CSharpSyntaxTree.ParseText(SourceText.From(IGNORE_CLONE_PROPERTY_ATTRIBUTE_TEXT, Encoding.UTF8), options));
|
||||||
|
return compilation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string CreateCloneableCode(INamedTypeSymbol classSymbol, bool isExplicit)
|
||||||
|
{
|
||||||
|
string namespaceName = classSymbol.ContainingNamespace.ToDisplayString();
|
||||||
|
var fieldAssignmentsCode = GenerateFieldAssignmentsCode(classSymbol, isExplicit).ToList();
|
||||||
|
var fieldAssignmentsCodeSafe = fieldAssignmentsCode.Select(x =>
|
||||||
|
{
|
||||||
|
if (x.isCloneable)
|
||||||
|
return x.line + "Safe(referenceChain)";
|
||||||
|
return x.line;
|
||||||
|
});
|
||||||
|
var fieldAssignmentsCodeFast = fieldAssignmentsCode.Select(x =>
|
||||||
|
{
|
||||||
|
if (x.isCloneable)
|
||||||
|
return x.line + "()";
|
||||||
|
return x.line;
|
||||||
|
});
|
||||||
|
|
||||||
|
return $@"using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace {namespaceName}
|
||||||
|
{{
|
||||||
|
{GetAccessModifier(classSymbol)} partial class {classSymbol.Name}
|
||||||
|
{{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a copy of {classSymbol.Name} with NO circular reference checking. This method should be used if performance matters.
|
||||||
|
///
|
||||||
|
/// <exception cref=""StackOverflowException"">Will occur on any object that has circular references in the hierarchy.</exception>
|
||||||
|
/// </summary>
|
||||||
|
public {classSymbol.Name} Clone()
|
||||||
|
{{
|
||||||
|
return new {classSymbol.Name}
|
||||||
|
{{
|
||||||
|
{string.Join(",\n", fieldAssignmentsCodeFast)}
|
||||||
|
}};
|
||||||
|
}}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a copy of {classSymbol.Name} with circular reference checking. If a circular reference was detected, only a reference of the leaf object is passed instead of cloning it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name=""referenceChain"">Should only be provided if specific objects should not be cloned but passed by reference instead.</param>
|
||||||
|
public {classSymbol.Name} CloneSafe(Stack<object> referenceChain = null)
|
||||||
|
{{
|
||||||
|
if(referenceChain?.Contains(this) == true)
|
||||||
|
return this;
|
||||||
|
referenceChain ??= new Stack<object>();
|
||||||
|
referenceChain.Push(this);
|
||||||
|
var result = new {classSymbol.Name}
|
||||||
|
{{
|
||||||
|
{string.Join($",\n", fieldAssignmentsCodeSafe)}
|
||||||
|
}};
|
||||||
|
referenceChain.Pop();
|
||||||
|
return result;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<(string line, bool isCloneable)> GenerateFieldAssignmentsCode(INamedTypeSymbol classSymbol, bool isExplicit)
|
||||||
|
{
|
||||||
|
var fieldNames = GetCloneableProperties(classSymbol, isExplicit);
|
||||||
|
|
||||||
|
var fieldAssignments = fieldNames.Select(field => IsFieldCloneable(field, classSymbol))
|
||||||
|
.OrderBy(x => x.isCloneable)
|
||||||
|
.Select(x => (GenerateAssignmentCode(x.item.Name, x.isCloneable), x.isCloneable));
|
||||||
|
return fieldAssignments;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GenerateAssignmentCode(string name, bool isCloneable)
|
||||||
|
{
|
||||||
|
if (isCloneable)
|
||||||
|
{
|
||||||
|
return $@" {name} = this.{name}?.Clone";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $@" {name} = this.{name}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private (IPropertySymbol item, bool isCloneable) IsFieldCloneable(IPropertySymbol x, INamedTypeSymbol classSymbol)
|
||||||
|
{
|
||||||
|
if (SymbolEqualityComparer.Default.Equals(x.Type, classSymbol))
|
||||||
|
{
|
||||||
|
return (x, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!x.Type.TryGetAttribute(_cloneableAttribute!, out var attributes))
|
||||||
|
{
|
||||||
|
return (x, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var preventDeepCopy = (bool?)attributes.Single().NamedArguments.FirstOrDefault(e => e.Key.Equals(PREVENT_DEEP_COPY_KEY_STRING)).Value.Value ?? false;
|
||||||
|
return (item: x, !preventDeepCopy);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetAccessModifier(INamedTypeSymbol classSymbol)
|
||||||
|
=> classSymbol.DeclaredAccessibility.ToString().ToLowerInvariant();
|
||||||
|
|
||||||
|
private IEnumerable<IPropertySymbol> GetCloneableProperties(ITypeSymbol classSymbol, bool isExplicit)
|
||||||
|
{
|
||||||
|
var targetSymbolMembers = classSymbol.GetMembers().OfType<IPropertySymbol>()
|
||||||
|
.Where(x => x.SetMethod is not null &&
|
||||||
|
x.CanBeReferencedByName);
|
||||||
|
if (isExplicit)
|
||||||
|
{
|
||||||
|
return targetSymbolMembers.Where(x => x.HasAttribute(_cloneAttribute!));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return targetSymbolMembers.Where(x => !x.HasAttribute(_ignoreCloneAttribute!));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<INamedTypeSymbol> GetClassSymbols(Compilation compilation, SyntaxReceiver receiver)
|
||||||
|
=> receiver.CandidateClasses.Select(clazz => GetClassSymbol(compilation, clazz));
|
||||||
|
|
||||||
|
private static INamedTypeSymbol GetClassSymbol(Compilation compilation, ClassDeclarationSyntax clazz)
|
||||||
|
{
|
||||||
|
var model = compilation.GetSemanticModel(clazz.SyntaxTree);
|
||||||
|
var classSymbol = model.GetDeclaredSymbol(clazz)!;
|
||||||
|
return classSymbol;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void InjectCloneableAttributes(GeneratorExecutionContext context)
|
||||||
|
{
|
||||||
|
context.AddSource(CLONEABLE_ATTRIBUTE_STRING, SourceText.From(CLONEABLE_ATTRIBUTE_TEXT, Encoding.UTF8));
|
||||||
|
context.AddSource(CLONE_ATTRIBUTE_STRING, SourceText.From(CLONE_PROPERTY_ATTRIBUTE_TEXT, Encoding.UTF8));
|
||||||
|
context.AddSource(IGNORE_CLONE_ATTRIBUTE_STRING, SourceText.From(IGNORE_CLONE_PROPERTY_ATTRIBUTE_TEXT, Encoding.UTF8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
24
src/EllieBot.Generators/Cloneable/SymbolExtensions.cs
Normal file
24
src/EllieBot.Generators/Cloneable/SymbolExtensions.cs
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
// Code temporarily yeeted from
|
||||||
|
// https://github.com/mostmand/Cloneable/blob/master/Cloneable/CloneableGenerator.cs
|
||||||
|
// because of NRT issue
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace Cloneable
|
||||||
|
{
|
||||||
|
internal static class SymbolExtensions
|
||||||
|
{
|
||||||
|
public static bool TryGetAttribute(this ISymbol symbol, INamedTypeSymbol attributeType,
|
||||||
|
out IEnumerable<AttributeData> attributes)
|
||||||
|
{
|
||||||
|
attributes = symbol.GetAttributes()
|
||||||
|
.Where(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, attributeType));
|
||||||
|
return attributes.Any();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool HasAttribute(this ISymbol symbol, INamedTypeSymbol attributeType)
|
||||||
|
=> symbol.GetAttributes()
|
||||||
|
.Any(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, attributeType));
|
||||||
|
}
|
||||||
|
}
|
27
src/EllieBot.Generators/Cloneable/SyntaxReceiver.cs
Normal file
27
src/EllieBot.Generators/Cloneable/SyntaxReceiver.cs
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
// Code temporarily yeeted from
|
||||||
|
// https://github.com/mostmand/Cloneable/blob/master/Cloneable/CloneableGenerator.cs
|
||||||
|
// because of NRT issue
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
|
||||||
|
namespace Cloneable
|
||||||
|
{
|
||||||
|
internal class SyntaxReceiver : ISyntaxReceiver
|
||||||
|
{
|
||||||
|
public IList<ClassDeclarationSyntax> CandidateClasses { get; } = new List<ClassDeclarationSyntax>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called for every syntax node in the compilation, we can inspect the nodes and save any information useful for generation
|
||||||
|
/// </summary>
|
||||||
|
public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
|
||||||
|
{
|
||||||
|
// any field with at least one attribute is a candidate for being cloneable
|
||||||
|
if (syntaxNode is ClassDeclarationSyntax classDeclarationSyntax &&
|
||||||
|
classDeclarationSyntax.AttributeLists.Count > 0)
|
||||||
|
{
|
||||||
|
CandidateClasses.Add(classDeclarationSyntax);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
336
src/EllieBot.Generators/Command/CommandAttributesGenerator.cs
Normal file
336
src/EllieBot.Generators/Command/CommandAttributesGenerator.cs
Normal file
|
@ -0,0 +1,336 @@
|
||||||
|
// #nullable enable
|
||||||
|
// using System;
|
||||||
|
// using System.CodeDom.Compiler;
|
||||||
|
// using System.Collections.Generic;
|
||||||
|
// using System.Collections.Immutable;
|
||||||
|
// using System.Collections.ObjectModel;
|
||||||
|
// using System.Diagnostics;
|
||||||
|
// using System.IO;
|
||||||
|
// using System.Linq;
|
||||||
|
// using System.Text;
|
||||||
|
// using System.Threading;
|
||||||
|
// using Microsoft.CodeAnalysis;
|
||||||
|
// using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
// using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
// using Microsoft.CodeAnalysis.Text;
|
||||||
|
//
|
||||||
|
// namespace EllieBot.Generators.Command;
|
||||||
|
//
|
||||||
|
// [Generator]
|
||||||
|
// public class CommandAttributesGenerator : IIncrementalGenerator
|
||||||
|
// {
|
||||||
|
// public const string ATTRIBUTE = @"// <AutoGenerated />
|
||||||
|
//
|
||||||
|
// namespace EllieBot.Common;
|
||||||
|
//
|
||||||
|
// [System.AttributeUsage(System.AttributeTargets.Method)]
|
||||||
|
// public class CmdAttribute : System.Attribute
|
||||||
|
// {
|
||||||
|
//
|
||||||
|
// }";
|
||||||
|
//
|
||||||
|
// public class MethodModel
|
||||||
|
// {
|
||||||
|
// public string? Namespace { get; }
|
||||||
|
// public IReadOnlyCollection<string> Classes { get; }
|
||||||
|
// public string ReturnType { get; }
|
||||||
|
// public string MethodName { get; }
|
||||||
|
// public IEnumerable<string> Params { get; }
|
||||||
|
//
|
||||||
|
// public MethodModel(string? ns, IReadOnlyCollection<string> classes, string returnType, string methodName, IEnumerable<string> @params)
|
||||||
|
// {
|
||||||
|
// Namespace = ns;
|
||||||
|
// Classes = classes;
|
||||||
|
// ReturnType = returnType;
|
||||||
|
// MethodName = methodName;
|
||||||
|
// Params = @params;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// public class FileModel
|
||||||
|
// {
|
||||||
|
// public string? Namespace { get; }
|
||||||
|
// public IReadOnlyCollection<string> ClassHierarchy { get; }
|
||||||
|
// public IReadOnlyCollection<MethodModel> Methods { get; }
|
||||||
|
//
|
||||||
|
// public FileModel(string? ns, IReadOnlyCollection<string> classHierarchy, IReadOnlyCollection<MethodModel> methods)
|
||||||
|
// {
|
||||||
|
// Namespace = ns;
|
||||||
|
// ClassHierarchy = classHierarchy;
|
||||||
|
// Methods = methods;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// public void Initialize(IncrementalGeneratorInitializationContext context)
|
||||||
|
// {
|
||||||
|
// // #if DEBUG
|
||||||
|
// // if (!Debugger.IsAttached)
|
||||||
|
// // Debugger.Launch();
|
||||||
|
// // // SpinWait.SpinUntil(() => Debugger.IsAttached);
|
||||||
|
// // #endif
|
||||||
|
// context.RegisterPostInitializationOutput(static ctx => ctx.AddSource(
|
||||||
|
// "CmdAttribute.g.cs",
|
||||||
|
// SourceText.From(ATTRIBUTE, Encoding.UTF8)));
|
||||||
|
//
|
||||||
|
// var methods = context.SyntaxProvider
|
||||||
|
// .CreateSyntaxProvider(
|
||||||
|
// static (node, _) => node is MethodDeclarationSyntax { AttributeLists.Count: > 0 },
|
||||||
|
// static (ctx, cancel) => Transform(ctx, cancel))
|
||||||
|
// .Where(static m => m is not null)
|
||||||
|
// .Where(static m => m?.ChildTokens().Any(static x => x.IsKind(SyntaxKind.PublicKeyword)) ?? false);
|
||||||
|
//
|
||||||
|
// var compilationMethods = context.CompilationProvider.Combine(methods.Collect());
|
||||||
|
//
|
||||||
|
// context.RegisterSourceOutput(compilationMethods,
|
||||||
|
// static (ctx, tuple) => RegisterAction(in ctx, tuple.Left, in tuple.Right));
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private static void RegisterAction(in SourceProductionContext ctx,
|
||||||
|
// Compilation comp,
|
||||||
|
// in ImmutableArray<MethodDeclarationSyntax?> methods)
|
||||||
|
// {
|
||||||
|
// if (methods is { IsDefaultOrEmpty: true })
|
||||||
|
// return;
|
||||||
|
//
|
||||||
|
// var models = GetModels(comp, methods, ctx.CancellationToken);
|
||||||
|
//
|
||||||
|
// foreach (var model in models)
|
||||||
|
// {
|
||||||
|
// var name = $"{model.Namespace}.{string.Join(".", model.ClassHierarchy)}.g.cs";
|
||||||
|
// try
|
||||||
|
// {
|
||||||
|
// var source = GetSourceText(model);
|
||||||
|
// ctx.AddSource(name, SourceText.From(source, Encoding.UTF8));
|
||||||
|
// }
|
||||||
|
// catch (Exception ex)
|
||||||
|
// {
|
||||||
|
// Console.WriteLine($"Error writing source file {name}\n" + ex);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private static string GetSourceText(FileModel model)
|
||||||
|
// {
|
||||||
|
// using var sw = new StringWriter();
|
||||||
|
// using var tw = new IndentedTextWriter(sw);
|
||||||
|
//
|
||||||
|
// tw.WriteLine("// <AutoGenerated />");
|
||||||
|
// tw.WriteLine("#pragma warning disable CS1066");
|
||||||
|
//
|
||||||
|
// if (model.Namespace is not null)
|
||||||
|
// {
|
||||||
|
// tw.WriteLine($"namespace {model.Namespace};");
|
||||||
|
// tw.WriteLine();
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// foreach (var className in model.ClassHierarchy)
|
||||||
|
// {
|
||||||
|
// tw.WriteLine($"public partial class {className}");
|
||||||
|
// tw.WriteLine("{");
|
||||||
|
// tw.Indent ++;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// foreach (var method in model.Methods)
|
||||||
|
// {
|
||||||
|
// tw.WriteLine("[EllieCommand]");
|
||||||
|
// tw.WriteLine("[EllieDescription]");
|
||||||
|
// tw.WriteLine("[Aliases]");
|
||||||
|
// tw.WriteLine($"public partial {method.ReturnType} {method.MethodName}({string.Join(", ", method.Params)});");
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// foreach (var _ in model.ClassHierarchy)
|
||||||
|
// {
|
||||||
|
// tw.Indent --;
|
||||||
|
// tw.WriteLine("}");
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// tw.Flush();
|
||||||
|
// return sw.ToString();
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private static IReadOnlyCollection<FileModel> GetModels(Compilation compilation,
|
||||||
|
// in ImmutableArray<MethodDeclarationSyntax?> inputMethods,
|
||||||
|
// CancellationToken cancel)
|
||||||
|
// {
|
||||||
|
// var models = new List<FileModel>();
|
||||||
|
//
|
||||||
|
// var methods = inputMethods
|
||||||
|
// .Where(static x => x is not null)
|
||||||
|
// .Distinct();
|
||||||
|
//
|
||||||
|
// var methodModels = methods
|
||||||
|
// .Select(x => MethodDeclarationToMethodModel(compilation, x!))
|
||||||
|
// .Where(static x => x is not null)
|
||||||
|
// .Cast<MethodModel>();
|
||||||
|
//
|
||||||
|
// var groups = methodModels
|
||||||
|
// .GroupBy(static x => $"{x.Namespace}.{string.Join(".", x.Classes)}");
|
||||||
|
//
|
||||||
|
// foreach (var group in groups)
|
||||||
|
// {
|
||||||
|
// if (cancel.IsCancellationRequested)
|
||||||
|
// return new Collection<FileModel>();
|
||||||
|
//
|
||||||
|
// if (group is null)
|
||||||
|
// continue;
|
||||||
|
//
|
||||||
|
// var elems = group.ToList();
|
||||||
|
// if (elems.Count is 0)
|
||||||
|
// continue;
|
||||||
|
//
|
||||||
|
// var model = new FileModel(
|
||||||
|
// methods: elems,
|
||||||
|
// ns: elems[0].Namespace,
|
||||||
|
// classHierarchy: elems![0].Classes
|
||||||
|
// );
|
||||||
|
//
|
||||||
|
// models.Add(model);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// return models;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private static MethodModel? MethodDeclarationToMethodModel(Compilation comp, MethodDeclarationSyntax decl)
|
||||||
|
// {
|
||||||
|
// // SpinWait.SpinUntil(static () => Debugger.IsAttached);
|
||||||
|
//
|
||||||
|
// SemanticModel semanticModel;
|
||||||
|
// try
|
||||||
|
// {
|
||||||
|
// semanticModel = comp.GetSemanticModel(decl.SyntaxTree);
|
||||||
|
// }
|
||||||
|
// catch
|
||||||
|
// {
|
||||||
|
// // for some reason this method can throw "Not part of this compilation" argument exception
|
||||||
|
// return null;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// var methodModel = new MethodModel(
|
||||||
|
// @params: decl.ParameterList.Parameters
|
||||||
|
// .Where(p => p.Type is not null)
|
||||||
|
// .Select(p =>
|
||||||
|
// {
|
||||||
|
// var prefix = p.Modifiers.Any(static x => x.IsKind(SyntaxKind.ParamsKeyword))
|
||||||
|
// ? "params "
|
||||||
|
// : string.Empty;
|
||||||
|
//
|
||||||
|
// var type = semanticModel
|
||||||
|
// .GetTypeInfo(p.Type!)
|
||||||
|
// .Type
|
||||||
|
// ?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// var name = p.Identifier.Text;
|
||||||
|
//
|
||||||
|
// var suffix = string.Empty;
|
||||||
|
// if (p.Default is not null)
|
||||||
|
// {
|
||||||
|
// if (p.Default.Value is LiteralExpressionSyntax)
|
||||||
|
// {
|
||||||
|
// suffix = " = " + p.Default.Value;
|
||||||
|
// }
|
||||||
|
// else if (p.Default.Value is MemberAccessExpressionSyntax maes)
|
||||||
|
// {
|
||||||
|
// var maesSemModel = comp.GetSemanticModel(maes.SyntaxTree);
|
||||||
|
// var sym = maesSemModel.GetSymbolInfo(maes.Name);
|
||||||
|
// if (sym.Symbol is null)
|
||||||
|
// {
|
||||||
|
// suffix = " = " + p.Default.Value;
|
||||||
|
// }
|
||||||
|
// else
|
||||||
|
// {
|
||||||
|
// suffix = " = " + sym.Symbol.ToDisplayString();
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return $"{prefix}{type} {name}{suffix}";
|
||||||
|
// })
|
||||||
|
// .ToList(),
|
||||||
|
// methodName: decl.Identifier.Text,
|
||||||
|
// returnType: decl.ReturnType.ToString(),
|
||||||
|
// ns: GetNamespace(decl),
|
||||||
|
// classes: GetClasses(decl)
|
||||||
|
// );
|
||||||
|
//
|
||||||
|
// return methodModel;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// //https://github.com/andrewlock/NetEscapades.EnumGenerators/blob/main/src/NetEscapades.EnumGenerators/EnumGenerator.cs
|
||||||
|
// static string? GetNamespace(MethodDeclarationSyntax declarationSyntax)
|
||||||
|
// {
|
||||||
|
// // determine the namespace the class is declared in, if any
|
||||||
|
// string? nameSpace = null;
|
||||||
|
// var parentOfInterest = declarationSyntax.Parent;
|
||||||
|
// while (parentOfInterest is not null)
|
||||||
|
// {
|
||||||
|
// parentOfInterest = parentOfInterest.Parent;
|
||||||
|
//
|
||||||
|
// if (parentOfInterest is BaseNamespaceDeclarationSyntax ns)
|
||||||
|
// {
|
||||||
|
// nameSpace = ns.Name.ToString();
|
||||||
|
// while (true)
|
||||||
|
// {
|
||||||
|
// if (ns.Parent is not NamespaceDeclarationSyntax parent)
|
||||||
|
// {
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// ns = parent;
|
||||||
|
// nameSpace = $"{ns.Name}.{nameSpace}";
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return nameSpace;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return nameSpace;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// static IReadOnlyCollection<string> GetClasses(MethodDeclarationSyntax declarationSyntax)
|
||||||
|
// {
|
||||||
|
// // determine the namespace the class is declared in, if any
|
||||||
|
// var classes = new LinkedList<string>();
|
||||||
|
// var parentOfInterest = declarationSyntax.Parent;
|
||||||
|
// while (parentOfInterest is not null)
|
||||||
|
// {
|
||||||
|
// if (parentOfInterest is ClassDeclarationSyntax cds)
|
||||||
|
// {
|
||||||
|
// classes.AddFirst(cds.Identifier.ToString());
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// parentOfInterest = parentOfInterest.Parent;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Debug.WriteLine($"Method {declarationSyntax.Identifier.Text} has {classes.Count} classes");
|
||||||
|
//
|
||||||
|
// return classes;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private static MethodDeclarationSyntax? Transform(GeneratorSyntaxContext ctx, CancellationToken cancel)
|
||||||
|
// {
|
||||||
|
// var methodDecl = ctx.Node as MethodDeclarationSyntax;
|
||||||
|
// if (methodDecl is null)
|
||||||
|
// return default;
|
||||||
|
//
|
||||||
|
// foreach (var attListSyntax in methodDecl.AttributeLists)
|
||||||
|
// {
|
||||||
|
// foreach (var attSyntax in attListSyntax.Attributes)
|
||||||
|
// {
|
||||||
|
// if (cancel.IsCancellationRequested)
|
||||||
|
// return default;
|
||||||
|
//
|
||||||
|
// var symbol = ctx.SemanticModel.GetSymbolInfo(attSyntax).Symbol;
|
||||||
|
// if (symbol is not IMethodSymbol attSymbol)
|
||||||
|
// continue;
|
||||||
|
//
|
||||||
|
// if (attSymbol.ContainingType.ToDisplayString() == "EllieBot.Common.CmdAttribute")
|
||||||
|
// return methodDecl;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return default;
|
||||||
|
// }
|
||||||
|
// }
|
25
src/EllieBot.Generators/EllieBot.Generators.csproj
Normal file
25
src/EllieBot.Generators/EllieBot.Generators.csproj
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>netstandard2.0</TargetFramework>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||||
|
<IsRoslynComponent>true</IsRoslynComponent>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.0.1" PrivateAssets="all" />
|
||||||
|
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.3" PrivateAssets="all" />
|
||||||
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" PrivateAssets="all" GeneratePathProperty="true" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<GetTargetPathDependsOn>$(GetTargetPathDependsOn);GetDependencyTargetPaths</GetTargetPathDependsOn>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<Target Name="GetDependencyTargetPaths">
|
||||||
|
<ItemGroup>
|
||||||
|
<TargetPathWithTargetPlatformMoniker Include="$(PKGNewtonsoft_Json)\lib\netstandard2.0\Newtonsoft.Json.dll" IncludeRuntimeDependency="false" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Target>
|
||||||
|
</Project>
|
144
src/EllieBot.Generators/LocalizedStringsGenerator.cs
Normal file
144
src/EllieBot.Generators/LocalizedStringsGenerator.cs
Normal file
|
@ -0,0 +1,144 @@
|
||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.CodeDom.Compiler;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace EllieBot.Generators
|
||||||
|
{
|
||||||
|
internal readonly struct TranslationPair
|
||||||
|
{
|
||||||
|
public string Name { get; }
|
||||||
|
public string Value { get; }
|
||||||
|
|
||||||
|
public TranslationPair(string name, string value)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
Value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Generator]
|
||||||
|
public class LocalizedStringsGenerator : ISourceGenerator
|
||||||
|
{
|
||||||
|
private const string LOC_STR_SOURCE = @"namespace EllieBot
|
||||||
|
{
|
||||||
|
public readonly struct LocStr
|
||||||
|
{
|
||||||
|
public readonly string Key;
|
||||||
|
public readonly object[] Params;
|
||||||
|
|
||||||
|
public LocStr(string key, params object[] data)
|
||||||
|
{
|
||||||
|
Key = key;
|
||||||
|
Params = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}";
|
||||||
|
|
||||||
|
public void Initialize(GeneratorInitializationContext context)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Execute(GeneratorExecutionContext context)
|
||||||
|
{
|
||||||
|
var file = context.AdditionalFiles.First(x => x.Path.EndsWith("responses.en-US.json"));
|
||||||
|
|
||||||
|
var fields = GetFields(file.GetText()?.ToString());
|
||||||
|
|
||||||
|
using (var stringWriter = new StringWriter())
|
||||||
|
using (var sw = new IndentedTextWriter(stringWriter))
|
||||||
|
{
|
||||||
|
sw.WriteLine("namespace EllieBot;");
|
||||||
|
sw.WriteLine();
|
||||||
|
|
||||||
|
sw.WriteLine("public static class strs");
|
||||||
|
sw.WriteLine("{");
|
||||||
|
sw.Indent++;
|
||||||
|
|
||||||
|
var typedParamStrings = new List<string>(10);
|
||||||
|
foreach (var field in fields)
|
||||||
|
{
|
||||||
|
var matches = Regex.Matches(field.Value, @"{(?<num>\d)[}:]");
|
||||||
|
var max = 0;
|
||||||
|
foreach (Match match in matches)
|
||||||
|
{
|
||||||
|
max = Math.Max(max, int.Parse(match.Groups["num"].Value) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
typedParamStrings.Clear();
|
||||||
|
var typeParams = new string[max];
|
||||||
|
var passedParamString = string.Empty;
|
||||||
|
for (var i = 0; i < max; i++)
|
||||||
|
{
|
||||||
|
typedParamStrings.Add($"in T{i} p{i}");
|
||||||
|
passedParamString += $", p{i}";
|
||||||
|
typeParams[i] = $"T{i}";
|
||||||
|
}
|
||||||
|
|
||||||
|
var sig = string.Empty;
|
||||||
|
var typeParamStr = string.Empty;
|
||||||
|
if (max > 0)
|
||||||
|
{
|
||||||
|
sig = $"({string.Join(", ", typedParamStrings)})";
|
||||||
|
typeParamStr = $"<{string.Join(", ", typeParams)}>";
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.WriteLine("public static LocStr {0}{1}{2} => new LocStr(\"{3}\"{4});",
|
||||||
|
field.Name,
|
||||||
|
typeParamStr,
|
||||||
|
sig,
|
||||||
|
field.Name,
|
||||||
|
passedParamString);
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.Indent--;
|
||||||
|
sw.WriteLine("}");
|
||||||
|
|
||||||
|
|
||||||
|
sw.Flush();
|
||||||
|
context.AddSource("strs.g.cs", stringWriter.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
context.AddSource("LocStr.g.cs", LOC_STR_SOURCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TranslationPair> GetFields(string? dataText)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(dataText))
|
||||||
|
return new();
|
||||||
|
|
||||||
|
Dictionary<string, string> data;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var output = JsonConvert.DeserializeObject<Dictionary<string, string>>(dataText!);
|
||||||
|
if (output is null)
|
||||||
|
return new();
|
||||||
|
|
||||||
|
data = output;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Debug.WriteLine("Failed parsing responses file.");
|
||||||
|
return new();
|
||||||
|
}
|
||||||
|
|
||||||
|
var list = new List<TranslationPair>();
|
||||||
|
foreach (var entry in data)
|
||||||
|
{
|
||||||
|
list.Add(new(
|
||||||
|
entry.Key,
|
||||||
|
entry.Value
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
24
src/EllieBot.Generators/README.md
Normal file
24
src/EllieBot.Generators/README.md
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
## Generators
|
||||||
|
|
||||||
|
Project which contains source generators required for EllieBot project
|
||||||
|
|
||||||
|
---
|
||||||
|
### 1) Localized Strings Generator
|
||||||
|
|
||||||
|
-- Why --
|
||||||
|
Type safe response strings access, and enforces correct usage of response strings.
|
||||||
|
|
||||||
|
-- How it works --
|
||||||
|
Creates a file "strs.cs" containing a class called "strs" in "EllieBot" namespace.
|
||||||
|
|
||||||
|
Loads "data/strings/responses.en-US.json" and creates a property or a function for each key in the responses json file based on whether the value has string format placeholders or not.
|
||||||
|
|
||||||
|
- If a value has no placeholders, it creates a property in the strs class which returns an instance of a LocStr struct containing only the key and no replacement parameters
|
||||||
|
|
||||||
|
- If a value has placeholders, it creates a function with the same number of arguments as the number of placeholders, and passes those arguments to the LocStr instance
|
||||||
|
|
||||||
|
-- How to use --
|
||||||
|
1. Add a new key to responses.en-US.json "greet_me": "Hello, {0}"
|
||||||
|
2. You now have access to a function strs.greet_me(obj p1)
|
||||||
|
3. Using "GetText(strs.greet_me("Me"))" will return "Hello, Me"
|
||||||
|
|
Loading…
Reference in a new issue