diff --git a/README.md b/README.md index 1bab07a..2c79861 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Test](https://github.com/simulation-tree/simulation/actions/workflows/test.yml/badge.svg)](https://github.com/simulation-tree/simulation/actions/workflows/test.yml) -Library providing a way to organize and update systems with support for message handling. +Library providing a way to broadcast messages to added listeners. ### Running simulators @@ -18,21 +18,16 @@ public static void Main() simulator.Remove(); } -public class ProgramSystems : ISystem, IDisposable +public class ProgramSystems : IDisposable { public ProgramSystems() { - //initialize + //before addition } public void Dispose() { - //clean up - } - - void ISystem.Update(Simulator simulator, double deltaTime) - { - //do work + //after removal } } ``` @@ -50,7 +45,6 @@ public partial class ListenerSystem : IListener { void IListener.Receive(ref float message) { - //do something with this } } ``` @@ -76,6 +70,32 @@ public struct LoadRequest } ``` +### Global simulator + +Another way to have listeners and broadcasting setup, is using the included `GlobalSimulator` type. +This approach is slimmer than with the `Simulator`, at the cost of the listeners being global to the entire +runtime. +```cs +public class Program +{ + public static void Main() + { + GlobalSimulatorLoader.Load(); + GlobalSimulator.Broadcast(32f); + GlobalSimulator.Broadcast(32f); + GlobalSimulator.Broadcast(32f); + } +} + +public static class Systems +{ + [Listener] + public static void Update(ref float message) + { + } +} +``` + ### Contributing and design This library is created for composing behaviour of programs using systems, ideally created by diff --git a/core/Attributes/ListenerAttribute.cs b/core/Attributes/ListenerAttribute.cs new file mode 100644 index 0000000..cd0a753 --- /dev/null +++ b/core/Attributes/ListenerAttribute.cs @@ -0,0 +1,12 @@ +using System; + +namespace Simulation +{ + /// + /// Attribute to mark static methods as listeners for messages of type . + /// + [AttributeUsage(AttributeTargets.Method)] + public class ListenerAttribute : Attribute where T : unmanaged + { + } +} diff --git a/core/GlobalSimulator.cs b/core/GlobalSimulator.cs new file mode 100644 index 0000000..6ceb714 --- /dev/null +++ b/core/GlobalSimulator.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; + +namespace Simulation +{ + /// + /// Dispatches messages for static methods with the . + /// + public static class GlobalSimulator + { + private static readonly List clearFunctions = new(); + + /// + /// Registers a listener for messages of type . + /// + public static void Register(Receive receive) where T : unmanaged + { + Array.Resize(ref Listeners.list, Listeners.list.Length + 1); + Listeners.list[^1] = receive; + } + + /// + /// Registers a . + /// + public static void Register(IListener listener) where T : unmanaged + { + Receive receive = listener.Receive; + Array.Resize(ref Listeners.list, Listeners.list.Length + 1); + Listeners.list[^1] = receive; + } + + /// + /// Resets the global simulator to initial state, with no listeners registered. + /// + public static void Reset() + { + foreach (Action clearFunction in clearFunctions) + { + clearFunction(); + } + } + + /// + /// Broadcasts the given . + /// + public static void Broadcast(T message) where T : unmanaged + { + int length = Listeners.list.Length; + for (int i = 0; i < length; i++) + { + Listeners.list[i](ref message); + } + } + + /// + /// Broadcasts the given . + /// + public static void Broadcast(ref T message) where T : unmanaged + { + int length = Listeners.list.Length; + for (int i = 0; i < length; i++) + { + Listeners.list[i](ref message); + } + } + + private static class Listeners where T : unmanaged + { + public static Receive[] list = []; + + static Listeners() + { + clearFunctions.Add(Reset); + } + + public static void Reset() + { + Array.Resize(ref list, 0); + } + } + + /// + /// Delegate for message receivers. + /// + public delegate void Receive(ref T message) where T : unmanaged; + } +} \ No newline at end of file diff --git a/generator/Constants.cs b/generator/Constants.cs index 83b1b48..1742604 100644 --- a/generator/Constants.cs +++ b/generator/Constants.cs @@ -1,7 +1,4 @@ -using System.Runtime.CompilerServices; - -[assembly: InternalsVisibleTo("Simulation.Generator.Tests")] -namespace Simulation +namespace Simulation { internal static class Constants { @@ -10,5 +7,8 @@ internal static class Constants public const string ListenerInterfaceTypeName = Namespace + ".IListener"; public const string SystemBankTypeNameFormat = "{0}SystemBank"; public const string PluralSystemBankTypeNameFormat = "{0}SystemsBank"; + public const string GlobalSimulatorTypeName = "GlobalSimulator"; + public const string GlobalSimulatorLoaderTypeName = GlobalSimulatorTypeName + "Loader"; + public const string ListenerAttributeTypeName = "ListenerAttribute"; } } \ No newline at end of file diff --git a/generator/Generators/GlobalSimulatorLoaderGenerator.cs b/generator/Generators/GlobalSimulatorLoaderGenerator.cs new file mode 100644 index 0000000..218a239 --- /dev/null +++ b/generator/Generators/GlobalSimulatorLoaderGenerator.cs @@ -0,0 +1,216 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; +using static Simulation.Constants; + +namespace Simulation.Generators +{ + [Generator(LanguageNames.CSharp)] + internal class GlobalSimulatorLoaderGenerator : IIncrementalGenerator + { + private static readonly SourceBuilder source = new(); + + void IIncrementalGenerator.Initialize(IncrementalGeneratorInitializationContext context) + { + IncrementalValuesProvider filter = context.SyntaxProvider.CreateSyntaxProvider(Predicate, Transform); + context.RegisterSourceOutput(filter.Collect().Combine(context.CompilationProvider), Generate); + } + + private static bool Predicate(SyntaxNode node, CancellationToken token) + { + return node.IsKind(SyntaxKind.MethodDeclaration); + } + + private static ListenerMethod? Transform(GeneratorSyntaxContext context, CancellationToken token) + { + if (context.Node is MethodDeclarationSyntax methodDeclaration) + { + //check if the method has an attribute + SyntaxList attributes = methodDeclaration.AttributeLists; + if (attributes.Count == 0) + { + return null; + } + + const string ListenerNamePrefix = Namespace + "." + ListenerAttributeTypeName + "<"; + foreach (AttributeListSyntax attributeList in attributes) + { + foreach (AttributeSyntax attribute in attributeList.Attributes) + { + if (context.SemanticModel.GetSymbolInfo(attribute, token).Symbol is IMethodSymbol attributeConstructorSymbol) + { + INamedTypeSymbol attributeSymbol = attributeConstructorSymbol.ContainingType; + if (attributeSymbol.TypeArguments.Length == 1) + { + ITypeSymbol messageTypeSymbol = attributeSymbol.TypeArguments[0]; + if (attributeSymbol.ToDisplayString().StartsWith(ListenerNamePrefix)) + { + if (context.SemanticModel.GetDeclaredSymbol(methodDeclaration, token) is IMethodSymbol methodSymbol) + { + return new ListenerMethod(methodSymbol, methodDeclaration, messageTypeSymbol); + } + } + } + } + } + } + } + + return null; + } + + private void Generate(SourceProductionContext context, (ImmutableArray methods, Compilation compilation) input) + { + if (input.compilation.GetEntryPoint(context.CancellationToken) is not null) + { + List methods = new(); + foreach (ListenerMethod? method in input.methods) + { + if (method is not null) + { + methods.Add(method); + } + } + + if (methods.Count > 0) + { + context.AddSource($"{GlobalSimulatorLoaderTypeName}.generated.cs", Generate(context, input.compilation, methods)); + } + } + } + + public static string Generate(SourceProductionContext context, Compilation compilation, IEnumerable methods) + { + string? assemblyName = compilation.AssemblyName; + source.Clear(); + source.AppendLine("using System;"); + source.AppendLine("using Simulation;"); + source.AppendLine(); + + if (assemblyName is not null) + { + source.Append("namespace "); + source.Append(assemblyName); + source.AppendLine(); + source.BeginGroup(); + } + + source.Append("public static class "); + source.Append(GlobalSimulatorLoaderTypeName); + source.AppendLine(); + + source.BeginGroup(); + { + source.AppendLine("public static void Load()"); + source.BeginGroup(); + { + source.Append(GlobalSimulatorTypeName); + source.Append(".Reset();"); + source.AppendLine(); + + foreach (ListenerMethod method in methods) + { + //emit error if not static or public + bool isStatic = method.methodSymbol.IsStatic; + bool isPublic = method.methodSymbol.DeclaredAccessibility.HasFlag(Accessibility.Public); + if (!isStatic || !isPublic) + { + const string ID = "S0001"; + const string Category = "Listeners"; + const DiagnosticSeverity Severity = DiagnosticSeverity.Error; + string methodName = $"{method.methodSymbol.ContainingType.ToDisplayString()}.{method.methodDeclaration.Identifier.Text}"; + string message; + if (!isPublic && !isStatic) + { + message = $"The method `{methodName}` is not public nor static, and cannot be registered as a listener"; + } + else if (!isStatic) + { + message = $"The method `{methodName}` is not static, and cannot be registered as a listener"; + } + else + { + message = $"The method `{methodName}` is not public, and cannot be registered as a listener"; + } + + Diagnostic diagnostic = Diagnostic.Create(ID, Category, message, Severity, Severity, true, 0, false, location: method.methodDeclaration.GetLocation()); + context.ReportDiagnostic(diagnostic); + continue; + } + + //emit error if parameter is missing + if (method.methodSymbol.Parameters.Length == 0) + { + const string ID = "S0002"; + const string Category = "Listeners"; + const DiagnosticSeverity Severity = DiagnosticSeverity.Error; + string message = $"Listener method is missing a ref {method.messageTypeSymbol.Name} parameter"; + Diagnostic diagnostic = Diagnostic.Create(ID, Category, message, Severity, Severity, true, 0, false, location: method.methodDeclaration.GetLocation()); + context.ReportDiagnostic(diagnostic); + continue; + } + else if (method.methodSymbol.Parameters.Length == 1) + { + IParameterSymbol parameter = method.methodSymbol.Parameters[0]; + if (parameter.Type.ToDisplayString() != method.messageTypeSymbol.ToDisplayString()) + { + const string ID = "S0003"; + const string Category = "Listeners"; + const DiagnosticSeverity Severity = DiagnosticSeverity.Error; + string message = $"Listener method is expected to accept the {method.messageTypeSymbol.Name} parameter as a ref, but got {parameter.Type.Name} instead"; + Diagnostic diagnostic = Diagnostic.Create(ID, Category, message, Severity, Severity, true, 0, false, location: method.methodDeclaration.GetLocation()); + context.ReportDiagnostic(diagnostic); + continue; + } + else + { + if (parameter.RefKind != RefKind.Ref) + { + const string ID = "S0004"; + const string Category = "Listeners"; + const DiagnosticSeverity Severity = DiagnosticSeverity.Error; + string message = $"Listener method is expected to accept the {parameter.Type.Name} parameter as a ref"; + Diagnostic diagnostic = Diagnostic.Create(ID, Category, message, Severity, Severity, true, 0, false, location: method.methodDeclaration.GetLocation()); + context.ReportDiagnostic(diagnostic); + continue; + } + } + } + else if (method.methodSymbol.Parameters.Length > 1) + { + const string ID = "S0005"; + const string Category = "Listeners"; + const DiagnosticSeverity Severity = DiagnosticSeverity.Error; + string message = $"Listener method has an invalid signature, it should only have a ref {method.messageTypeSymbol.Name} parameter"; + Diagnostic diagnostic = Diagnostic.Create(ID, Category, message, Severity, Severity, true, 0, false, location: method.methodDeclaration.GetLocation()); + context.ReportDiagnostic(diagnostic); + continue; + } + + source.Append(GlobalSimulatorTypeName); + source.Append(".Register<"); + source.Append(method.messageTypeSymbol.ToDisplayString()); + source.Append(">("); + source.Append(method.methodSymbol.ContainingType.ToDisplayString()); + source.Append("."); + source.Append(method.methodDeclaration.Identifier.Text); + source.Append(");"); + source.AppendLine(); + } + } + source.EndGroup(); + } + source.EndGroup(); + + if (assemblyName is not null) + { + source.EndGroup(); + } + + return source.ToString(); + } + } +} \ No newline at end of file diff --git a/generator/Generators/ListenerCollectorGenerator.cs b/generator/Generators/ListenerCollectorGenerator.cs index 338742d..f5127a3 100644 --- a/generator/Generators/ListenerCollectorGenerator.cs +++ b/generator/Generators/ListenerCollectorGenerator.cs @@ -80,11 +80,14 @@ public static string Generate(SystemType input) if (input.containingNamespace is not null) { - source.AppendLine($"namespace {input.containingNamespace}"); + source.Append("namespace "); + source.Append(input.containingNamespace); + source.AppendLine(); source.BeginGroup(); } - source.Append($"public partial class {input.typeName}"); + source.Append("public partial class "); + source.Append(input.typeName); source.Append(" : "); source.Append(ListenerInterfaceTypeName); source.AppendLine(); diff --git a/generator/ListenerMethod.cs b/generator/ListenerMethod.cs new file mode 100644 index 0000000..50e45bd --- /dev/null +++ b/generator/ListenerMethod.cs @@ -0,0 +1,19 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Simulation +{ + internal class ListenerMethod + { + public readonly IMethodSymbol methodSymbol; + public readonly MethodDeclarationSyntax methodDeclaration; + public readonly ITypeSymbol messageTypeSymbol; + + public ListenerMethod(IMethodSymbol methodSymbol, MethodDeclarationSyntax methodDeclaration, ITypeSymbol messageTypeSymbol) + { + this.methodSymbol = methodSymbol; + this.methodDeclaration = methodDeclaration; + this.messageTypeSymbol = messageTypeSymbol; + } + } +} \ No newline at end of file diff --git a/generator/SystemType.cs b/generator/SystemType.cs index 5ffd06e..54b8ad7 100644 --- a/generator/SystemType.cs +++ b/generator/SystemType.cs @@ -4,7 +4,7 @@ namespace Simulation { - public class SystemType + internal class SystemType { public readonly TypeDeclarationSyntax typeDeclaration; public readonly ITypeSymbol typeSymbol; diff --git a/tests/BroadcastingTests.cs b/tests/BroadcastingTests.cs index af20c22..f3428f6 100644 --- a/tests/BroadcastingTests.cs +++ b/tests/BroadcastingTests.cs @@ -18,6 +18,18 @@ public void BroadcastingMessages() Assert.That(system.time, Is.EqualTo(2)); } + [Test] + public void BroadcastingToGlobalSimulator() + { + GlobalSimulatorLoader.Load(); + Assert.That(GlobalTimeSystem.time, Is.EqualTo(0)); + GlobalSimulator.Broadcast(new UpdateMessage(1)); + Assert.That(GlobalTimeSystem.time, Is.EqualTo(1)); + GlobalSimulator.Reset(); + GlobalSimulator.Broadcast(new UpdateMessage(1)); + Assert.That(GlobalTimeSystem.time, Is.EqualTo(1)); + } + [Test] public void SystemsOutOfOrder() { diff --git a/tests/Systems/GlobalTimeSystem.cs b/tests/Systems/GlobalTimeSystem.cs new file mode 100644 index 0000000..d96482a --- /dev/null +++ b/tests/Systems/GlobalTimeSystem.cs @@ -0,0 +1,13 @@ +namespace Simulation.Tests +{ + public static class GlobalTimeSystem + { + public static double time; + + [Listener] + public static void OnUpdate(ref UpdateMessage message) + { + time += message.deltaTime; + } + } +} \ No newline at end of file