diff --git a/Silksong.ModMenu/Directory.Build.props b/Silksong.ModMenu/Directory.Build.props index 7f840fa..cd8f36e 100644 --- a/Silksong.ModMenu/Directory.Build.props +++ b/Silksong.ModMenu/Directory.Build.props @@ -11,6 +11,6 @@ It should follow the format major.minor.patch (semantic versioning). If you publish your mod as a library to NuGet, this version will also be used as the package version. --> - 0.7.0 + 0.7.1 diff --git a/Silksong.ModMenu/Generator/Attributes.cs b/Silksong.ModMenu/Generator/Attributes.cs index 274f129..da6dc7b 100644 --- a/Silksong.ModMenu/Generator/Attributes.cs +++ b/Silksong.ModMenu/Generator/Attributes.cs @@ -23,7 +23,19 @@ public class ElementFactoryAttribute : Attribute public class ModMenuIncludeAttribute : Attribute { } /// -/// Attribute to apply to a any numeric property, to specify a minimum and maximum. +/// Attribute to apply to any property with a finite number of acceptable values. +/// +[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false)] +public class ModMenuOptionsAttribute(params object[] options) : Attribute +{ + /// + /// + /// + public readonly object[] Options = options; +} + +/// +/// Attribute to apply to any numeric property, to specify a minimum and maximum. /// Dynamic mins/maxes are not yet supported. /// [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false)] diff --git a/Silksong.ModMenu/Plugin/ConfigEntryFactory.cs b/Silksong.ModMenu/Plugin/ConfigEntryFactory.cs index 89ed00f..143c257 100644 --- a/Silksong.ModMenu/Plugin/ConfigEntryFactory.cs +++ b/Silksong.ModMenu/Plugin/ConfigEntryFactory.cs @@ -141,7 +141,7 @@ TreeNode tree } return ArrangeScreen( - elements.OrderBy(e => e.path).ToList(), + [.. elements.OrderBy(e => e.path)], subpageNames.LastOrDefault() ?? menuName ); } diff --git a/Silksong.ModMenuAnalyzerTest/GeneratorTest.cs b/Silksong.ModMenuAnalyzerTest/GeneratorTest.cs index 14e55cf..966be9c 100644 --- a/Silksong.ModMenuAnalyzerTest/GeneratorTest.cs +++ b/Silksong.ModMenuAnalyzerTest/GeneratorTest.cs @@ -1,6 +1,4 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.CSharp.Testing; using Microsoft.CodeAnalysis.Testing; using Silksong.ModMenu.Generator; using Silksong.ModMenuAnalyzers; @@ -24,13 +22,13 @@ public class TestData """; string gen = /*lang=c#-test*/ - """ + $$""" #nullable enable namespace Test; /// Custom menu class generated for Test.TestData. - [System.CodeDom.Compiler.GeneratedCode("ModMenuGenerator", "1.0.0")] + [System.CodeDom.Compiler.GeneratedCode("ModMenuGenerator", "{{ModMenuGenerator.VERSION}}")] public class TestDataMenu : Silksong.ModMenu.Generator.ICustomMenu { public Silksong.ModMenu.Elements.SelectableValueElement MyInt @@ -116,13 +114,13 @@ public class SubData """; string gen1 = /*lang=c#-test*/ - """ + $$""" #nullable enable namespace Test; /// Custom menu class generated for Test.TestData. - [System.CodeDom.Compiler.GeneratedCode("ModMenuGenerator", "1.0.0")] + [System.CodeDom.Compiler.GeneratedCode("ModMenuGenerator", "{{ModMenuGenerator.VERSION}}")] public class TestDataMenu : Silksong.ModMenu.Generator.ICustomMenu { public Silksong.ModMenu.Generator.SubMenuElement SubData @@ -184,13 +182,13 @@ private void InvokeValueChanged(Silksong.ModMenu.Generator.CustomMenuValueChange """; string gen2 = /*lang=c#-test*/ - """ + $$""" #nullable enable namespace Test; /// Custom menu class generated for Test.SubData. - [System.CodeDom.Compiler.GeneratedCode("ModMenuGenerator", "1.0.0")] + [System.CodeDom.Compiler.GeneratedCode("ModMenuGenerator", "{{ModMenuGenerator.VERSION}}")] public class SubDataMenu : Silksong.ModMenu.Generator.ICustomMenu { public Silksong.ModMenu.Elements.SelectableValueElement MyString @@ -254,6 +252,123 @@ private void InvokeValueChanged(Silksong.ModMenu.Generator.CustomMenuValueChange await ExpectSourceCode(source, ("TestDataMenu.g.cs", gen1), ("SubDataMenu.g.cs", gen2)); } + [Fact] + public async Task TestOptions() + { + string source = /*lang=c#-test*/ + """ + namespace Test; + + public enum TestEnum + { + ONE, + TWO, + THREE, + OMITTED, + } + + [Silksong.ModMenu.Generator.GenerateMenu] + public class TestData + { + [Silksong.ModMenu.Generator.ModMenuOptions(2, 3, 5, 7, 11, 13, 17, 19)] + public int PrimeInt = 2; + [Silksong.ModMenu.Generator.ModMenuOptions(TestEnum.ONE, TestEnum.TWO, TestEnum.THREE)] + public TestEnum MyEnum = TestEnum.ONE; + } + """; + + string gen = /*lang=c#-test*/ + $$""" + #nullable enable + + namespace Test; + + /// Custom menu class generated for Test.TestData. + [System.CodeDom.Compiler.GeneratedCode("ModMenuGenerator", "{{ModMenuGenerator.VERSION}}")] + public class TestDataMenu : Silksong.ModMenu.Generator.ICustomMenu + { + public Silksong.ModMenu.Elements.SelectableValueElement PrimeInt + { + get => _PrimeInt; + set + { + if (value == null) throw new System.ArgumentNullException(nameof(PrimeInt)); + if (_PrimeInt == value) return; + + if (_PrimeInt != null) + _PrimeInt.OnValueChanged -= _PrimeInt_subscriber; + _PrimeInt = value; + _PrimeInt.OnValueChanged += _PrimeInt_subscriber; + } + } + private Silksong.ModMenu.Elements.SelectableValueElement _PrimeInt; + public Silksong.ModMenu.Elements.SelectableValueElement MyEnum + { + get => _MyEnum; + set + { + if (value == null) throw new System.ArgumentNullException(nameof(MyEnum)); + if (_MyEnum == value) return; + + if (_MyEnum != null) + _MyEnum.OnValueChanged -= _MyEnum_subscriber; + _MyEnum = value; + _MyEnum.OnValueChanged += _MyEnum_subscriber; + } + } + private Silksong.ModMenu.Elements.SelectableValueElement _MyEnum; + + /// An aggregate event notified whenever any menu element in this class has its value changed. + public event System.Action? OnValueChanged; + + public TestDataMenu() + { + _PrimeInt_subscriber = value => InvokeValueChanged(new(nameof(PrimeInt), value)); + PrimeInt = new Silksong.ModMenu.Elements.ChoiceElement("Prime Int", Silksong.ModMenu.Models.ChoiceModels.ForValues([2, 3, 5, 7, 11, 13, 17, 19]), ""); + _MyEnum_subscriber = value => InvokeValueChanged(new(nameof(MyEnum), value)); + MyEnum = new Silksong.ModMenu.Elements.ChoiceElement("My Enum", Silksong.ModMenu.Models.ChoiceModels.ForValues([Test.TestEnum.ONE, Test.TestEnum.TWO, Test.TestEnum.THREE]), ""); + } + + /// + public void ExportTo(Test.TestData data) + { + data.PrimeInt = PrimeInt.Value; + data.MyEnum = MyEnum.Value; + } + + /// + public void ApplyFrom(Test.TestData data) + { + using (notifySubscribers.Suppress()) + { + PrimeInt.Value = data.PrimeInt; + MyEnum.Value = data.MyEnum; + } + } + + /// + public System.Collections.Generic.IEnumerable Elements() + { + yield return PrimeInt; + yield return MyEnum; + } + + private readonly Silksong.ModMenu.Util.EventSuppressor notifySubscribers = new(); + + private void InvokeValueChanged(Silksong.ModMenu.Generator.CustomMenuValueChangedEvent args) + { + if (notifySubscribers.Suppressed) return; + OnValueChanged?.Invoke(args); + } + + private readonly System.Action _PrimeInt_subscriber; + private readonly System.Action _MyEnum_subscriber; + } + """; + + await ExpectSourceCode(source, ("TestDataMenu.g.cs", gen)); + } + // TODO: Add Diagnostic tests. private static Task ExpectSourceCode( diff --git a/Silksong.ModMenuAnalyzers/AnalyzerReleases.Shipped.md b/Silksong.ModMenuAnalyzers/AnalyzerReleases.Shipped.md index 3733f5a..242ca88 100644 --- a/Silksong.ModMenuAnalyzers/AnalyzerReleases.Shipped.md +++ b/Silksong.ModMenuAnalyzers/AnalyzerReleases.Shipped.md @@ -1,6 +1,14 @@ https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md -## Release 1.0.0 +## Release 0.7.1 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +SSMM0012 | SilksongModMenu | Error | Diagnostics + +## Release 0.7.0 ### New Rules diff --git a/Silksong.ModMenuAnalyzers/Diagnostics.cs b/Silksong.ModMenuAnalyzers/Diagnostics.cs index b2039c4..befa88a 100644 --- a/Silksong.ModMenuAnalyzers/Diagnostics.cs +++ b/Silksong.ModMenuAnalyzers/Diagnostics.cs @@ -156,4 +156,15 @@ internal static DiagnosticDescriptorWrapper ModMenuRangeBoundError(string typeNa ); internal static DiagnosticDescriptorWrapper IncludedPublicField => new(includedPublicField, []); + + private static readonly DiagnosticDescriptor invalidOptions = new( + id: "SSMM0012", + title: "Invalid ModMenuOptions", + messageFormat: "Could not handle ModMenuOptions argument", + category: CATEGORY, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static DiagnosticDescriptorWrapper InvalidOptions => new(invalidOptions, []); } diff --git a/Silksong.ModMenuAnalyzers/MenuProperty.cs b/Silksong.ModMenuAnalyzers/MenuProperty.cs index a0a900b..ea71ee3 100644 --- a/Silksong.ModMenuAnalyzers/MenuProperty.cs +++ b/Silksong.ModMenuAnalyzers/MenuProperty.cs @@ -36,6 +36,14 @@ private ITypeSymbol DataType } } + private static readonly IReadOnlyCollection RelevantTypes = new HashSet() + { + "Silksong.ModMenu.Generator.ModMenuIncludeAttribute", + "Silksong.ModMenu.Generator.ModMenuOptionsAttribute", + "Silksong.ModMenu.Generator.ModMenuRangeAttribute", + "Silksong.ModMenu.Models.ModMenuNameAttribute", + }; + private static bool IsRelevant(KnownTypes knownTypes, AttributeData data) { if (data.AttributeClass == null) @@ -43,17 +51,7 @@ private static bool IsRelevant(KnownTypes knownTypes, AttributeData data) if (data.AttributeClass.Name == "ModMenuIgnoreAttribute") return true; - if (data.AttributeClass.ToDisplayString() == "Silksong.ModMenu.Models.ModMenuNameAttribute") - return true; - if ( - data.AttributeClass.ToDisplayString() - == "Silksong.ModMenu.Generator.ModMenuRangeAttribute" - ) - return true; - if ( - data.AttributeClass.ToDisplayString() - == "Silksong.ModMenu.Generator.ModMenuIncludeAttribute" - ) + if (RelevantTypes.Contains(data.AttributeClass.ToDisplayString())) return true; var origDef = data.AttributeClass.OriginalDefinition; @@ -173,6 +171,17 @@ out var elementFactoryAttr 0 ); + if ( + !GetUniqueAttr( + diagnostics, + a => + a.AttributeClass?.ToDisplayString() + == "Silksong.ModMenu.Generator.ModMenuOptionsAttribute", + out var menuOptionsAttr + ) + ) + return false; + if ( !GetUniqueAttr( diagnostics, @@ -184,28 +193,31 @@ out var menuRangeAttr ) return false; - int distinct = - (subMenuAttr != null ? 1 : 0) - + (elementFactoryAttr != null ? 1 : 0) - + (menuRangeAttr != null ? 1 : 0); - if (distinct > 1) + List uniqueAttrs = + [ + subMenuAttr, + elementFactoryAttr, + menuOptionsAttr, + menuRangeAttr, + ]; + if (uniqueAttrs.Count(a => a != null) > 1) { Diagnostics .ConflictingAttributes(Name) - .Add( - diagnostics, - [subMenuAttr?.Location, elementFactoryAttr?.Location, menuRangeAttr?.Location] - ); + .Add(diagnostics, [.. uniqueAttrs.Where(a => a != null).Select(a => a?.Location)]); return false; } if (SubMenuType == null && ElementFactoryType == null) { + if (!ValidateModMenuOptions(diagnostics, menuOptionsAttr, out var literals)) + return false; if (!ValidateModMenuRange(diagnostics, menuRangeAttr, out var bounds)) return false; if ( - !InitNumericType(bounds) + !InitChoiceType(literals) + && !InitNumericType(bounds) && !InitBoolType() && !InitKeyCodeType() && !InitEnumType() @@ -222,35 +234,39 @@ out var menuRangeAttr return true; } - private bool InitBoolType() + private bool ValidateModMenuOptions( + List diagnostics, + AttributeData? menuOptionsAttr, + out string? values + ) { - if (DataType.SpecialType != SpecialType.System_Boolean) - return false; + values = null; + if (menuOptionsAttr == null) + return true; - DefaultInitializer.Add( - $@"{Name} = new Silksong.ModMenu.Elements.ChoiceElement({DisplayName.MakeLiteral()}, Silksong.ModMenu.Models.ChoiceModels.ForBool(), {Description.MakeLiteral()});" - ); - return true; - } + List strings = []; + foreach (var arg in menuOptionsAttr.ConstructorArguments[0].Values) + { + if (!arg.MakeLiteral(out var literal)) + { + Diagnostics.InvalidOptions.Add(diagnostics, menuOptionsAttr.Location); + return false; + } - private bool InitKeyCodeType() - { - if (DataType.ToDisplayString() != "UnityEngine.KeyCode") - return false; + strings.Add(literal); + } - DefaultInitializer.Add( - $@"{Name} = new Silksong.ModMenu.Elements.KeyBindElement({DisplayName.MakeLiteral()});" - ); + values = string.Join(", ", strings); return true; } - private bool InitEnumType() + private bool InitChoiceType(string? values) { - if (DataType.TypeKind != TypeKind.Enum) + if (values == null) return false; DefaultInitializer.Add( - $@"{Name} = new Silksong.ModMenu.Elements.ChoiceElement<{DataType.ToDisplayString()}>({DisplayName.MakeLiteral()}, Silksong.ModMenu.Models.ChoiceModels.ForEnum<{DataType.ToDisplayString()}>(), {Description.MakeLiteral()});" + $@"{Name} = new Silksong.ModMenu.Elements.ChoiceElement<{DataType.ToDisplayString()}>({DisplayName.MakeLiteral()}, Silksong.ModMenu.Models.ChoiceModels.ForValues([{values}]), {Description.MakeLiteral()});" ); return true; } @@ -306,6 +322,39 @@ private bool InitNumericType((object min, object max)? range) return true; } + private bool InitBoolType() + { + if (DataType.SpecialType != SpecialType.System_Boolean) + return false; + + DefaultInitializer.Add( + $@"{Name} = new Silksong.ModMenu.Elements.ChoiceElement({DisplayName.MakeLiteral()}, Silksong.ModMenu.Models.ChoiceModels.ForBool(), {Description.MakeLiteral()});" + ); + return true; + } + + private bool InitKeyCodeType() + { + if (DataType.ToDisplayString() != "UnityEngine.KeyCode") + return false; + + DefaultInitializer.Add( + $@"{Name} = new Silksong.ModMenu.Elements.KeyBindElement({DisplayName.MakeLiteral()});" + ); + return true; + } + + private bool InitEnumType() + { + if (DataType.TypeKind != TypeKind.Enum) + return false; + + DefaultInitializer.Add( + $@"{Name} = new Silksong.ModMenu.Elements.ChoiceElement<{DataType.ToDisplayString()}>({DisplayName.MakeLiteral()}, Silksong.ModMenu.Models.ChoiceModels.ForEnum<{DataType.ToDisplayString()}>(), {Description.MakeLiteral()});" + ); + return true; + } + private bool InitTextType() { if (DataType.SpecialType != SpecialType.System_String) diff --git a/Silksong.ModMenuAnalyzers/ModMenuGenerator.cs b/Silksong.ModMenuAnalyzers/ModMenuGenerator.cs index d29c450..d71ad23 100644 --- a/Silksong.ModMenuAnalyzers/ModMenuGenerator.cs +++ b/Silksong.ModMenuAnalyzers/ModMenuGenerator.cs @@ -14,7 +14,7 @@ namespace Silksong.ModMenuAnalyzers; [Generator(LanguageNames.CSharp)] public class ModMenuGenerator : IIncrementalGenerator { - private const string VERSION = "1.0.0"; + public const string VERSION = "0.7.1"; /// public void Initialize(IncrementalGeneratorInitializationContext context) diff --git a/Silksong.ModMenuAnalyzers/RoslynExtensions.cs b/Silksong.ModMenuAnalyzers/RoslynExtensions.cs index 10e8768..a84f78e 100644 --- a/Silksong.ModMenuAnalyzers/RoslynExtensions.cs +++ b/Silksong.ModMenuAnalyzers/RoslynExtensions.cs @@ -1,6 +1,9 @@ using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; +using System.Xml.Schema; using Microsoft.CodeAnalysis; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; namespace Silksong.ModMenuAnalyzers; @@ -98,4 +101,84 @@ internal IEnumerable BaseProperties .AllInterfaces.Select(i => i.GetTypeArgument(baseType, index)) .FirstOrDefault(i => i != null); } + + extension(TypedConstant self) + { + internal bool MakeLiteral(out string literal) + { + literal = ""; + if (self.IsNull) + { + literal = "null"; + return true; + } + + switch (self.Kind) + { + case TypedConstantKind.Primitive: + return LiteralFromPrimitive(self.Value, out literal); + case TypedConstantKind.Enum: + { + if (self.Value is IFieldSymbol field) + literal = $"{self.Type!.ToDisplayString()}.{field.Name}"; + else if (FindMatchingEnum(self.Value, self.Type!, out var name)) + literal = $"{self.Type!.ToDisplayString()}.{name}"; + else if (LiteralFromPrimitive(self.Value, out var repr)) + literal = $"({self.Type!.ToDisplayString()}){repr}"; + else + return false; + + return true; + } + default: + return false; + } + + static bool LiteralFromPrimitive(object? o, out string repr) + { + repr = ""; + if (o is bool b) + { + repr = b ? "true" : "false"; + return true; + } + + SyntaxToken? token = o switch + { + sbyte value => Literal(value), + byte value => Literal(value), + short value => Literal(value), + ushort value => Literal(value), + int value => Literal(value), + uint value => Literal(value), + long value => Literal(value), + ulong value => Literal(value), + float value => Literal(value), + double value => Literal(value), + char value => Literal(value), + string value => Literal(value), + _ => null, + }; + if (token == null) + return false; + + repr = token.Value.ToFullString(); + return true; + } + + static bool FindMatchingEnum(object? value, ITypeSymbol type, out string name) + { + var match = type.GetMembers() + .OfType() + .FirstOrDefault(f => + !f.IsImplicitlyDeclared + && f.HasConstantValue + && Equals(value, f.ConstantValue) + ); + + name = match?.Name ?? ""; + return match != null; + } + } + } } diff --git a/Silksong.ModMenuAnalyzers/StringExtensions.cs b/Silksong.ModMenuAnalyzers/StringExtensions.cs index 321d35e..3bf7ca8 100644 --- a/Silksong.ModMenuAnalyzers/StringExtensions.cs +++ b/Silksong.ModMenuAnalyzers/StringExtensions.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Linq; using System.Text; using Microsoft.CodeAnalysis.CSharp; diff --git a/Silksong.ModMenuTesting/Tests/GeneratorTest.cs b/Silksong.ModMenuTesting/Tests/GeneratorTest.cs index 5e3423a..a23402f 100644 --- a/Silksong.ModMenuTesting/Tests/GeneratorTest.cs +++ b/Silksong.ModMenuTesting/Tests/GeneratorTest.cs @@ -64,6 +64,9 @@ public int IntProperty set => field = value - 1; } + [ModMenuOptions(2, 3, 5, 7)] + public int PrimeInt; + [SubMenu] public SubMenuData SubMenu1 = new();