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();