diff --git a/Discord.Net.sln b/Discord.Net.sln
index 48c80d54fe..5969f786f5 100644
--- a/Discord.Net.sln
+++ b/Discord.Net.sln
@@ -42,6 +42,16 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{BB59
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discord.Net.BuildOverrides", "experiment\Discord.Net.BuildOverrides\Discord.Net.BuildOverrides.csproj", "{115F4921-B44D-4F69-996B-69796959C99D}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ComponentDesigner", "ComponentDesigner", "{3752F226-625C-4564-8A19-B6E9F2329D1E}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discord.Net.ComponentDesigner", "src\Discord.Net.ComponentDesigner\Discord.Net.ComponentDesigner.csproj", "{11317A05-C2AF-4F5D-829F-129046C8E326}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discord.Net.ComponentDesigner.Generator", "src\Discord.Net.ComponentDesigner.Generator\Discord.Net.ComponentDesigner.Generator.csproj", "{ACBDEE4C-FD57-4A47-B58B-6E10872D0464}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discord.Net.ComponentDesigner.Parser", "src\Discord.Net.ComponentDesigner.Parser\Discord.Net.ComponentDesigner.Parser.csproj", "{F08906A4-7F99-47D9-B43A-905F631F81F8}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discord.Net.ComponentDesigner.LanguageServer", "src\Discord.Net.ComponentDesigner.LanguageServer\Discord.Net.ComponentDesigner.LanguageServer.csproj", "{3FD59032-5BA1-418F-88D3-EC385A63E6F2}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -244,6 +254,54 @@ Global
{115F4921-B44D-4F69-996B-69796959C99D}.Release|x64.Build.0 = Release|Any CPU
{115F4921-B44D-4F69-996B-69796959C99D}.Release|x86.ActiveCfg = Release|Any CPU
{115F4921-B44D-4F69-996B-69796959C99D}.Release|x86.Build.0 = Release|Any CPU
+ {11317A05-C2AF-4F5D-829F-129046C8E326}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {11317A05-C2AF-4F5D-829F-129046C8E326}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {11317A05-C2AF-4F5D-829F-129046C8E326}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {11317A05-C2AF-4F5D-829F-129046C8E326}.Debug|x64.Build.0 = Debug|Any CPU
+ {11317A05-C2AF-4F5D-829F-129046C8E326}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {11317A05-C2AF-4F5D-829F-129046C8E326}.Debug|x86.Build.0 = Debug|Any CPU
+ {11317A05-C2AF-4F5D-829F-129046C8E326}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {11317A05-C2AF-4F5D-829F-129046C8E326}.Release|Any CPU.Build.0 = Release|Any CPU
+ {11317A05-C2AF-4F5D-829F-129046C8E326}.Release|x64.ActiveCfg = Release|Any CPU
+ {11317A05-C2AF-4F5D-829F-129046C8E326}.Release|x64.Build.0 = Release|Any CPU
+ {11317A05-C2AF-4F5D-829F-129046C8E326}.Release|x86.ActiveCfg = Release|Any CPU
+ {11317A05-C2AF-4F5D-829F-129046C8E326}.Release|x86.Build.0 = Release|Any CPU
+ {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Debug|x64.Build.0 = Debug|Any CPU
+ {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Debug|x86.Build.0 = Debug|Any CPU
+ {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Release|Any CPU.Build.0 = Release|Any CPU
+ {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Release|x64.ActiveCfg = Release|Any CPU
+ {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Release|x64.Build.0 = Release|Any CPU
+ {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Release|x86.ActiveCfg = Release|Any CPU
+ {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Release|x86.Build.0 = Release|Any CPU
+ {F08906A4-7F99-47D9-B43A-905F631F81F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F08906A4-7F99-47D9-B43A-905F631F81F8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F08906A4-7F99-47D9-B43A-905F631F81F8}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {F08906A4-7F99-47D9-B43A-905F631F81F8}.Debug|x64.Build.0 = Debug|Any CPU
+ {F08906A4-7F99-47D9-B43A-905F631F81F8}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {F08906A4-7F99-47D9-B43A-905F631F81F8}.Debug|x86.Build.0 = Debug|Any CPU
+ {F08906A4-7F99-47D9-B43A-905F631F81F8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F08906A4-7F99-47D9-B43A-905F631F81F8}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F08906A4-7F99-47D9-B43A-905F631F81F8}.Release|x64.ActiveCfg = Release|Any CPU
+ {F08906A4-7F99-47D9-B43A-905F631F81F8}.Release|x64.Build.0 = Release|Any CPU
+ {F08906A4-7F99-47D9-B43A-905F631F81F8}.Release|x86.ActiveCfg = Release|Any CPU
+ {F08906A4-7F99-47D9-B43A-905F631F81F8}.Release|x86.Build.0 = Release|Any CPU
+ {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Debug|x64.Build.0 = Debug|Any CPU
+ {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Debug|x86.Build.0 = Debug|Any CPU
+ {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Release|x64.ActiveCfg = Release|Any CPU
+ {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Release|x64.Build.0 = Release|Any CPU
+ {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Release|x86.ActiveCfg = Release|Any CPU
+ {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -264,6 +322,10 @@ Global
{B61AAE66-15CC-40E4-873A-C23E697C3411} = {BB59D5B5-E7B0-4BF4-8F82-D14431B2799B}
{4A03840B-9EBE-47E3-89AB-E0914DF21AFB} = {BB59D5B5-E7B0-4BF4-8F82-D14431B2799B}
{115F4921-B44D-4F69-996B-69796959C99D} = {CC3D4B1C-9DE0-448B-8AE7-F3F1F3EC5C3A}
+ {11317A05-C2AF-4F5D-829F-129046C8E326} = {3752F226-625C-4564-8A19-B6E9F2329D1E}
+ {ACBDEE4C-FD57-4A47-B58B-6E10872D0464} = {3752F226-625C-4564-8A19-B6E9F2329D1E}
+ {F08906A4-7F99-47D9-B43A-905F631F81F8} = {3752F226-625C-4564-8A19-B6E9F2329D1E}
+ {3FD59032-5BA1-418F-88D3-EC385A63E6F2} = {3752F226-625C-4564-8A19-B6E9F2329D1E}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D2404771-EEC8-45F2-9D71-F3373F6C1495}
diff --git a/Discord.Net.targets b/Discord.Net.targets
index 7f9836d47f..2c4bbc351f 100644
--- a/Discord.Net.targets
+++ b/Discord.Net.targets
@@ -28,10 +28,10 @@
$(NoWarn);CS1573;CS1591
true
true
- true
+ true
-
-
+
+
-
+
\ No newline at end of file
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Constants.cs b/src/Discord.Net.ComponentDesigner.Generator/Constants.cs
new file mode 100644
index 0000000000..6b881cc537
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Constants.cs
@@ -0,0 +1,43 @@
+namespace Discord.CX;
+
+public static class Constants
+{
+ public const string COMPONENT_DESIGNER_QUALIFIED_NAME = "Discord.DesignerInterpolationHandler";
+ public const string INTERPOLATION_DESIGNER_QUALIFIED_NAME = "Discord.DesignerInterpolationHandler";
+
+ public const int PLACEHOLDER_MAX_LENGTH = 150;
+
+ public const int BUTTON_MAX_LABEL_LENGTH = 80;
+ public const int CUSTOM_ID_MAX_LENGTH = 100;
+ public const int BUTTON_URL_MAX_LENGTH = 512;
+
+ public const int MAX_ACTION_ROW_COMPONENTS = 5;
+
+ public const int SELECT_MIN_VALUES = 0;
+ public const int SELECT_MAX_VALUES = 25;
+
+ public const int MAX_MEDIA_ITEMS = 25;
+ public const int MAX_MEDIA_ITEM_DESCRIPTION_LENGTH = 1024;
+
+ public const int MAX_SECTION_CHILDREN = 3;
+
+ public const int STRING_SELECT_OPTION_LABEL_MAX_LENGTH = 100;
+ public const int STRING_SELECT_OPTION_VALUE_MAX_LENGTH = 100;
+ public const int STRING_SELECT_OPTION_DESCRIPTION_MAX_LENGTH = 100;
+
+ public const int TEXT_INPUT_LABEL_MAX_LENGTH = 45;
+
+ public const int TEXT_INPUT_MIN_LENGTH_MIN_VALUE = 0;
+ public const int TEXT_INPUT_MIN_LENGTH_MAX_VALUE = 4000;
+
+ public const int TEXT_INPUT_MAX_LENGTH_MIN_VALUE = 1;
+ public const int TEXT_INPUT_MAX_LENGTH_MAX_VALUE = 4000;
+
+ public const int TEXT_INPUT_VALUE_MAX_LENGTH = 4000;
+ public const int TEXT_INPUT_PLACEHOLDER_MAX_LENGTH = 100;
+
+ public const int THUMBNAIL_DESCRIPTION_MAX_LENGTH = 1024;
+
+
+
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs b/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs
new file mode 100644
index 0000000000..3fef610442
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs
@@ -0,0 +1,249 @@
+using Microsoft.CodeAnalysis;
+
+namespace Discord.CX;
+
+public static partial class Diagnostics
+{
+ public static readonly DiagnosticDescriptor ParseError = new(
+ "DCP001",
+ "CX Parsing error",
+ "{0}",
+ "Component Parser (CX)",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor InvalidEnumVariant = new(
+ "DC0001",
+ "Invalid enum variant",
+ "'{0}' is not a valid variant of '{1}'; valid values are '{2}'",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor TypeMismatch = new(
+ "DC0002",
+ "Type mismatch",
+ "'{0}' is not of expected type '{1}'",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor OutOfRange = new(
+ "DC0003",
+ "Type mismatch",
+ "'{0}' must be {1} in length",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor UnknownComponent = new(
+ "DC0004",
+ "Unknown component",
+ "'{0}' is not a known component",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor ButtonCustomIdUrlConflict = new(
+ "DC0005",
+ "Invalid button",
+ "Buttons cannot contain both a 'url' and a 'customid'",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor ButtonCustomIdOrUrlMissing = new(
+ "DC0006",
+ "Invalid button",
+ "A button must specify either a 'customId' or a 'url'",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor LinkButtonUrlMissing = new(
+ "DC0007",
+ "Invalid button",
+ "A 'link' button must specify 'url'",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor PremiumButtonSkuMissing = new(
+ "DC0008",
+ "Invalid button",
+ "A 'premium' button must specify 'skuId'",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor PremiumButtonPropertyNotAllowed = new(
+ "DC0009",
+ "Invalid button",
+ "A 'premium' button cannot specify '{0}'",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor ButtonLabelDuplicate = new(
+ "DC0010",
+ "Duplicate label definition",
+ "A button cannot specify both a body and a 'label'",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor EmptyActionRow = new(
+ "DC0011",
+ "Empty Action Row",
+ "An action row must contain at least one child",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor MissingRequiredProperty = new(
+ "DC0012",
+ "Missing Property",
+ "'{0}' requires the property '{1}' to be specified",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor UnknownProperty = new(
+ "DC0013",
+ "Unknown Property",
+ "'{0}' is not a known property of '{1}'",
+ "Components",
+ DiagnosticSeverity.Warning,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor EmptyAccessory = new(
+ "DC0014",
+ "Empty Accessory",
+ "An accessory must have 1 child",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor TooManyAccessoryChildren = new(
+ "DC0015",
+ "Too many accessory children",
+ "An accessory must have 1 child",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor EmptySection = new(
+ "DC0016",
+ "Section cannot be empty",
+ "A section must have an accessory and a child",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor InvalidAccessoryChild = new(
+ "DC0017",
+ "Invalid accessory child",
+ "'{0}' is not a valid accessory, only buttons and thumbnails are allowed",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor MissingAccessory = new(
+ "DC0018",
+ "Missing accessory",
+ "A section must contain an accessory",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor TooManyAccessories = new(
+ "DC0019",
+ "Too many accessories",
+ "A section can only contain one accessory",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor MissingSectionChild = new(
+ "DC0020",
+ "Missing section child",
+ "A section must contain at least 1 non-accessory component",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor TooManySectionChildren = new(
+ "DC0021",
+ "Too many section children",
+ "A section must contain at most 3 non-accessory components",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor InvalidSectionChildComponentType = new(
+ "DC0022",
+ "Invalid section child component type",
+ "'{0}' is not a valid child component of a section; only text displays are allowed",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor MissingSelectMenuType = new(
+ "DC0023",
+ "Missing select menu type",
+ "You must specify the type of the select menu, being one of 'string', 'user', 'role', 'channel', or 'mentionable'",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor InvalidSelectMenuType = new(
+ "DC0024",
+ "Invalid select menu type",
+ "Select menu type must be either 'string', 'user', 'role', 'channel', or 'mentionable'",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor SpecifiedInvalidSelectMenuType = new(
+ "DC0025",
+ "Invalid select menu type",
+ "'{0}' is not a valid elect menu type; must be either 'string', 'user', 'role', 'channel', or 'mentionable'",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor ActionRowInvalidChild = new(
+ "DC0026",
+ "Invalid action row child component",
+ "An action row can only contain 1 select menu OR at most 5 buttons",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Discord.Net.ComponentDesigner.Generator.csproj b/src/Discord.Net.ComponentDesigner.Generator/Discord.Net.ComponentDesigner.Generator.csproj
new file mode 100644
index 0000000000..b67d1b5dcd
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Discord.Net.ComponentDesigner.Generator.csproj
@@ -0,0 +1,27 @@
+
+
+
+ netstandard2.0
+ false
+ enable
+ latest
+
+ true
+ true
+ Discord.Net.ComponentDesignerGenerator
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraph.cs b/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraph.cs
new file mode 100644
index 0000000000..3b49145129
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraph.cs
@@ -0,0 +1,220 @@
+using Discord.CX.Nodes;
+using Discord.CX.Nodes.Components;
+using Discord.CX.Parser;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Text;
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+
+namespace Discord.CX;
+
+public readonly struct CXGraph
+{
+ public readonly CXGraphManager Manager;
+ public readonly ImmutableArray RootNodes;
+ public readonly ImmutableArray Diagnostics;
+ public readonly IReadOnlyDictionary NodeMap;
+
+ public CXGraph(
+ CXGraphManager manager,
+ ImmutableArray rootNodes,
+ ImmutableArray diagnostics,
+ IReadOnlyDictionary nodeMap
+ )
+ {
+ Manager = manager;
+ RootNodes = rootNodes;
+ Diagnostics = diagnostics;
+ NodeMap = nodeMap;
+ }
+
+ public Location GetLocation(ICXNode node) => GetLocation(Manager, node);
+ public Location GetLocation(TextSpan span) => GetLocation(Manager, span);
+
+ public static Location GetLocation(CXGraphManager manager, ICXNode node)
+ => GetLocation(manager, node.Span);
+
+ public static Location GetLocation(CXGraphManager manager, TextSpan span)
+ => manager.SyntaxTree.GetLocation(span);
+
+ public CXGraph Update(
+ CXGraphManager manager,
+ IncrementalParseResult parseResult,
+ CXDoc document
+ )
+ {
+ if (manager == Manager) return this;
+
+ var map = new Dictionary();
+ var diagnostics = ImmutableArray.CreateBuilder();
+
+ var rootNodes = ImmutableArray.CreateBuilder();
+
+ foreach (var cxNode in document.RootElements)
+ {
+ var node = CreateNode(
+ manager,
+ cxNode,
+ null,
+ parseResult.ReusedNodes,
+ this,
+ map, diagnostics
+ );
+
+ if (node is not null) rootNodes.Add(node);
+ }
+
+ return new(manager, rootNodes.ToImmutable(), diagnostics.ToImmutable(), map);
+ }
+
+ public static CXGraph Create(
+ CXGraphManager manager
+ )
+ {
+ var map = new Dictionary();
+ var diagnostics = ImmutableArray.CreateBuilder();
+
+ var rootNodes = manager.Document
+ .RootElements
+ .Select(x =>
+ CreateNode(
+ manager,
+ x,
+ null,
+ [],
+ null,
+ map,
+ diagnostics
+ )
+ )
+ .Where(x => x is not null)
+ .ToImmutableArray();
+
+ return new(manager, rootNodes!, diagnostics.ToImmutable(), map);
+ }
+
+ private static Node? CreateNode(
+ CXGraphManager manager,
+ CXNode cxNode,
+ Node? parent,
+ IReadOnlyList reusedNodes,
+ CXGraph? oldGraph,
+ Dictionary map,
+ ImmutableArray.Builder diagnostics
+ )
+ {
+ if (
+ oldGraph.HasValue &&
+ reusedNodes.Contains(cxNode) &&
+ oldGraph.Value.NodeMap.TryGetValue(cxNode, out var existing)
+ ) return map[cxNode] = existing with {Parent = parent};
+
+ switch (cxNode)
+ {
+ case CXValue.Interpolation interpolation:
+ {
+ var info = manager.InterpolationInfos[interpolation.InterpolationIndex];
+
+ if (
+ manager.Compilation.HasImplicitConversion(
+ info.Symbol,
+ manager.Compilation.GetKnownTypes()
+ .IMessageComponentBuilderType
+ )
+ )
+ {
+ var inner = ComponentNode.GetComponentNode();
+
+ var state = inner.Create(interpolation, []);
+
+ if (state is null) return null;
+
+ return map[interpolation] = new(
+ inner,
+ state,
+ parent,
+ []
+ );
+ }
+
+ return null;
+ }
+ case CXElement element:
+ {
+ if (!ComponentNode.TryGetNode(element.Identifier, out var componentNode))
+ {
+ diagnostics.Add(
+ Diagnostic.Create(
+ CX.Diagnostics.UnknownComponent,
+ GetLocation(manager, element),
+ element.Identifier
+ )
+ );
+
+ return null;
+ }
+
+ var children = new List();
+
+ var state = componentNode.Create(element, children);
+
+ if (state is null) return null;
+
+ var nodeChildren = new List();
+ var node = map[element] = state.OwningNode = new(
+ componentNode,
+ state,
+ parent,
+ nodeChildren
+ );
+
+ nodeChildren.AddRange(
+ children
+ .Select(x => CreateNode(
+ manager,
+ x,
+ node,
+ reusedNodes,
+ oldGraph,
+ map,
+ diagnostics
+ )
+ )
+ .Where(x => x is not null)!
+ );
+
+ return node;
+ }
+ default: return null;
+ }
+ }
+
+ public void Validate(ComponentContext context)
+ {
+ foreach (var node in RootNodes) node.Validate(context);
+ }
+
+ public string Render(ComponentContext context)
+ => string.Join(",\n", RootNodes.Select(x => x.Render(context)));
+
+ public sealed record Node(
+ ComponentNode Inner,
+ ComponentState State,
+ Node? Parent,
+ IReadOnlyList Children
+ )
+ {
+ private string? _render;
+
+ public string Render(ComponentContext context)
+ => _render ??= Inner.Render(State, context);
+
+ public void Validate(ComponentContext context)
+ {
+ Inner.Validate(State, context);
+ foreach (var child in Children) child.Validate(context);
+ }
+ }
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraphManager.cs b/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraphManager.cs
new file mode 100644
index 0000000000..09223c4ebe
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraphManager.cs
@@ -0,0 +1,206 @@
+using Discord.CX.Nodes;
+using Discord.CX.Parser;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Text;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Text;
+using System.Threading;
+
+namespace Discord.CX;
+
+public sealed record CXGraphManager(
+ SourceGenerator Generator,
+ string Key,
+ Target Target,
+ CXDoc Document
+)
+{
+ public SyntaxTree SyntaxTree => InvocationSyntax.SyntaxTree;
+ public InterceptableLocation InterceptLocation => Target.InterceptLocation;
+ public InvocationExpressionSyntax InvocationSyntax => Target.InvocationSyntax;
+ public ExpressionSyntax ArgumentExpressionSyntax => Target.ArgumentExpressionSyntax;
+ public IOperation Operation => Target.Operation;
+ public Compilation Compilation => Target.Compilation;
+
+ public string CXDesigner => Target.CXDesigner;
+ public DesignerInterpolationInfo[] InterpolationInfos => Target.Interpolations;
+
+ public TextSpan CXDesignerSpan => Target.CXDesignerSpan;
+
+ public CXParser Parser => Document.Parser;
+
+ public CXGraph Graph
+ {
+ get => _graph ??= CXGraph.Create(this);
+ init => _graph = value;
+ }
+
+ public string SimpleSource => _simpleSource ??= (
+ GetCXWithoutInterpolations(
+ CXDesignerSpan.Start,
+ CXDesigner,
+ InterpolationInfos
+ )
+ );
+
+ private string? _simpleSource;
+
+ private CXGraph? _graph;
+
+ public CXGraphManager(CXGraphManager other)
+ {
+ _graph = other.Graph;
+ Generator = other.Generator;
+ Key = other.Key;
+ Target = other.Target;
+ Document = other.Document;
+ }
+
+ public static CXGraphManager Create(SourceGenerator generator, string key, Target target, CancellationToken token)
+ {
+ var source = new CXSource(
+ target.CXDesignerSpan,
+ target.CXDesigner,
+ target.Interpolations.Select(x => x.Span).ToArray(),
+ target.CXQuoteCount
+ );
+
+ var doc = CXParser.Parse(source, token);
+
+ return new CXGraphManager(
+ generator,
+ key,
+ target,
+ doc
+ );
+ }
+
+ public CXGraphManager OnUpdate(string key, Target target, CancellationToken token)
+ {
+ /*
+ * TODO:
+ * There are 2 modes of incremental updating: re-parse and re-gen,
+ *
+ * Reparsing:
+ * This requires incremental parsing and then re-generating the updated nodes that were parsed, we can
+ * re-use old gen information
+ *
+ * Regenerating
+ * Caused mostly by interpolation types changing, the actual values don't matter since it doesn't change
+ * out emitted code
+ *
+ * Some key things to note:
+ * A fast-path is possible for regenerating, if an interpolations content (source code) has changed, we
+ * can skip reparse and regeneration, and simply update any diagnostics' text spans.
+ * If an interpolations type has changed, we re-run the validator wrapping the interpolation, and regenerate
+ * our emitted source.
+ */
+
+ var result = this with {Key = key, Target = target};
+
+ var newCXWithoutInterpolations = GetCXWithoutInterpolations(
+ target.ArgumentExpressionSyntax.SpanStart,
+ target.CXDesigner,
+ target.Interpolations
+ );
+
+ if (newCXWithoutInterpolations != SimpleSource)
+ {
+ // we're going to need to reparse, the underlying CX structure changed
+ result.DoReparse(target, this, ref result, token);
+ }
+
+ return result;
+ }
+
+ private void DoReparse(Target target, CXGraphManager old, ref CXGraphManager result, CancellationToken token)
+ {
+ var source = new CXSource(
+ target.CXDesignerSpan,
+ target.CXDesigner,
+ target.Interpolations.Select(x => x.Span).ToArray(),
+ target.CXQuoteCount
+ );
+
+ var changes = target
+ .SyntaxTree
+ .GetChanges(old.SyntaxTree)
+ .Where(x => CXDesignerSpan.IntersectsWith(x.Span))
+ .ToArray();
+
+ var document = Document.IncrementalParse(
+ source,
+ changes,
+ out var parseResult,
+ token
+ );
+
+ result = result with
+ {
+ Graph = result.Graph.Update(result, parseResult, document),
+ Document = document
+ };
+ }
+
+ public RenderedInterceptor Render(CancellationToken token = default)
+ {
+ var diagnostics = new List(
+ Document
+ .Diagnostics
+ .Select(x => Diagnostic.Create(
+ Diagnostics.ParseError,
+ SyntaxTree.GetLocation(x.Span),
+ x.Message
+ )
+ )
+ .Concat(
+ Graph.Diagnostics
+ )
+ );
+
+ if (diagnostics.Count > 0)
+ {
+ return new(InterceptLocation, string.Empty, [..diagnostics]);
+ }
+
+ var context = new ComponentContext(Graph) {Diagnostics = diagnostics};
+
+ Graph.Validate(context);
+
+ var source = context.HasErrors
+ ? string.Empty
+ : Graph.Render(context);
+
+ return new(
+ this.InterceptLocation,
+ source,
+ [..diagnostics]
+ );
+ }
+
+ private static string GetCXWithoutInterpolations(
+ int offset,
+ string cx,
+ DesignerInterpolationInfo[] interpolations
+ )
+ {
+ if (interpolations.Length is 0) return cx;
+
+ var builder = new StringBuilder(cx);
+
+ var rmDelta = 0;
+ for (var i = 0; i < interpolations.Length; i++)
+ {
+ var interpolation = interpolations[i];
+ builder.Remove(interpolation.Span.Start - offset - rmDelta, interpolation.Span.Length);
+ rmDelta += interpolation.Span.Length;
+ }
+
+ return builder.ToString();
+ }
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Graph/RenderedInterceptor.cs b/src/Discord.Net.ComponentDesigner.Generator/Graph/RenderedInterceptor.cs
new file mode 100644
index 0000000000..c7c08632b4
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Graph/RenderedInterceptor.cs
@@ -0,0 +1,30 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using System.Collections.Immutable;
+using System.Linq;
+
+namespace Discord.CX;
+
+public readonly record struct RenderedInterceptor(
+ InterceptableLocation Location,
+ string Source,
+ ImmutableArray Diagnostics
+)
+{
+ public bool Equals(RenderedInterceptor other)
+ => Location.Data == other.Location.Data &&
+ Location.Version == other.Location.Version &&
+ Source == other.Source &&
+ Diagnostics.SequenceEqual(other.Diagnostics);
+
+ public override int GetHashCode()
+ {
+ unchecked
+ {
+ var hashCode = Location.GetHashCode();
+ hashCode = (hashCode * 397) ^ Source.GetHashCode();
+ hashCode = (hashCode * 397) ^ Diagnostics.Aggregate(0, (a, b) => (a * 397) ^ b.GetHashCode());
+ return hashCode;
+ }
+ }
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/InterpolationInfo.cs b/src/Discord.Net.ComponentDesigner.Generator/InterpolationInfo.cs
new file mode 100644
index 0000000000..0f96afc3ff
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/InterpolationInfo.cs
@@ -0,0 +1,27 @@
+using Microsoft.CodeAnalysis;
+
+namespace Discord.CX;
+
+public readonly record struct InterpolationInfo(
+ int Id,
+ int Length,
+ ITypeSymbol Type
+)
+{
+ public bool Equals(InterpolationInfo? other)
+ => other is { } info &&
+ Id == info.Id &&
+ Length == info.Length &&
+ Type.ToDisplayString() == info.Type.ToDisplayString();
+
+ public override int GetHashCode()
+ {
+ unchecked
+ {
+ var hashCode = Id;
+ hashCode = (hashCode * 397) ^ Length;
+ hashCode = (hashCode * 397) ^ Type.ToDisplayString().GetHashCode();
+ return hashCode;
+ }
+ }
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentContext.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentContext.cs
new file mode 100644
index 0000000000..2946dfc516
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentContext.cs
@@ -0,0 +1,56 @@
+using Discord.CX.Parser;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Text;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Discord.CX.Nodes;
+
+public sealed class ComponentContext
+{
+ public KnownTypes KnownTypes => Compilation.GetKnownTypes();
+ public Compilation Compilation => _graph.Manager.Compilation;
+
+ public bool HasErrors => Diagnostics.Any(x => x.Severity is DiagnosticSeverity.Error);
+
+ public List Diagnostics { get; init; } = [];
+
+ private readonly CXGraph _graph;
+
+ public ComponentContext(CXGraph graph)
+ {
+ _graph = graph;
+ }
+
+ public string GetDesignerValue(CXValue.Interpolation interpolation, string? type = null)
+ => GetDesignerValue(interpolation.InterpolationIndex, type);
+
+ public string GetDesignerValue(DesignerInterpolationInfo interpolation, string? type = null)
+ => GetDesignerValue(interpolation.Id, type);
+
+ public string GetDesignerValue(int index, string? type = null)
+ => type is not null ? $"designer.GetValue<{type}>({index})" : $"designer.GetValueAsString({index})";
+
+
+ public Location GetLocation(ICXNode node)
+ => _graph.GetLocation(node);
+ public Location GetLocation(TextSpan span)
+ => _graph.GetLocation(span);
+
+ public void AddDiagnostic(DiagnosticDescriptor descriptor, ICXNode node, params object?[]? args)
+ => AddDiagnostic(Diagnostic.Create(descriptor, GetLocation(node), args));
+
+ public void AddDiagnostic(DiagnosticDescriptor descriptor, TextSpan span, params object?[]? args)
+ => AddDiagnostic(Diagnostic.Create(descriptor, GetLocation(span), args));
+
+
+ public DesignerInterpolationInfo GetInterpolationInfo(CXValue.Interpolation interpolation)
+ => GetInterpolationInfo(interpolation.InterpolationIndex);
+
+ public DesignerInterpolationInfo GetInterpolationInfo(int index) => _graph.Manager.InterpolationInfos[index];
+
+ public void AddDiagnostic(Diagnostic diagnostics)
+ {
+ Diagnostics.Add(diagnostics);
+ }
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs
new file mode 100644
index 0000000000..4e9ca9480f
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs
@@ -0,0 +1,132 @@
+using Discord.CX.Parser;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
+
+namespace Discord.CX.Nodes;
+
+public abstract class ComponentNode : ComponentNode
+ where TState : ComponentState
+{
+ public abstract string Render(TState state, ComponentContext context);
+
+ public virtual void UpdateState(ref TState state) { }
+
+ public sealed override void UpdateState(ref ComponentState state)
+ => UpdateState(ref Unsafe.As(ref state));
+
+ public abstract TState? CreateState(ICXNode source, List children);
+
+ public sealed override ComponentState? Create(ICXNode source, List children)
+ => CreateState(source, children);
+
+ public sealed override string Render(ComponentState state, ComponentContext context)
+ => Render((TState)state, context);
+
+ public virtual void Validate(TState state, ComponentContext context) { }
+
+ public sealed override void Validate(ComponentState state, ComponentContext context)
+ => Validate((TState)state, context);
+}
+
+public abstract class ComponentNode
+{
+ public abstract string Name { get; }
+ public virtual IReadOnlyList Aliases { get; } = [];
+
+ public virtual bool HasChildren => false;
+
+ public virtual IReadOnlyList Properties { get; } = [];
+
+ public virtual void Validate(ComponentState state, ComponentContext context)
+ {
+ // validate properties
+ foreach (var property in Properties)
+ foreach (var validator in property.Validators)
+ {
+ var propertyValue = state.GetProperty(property);
+
+ validator(context, propertyValue);
+
+ if (!property.IsOptional && !propertyValue.HasValue)
+ {
+ context.AddDiagnostic(
+ Diagnostics.MissingRequiredProperty,
+ state.Source,
+ Name,
+ property.Name
+ );
+ }
+ }
+
+ // report any unknown properties
+ if (state.Source is CXElement element)
+ {
+ foreach (var attribute in element.Attributes)
+ {
+ if (!TryGetPropertyFromName(attribute.Identifier.Value, out _))
+ {
+ context.AddDiagnostic(
+ Diagnostics.UnknownProperty,
+ attribute,
+ attribute.Identifier.Value,
+ Name
+ );
+ }
+ }
+ }
+ }
+
+ private bool TryGetPropertyFromName(string name, out ComponentProperty result)
+ {
+ foreach (var property in Properties)
+ {
+ if (property.Name == name || property.Aliases.Contains(name))
+ {
+ result = property;
+ return true;
+ }
+ }
+
+ result = null!;
+ return false;
+ }
+
+ public abstract string Render(ComponentState state, ComponentContext context);
+
+ public virtual void UpdateState(ref ComponentState state) { }
+
+ public virtual ComponentState? Create(ICXNode source, List children)
+ {
+ if (HasChildren && source is CXElement element)
+ {
+ children.AddRange(element.Children);
+ }
+
+ return new ComponentState() {Source = source};
+ }
+
+
+ private static readonly Dictionary _nodes;
+
+ static ComponentNode()
+ {
+ _nodes = typeof(ComponentNode)
+ .Assembly
+ .GetTypes()
+ .Where(x => !x.IsAbstract && typeof(ComponentNode).IsAssignableFrom(x))
+ .Select(x => (ComponentNode)Activator.CreateInstance(x)!)
+ .SelectMany(x => x
+ .Aliases
+ .Prepend(x.Name)
+ .Select(y => new KeyValuePair(y, x)))
+ .ToDictionary(x => x.Key, x => x.Value);
+ }
+
+ public static T GetComponentNode() where T : ComponentNode
+ => _nodes.Values.OfType().First();
+
+ public static bool TryGetNode(string name, out ComponentNode node)
+ => _nodes.TryGetValue(name, out node);
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs
new file mode 100644
index 0000000000..66cd41afab
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs
@@ -0,0 +1,46 @@
+using Discord.CX.Parser;
+using System.Collections.Generic;
+
+namespace Discord.CX.Nodes;
+
+public delegate void PropertyValidator(ComponentContext context, ComponentPropertyValue value);
+public delegate string PropertyRenderer(ComponentContext context, ComponentPropertyValue value);
+
+public sealed class ComponentProperty
+{
+ public static ComponentProperty Id => new(
+ "id",
+ isOptional: true,
+ renderer: Renderers.Integer,
+ dotnetPropertyName: "Id"
+ );
+
+ public string Name { get; }
+ public IReadOnlyList Aliases { get; }
+
+ public bool IsOptional { get; }
+ public string DotnetPropertyName { get; }
+ public string DotnetParameterName { get; }
+ public PropertyRenderer Renderer { get; }
+
+ public IReadOnlyList Validators { get; }
+
+ public ComponentProperty(
+ string name,
+ bool isOptional = false,
+ IEnumerable? aliases = null,
+ IEnumerable? validators = null,
+ PropertyRenderer? renderer = null,
+ string? dotnetParameterName = null,
+ string? dotnetPropertyName = null
+ )
+ {
+ Name = name;
+ Aliases = [..aliases ?? []];
+ IsOptional = isOptional;
+ DotnetPropertyName = dotnetPropertyName ?? name;
+ DotnetParameterName = dotnetParameterName ?? name;
+ Renderer = renderer ?? Renderers.CreateDefault(this);
+ Validators = [..validators ?? []];
+ }
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs
new file mode 100644
index 0000000000..1cb783ca13
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs
@@ -0,0 +1,48 @@
+using Discord.CX.Parser;
+using Microsoft.CodeAnalysis;
+using System.Collections.Generic;
+
+namespace Discord.CX.Nodes;
+
+public sealed record ComponentPropertyValue(
+ ComponentProperty Property,
+ CXAttribute? Attribute
+)
+{
+ private CXValue? _value;
+
+ public CXValue? Value
+ {
+ get => _value ??= Attribute?.Value;
+ init => _value = value;
+ }
+
+ public bool IsSpecified => Attribute is not null;
+
+ public bool HasValue => Value is not null;
+
+ private readonly List _diagnostics = [];
+
+ public void AddDiagnostic(Diagnostic diagnostic) => _diagnostics.Add(diagnostic);
+
+ public bool TryGetLiteralValue(ComponentContext context, out string value)
+ {
+ switch (Value)
+ {
+ case CXValue.Scalar scalar:
+ value = scalar.Value;
+ return true;
+ case CXValue.StringLiteral {HasInterpolations: false} literal:
+ value = literal.Tokens.ToString();
+ return true;
+ // case CXValue.Interpolation interpolation:
+ // var info = context.GetInterpolationInfo(interpolation);
+ //
+ // break;
+
+ default:
+ value = string.Empty;
+ return false;
+ }
+ }
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentState.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentState.cs
new file mode 100644
index 0000000000..6de549a598
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentState.cs
@@ -0,0 +1,101 @@
+using Discord.CX.Parser;
+using Microsoft.CodeAnalysis;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Discord.CX.Nodes;
+
+public class ComponentState
+{
+ public CXGraph.Node? OwningNode { get; set; }
+ public required ICXNode Source { get; init; }
+
+ public bool HasChildren => OwningNode?.Children.Count > 0;
+
+ public IReadOnlyList Children
+ => OwningNode?.Children ?? [];
+
+ public bool IsElement => Source is CXElement;
+
+ private readonly Dictionary _properties = [];
+
+ public ComponentPropertyValue GetProperty(ComponentProperty property)
+ {
+ //if (!IsElement) return null;
+
+ if (_properties.TryGetValue(property, out var value)) return value;
+
+ var attribute = (Source as CXElement)?
+ .Attributes
+ .FirstOrDefault(x =>
+ property.Name == x.Identifier.Value || property.Aliases.Contains(x.Identifier.Value)
+ );
+
+ return _properties[property] = new(property, attribute);
+ }
+
+ public void SubstitutePropertyValue(ComponentProperty property, CXValue value)
+ {
+ if (!_properties.TryGetValue(property, out var existing))
+ _properties[property] = new(property, null) {Value = value};
+ else
+ _properties[property] = _properties[property] with {Value = value};
+ }
+
+ public string RenderProperties(
+ ComponentNode node,
+ ComponentContext context,
+ bool asInitializers = false
+ )
+ {
+ // TODO: correct handling?
+ if (Source is not CXElement element) return string.Empty;
+
+ var values = new List();
+
+ foreach (var property in node.Properties)
+ {
+ var propertyValue = GetProperty(property);
+
+ if (propertyValue?.Value is null) continue;
+
+ var prefix = asInitializers
+ ? $"{property.DotnetPropertyName} = "
+ : $"{property.DotnetPropertyName}: ";
+
+ values.Add($"{prefix}{property.Renderer(context, propertyValue)}");
+ }
+
+ var joiner = asInitializers ? "," : string.Empty;
+ return string.Join($"{joiner}\n", values);
+ }
+
+ public string RenderInitializer(ComponentNode node, ComponentContext context)
+ {
+ var props = RenderProperties(node, context, asInitializers: true);
+
+ if (string.IsNullOrWhiteSpace(props)) return string.Empty;
+
+ return
+ $$"""
+ {
+ {{props.WithNewlinePadding(4)}}
+ }
+ """;
+ }
+
+ public string RenderChildren(ComponentContext context, Func? predicate = null)
+ {
+ if (OwningNode is null || !HasChildren) return string.Empty;
+
+ IEnumerable children = OwningNode.Children;
+
+ if (predicate is not null) children = children.Where(predicate);
+
+ return string.Join(
+ ",\n",
+ children.Select(x => x.Render(context))
+ );
+ }
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs
new file mode 100644
index 0000000000..6a0804e9cf
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs
@@ -0,0 +1,106 @@
+using Discord.CX.Parser;
+using Discord.CX.Nodes.Components.SelectMenus;
+using Microsoft.CodeAnalysis;
+using System.Collections.Generic;
+using System.Linq;
+using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat;
+
+namespace Discord.CX.Nodes.Components;
+
+public sealed class ActionRowComponentNode : ComponentNode
+{
+ public override string Name => "row";
+
+ public override bool HasChildren => true;
+
+ public override IReadOnlyList Properties { get; } = [ComponentProperty.Id];
+
+ public override void Validate(ComponentState state, ComponentContext context)
+ {
+ if (!state.HasChildren)
+ {
+ context.AddDiagnostic(
+ Diagnostics.EmptyActionRow,
+ state.Source
+ );
+
+ base.Validate(state, context);
+ return;
+ }
+
+ switch (state.Children[0].Inner)
+ {
+ case ButtonComponentNode:
+ foreach (var rest in state.Children.Skip(1))
+ {
+ if (rest.Inner is not ButtonComponentNode)
+ {
+ context.AddDiagnostic(
+ Diagnostics.ActionRowInvalidChild,
+ rest.State.Source
+ );
+ }
+ }
+
+ break;
+ case SelectMenuComponentNode:
+ foreach (var rest in state.Children.Skip(1))
+ {
+ context.AddDiagnostic(
+ Diagnostics.ActionRowInvalidChild,
+ rest.State.Source
+ );
+ }
+
+ break;
+
+ case InterleavedComponentNode: break;
+
+ default:
+ foreach (
+ var rest
+ in state.Children.Where(x => !IsValidChild(x.Inner))
+ )
+ {
+ context.AddDiagnostic(
+ Diagnostics.ActionRowInvalidChild,
+ rest.State.Source
+ );
+ }
+
+ break;
+ }
+
+ base.Validate(state, context);
+ }
+
+ private static bool IsValidChild(ComponentNode node)
+ => node is ButtonComponentNode
+ or SelectMenuComponentNode
+ or InterleavedComponentNode;
+
+ public override string Render(ComponentState state, ComponentContext context)
+ => $$"""
+ new {{context.KnownTypes.ActionRowBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}}{{
+ $"{
+ state
+ .RenderProperties(this, context, asInitializers: true)
+ .PostfixIfSome("\n")
+ }{
+ state.RenderChildren(context)
+ .Map(x =>
+ $"""
+ Components =
+ [
+ {x.WithNewlinePadding(4)}
+ ]
+ """
+ )
+ }"
+ .TrimEnd()
+ .WithNewlinePadding(4)
+ .PrefixIfSome("\n{\n".Postfix(4))
+ .PostfixIfSome("\n}")
+ }}
+ """;
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs
new file mode 100644
index 0000000000..857919be4d
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs
@@ -0,0 +1,209 @@
+using Discord.CX.Parser;
+using Microsoft.CodeAnalysis;
+using System.Collections.Generic;
+using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat;
+
+namespace Discord.CX.Nodes.Components;
+
+public sealed class ButtonComponentNode : ComponentNode
+{
+
+ public const string BUTTON_STYLE_ENUM = "Discord.ButtonStyle";
+
+ public override string Name => "button";
+
+ public override IReadOnlyList Properties { get; }
+
+ public ComponentProperty Style { get; }
+ public ComponentProperty Label { get; }
+ public ComponentProperty Emoji { get; }
+ public ComponentProperty CustomId { get; }
+ public ComponentProperty SkuId { get; }
+ public ComponentProperty Url { get; }
+
+ public ButtonComponentNode()
+ {
+ Properties =
+ [
+ ComponentProperty.Id,
+ Style = new ComponentProperty(
+ "style",
+ isOptional: true,
+ validators: [Validators.EnumVariant(BUTTON_STYLE_ENUM)],
+ renderer: Renderers.RenderEnum(BUTTON_STYLE_ENUM)
+ ),
+ Label = new ComponentProperty(
+ "label",
+ isOptional: true,
+ validators: [Validators.Range(upper: Constants.BUTTON_MAX_LABEL_LENGTH)],
+ renderer: Renderers.String
+ ),
+ Emoji = new ComponentProperty(
+ "emoji",
+ isOptional: true,
+ aliases: ["emote"],
+ validators: [Validators.Emote],
+ renderer: Renderers.Emoji
+ ),
+ CustomId = new(
+ "customId",
+ isOptional: true,
+ validators: [Validators.Range(upper: Constants.CUSTOM_ID_MAX_LENGTH)],
+ renderer: Renderers.String
+ ),
+ SkuId = new(
+ "skuId",
+ aliases: ["sku"],
+ isOptional: true,
+ validators: [Validators.Snowflake],
+ renderer: Renderers.Snowflake
+ ),
+ Url = new(
+ "url",
+ isOptional: true,
+ validators: [Validators.Range(upper: Constants.BUTTON_URL_MAX_LENGTH)],
+ renderer: Renderers.String
+ )
+ ];
+ }
+
+ public override ComponentState? Create(ICXNode source, List children)
+ {
+ var state = base.Create(source, children);
+
+ if (source is CXElement {Children.Count: 1} element && element.Children[0] is CXValue value)
+ state?.SubstitutePropertyValue(Label, value);
+
+ return state;
+ }
+
+ public override void Validate(ComponentState state, ComponentContext context)
+ {
+ var label = state.GetProperty(Label);
+
+ if (
+ label.Attribute?.Value is not null &&
+ label.Value is not null &&
+ label.Value != label.Attribute.Value
+ )
+ {
+ context.AddDiagnostic(
+ Diagnostic.Create(
+ Diagnostics.ButtonLabelDuplicate,
+ context.GetLocation(label.Value!)
+ )
+ );
+ }
+
+ if (state.GetProperty(Url)!.IsSpecified && state.GetProperty(CustomId)!.IsSpecified)
+ {
+ context.AddDiagnostic(
+ Diagnostic.Create(
+ Diagnostics.ButtonCustomIdUrlConflict,
+ context.GetLocation(state.Source)
+ )
+ );
+ }
+
+ // TODO: interpolations with constants can be checked
+ if (
+ state.GetProperty(Style).TryGetLiteralValue(context, out var style)
+ )
+ {
+ switch (style.ToLowerInvariant())
+ {
+ case "link" when !state.GetProperty(Url).IsSpecified:
+ context.AddDiagnostic(
+ Diagnostic.Create(
+ Diagnostics.LinkButtonUrlMissing,
+ context.GetLocation(state.Source)
+ )
+ );
+ break;
+ case "premium" when !state.GetProperty(SkuId).IsSpecified:
+ context.AddDiagnostic(
+ Diagnostic.Create(
+ Diagnostics.PremiumButtonSkuMissing,
+ context.GetLocation(state.Source)
+ )
+ );
+
+ if (state.GetProperty(CustomId).IsSpecified)
+ {
+ context.AddDiagnostic(
+ Diagnostic.Create(
+ Diagnostics.PremiumButtonPropertyNotAllowed,
+ context.GetLocation(state.Source),
+ "customId"
+ )
+ );
+ }
+
+ if (state.GetProperty(Label).IsSpecified)
+ {
+ context.AddDiagnostic(
+ Diagnostic.Create(
+ Diagnostics.PremiumButtonPropertyNotAllowed,
+ context.GetLocation(state.Source),
+ "label"
+ )
+ );
+ }
+
+ if (state.GetProperty(Url).IsSpecified)
+ {
+ context.AddDiagnostic(
+ Diagnostic.Create(
+ Diagnostics.PremiumButtonPropertyNotAllowed,
+ context.GetLocation(state.Source),
+ "url"
+ )
+ );
+ }
+
+ if (state.GetProperty(Emoji).IsSpecified)
+ {
+ context.AddDiagnostic(
+ Diagnostic.Create(
+ Diagnostics.PremiumButtonPropertyNotAllowed,
+ context.GetLocation(state.Source),
+ "emoji"
+ )
+ );
+ }
+
+ break;
+ default: CheckForCustomIdAndUrl(); break;
+ }
+ }
+ else
+ {
+ CheckForCustomIdAndUrl();
+ }
+
+ base.Validate(state, context);
+
+ void CheckForCustomIdAndUrl()
+ {
+ if (!state.GetProperty(Url)!.IsSpecified && !state.GetProperty(CustomId)!.IsSpecified)
+ {
+ context.AddDiagnostic(
+ Diagnostic.Create(
+ Diagnostics.ButtonCustomIdOrUrlMissing,
+ context.GetLocation(state.Source)
+ )
+ );
+ }
+ }
+ }
+
+ public override string Render(ComponentState state, ComponentContext context)
+ => $"""
+ new {context.KnownTypes.ButtonBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({
+ state.RenderProperties(this, context)
+ .WithNewlinePadding(4)
+ .PrefixIfSome(4)
+ .WrapIfSome("\n")
+ })
+ """;
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs
new file mode 100644
index 0000000000..ffc064d1d8
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs
@@ -0,0 +1,74 @@
+using Discord.CX.Parser;
+using System.Collections.Generic;
+using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat;
+
+namespace Discord.CX.Nodes.Components;
+
+public sealed class ContainerComponentNode : ComponentNode
+{
+ public override string Name => "container";
+
+ public override bool HasChildren => true;
+
+ public ComponentProperty Id { get; }
+ public ComponentProperty AccentColor { get; }
+ public ComponentProperty Spoiler { get; }
+
+ public override IReadOnlyList Properties { get; }
+
+ public ContainerComponentNode()
+ {
+ Properties =
+ [
+ Id = ComponentProperty.Id,
+ AccentColor = new(
+ "accentColor",
+ isOptional: true,
+ aliases: ["color", "accent"],
+ renderer: Renderers.Color,
+ dotnetPropertyName: "AccentColor"
+ ),
+ Spoiler = new(
+ "spoiler",
+ isOptional: true,
+ renderer: Renderers.Boolean,
+ dotnetPropertyName: "IsSpoiler"
+ )
+ ];
+ }
+
+ public override void Validate(ComponentState state, ComponentContext context)
+ {
+ foreach (var child in state.Children)
+ {
+ // TODO: check for allowed children
+ }
+
+ base.Validate(state, context);
+ }
+
+ public override string Render(ComponentState state, ComponentContext context)
+ => $$"""
+ new {{context.KnownTypes.ContainerBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}}{{
+ $"{
+ state
+ .RenderProperties(this, context, asInitializers: true)
+ .PostfixIfSome("\n")
+ }{
+ state.RenderChildren(context)
+ .Map(x =>
+ $"""
+ Components =
+ [
+ {x.WithNewlinePadding(4)}
+ ]
+ """
+ )
+ }"
+ .TrimEnd()
+ .WithNewlinePadding(4)
+ .PrefixIfSome("\n{\n".Postfix(4))
+ .PostfixIfSome("\n}")
+ }}
+ """;
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs
new file mode 100644
index 0000000000..634ae445f7
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs
@@ -0,0 +1,43 @@
+using System.Collections.Generic;
+using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat;
+
+namespace Discord.CX.Nodes.Components;
+
+public sealed class FileComponentNode : ComponentNode
+{
+ public override string Name => "file";
+
+ public ComponentProperty File { get; }
+ public ComponentProperty Spoiler { get; }
+
+ public override IReadOnlyList Properties { get; }
+
+ public FileComponentNode()
+ {
+ Properties =
+ [
+ ComponentProperty.Id,
+ File = new(
+ "file",
+ renderer: Renderers.UnfurledMediaItem,
+ dotnetParameterName: "media"
+ ),
+ Spoiler = new(
+ "spoiler",
+ isOptional: true,
+ renderer: Renderers.Boolean,
+ dotnetParameterName: "isSpoiler"
+ )
+ ];
+ }
+
+ public override string Render(ComponentState state, ComponentContext context)
+ => $"""
+ new {context.KnownTypes.FileComponentBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({
+ state.RenderProperties(this, context)
+ .WithNewlinePadding(4)
+ .PrefixIfSome(4)
+ .WrapIfSome("\n")
+ })
+ """;
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterleavedComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterleavedComponentNode.cs
new file mode 100644
index 0000000000..b136ce091c
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterleavedComponentNode.cs
@@ -0,0 +1,23 @@
+using Discord.CX.Parser;
+using System.Collections.Generic;
+using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat;
+
+namespace Discord.CX.Nodes.Components;
+
+public sealed class InterleavedComponentNode : ComponentNode
+{
+ public override string Name => "";
+
+ public override ComponentState? Create(ICXNode source, List children)
+ {
+ if (source is not CXValue.Interpolation interpolation) return null;
+
+ return base.Create(source, children);
+ }
+
+ public override string Render(ComponentState state, ComponentContext context)
+ => context.GetDesignerValue(
+ (CXValue.Interpolation)state.Source,
+ context.KnownTypes.IMessageComponentBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
+ );
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs
new file mode 100644
index 0000000000..41753ec1a1
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs
@@ -0,0 +1,35 @@
+using System.Collections.Generic;
+
+namespace Discord.CX.Nodes.Components;
+
+public sealed class LabelComponentNode : ComponentNode
+{
+ public override string Name => "label";
+
+ public ComponentProperty Value { get; }
+ public ComponentProperty Description { get; }
+
+ public override bool HasChildren => true;
+
+ public override IReadOnlyList Properties { get; }
+
+ public LabelComponentNode()
+ {
+ Properties =
+ [
+ ComponentProperty.Id,
+ Value = new(
+ "value",
+ renderer: Renderers.String
+ ),
+ Description = new(
+ "description",
+ isOptional: true,
+ renderer: Renderers.String
+ )
+ ];
+ }
+
+ public override string Render(ComponentState state, ComponentContext context)
+ => string.Empty;
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs
new file mode 100644
index 0000000000..f4f713e984
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs
@@ -0,0 +1,78 @@
+using System.Collections.Generic;
+using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat;
+
+namespace Discord.CX.Nodes.Components;
+
+public sealed class MediaGalleryComponentNode : ComponentNode
+{
+ public override string Name => "gallery";
+
+ public override IReadOnlyList Properties { get; } = [ComponentProperty.Id];
+
+ public override bool HasChildren => true;
+
+ public override string Render(ComponentState state, ComponentContext context)
+ => $$"""
+ new {{context.KnownTypes.MediaGalleryBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}}{{
+ $"{
+ state
+ .RenderProperties(this, context, asInitializers: true)
+ .PostfixIfSome("\n")
+ }{
+ state.RenderChildren(context)
+ .Map(x =>
+ $"""
+ Items =
+ [
+ {x.WithNewlinePadding(4)}
+ ]
+ """
+ )
+ }"
+ .TrimEnd()
+ .WithNewlinePadding(4)
+ .PrefixIfSome("\n{\n".Postfix(4))
+ .PostfixIfSome("\n}")
+ }}
+ """;
+}
+
+public sealed class MediaGalleryItemComponentNode : ComponentNode
+{
+ public override string Name => "media";
+
+ public ComponentProperty Url { get; }
+ public ComponentProperty Description { get; }
+ public ComponentProperty Spoiler { get; }
+
+ public override IReadOnlyList Properties { get; }
+
+ public MediaGalleryItemComponentNode()
+ {
+ Properties =
+ [
+ Url = new(
+ "url",
+ renderer: Renderers.UnfurledMediaItem
+ ),
+ Description = new(
+ "description",
+ isOptional: true,
+ renderer: Renderers.String
+ ),
+ Spoiler = new(
+ "spoiler",
+ isOptional: true,
+ renderer: Renderers.Boolean,
+ dotnetParameterName: "isSpoiler"
+ )
+ ];
+ }
+
+ public override string Render(ComponentState state, ComponentContext context)
+ => $"""
+ new {context.KnownTypes.MediaGalleryItemPropertiesType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}(
+ {state.RenderProperties(this, context).WithNewlinePadding(4)}
+ )
+ """;
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentnode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentnode.cs
new file mode 100644
index 0000000000..f66266e20e
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentnode.cs
@@ -0,0 +1,160 @@
+using Microsoft.CodeAnalysis.Text;
+using System.Collections.Generic;
+using System.Linq;
+using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat;
+
+namespace Discord.CX.Nodes.Components;
+
+public sealed class SectionComponentnode : ComponentNode
+{
+ public override string Name => "section";
+
+ public override bool HasChildren => true;
+
+ public override IReadOnlyList Properties { get; } = [ComponentProperty.Id];
+
+ public override void Validate(ComponentState state, ComponentContext context)
+ {
+ if (!state.HasChildren)
+ {
+ context.AddDiagnostic(
+ Diagnostics.EmptySection,
+ state.Source
+ );
+
+ base.Validate(state, context);
+ return;
+ }
+
+ var accessoryCount = state.Children.Count(x => x.Inner is AccessoryComponentNode);
+ var nonAccessoryCount = state.Children.Count - accessoryCount;
+
+ switch (accessoryCount)
+ {
+ case 0:
+ context.AddDiagnostic(
+ Diagnostics.MissingAccessory,
+ state.Source
+ );
+ break;
+ case > 1:
+ foreach (var accessory in state.Children.Where(x => x.Inner is AccessoryComponentNode).Skip(1))
+ {
+ context.AddDiagnostic(
+ Diagnostics.TooManyAccessories,
+ accessory.State.Source
+ );
+ }
+
+ break;
+ }
+
+ switch (nonAccessoryCount)
+ {
+ case 0:
+ context.AddDiagnostic(
+ Diagnostics.MissingSectionChild,
+ state.Source
+ );
+ break;
+ case > 3:
+ foreach (var child in state.Children.Where(x => x.Inner is not AccessoryComponentNode).Skip(3))
+ {
+ context.AddDiagnostic(
+ Diagnostics.TooManySectionChildren,
+ child.State.Source
+ );
+ }
+ break;
+ }
+
+ foreach (var child in state.Children.Where(x => x.Inner is not AccessoryComponentNode))
+ {
+ if (!IsValidChildType(child.Inner))
+ {
+ context.AddDiagnostic(
+ Diagnostics.InvalidSectionChildComponentType,
+ child.State.Source,
+ child.Inner.Name
+ );
+ }
+ }
+
+ static bool IsValidChildType(ComponentNode node)
+ => node is TextDisplayComponentNode;
+ }
+
+ public override string Render(ComponentState state, ComponentContext context)
+ {
+ var accessory = state.Children
+ .FirstOrDefault(x => x.Inner is AccessoryComponentNode);
+
+ return
+ $"""
+ new {context.KnownTypes.SectionBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}(
+ accessory: {accessory?.Render(context).WithNewlinePadding(4) ?? "null"},
+ components:
+ [
+ {
+ state
+ .RenderChildren(context, x => x.Inner is not AccessoryComponentNode)
+ .WithNewlinePadding(4)
+ }
+ ]
+ ){state.RenderInitializer(this, context).PrefixIfSome("\n")}
+ """;
+ }
+}
+
+public sealed class AccessoryComponentNode : ComponentNode
+{
+ public override string Name => "accessory";
+
+ public override bool HasChildren => true;
+
+ public override void Validate(ComponentState state, ComponentContext context)
+ {
+ if (!state.HasChildren)
+ {
+ context.AddDiagnostic(
+ Diagnostics.EmptyAccessory,
+ state.Source
+ );
+
+ base.Validate(state, context);
+ return;
+ }
+
+
+ if (state.Children.Count is not 1)
+ {
+ var start = state.OwningNode!.Children[0].State.Source.Span.Start;
+ var end = state.OwningNode!.Children[state.Children.Count - 1].State.Source.Span.End;
+
+ context.AddDiagnostic(
+ Diagnostics.TooManyAccessoryChildren,
+ TextSpan.FromBounds(start, end)
+ );
+
+ base.Validate(state, context);
+ return;
+ }
+
+ if (!IsAllowedChild(state.Children[0].Inner))
+ {
+ context.AddDiagnostic(
+ Diagnostics.InvalidAccessoryChild,
+ state.Children[0].State.Source,
+ state.Children[0].Inner.Name
+ );
+ }
+
+ base.Validate(state, context);
+ }
+
+ private static bool IsAllowedChild(ComponentNode node)
+ => node is ButtonComponentNode or ThumbnailComponentNode;
+
+ public override string Render(ComponentState state, ComponentContext context)
+ => state.RenderChildren(context);
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuComponentNode.cs
new file mode 100644
index 0000000000..8049974f40
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuComponentNode.cs
@@ -0,0 +1,223 @@
+using Discord.CX.Parser;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat;
+
+namespace Discord.CX.Nodes.Components.SelectMenus;
+
+public sealed class SelectMenuComponentNode : ComponentNode
+{
+ public sealed class MissingTypeState : ComponentState;
+
+ public sealed class InvalidTypeState : ComponentState
+ {
+ public string? Kind { get; init; }
+ }
+
+ public sealed class StringSelectState : ComponentState;
+
+ public abstract class SelectStateWithDefaults : ComponentState
+ {
+ public required IReadOnlyList Defaults { get; init; }
+ }
+
+ public sealed class UserSelectState : SelectStateWithDefaults;
+
+ public sealed class RoleSelectState : SelectStateWithDefaults;
+
+ // TODO: channel types?
+ public sealed class ChannelSelectState : SelectStateWithDefaults;
+
+ public sealed class MentionableSelectState : SelectStateWithDefaults;
+
+ public override string Name => "select";
+
+ public ComponentProperty CustomId { get; }
+ public ComponentProperty Placeholder { get; }
+ public ComponentProperty MinValues { get; }
+ public ComponentProperty MaxValues { get; }
+ public ComponentProperty Required { get; }
+ public ComponentProperty Disabled { get; }
+
+ public override IReadOnlyList Properties { get; }
+
+ public SelectMenuComponentNode()
+ {
+ Properties =
+ [
+ ComponentProperty.Id,
+ CustomId = new(
+ "customId",
+ isOptional: false,
+ renderer: Renderers.String
+ ),
+ Placeholder = new(
+ "placeholder",
+ isOptional: true,
+ renderer: Renderers.String
+ ),
+ MinValues = new(
+ "minValues",
+ isOptional: true,
+ aliases: ["min"],
+ renderer: Renderers.Integer
+ ),
+ MaxValues = new(
+ "maxValues",
+ isOptional: true,
+ aliases: ["max"],
+ renderer: Renderers.Integer
+ ),
+ Required = new(
+ "required",
+ isOptional: true,
+ renderer: Renderers.Boolean
+ ),
+ Disabled = new(
+ "disabled",
+ isOptional: true,
+ renderer: Renderers.Boolean
+ )
+ ];
+ }
+
+ public override ComponentState? Create(ICXNode source, List children)
+ {
+ if (source is not CXElement element) return null;
+
+ var typeAttribute = element.Attributes
+ .FirstOrDefault(x => x.Identifier.Value.ToLowerInvariant() is "type");
+
+ if (typeAttribute is null) return new MissingTypeState() {Source = source};
+
+ if (typeAttribute.Value is not CXValue.StringLiteral {HasInterpolations: false} typeValue)
+ return new InvalidTypeState() {Source = source,};
+
+ var kind = typeValue.Tokens.ToString().ToLowerInvariant();
+ switch (kind)
+ {
+ case "string" or "text":
+ children.AddRange(element.Children);
+ return new StringSelectState() {Source = source};
+ case "user":
+ return new UserSelectState() {Source = source, Defaults = ExtractDefaultValues()};
+ case "role":
+ return new RoleSelectState() {Source = source, Defaults = ExtractDefaultValues()};
+ case "channel":
+ return new ChannelSelectState() {Source = source, Defaults = ExtractDefaultValues()};
+ case "mention" or "mentionable":
+ return new MentionableSelectState() {Source = source, Defaults = ExtractDefaultValues()};
+ default: return new InvalidTypeState() {Source = source, Kind = kind};
+ }
+
+ IReadOnlyList ExtractDefaultValues()
+ {
+ var result = new List();
+
+ foreach (var child in element.Children)
+ {
+ if (child is not CXElement element)
+ {
+ // TODO: diagnostics
+ continue;
+ }
+
+ if (!Enum.TryParse(element.Identifier, true, out var kind))
+ {
+ // TODO: diagnostics
+ continue;
+ }
+
+ if (element.Children.Count is not 1 || element.Children[0] is not CXValue value)
+ {
+ // TODO: diagnostics
+ continue;
+ }
+
+ result.Add(new(kind, value));
+ }
+
+ return result;
+ }
+ }
+
+ public override void Validate(ComponentState state, ComponentContext context)
+ {
+ switch (state)
+ {
+ case MissingTypeState:
+ context.AddDiagnostic(
+ Diagnostics.MissingSelectMenuType,
+ state.Source
+ );
+ return;
+ case InvalidTypeState {Kind: var kind}:
+ context.AddDiagnostic(
+ kind is not null ? Diagnostics.SpecifiedInvalidSelectMenuType : Diagnostics.InvalidSelectMenuType,
+ state.Source,
+ kind is not null ? [kind] : null
+ );
+ return;
+ }
+ }
+
+ private static string RenderDefaultValue(ComponentContext context, SelectMenuDefautValue defaultValue)
+ => $"""
+ new {context.KnownTypes.SelectMenuDefaultValueType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}(
+ id: {Renderers.Snowflake(context, defaultValue.Value)},
+ type: {context.KnownTypes.SelectDefaultValueTypeEnumType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.{defaultValue.Kind}
+ )
+ """;
+
+ public override string Render(ComponentState state, ComponentContext context)
+ => $"""
+ new {context.KnownTypes.SelectMenuBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({
+ string
+ .Join(
+ ",\n",
+ ((IEnumerable)
+ [
+ (
+ state switch
+ {
+ UserSelectState => "UserSelect",
+ RoleSelectState => "RoleSelect",
+ MentionableSelectState => "MentionableSelect",
+ ChannelSelectState => "ChannelSelect",
+ _ => string.Empty
+ }
+ ).Map(x => $"type: {context.KnownTypes.ComponentTypeEnumType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.{x}"),
+ state.RenderProperties(this, context),
+ state.RenderChildren(context, x => x.Inner is StringSelectOptionComponentNode)
+ .Map(x =>
+ $"""
+ options:
+ [
+ {x.WithNewlinePadding(4)}
+ ]
+ """
+ ),
+ state is SelectStateWithDefaults {Defaults: var defaults}
+ ? string
+ .Join(
+ ",\n",
+ defaults.Select(x => RenderDefaultValue(context, x))
+ )
+ .Map(x =>
+ $"""
+ defaultValues:
+ [
+ {x.WithNewlinePadding(4)}
+ ]
+ """
+ )
+ : string.Empty
+ ]).Where(x => !string.IsNullOrEmpty(x))
+ )
+ .PrefixIfSome(4)
+ .WithNewlinePadding(4)
+ .WrapIfSome("\n")
+ })
+ """;
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefaultValueKind.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefaultValueKind.cs
new file mode 100644
index 0000000000..e87d675532
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefaultValueKind.cs
@@ -0,0 +1,8 @@
+namespace Discord.CX.Nodes.Components.SelectMenus;
+
+public enum SelectMenuDefaultValueKind
+{
+ User,
+ Role,
+ Channel
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefautValue.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefautValue.cs
new file mode 100644
index 0000000000..493658e4e8
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefautValue.cs
@@ -0,0 +1,8 @@
+using Discord.CX.Parser;
+
+namespace Discord.CX.Nodes.Components.SelectMenus;
+
+public readonly record struct SelectMenuDefautValue(
+ SelectMenuDefaultValueKind Kind,
+ CXValue Value
+);
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/StringSelectOptionComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/StringSelectOptionComponentNode.cs
new file mode 100644
index 0000000000..1bbebc9c04
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/StringSelectOptionComponentNode.cs
@@ -0,0 +1,74 @@
+using Discord.CX.Parser;
+using Microsoft.CodeAnalysis;
+using System.Collections.Generic;
+
+namespace Discord.CX.Nodes.Components.SelectMenus;
+
+public sealed class StringSelectOptionComponentNode : ComponentNode
+{
+ public override string Name => "option";
+
+ public ComponentProperty Label { get; }
+ public ComponentProperty Value { get; }
+ public ComponentProperty Description { get; }
+ public ComponentProperty Emoji { get; }
+ public ComponentProperty Default { get; }
+
+ public override IReadOnlyList Properties { get; }
+
+ public StringSelectOptionComponentNode()
+ {
+ Properties =
+ [
+ Label = new(
+ "label",
+ renderer: Renderers.String
+ ),
+ Value = new(
+ "value",
+ renderer: Renderers.String
+ ),
+ Description = new(
+ "description",
+ isOptional: true,
+ renderer: Renderers.String
+ ),
+ Emoji = new(
+ "emoji",
+ isOptional: false,
+ renderer: Renderers.Emoji
+ ),
+ Default = new(
+ "default",
+ isOptional: true,
+ renderer: Renderers.Boolean,
+ dotnetParameterName: "isDefault"
+ )
+ ];
+ }
+
+ public override ComponentState? Create(ICXNode source, List children)
+ {
+ var state = base.Create(source, children);
+
+ if (
+ source is CXElement {Children.Count: 1} element &&
+ element.Children[0] is CXValue value
+ )
+ {
+ state!.SubstitutePropertyValue(Value, value);
+ }
+
+ return state;
+ }
+
+ public override string Render(ComponentState state, ComponentContext context)
+ => $"""
+ new {context.KnownTypes.SelectMenuOptionBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({
+ state.RenderProperties(this, context)
+ .WithNewlinePadding(4)
+ .PrefixIfSome(4)
+ .WrapIfSome("\n")
+ })
+ """;
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs
new file mode 100644
index 0000000000..97603112a1
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs
@@ -0,0 +1,45 @@
+using System.Collections.Generic;
+using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat;
+
+namespace Discord.CX.Nodes.Components;
+
+public sealed class SeparatorComponentNode : ComponentNode
+{
+ public const string SEPARATOR_SPACING_QUALIFIED_NAME = "Discord.SeparatorSpacingSize";
+
+ public override string Name => "separator";
+
+ public ComponentProperty Id { get; }
+ public ComponentProperty Divider { get; }
+ public ComponentProperty Spacing { get; }
+
+ public override IReadOnlyList Properties { get; }
+
+ public SeparatorComponentNode()
+ {
+ Properties =
+ [
+ Id = ComponentProperty.Id,
+ Divider = new(
+ "divider",
+ isOptional: true,
+ renderer: Renderers.Boolean
+ ),
+ Spacing = new(
+ "spacing",
+ isOptional: true,
+ renderer: Renderers.RenderEnum(SEPARATOR_SPACING_QUALIFIED_NAME)
+ )
+ ];
+ }
+
+ public override string Render(ComponentState state, ComponentContext context)
+ => $"""
+ new {context.KnownTypes.SeparatorBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({
+ state.RenderProperties(this, context)
+ .WithNewlinePadding(4)
+ .PrefixIfSome(4)
+ .WrapIfSome("\n")
+ })
+ """;
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs
new file mode 100644
index 0000000000..08c6228387
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs
@@ -0,0 +1,45 @@
+using Discord.CX.Parser;
+using System.Collections.Generic;
+using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat;
+
+namespace Discord.CX.Nodes.Components;
+
+public sealed class TextDisplayComponentNode : ComponentNode
+{
+ public override string Name => "text";
+
+ public ComponentProperty Content { get; }
+ public override IReadOnlyList Properties { get; }
+
+ public TextDisplayComponentNode()
+ {
+ Properties =
+ [
+ ComponentProperty.Id,
+ Content = new(
+ "content",
+ renderer: Renderers.String
+ )
+ ];
+ }
+
+ public override ComponentState? Create(ICXNode source, List children)
+ {
+ var state = base.Create(source, children)!;
+
+ if (source is CXElement {Children.Count: 1} element && element.Children[0] is CXValue value)
+ state.SubstitutePropertyValue(Content, value);
+
+ return state;
+ }
+
+ public override string Render(ComponentState state, ComponentContext context)
+ => $"""
+ new {context.KnownTypes.TextDisplayBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({
+ state.RenderProperties(this, context)
+ .WithNewlinePadding(4)
+ .PrefixIfSome(4)
+ .WrapIfSome("\n")
+ })
+ """;
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs
new file mode 100644
index 0000000000..ebc7935804
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs
@@ -0,0 +1,76 @@
+using System.Collections.Generic;
+using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat;
+
+namespace Discord.CX.Nodes.Components;
+
+public sealed class TextInputComponentNode : ComponentNode
+{
+ public const string LIBRARY_TEXT_INPUT_STYLE_ENUM = "Discord.TextInputStyle";
+
+ public override string Name => "input";
+
+ public ComponentProperty CustomId { get; }
+ public ComponentProperty Style { get; }
+ public ComponentProperty MinLength { get; }
+ public ComponentProperty MaxLength { get; }
+ public ComponentProperty Required { get; }
+ public ComponentProperty Value { get; }
+ public ComponentProperty Placeholder { get; }
+
+ public override IReadOnlyList Properties { get; }
+
+ public TextInputComponentNode()
+ {
+ Properties =
+ [
+ ComponentProperty.Id,
+ CustomId = new(
+ "customId",
+ isOptional: false,
+ renderer: Renderers.String
+ ),
+ Style = new(
+ "style",
+ isOptional: false,
+ renderer: Renderers.RenderEnum(LIBRARY_TEXT_INPUT_STYLE_ENUM)
+ ),
+ MinLength = new(
+ "minLength",
+ aliases: ["min"],
+ isOptional: true,
+ renderer: Renderers.Integer
+ ),
+ MaxLength = new(
+ "maxLength",
+ aliases: ["max"],
+ isOptional: true,
+ renderer: Renderers.Integer
+ ),
+ Required = new(
+ "required",
+ isOptional: true,
+ renderer: Renderers.Boolean
+ ),
+ Value = new(
+ "value",
+ isOptional: true,
+ renderer: Renderers.String
+ ),
+ Placeholder = new(
+ "placeholder",
+ isOptional: true,
+ renderer: Renderers.String
+ )
+ ];
+ }
+
+ public override string Render(ComponentState state, ComponentContext context)
+ => $"""
+ new {context.KnownTypes.TextInputBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({
+ state.RenderProperties(this, context)
+ .PrefixIfSome(4)
+ .WithNewlinePadding(4)
+ .WrapIfSome("\n")
+ })
+ """;
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs
new file mode 100644
index 0000000000..9ebd2c838d
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs
@@ -0,0 +1,49 @@
+using System.Collections.Generic;
+using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat;
+
+namespace Discord.CX.Nodes.Components;
+
+public sealed class ThumbnailComponentNode : ComponentNode
+{
+ public override string Name => "thumbnail";
+
+ public ComponentProperty Id { get; }
+ public ComponentProperty Media { get; }
+ public ComponentProperty Description { get; }
+ public ComponentProperty Spoiler { get; }
+
+ public override IReadOnlyList Properties { get; }
+
+ public ThumbnailComponentNode()
+ {
+ Properties =
+ [
+ Id = ComponentProperty.Id,
+ Media = new(
+ "media",
+ aliases: ["href", "url"],
+ renderer: Renderers.UnfurledMediaItem
+ ),
+ Description = new(
+ "description",
+ isOptional: true,
+ renderer: Renderers.String
+ ),
+ Spoiler = new(
+ "spoiler",
+ isOptional: true,
+ renderer: Renderers.Boolean,
+ dotnetParameterName: "isSpoiler"
+ )
+ ];
+ }
+
+ public override string Render(ComponentState state, ComponentContext context)
+ => $"""
+ new {context.KnownTypes.ThumbnailBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({
+ state.RenderProperties(this, context)
+ .PrefixIfSome(4)
+ .WrapIfSome("\n")
+ })
+ """;
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs
new file mode 100644
index 0000000000..ec28fbff18
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs
@@ -0,0 +1,605 @@
+using Discord.CX.Parser;
+using Microsoft.CodeAnalysis;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text;
+
+namespace Discord.CX.Nodes;
+
+public static class Renderers
+{
+ public static PropertyRenderer CreateDefault(ComponentProperty property)
+ {
+ return (context, value) =>
+ {
+ return string.Empty;
+ };
+ }
+
+ private static bool IsLoneInterpolatedLiteral(
+ ComponentContext context,
+ CXValue.StringLiteral literal,
+ out DesignerInterpolationInfo info)
+ {
+ if (
+ literal is {HasInterpolations: true, Tokens.Count: 1} &&
+ literal.Document.TryGetInterpolationIndex(literal.Tokens[0], out var index)
+ )
+ {
+ info = context.GetInterpolationInfo(index);
+ return true;
+ }
+
+ info = null!;
+ return false;
+ }
+
+ public static string UnfurledMediaItem(ComponentContext context, ComponentPropertyValue propertyValue)
+ => $"new {context.KnownTypes.UnfurledMediaItemPropertiesType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({String(context, propertyValue)})";
+
+ public static string Integer(ComponentContext context, ComponentPropertyValue propertyValue)
+ {
+ switch (propertyValue.Value)
+ {
+ case CXValue.Scalar scalar:
+ return FromText(scalar.Value);
+
+ case CXValue.Interpolation interpolation:
+ return FromInterpolation(interpolation, context.GetInterpolationInfo(interpolation));
+
+ case CXValue.StringLiteral literal:
+ if (!literal.HasInterpolations)
+ return FromText(literal.Tokens.ToString().Trim());
+
+ if (IsLoneInterpolatedLiteral(context, literal, out var info))
+ return FromInterpolation(literal, info);
+
+ return $"int.Parse({RenderStringLiteral(literal)})";
+ default: return "default";
+ }
+
+ string FromInterpolation(ICXNode owner, DesignerInterpolationInfo info)
+ {
+ if (info.Constant.Value is int || int.TryParse(info.Constant.Value?.ToString(), out _))
+ return info.Constant.Value!.ToString();
+
+ if (
+ context.Compilation.HasImplicitConversion(
+ info.Symbol,
+ context.Compilation.GetSpecialType(SpecialType.System_Int32)
+ )
+ )
+ {
+ return context.GetDesignerValue(info, "int");
+ }
+
+ return $"int.Parse({context.GetDesignerValue(info)})";
+ }
+
+ string FromText(string text)
+ {
+ if (int.TryParse(text, out _)) return text;
+
+ return $"int.Parse({ToCSharpString(text)})";
+ }
+ }
+
+ public static string Boolean(ComponentContext context, ComponentPropertyValue propertyValue)
+ {
+ switch (propertyValue.Value)
+ {
+ case CXValue.Interpolation interpolation:
+ return FromInterpolation(interpolation, context.GetInterpolationInfo(interpolation));
+
+ case CXValue.Scalar scalar:
+ return FromText(scalar, scalar.Value.Trim().ToLowerInvariant());
+
+ case CXValue.StringLiteral stringLiteral:
+ if (!stringLiteral.HasInterpolations)
+ return FromText(stringLiteral, stringLiteral.Tokens.ToString().Trim().ToLowerInvariant());
+
+ if (IsLoneInterpolatedLiteral(context, stringLiteral, out var info))
+ return FromInterpolation(stringLiteral, info);
+
+
+ return $"bool.Parse({context.GetDesignerValue(info)})";
+ default: return "default";
+ }
+
+ string FromInterpolation(ICXNode node, DesignerInterpolationInfo info)
+ {
+ if (
+ context.Compilation.HasImplicitConversion(
+ info.Symbol,
+ context.Compilation.GetSpecialType(SpecialType.System_Boolean)
+ )
+ )
+ {
+ return context.GetDesignerValue(info, "bool");
+ }
+
+ if (info.Constant.Value is bool b) return b ? "true" : "false";
+
+ if (info.Constant.Value?.ToString().Trim().ToLowerInvariant() is { } str and ("true" or "false"))
+ return str;
+
+ return $"bool.Parse({context.GetDesignerValue(info)})";
+ }
+
+ string FromText(ICXNode owner, string value)
+ {
+ if (value is not "true" or "false")
+ {
+ context.AddDiagnostic(
+ Diagnostics.TypeMismatch,
+ owner,
+ "string",
+ "bool"
+ );
+ }
+
+ return value;
+ }
+ }
+
+ private static readonly Dictionary _colorPresets = [];
+
+ private static bool TryGetColorPreset(
+ ComponentContext context,
+ string value,
+ out string fieldName)
+ {
+ var colorSymbol = context.KnownTypes.ColorType;
+
+ if (colorSymbol is null)
+ {
+ fieldName = null!;
+ return false;
+ }
+
+ if (_colorPresets.Count is 0)
+ {
+ foreach (
+ var field
+ in colorSymbol.GetMembers()
+ .OfType()
+ .Where(x =>
+ x.Type.Equals(colorSymbol, SymbolEqualityComparer.Default) &&
+ x.IsStatic
+ )
+ )
+ {
+ _colorPresets[field.Name.ToLowerInvariant()] = field.Name;
+ }
+ }
+
+ return _colorPresets.TryGetValue(value.ToLowerInvariant(), out fieldName);
+ }
+
+ public static string Color(ComponentContext context, ComponentPropertyValue propertyValue)
+ {
+ var colorSymbol = context.KnownTypes.ColorType;
+ var qualifiedColor = colorSymbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+
+ switch (propertyValue.Value)
+ {
+ case CXValue.Interpolation interpolation:
+ var info = context.GetInterpolationInfo(interpolation);
+
+ if (
+ info.Symbol is not null &&
+ context.Compilation.HasImplicitConversion(
+ info.Symbol,
+ colorSymbol
+ )
+ )
+ {
+ return context.GetDesignerValue(
+ interpolation,
+ qualifiedColor
+ );
+ }
+
+ if (
+ context.Compilation.HasImplicitConversion(
+ info.Symbol,
+ context.Compilation.GetSpecialType(SpecialType.System_UInt32)
+ )
+ )
+ {
+ return $"new {qualifiedColor}({context.GetDesignerValue(interpolation, "uint")})";
+ }
+
+
+ if (info.Constant.Value is string str)
+ {
+ if (TryGetColorPreset(context, str, out var preset))
+ return $"{qualifiedColor}.{preset}";
+
+ if (TryParseHexColor(str, out var hexColor))
+ return $"new {qualifiedColor}({hexColor})";
+ }
+ else if (info.Constant.HasValue && uint.TryParse(info.Constant.Value?.ToString(), out var hexColor))
+ {
+ return $"new {qualifiedColor}({hexColor})";
+ }
+
+ return $"{qualifiedColor}.Parse({context.GetDesignerValue(interpolation)})";
+ case CXValue.Scalar scalar:
+ return UseLibraryParser(scalar.Value);
+
+ case CXValue.StringLiteral stringLiteral:
+ return UseLibraryParser(RenderStringLiteral(stringLiteral));
+ default: return "default";
+ }
+
+ string UseLibraryParser(string source)
+ => $"{qualifiedColor}.Parse({source})";
+
+ static bool TryParseHexColor(string hexColor, out uint color)
+ {
+ if (string.IsNullOrWhiteSpace(hexColor))
+ {
+ color = 0;
+ return false;
+ }
+
+ if (hexColor[0] is '#')
+ hexColor = hexColor.Substring(1);
+ else if (hexColor.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
+ hexColor = hexColor.Substring(2);
+
+ return uint.TryParse(hexColor, NumberStyles.HexNumber, null, out color);
+ }
+ }
+
+ public static string Snowflake(ComponentContext context, ComponentPropertyValue propertyValue)
+ => Snowflake(context, propertyValue.Value);
+
+ public static string Snowflake(ComponentContext context, CXValue? value)
+ {
+ switch (value)
+ {
+ case CXValue.Interpolation interpolation:
+ var targetType = context.Compilation.GetSpecialType(SpecialType.System_UInt64);
+
+ var interpolationInfo = context.GetInterpolationInfo(interpolation);
+
+ if (
+ interpolationInfo.Symbol is not null &&
+ context.Compilation.HasImplicitConversion(interpolationInfo.Symbol, targetType)
+ )
+ {
+ return $"designer.GetValue({interpolation.InterpolationIndex})";
+ }
+
+ return UseParseMethod($"designer.GetValueAsString({interpolation.InterpolationIndex})");
+
+ case CXValue.Scalar scalar:
+ return FromText(scalar.Value.Trim());
+
+ case CXValue.StringLiteral stringLiteral:
+ if (!stringLiteral.HasInterpolations)
+ return FromText(stringLiteral.Tokens.ToString().Trim());
+
+ return UseParseMethod(RenderStringLiteral(stringLiteral));
+
+ default: return "default";
+ }
+
+ string FromText(string text)
+ {
+ if (ulong.TryParse(text, out _)) return text;
+
+ return UseParseMethod(ToCSharpString(text));
+ }
+
+ static string UseParseMethod(string input)
+ => $"ulong.Parse({input})";
+ }
+
+ public static string String(ComponentContext context, ComponentPropertyValue propertyValue)
+ {
+ switch (propertyValue.Value)
+ {
+ default: return "string.Empty";
+
+ case CXValue.Interpolation interpolation:
+ if (context.GetInterpolationInfo(interpolation).Constant.Value is string constant)
+ return ToCSharpString(constant);
+
+ return context.GetDesignerValue(interpolation);
+ case CXValue.StringLiteral literal: return RenderStringLiteral(literal);
+ case CXValue.Scalar scalar:
+ return ToCSharpString(scalar.Value.Trim());
+ }
+ }
+
+ private static string RenderStringLiteral(CXValue.StringLiteral literal)
+ {
+ var sb = new StringBuilder();
+
+ var parts = literal.Tokens
+ .Where(x => x.Kind is CXTokenKind.Text)
+ .Select(x => x.Value)
+ .ToArray();
+
+ if (parts.Length is 0) return string.Empty;
+
+ parts[0] = parts[0].TrimStart();
+
+ parts[parts.Length - 1] = parts[parts.Length - 1].TrimEnd();
+
+ var quoteCount = parts.Select(x => x.Count(x => x is '"')).Max() + 1;
+
+ var dollars = new string(
+ '$',
+ parts.Select(GetInterpolationDollarRequirement).Max() +
+ (
+ literal.Tokens.Any(x => x.Kind is CXTokenKind.Interpolation)
+ ? 1
+ : 0
+ )
+ );
+
+ var startInterpolation = dollars.Length > 0
+ ? new string('{', dollars.Length)
+ : string.Empty;
+
+ var endInterpolation = dollars.Length > 0
+ ? new string('}', dollars.Length)
+ : string.Empty;
+
+ var isMultiline = parts.Any(x => x.Contains('\n'));
+
+ if (isMultiline)
+ {
+ sb.AppendLine();
+ quoteCount = Math.Max(quoteCount, 3);
+ }
+
+ var quotes = new string('"', quoteCount);
+
+ sb.Append(dollars).Append(quotes);
+
+ if (isMultiline) sb.AppendLine();
+
+ foreach (var token in literal.Tokens)
+ {
+ switch (token.Kind)
+ {
+ case CXTokenKind.Text:
+ sb.Append(EscapeBackslashes(token.Value));
+ break;
+ case CXTokenKind.Interpolation:
+ var index = Array.IndexOf(literal.Document.InterpolationTokens, token);
+
+ // TODO: handle better
+ if (index is -1) throw new InvalidOperationException();
+
+ sb.Append(startInterpolation).Append($"designer.GetValueAsString({index})")
+ .Append(endInterpolation);
+ break;
+
+ default: continue;
+ }
+ }
+
+ if (isMultiline) sb.AppendLine();
+ sb.Append(quotes);
+
+ return sb.ToString();
+
+ static int GetInterpolationDollarRequirement(string part)
+ {
+ var result = 0;
+
+ var count = 0;
+ char? last = null;
+
+ foreach (var ch in part)
+ {
+ if (ch is '{' or '}')
+ {
+ if (last is null)
+ {
+ last = ch;
+ count = 1;
+ continue;
+ }
+
+ if (last == ch)
+ {
+ count++;
+ continue;
+ }
+ }
+
+ if (count > 0)
+ {
+ result = Math.Max(result, count);
+ last = null;
+ count = 0;
+ }
+ }
+
+ return result;
+ }
+ }
+
+ private static string ToCSharpString(string text)
+ {
+ var quoteCount = (GetSequentialQuoteCount(text) + 1) switch
+ {
+ 2 => 3,
+ var r => r
+ };
+
+ var isMultiline = text.Contains('\n');
+
+ if (isMultiline)
+ quoteCount = Math.Max(3, quoteCount);
+
+ var quotes = new string('"', quoteCount);
+
+ var sb = new StringBuilder();
+
+ sb.Append(quotes);
+
+ if (isMultiline) sb.AppendLine();
+
+ sb.Append(text);
+
+ if (isMultiline)
+ sb.AppendLine();
+
+ sb.Append(quotes);
+
+ return sb.ToString();
+ }
+
+ private static string EscapeBackslashes(string text)
+ => text.Replace("\\", @"\\");
+
+ private static int GetSequentialQuoteCount(string text)
+ {
+ var result = 0;
+ var count = 0;
+
+ foreach (var ch in text)
+ {
+ if (ch is '"')
+ {
+ count++;
+ continue;
+ }
+
+ if (count > 0)
+ {
+ result = Math.Max(result, count);
+ count = 0;
+ }
+ }
+
+ return result;
+ }
+
+ public static PropertyRenderer RenderEnum(string fullyQualifiedName)
+ {
+ ITypeSymbol? symbol = null;
+ Dictionary variants = [];
+
+ return (context, propertyValue) =>
+ {
+ if (symbol is null || variants.Count is 0)
+ {
+ symbol = context.Compilation.GetTypeByMetadataName(fullyQualifiedName);
+
+ if (symbol is null) throw new InvalidOperationException($"Unknown type '{fullyQualifiedName}'");
+
+ if (symbol.TypeKind is not TypeKind.Enum)
+ throw new InvalidOperationException($"'{symbol}' is not an enum type.");
+
+ variants = symbol
+ .GetMembers()
+ .OfType()
+ .Where(x => x.Type == symbol)
+ .ToDictionary(x => x.Name.ToLowerInvariant(), x => x.Name);
+ }
+
+ switch (propertyValue.Value)
+ {
+ case CXValue.Scalar scalar:
+ return FromText(scalar.Value.Trim());
+ case CXValue.Interpolation interpolation:
+ return FromInterpolation(interpolation, context.GetInterpolationInfo(interpolation));
+ case CXValue.StringLiteral literal:
+ if (!literal.HasInterpolations)
+ return FromText(literal.Tokens.ToString().Trim().ToLowerInvariant());
+
+ if (IsLoneInterpolatedLiteral(context, literal, out var info))
+ return FromInterpolation(literal, info);
+
+ return
+ $"Enum.Parse<{symbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}>({RenderStringLiteral(literal)})";
+ default: return "default";
+ }
+
+ string FromInterpolation(ICXNode owner, DesignerInterpolationInfo info)
+ {
+ if (
+ context.Compilation.HasImplicitConversion(
+ info.Symbol,
+ symbol
+ )
+ )
+ {
+ return context.GetDesignerValue(
+ info,
+ symbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
+ );
+ }
+
+ if (info.Constant.Value?.ToString() is {} str)
+ {
+ return FromText(str.Trim().ToLowerInvariant());
+ }
+
+ return $"Enum.Parse<{symbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}>({context.GetDesignerValue(info)})";
+ }
+
+ string FromText(string text)
+ {
+ if (variants.TryGetValue(text, out var name))
+ return $"{symbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.{name}";
+
+ return
+ $"Enum.Parse<{symbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}>({ToCSharpString(text)})";
+ }
+ };
+ }
+
+ public static string Emoji(ComponentContext context, ComponentPropertyValue propertyValue)
+ {
+ switch (propertyValue.Value)
+ {
+ case CXValue.Interpolation interpolation:
+ var interpolationInfo = context.GetInterpolationInfo(interpolation);
+
+ if (
+ interpolationInfo.Symbol is not null &&
+ context.Compilation.HasImplicitConversion(
+ interpolationInfo.Symbol,
+ context.KnownTypes.IEmoteType
+ )
+ )
+ {
+ return context.GetDesignerValue(
+ interpolation,
+ $"{context.KnownTypes.IEmoteType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}"
+ );
+ }
+
+ return UseLibraryParser(
+ context.GetDesignerValue(interpolation)
+ );
+
+ case CXValue.Scalar scalar:
+ return UseLibraryParser(ToCSharpString(scalar.Value));
+
+ case CXValue.StringLiteral stringLiteral:
+ return UseLibraryParser(RenderStringLiteral(stringLiteral));
+
+ default: return "null";
+ }
+
+ static string UseLibraryParser(string source)
+ => $"""
+ global::Discord.Emoji.TryPase({source}, out var emoji)
+ ? (global::Discord.IEmote)emoji
+ : global::Discord.Emote.Parse({source})
+ """;
+ }
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.cs
new file mode 100644
index 0000000000..0c6a5dcb50
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.cs
@@ -0,0 +1,186 @@
+using Discord.CX.Parser;
+using Microsoft.CodeAnalysis;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.Linq;
+
+namespace Discord.CX.Nodes;
+
+public static class Validators
+{
+
+ public static void Snowflake(ComponentContext context, ComponentPropertyValue propertyValue)
+ {
+ switch (propertyValue.Value)
+ {
+ case null or CXValue.Invalid: return;
+
+ case CXValue.Scalar scalar:
+ if (!ulong.TryParse(scalar.Value, out _))
+ {
+ context.AddDiagnostic(
+ Diagnostics.TypeMismatch,
+ scalar,
+ scalar.Value,
+ "Snowflake"
+ );
+ }
+
+ return;
+ case CXValue.Interpolation interpolation:
+ var symbol = context.GetInterpolationInfo(interpolation).Symbol;
+
+ if (
+ symbol?.SpecialType is not SpecialType.System_UInt64
+ )
+ {
+ context.AddDiagnostic(
+ Diagnostics.TypeMismatch,
+ interpolation,
+ symbol,
+ "Snowflake"
+ );
+ }
+
+ return;
+ }
+ }
+
+ public static void Emote(ComponentContext context, ComponentPropertyValue propertyValue)
+ {
+ switch (propertyValue.Value)
+ {
+ case null or CXValue.Invalid: return;
+
+ case CXValue.Scalar scalar:
+
+ return;
+ }
+ }
+
+ public static PropertyValidator Range(int? lower = null, int? upper = null)
+ {
+ Debug.Assert(lower.HasValue || upper.HasValue);
+
+ var bounds = (lower, upper) switch
+ {
+ (not null, null) => $"at least {lower}",
+ (null, not null) => $"at most {lower}",
+ (not null, not null) => $"between {lower} and {upper}",
+ _ => string.Empty
+ };
+
+ return (context, propertyValue) =>
+ {
+ switch (propertyValue.Value)
+ {
+ case null or CXValue.Invalid: return;
+ case CXValue.Interpolation interpolation:
+ if (context.GetInterpolationInfo(interpolation).Constant.Value is string constantValue)
+ Check(constantValue.Length);
+ break;
+
+ case CXValue.StringLiteral literal:
+ int? length = null;
+
+ foreach (var token in literal.Tokens)
+ {
+ switch (token.Kind)
+ {
+ case CXTokenKind.Text:
+ length += token.Span.Length;
+ break;
+ case CXTokenKind.Interpolation
+ when literal.Document.TryGetInterpolationIndex(token, out var index):
+ var info = context.GetInterpolationInfo(index);
+ if (info.Constant.Value is string str)
+ length += str.Length;
+ break;
+ }
+ }
+
+ if (length.HasValue) Check(length.Value);
+
+ break;
+ case CXValue.Scalar scalar:
+ Check(scalar.Value.Length);
+
+ return;
+ }
+
+ void Check(int length)
+ {
+ if (
+ length > upper || length < lower
+ )
+ {
+ context.AddDiagnostic(Diagnostics.OutOfRange, propertyValue.Value, propertyValue.Property.Name,
+ bounds);
+ }
+ }
+ };
+ }
+
+ public static PropertyValidator EnumVariant(string fullyQualifiedName)
+ {
+ ITypeSymbol? symbol = null;
+ IFieldSymbol[]? variants = null;
+
+ return (context, propertyValue) =>
+ {
+ if (symbol is null || variants is null)
+ {
+ symbol = context.Compilation.GetTypeByMetadataName(fullyQualifiedName);
+
+ if (symbol is null) throw new InvalidOperationException($"Unknown type '{fullyQualifiedName}'");
+
+ if (symbol.TypeKind is not TypeKind.Enum)
+ throw new InvalidOperationException($"'{symbol}' is not an enum type.");
+
+ variants = symbol
+ .GetMembers()
+ .OfType()
+ .ToArray();
+ }
+
+ switch (propertyValue.Value)
+ {
+ case null or CXValue.Invalid: return;
+
+ case CXValue.Scalar scalar:
+ if (variants.All(x =>
+ !string.Equals(x.Name, scalar.Value, StringComparison.InvariantCultureIgnoreCase)))
+ {
+ context.AddDiagnostic(
+ Diagnostics.InvalidEnumVariant,
+ scalar,
+ scalar.Value,
+ string.Join(", ", variants.Select(x => x.Name))
+ );
+ }
+
+ return;
+ case CXValue.Interpolation interpolation:
+ // verify the value is the correct type
+ var interpolationInfo = context.GetInterpolationInfo(interpolation);
+
+ if (
+ interpolationInfo.Symbol is not null &&
+ !symbol.Equals(interpolationInfo.Symbol, SymbolEqualityComparer.Default)
+ )
+ {
+ context.AddDiagnostic(
+ Diagnostics.TypeMismatch,
+ interpolation,
+ interpolationInfo.Symbol,
+ symbol
+ );
+ }
+
+ return;
+ }
+ };
+ }
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs b/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs
new file mode 100644
index 0000000000..9feadc8568
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs
@@ -0,0 +1,366 @@
+using Discord.CX.Nodes;
+using Discord.CX.Parser;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Operations;
+using Microsoft.CodeAnalysis.Text;
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Text;
+using System.Threading;
+
+namespace Discord.CX;
+
+public sealed record Target(
+ InterceptableLocation InterceptLocation,
+ InvocationExpressionSyntax InvocationSyntax,
+ ExpressionSyntax ArgumentExpressionSyntax,
+ IOperation Operation,
+ Compilation Compilation,
+ string? ParentKey,
+ string CXDesigner,
+ TextSpan CXDesignerSpan,
+ DesignerInterpolationInfo[] Interpolations,
+ int CXQuoteCount
+)
+{
+ public SyntaxTree SyntaxTree => InvocationSyntax.SyntaxTree;
+}
+
+public sealed record DesignerInterpolationInfo(
+ int Id,
+ TextSpan Span,
+ ITypeSymbol? Symbol,
+ Optional