From 85547a9be7182b2b258f41c87bbd407d06e7f4cf Mon Sep 17 00:00:00 2001 From: Kara Date: Thu, 6 Apr 2023 10:32:57 -0700 Subject: [PATCH] Auto-componentstate source generator (#3684) * dog what am i doing * finish gen source part from class symbol * we are dangerously close to things happening * generation fixes * oh? on god? * stop autogenerating the attribute for no reason + diagnostics * testing diagnostics * proper type name handling + clonedata bool * thank you material storage for making me realize this * forgot to commit * p * fixes for afterautohandlestate * make it work with access --- MSBuild/Robust.CompNetworkGenerator.targets | 5 + Robust.Analyzers/AccessAnalyzer.cs | 11 + .../ComponentNetworkGenerator.cs | 295 ++++++++++++++++++ .../NameReferenceSyntaxReceiver.cs | 19 ++ .../Robust.Shared.CompNetworkGenerator.csproj | 11 + .../ComponentNetworkGeneratorAuxiliary.cs | 57 ++++ .../Analyzers/RobustAutoGeneratedAttribute.cs | 12 + .../GameObjects/EntityEventBus.Directed.cs | 15 +- Robust.Shared/Robust.Shared.csproj | 1 + 9 files changed, 420 insertions(+), 6 deletions(-) create mode 100644 MSBuild/Robust.CompNetworkGenerator.targets create mode 100644 Robust.Shared.CompNetworkGenerator/ComponentNetworkGenerator.cs create mode 100644 Robust.Shared.CompNetworkGenerator/NameReferenceSyntaxReceiver.cs create mode 100644 Robust.Shared.CompNetworkGenerator/Robust.Shared.CompNetworkGenerator.csproj create mode 100644 Robust.Shared/Analyzers/ComponentNetworkGeneratorAuxiliary.cs create mode 100644 Robust.Shared/Analyzers/RobustAutoGeneratedAttribute.cs diff --git a/MSBuild/Robust.CompNetworkGenerator.targets b/MSBuild/Robust.CompNetworkGenerator.targets new file mode 100644 index 000000000..944d6f0f0 --- /dev/null +++ b/MSBuild/Robust.CompNetworkGenerator.targets @@ -0,0 +1,5 @@ + + + + + diff --git a/Robust.Analyzers/AccessAnalyzer.cs b/Robust.Analyzers/AccessAnalyzer.cs index 03ab376e4..adb5880af 100644 --- a/Robust.Analyzers/AccessAnalyzer.cs +++ b/Robust.Analyzers/AccessAnalyzer.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; +using System.Linq; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Operations; @@ -12,6 +13,7 @@ namespace Robust.Analyzers public class AccessAnalyzer : DiagnosticAnalyzer { private const string AccessAttributeType = "Robust.Shared.Analyzers.AccessAttribute"; + private const string RobustAutoGeneratedAttributeType = "Robust.Shared.Analyzers.RobustAutoGeneratedAttribute"; private const string PureAttributeType = "System.Diagnostics.Contracts.PureAttribute"; [SuppressMessage("ReSharper", "RS2008")] @@ -73,11 +75,20 @@ namespace Robust.Analyzers // Get the attributes var friendAttribute = context.Compilation.GetTypeByMetadataName(AccessAttributeType); + var autoGenAttribute = context.Compilation.GetTypeByMetadataName(RobustAutoGeneratedAttributeType); // Get the type that is containing this expression, or, the type where this is happening. if (context.ContainingSymbol?.ContainingType is not {} accessingType) return; + // Should we ignore the access attempt due to the accessing type being auto-generated? + if (accessingType.GetAttributes().FirstOrDefault(a => + a.AttributeClass != null && + a.AttributeClass.Equals(autoGenAttribute, SymbolEqualityComparer.Default)) is { } attr) + { + return; + } + // Determine which type of access is happening here... Read, write or execute? var accessAttempt = DetermineAccess(context, targetAccess, operation); diff --git a/Robust.Shared.CompNetworkGenerator/ComponentNetworkGenerator.cs b/Robust.Shared.CompNetworkGenerator/ComponentNetworkGenerator.cs new file mode 100644 index 000000000..dce955c70 --- /dev/null +++ b/Robust.Shared.CompNetworkGenerator/ComponentNetworkGenerator.cs @@ -0,0 +1,295 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; + +namespace Robust.Shared.CompNetworkGenerator +{ + [Generator] + public class ComponentNetworkGenerator : ISourceGenerator + { + private const string ClassAttributeName = "Robust.Shared.Analyzers.AutoGenerateComponentStateAttribute"; + private const string MemberAttributeName = "Robust.Shared.Analyzers.AutoNetworkedFieldAttribute"; + + private static string GenerateSource(in GeneratorExecutionContext context, INamedTypeSymbol classSymbol, CSharpCompilation comp, bool raiseAfterAutoHandle) + { + var nameSpace = classSymbol.ContainingNamespace.ToDisplayString(); + var componentName = classSymbol.Name; + var stateName = $"{componentName}_AutoState"; + + var members = classSymbol.GetMembers(); + var fields = new List<(ITypeSymbol Type, string FieldName, AttributeData Attribute)>(); + var fieldAttr = comp.GetTypeByMetadataName(MemberAttributeName); + + foreach (var mem in members) + { + var attribute = mem.GetAttributes().FirstOrDefault(a => + a.AttributeClass != null && + a.AttributeClass.Equals(fieldAttr, SymbolEqualityComparer.Default)); + + if (attribute == null) + { + continue; + } + + switch (mem) + { + case IFieldSymbol field: + fields.Add((field.Type, field.Name, attribute)); + break; + case IPropertySymbol prop: + { + if (prop.SetMethod == null || prop.SetMethod.DeclaredAccessibility != Accessibility.Public) + { + var msg = "Property is marked with [AutoNetworkField], but has no accessible setter method."; + context.ReportDiagnostic( + Diagnostic.Create( + new DiagnosticDescriptor( + "RXN0008", + msg, + msg, + "Usage", + DiagnosticSeverity.Error, + true), + classSymbol.Locations[0])); + continue; + } + + if (prop.GetMethod == null || prop.GetMethod.DeclaredAccessibility != Accessibility.Public) + { + var msg = "Property is marked with [AutoNetworkField], but has no accessible getter method."; + context.ReportDiagnostic( + Diagnostic.Create( + new DiagnosticDescriptor( + "RXN0008", + msg, + msg, + "Usage", + DiagnosticSeverity.Error, + true), + classSymbol.Locations[0])); + continue; + } + + fields.Add((prop.Type, prop.Name, attribute)); + break; + } + } + } + + if (fields.Count == 0) + { + var msg = "Component is marked with [AutoGenerateComponentState], but has no valid members marked with [AutoNetworkField]."; + context.ReportDiagnostic( + Diagnostic.Create( + new DiagnosticDescriptor( + "RXN0007", + msg, + msg, + "Usage", + DiagnosticSeverity.Error, + true), + classSymbol.Locations[0])); + + return null; + } + + // eg: + // public string Name = default!; + // public int Count = default!; + var stateFields = new StringBuilder(); + + // eg: + // Name = component.Name, + // Count = component.Count, + var getStateInit = new StringBuilder(); + + // eg: + // component.Name = state.Name; + // component.Count = state.Count; + var handleStateSetters = new StringBuilder(); + + foreach (var (type, name, attribute) in fields) + { + stateFields.Append($@" + public {type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)} {name} = default!;"); + + // get first ctor arg of the field attribute, which determines whether the field should be cloned + // (like if its a dict or list) + if (attribute.ConstructorArguments[0].Value is bool val && val) + { + getStateInit.Append($@" + {name} = component.{name},"); + + handleStateSetters.Append($@" + if (state.{name} != null) + component.{name} = new(state.{name});"); + } + else + { + getStateInit.Append($@" + {name} = component.{name},"); + + handleStateSetters.Append($@" + component.{name} = state.{name};"); + } + } + + var eventRaise = ""; + if (raiseAfterAutoHandle) + { + eventRaise = @" + var ev = new AfterAutoHandleStateEvent(args.Current); + EntityManager.EventBus.RaiseComponentEvent(component, ref ev);"; + } + + return $@"// +using Robust.Shared.GameStates; +using Robust.Shared.GameObjects; +using Robust.Shared.Analyzers; +using Robust.Shared.Serialization; + +namespace {nameSpace}; + +public partial class {componentName} +{{ + [Serializable, NetSerializable] + public class {stateName} : ComponentState + {{{stateFields} + }} + + [RobustAutoGenerated] + public class {componentName}_AutoNetworkSystem : EntitySystem + {{ + public override void Initialize() + {{ + SubscribeLocalEvent<{componentName}, ComponentGetState>(OnGetState); + SubscribeLocalEvent<{componentName}, ComponentHandleState>(OnHandleState); + }} + + private void OnGetState(EntityUid uid, {componentName} component, ref ComponentGetState args) + {{ + args.State = new {stateName} + {{{getStateInit} + }}; + }} + + private void OnHandleState(EntityUid uid, {componentName} component, ref ComponentHandleState args) + {{ + if (args.Current is not {stateName} state) + return; +{handleStateSetters}{eventRaise} + }} + }} +}} +"; + } + + public void Execute(GeneratorExecutionContext context) + { + var comp = (CSharpCompilation) context.Compilation; + + if (!(context.SyntaxReceiver is NameReferenceSyntaxReceiver receiver)) + { + return; + } + + var symbols = GetAnnotatedTypes(context, comp, receiver); + + // Generate component sources and add + foreach (var type in symbols) + { + try + { + var attr = type.Attribute; + var raiseEv = false; + if (attr.ConstructorArguments.Length == 1 && attr.ConstructorArguments[0].Value != null) + { + // Get the afterautohandle bool, which is first constructor arg + raiseEv = (bool) attr.ConstructorArguments[0].Value; + } + + var source = GenerateSource(context, type.Type, comp, raiseEv); + // can be null if no members marked with network field, which already has a diagnostic, so + // just continue + if (source == null) + continue; + + context.AddSource($"{type.Type.Name}_CompNetwork.g.cs", SourceText.From(source, Encoding.UTF8)); + } + catch (Exception e) + { + context.ReportDiagnostic( + Diagnostic.Create( + new DiagnosticDescriptor( + "RXN0003", + "Unhandled exception occured while generating automatic component state handling.", + $"Unhandled exception occured while generating automatic component state handling: {e}", + "Usage", + DiagnosticSeverity.Error, + true), + type.Type.Locations[0])); + } + } + } + + private IReadOnlyList<(INamedTypeSymbol Type, AttributeData Attribute)> GetAnnotatedTypes(in GeneratorExecutionContext context, + CSharpCompilation comp, NameReferenceSyntaxReceiver receiver) + { + var symbols = new List<(INamedTypeSymbol, AttributeData)>(); + var attributeSymbol = comp.GetTypeByMetadataName(ClassAttributeName); + foreach (var candidateClass in receiver.CandidateClasses) + { + var model = comp.GetSemanticModel(candidateClass.SyntaxTree); + var typeSymbol = model.GetDeclaredSymbol(candidateClass); + var relevantAttribute = typeSymbol?.GetAttributes().FirstOrDefault(attr => + attr.AttributeClass != null && + attr.AttributeClass.Equals(attributeSymbol, SymbolEqualityComparer.Default)); + + if (relevantAttribute == null) + { + continue; + } + + var isPartial = candidateClass.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)); + + if (isPartial) + { + symbols.Add((typeSymbol, relevantAttribute)); + } + else + { + var missingPartialKeywordMessage = + $"The type {typeSymbol.Name} should be declared with the 'partial' keyword " + + "as it is annotated with the [AutoGenerateComponentState] attribute."; + + context.ReportDiagnostic( + Diagnostic.Create( + new DiagnosticDescriptor( + "RXN0006", + missingPartialKeywordMessage, + missingPartialKeywordMessage, + "Usage", + DiagnosticSeverity.Error, + true), + Location.None)); + } + } + + return symbols; + } + + public void Initialize(GeneratorInitializationContext context) + { + if (!Debugger.IsAttached) + { + //Debugger.Launch(); + } + context.RegisterForSyntaxNotifications(() => new NameReferenceSyntaxReceiver()); + } + } +} diff --git a/Robust.Shared.CompNetworkGenerator/NameReferenceSyntaxReceiver.cs b/Robust.Shared.CompNetworkGenerator/NameReferenceSyntaxReceiver.cs new file mode 100644 index 000000000..e449b0d71 --- /dev/null +++ b/Robust.Shared.CompNetworkGenerator/NameReferenceSyntaxReceiver.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Robust.Shared.CompNetworkGenerator +{ + public sealed class NameReferenceSyntaxReceiver : ISyntaxReceiver + { + public List CandidateClasses { get; } = new List(); + + public void OnVisitSyntaxNode(SyntaxNode syntaxNode) + { + if (syntaxNode is ClassDeclarationSyntax classDeclarationSyntax && + classDeclarationSyntax.AttributeLists.Count > 0) + CandidateClasses.Add(classDeclarationSyntax); + } + } +} diff --git a/Robust.Shared.CompNetworkGenerator/Robust.Shared.CompNetworkGenerator.csproj b/Robust.Shared.CompNetworkGenerator/Robust.Shared.CompNetworkGenerator.csproj new file mode 100644 index 000000000..878ba53d5 --- /dev/null +++ b/Robust.Shared.CompNetworkGenerator/Robust.Shared.CompNetworkGenerator.csproj @@ -0,0 +1,11 @@ + + + + netstandard2.0 + + + + + + + diff --git a/Robust.Shared/Analyzers/ComponentNetworkGeneratorAuxiliary.cs b/Robust.Shared/Analyzers/ComponentNetworkGeneratorAuxiliary.cs new file mode 100644 index 000000000..4018bdd8c --- /dev/null +++ b/Robust.Shared/Analyzers/ComponentNetworkGeneratorAuxiliary.cs @@ -0,0 +1,57 @@ +using System; +using JetBrains.Annotations; +using Robust.Shared.GameObjects; + +namespace Robust.Shared.Analyzers; + +/// +/// When a component is marked with this attribute, any members it has marked with +/// will automatically be replicated using component states to clients. Systems which need to have more intelligent +/// component state replication beyond just directly setting variables should not use this attribute. +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +[BaseTypeRequired(typeof(Component))] +public sealed class AutoGenerateComponentStateAttribute : Attribute +{ + /// + /// If this is true, the autogenerated code will raise a component event + /// so that user-defined systems can have effects after handling state without redefining all replication. + /// + public bool RaiseAfterAutoHandleState; + + public AutoGenerateComponentStateAttribute(bool raiseAfterAutoHandleState = false) + { + RaiseAfterAutoHandleState = raiseAfterAutoHandleState; + } +} + +/// +/// Used to mark component members which should be automatically replicated, assuming the component is marked with +/// . +/// +[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)] +public sealed class AutoNetworkedFieldAttribute : Attribute +{ + /// + /// Determines whether the data should be wrapped in a new() when setting in get/handlestate + /// e.g. for cloning collections like dictionaries or hashsets which is sometimes necessary. + /// + /// + /// This should only be true if the type actually has a constructor that takes in itself. + /// + [UsedImplicitly] + public bool CloneData; + + public AutoNetworkedFieldAttribute(bool cloneData=false) + { + CloneData = cloneData; + } +} + +/// +/// Raised as a component event after auto handling state is done, if +/// is true, so that other systems +/// can have effects after handling state without having to redefine all replication. +/// +[ByRefEvent] +public record struct AfterAutoHandleStateEvent(ComponentState State); diff --git a/Robust.Shared/Analyzers/RobustAutoGeneratedAttribute.cs b/Robust.Shared/Analyzers/RobustAutoGeneratedAttribute.cs new file mode 100644 index 000000000..6c48e5445 --- /dev/null +++ b/Robust.Shared/Analyzers/RobustAutoGeneratedAttribute.cs @@ -0,0 +1,12 @@ +using System; + +namespace Robust.Shared.Analyzers; + +/// +/// Placed on auto-generated classes to mark to certain robust analyzers that they are auto-generated +/// and may need to be ignored (e.g. the access analyzer) +/// +[AttributeUsage(AttributeTargets.Class)] +public sealed class RobustAutoGeneratedAttribute : Attribute +{ +} diff --git a/Robust.Shared/GameObjects/EntityEventBus.Directed.cs b/Robust.Shared/GameObjects/EntityEventBus.Directed.cs index 296fe186e..685410d04 100644 --- a/Robust.Shared/GameObjects/EntityEventBus.Directed.cs +++ b/Robust.Shared/GameObjects/EntityEventBus.Directed.cs @@ -57,12 +57,13 @@ namespace Robust.Shared.GameObjects /// /// /// This has a very specific purpose, and has massive potential to be abused. - /// DO NOT EXPOSE THIS TO CONTENT. + /// DO NOT USE THIS IN CONTENT UNLESS YOU KNOW WHAT YOU'RE DOING, the only reason it's not internal + /// is because of the component network source generator. /// /// Event to dispatch. /// Component receiving the event. /// Event arguments for the event. - internal void RaiseComponentEvent(IComponent component, TEvent args) + public void RaiseComponentEvent(IComponent component, TEvent args) where TEvent : notnull; /// @@ -70,13 +71,14 @@ namespace Robust.Shared.GameObjects /// /// /// This has a very specific purpose, and has massive potential to be abused. - /// DO NOT EXPOSE THIS TO CONTENT. + /// DO NOT USE THIS IN CONTENT UNLESS YOU KNOW WHAT YOU'RE DOING, the only reason it's not internal + /// is because of the component network source generator. /// /// Event to dispatch. /// Component receiving the event. /// Type of the component, for faster lookups. /// Event arguments for the event. - internal void RaiseComponentEvent(IComponent component, CompIdx idx, TEvent args) + public void RaiseComponentEvent(IComponent component, CompIdx idx, TEvent args) where TEvent : notnull; /// @@ -84,12 +86,13 @@ namespace Robust.Shared.GameObjects /// /// /// This has a very specific purpose, and has massive potential to be abused. - /// DO NOT EXPOSE THIS TO CONTENT. + /// DO NOT USE THIS IN CONTENT UNLESS YOU KNOW WHAT YOU'RE DOING, the only reason it's not internal + /// is because of the component network source generator. /// /// Event to dispatch. /// Component receiving the event. /// Event arguments for the event. - internal void RaiseComponentEvent(IComponent component, ref TEvent args) + public void RaiseComponentEvent(IComponent component, ref TEvent args) where TEvent : notnull; public void OnlyCallOnRobustUnitTestISwearToGodPleaseSomebodyKillThisNightmare(); diff --git a/Robust.Shared/Robust.Shared.csproj b/Robust.Shared/Robust.Shared.csproj index b88aa016a..f4d422be2 100644 --- a/Robust.Shared/Robust.Shared.csproj +++ b/Robust.Shared/Robust.Shared.csproj @@ -50,4 +50,5 @@ +