From ddcff3f5049747f5d67d0be9ba9bd74d44335a85 Mon Sep 17 00:00:00 2001
From: Quin Lynch <49576606+quinchs@users.noreply.github.com>
Date: Fri, 5 Sep 2025 17:54:32 -0300
Subject: [PATCH 01/17] init
---
Discord.Net.sln | 32 +
.../Constants.cs | 43 +
.../Diagnostics.cs | 488 ++++++++
...ord.Net.ComponentDesigner.Generator.csproj | 22 +
.../InterpolationInfo.cs | 27 +
.../Nodes/ComponentNode.cs | 444 +++++++
.../Nodes/ComponentNodeContext.cs | 101 ++
.../Nodes/ComponentPropertyValue.cs | 268 ++++
.../Components/ActionRowComponentNode.cs | 203 +++
.../Components/BaseSelectComponentNode.cs | 115 ++
.../Nodes/Components/ButtonComponentNode.cs | 178 +++
.../Components/ChannelSelectComponentNode.cs | 30 +
.../Components/ContainerComponentNode.cs | 197 +++
.../Nodes/Components/CustomComponent.cs | 101 ++
.../Nodes/Components/FileComponentNode.cs | 29 +
.../Components/InterpolatedComponentNode.cs | 49 +
.../Nodes/Components/LabelComponentNode.cs | 122 ++
.../Components/MediaGalleryComponentNode.cs | 120 ++
.../MentionableSelectComponentNode.cs | 28 +
.../Components/RoleSelectComponentNode.cs | 27 +
.../Nodes/Components/SectionComponentNode.cs | 244 ++++
.../Nodes/Components/SelectDefaultValue.cs | 44 +
.../Components/SeparatorComponentNode.cs | 45 +
.../Components/StringSelectComponentNode.cs | 124 ++
.../Components/TextDisplayComponentNode.cs | 38 +
.../Components/TextInputComponentNode.cs | 23 +
.../Components/ThumbnailComponentNode.cs | 37 +
.../Components/UserSelectComponentNode.cs | 30 +
.../Nodes/IComponentProperty.cs | 17 +
.../Nodes/NodeKind.cs | 88 ++
.../Nodes/Validators/Validators.Numeric.cs | 34 +
.../Validators/Validators.StringLength.cs | 32 +
.../Parser/CXmlAttribute.cs | 14 +
.../Parser/CXmlDiagnostic.cs | 9 +
.../Parser/CXmlDoc.cs | 14 +
.../Parser/CXmlElement.cs | 21 +
.../Parser/CXmlTriviaToken.cs | 37 +
.../Parser/CXmlValue.cs | 36 +
.../Parser/ComponentParser.cs | 1105 +++++++++++++++++
.../Parser/ICXml.cs | 12 +
.../Parser/SourceLocation.cs | 10 +
.../Parser/SourceSpan.cs | 15 +
.../SourceGenerator.cs | 283 +++++
.../Utils/IsExternalInit.cs | 3 +
.../Utils/KnownTypes.cs | 504 ++++++++
.../Utils/StringUtils.cs | 13 +
.../ComponentDesigner.cs | 18 +
.../DesignerInterpolationHandler.cs | 9 +
.../Discord.Net.ComponentDesigner.csproj | 13 +
49 files changed, 5496 insertions(+)
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Constants.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Discord.Net.ComponentDesigner.Generator.csproj
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/InterpolationInfo.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNodeContext.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/BaseSelectComponentNode.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ChannelSelectComponentNode.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/CustomComponent.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterpolatedComponentNode.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MentionableSelectComponentNode.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/RoleSelectComponentNode.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentNode.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectDefaultValue.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/StringSelectComponentNode.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/UserSelectComponentNode.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/IComponentProperty.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/NodeKind.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.Numeric.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.StringLength.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlAttribute.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlDiagnostic.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlDoc.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlElement.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlTriviaToken.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlValue.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parser/ComponentParser.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parser/ICXml.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parser/SourceLocation.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parser/SourceSpan.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Utils/IsExternalInit.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Utils/KnownTypes.cs
create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Utils/StringUtils.cs
create mode 100644 src/Discord.Net.ComponentDesigner/ComponentDesigner.cs
create mode 100644 src/Discord.Net.ComponentDesigner/DesignerInterpolationHandler.cs
create mode 100644 src/Discord.Net.ComponentDesigner/Discord.Net.ComponentDesigner.csproj
diff --git a/Discord.Net.sln b/Discord.Net.sln
index 48c80d54fe..c7dd1fd89d 100644
--- a/Discord.Net.sln
+++ b/Discord.Net.sln
@@ -42,6 +42,12 @@ 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
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -244,6 +250,30 @@ 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
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -264,6 +294,8 @@ 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}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D2404771-EEC8-45F2-9D71-F3373F6C1495}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Constants.cs b/src/Discord.Net.ComponentDesigner.Generator/Constants.cs
new file mode 100644
index 0000000000..a91d17941d
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Constants.cs
@@ -0,0 +1,43 @@
+namespace Discord.ComponentDesigner.Generator;
+
+public static class Constants
+{
+ public const string COMPONENT_DESIGNER_QUALIFIED_NAME = "Discord.ComponentDesigner";
+ 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..bcd4fdc271
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs
@@ -0,0 +1,488 @@
+using Microsoft.CodeAnalysis;
+
+namespace Discord.ComponentDesigner.Generator;
+
+public static class Diagnostics
+{
+ public static readonly DiagnosticDescriptor UnknownComponentType = new(
+ "DC0001",
+ "Unknown component type",
+ "Unknown component '{0}'",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor EmptyActionRow = new(
+ "DC0002",
+ "Action row empty",
+ "An action row must contain at least one child",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor TooManyChildrenInActionRow = new(
+ "DC0003",
+ "Too many children in action row",
+ "An action row can contain up to 5 buttons OR 1 select menu",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor ActionRowCanOnlyContainMultipleButtons = new(
+ "DC0004",
+ "Invalid mix of components in action row",
+ "'{0}' is not a valid child of this action row, an action row can ONLY contain up to 5 buttons OR 1 select menu",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor ButtonLabelMaxLengthExceeded = new(
+ "DC0005",
+ "Button label too long",
+ "A buttons label may only be at most 80 characters long",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor InvalidSnowflakeIdentifier = new(
+ "DC0006",
+ "Invalid snowflake identifier",
+ "'{0}' is not a valid snowflake identifier",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor UrlAndCustomIdBothSpecified = new(
+ "DC0007",
+ "Invalid button configuration",
+ "A button may not contain a URL and a custom id",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor MissingButtonUrl = new(
+ "DC0008",
+ "Missing 'url' attribute for link-style button",
+ "A 'Link' button must contain a URL attribute",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor CustomIdTooLong = new(
+ "DC0009",
+ "Custom Id too long",
+ "A custom id may only be a maximum of 80 characters long",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor InvalidContainerChild = new(
+ "DC0010",
+ "Invalid Container Child",
+ "The component '{0}' may not be used as a child of a container, valid components are: Action Rows, Text Displays, Media Galleries, Separators, and Files",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor MissingCustomId = new(
+ "DC0011",
+ "Missing custom ID",
+ "The '{0}' component requires a custom ID",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor PlaceholderTooLong = new(
+ "DC0012",
+ "Placeholder too long",
+ "A placeholder may only be a maximum of {0} characters long",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor MinValuesTooSmall = new(
+ "DC0013",
+ "Invalid minimum value",
+ "The minimum number of items must be at least '{0}'",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+ public static readonly DiagnosticDescriptor MinValuesTooLarge = new(
+ "DC0014",
+ "Invalid minimum value",
+ "The minimum number of items must be at most '{0}'",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor MaxValuesTooLarge = new(
+ "DC0015",
+ "Invalid maximum value",
+ "The maximum number of items must be at most '{0}'",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+ public static readonly DiagnosticDescriptor MaxValuesTooSmall = new(
+ "DC0016",
+ "Invalid maximum value",
+ "The maximum number of items must be at least '{0}'",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor EmptyMediaGallery = new(
+ "DC0017",
+ "A media gallery cannot be empty",
+ $"A media gallery must contain at least one media item and at most {Constants.MAX_MEDIA_ITEMS}",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor TooManyMediaGalleryItems = new(
+ "DC0018",
+ "Too many media gallery items",
+ $"A media gallery must contain at most {Constants.MAX_MEDIA_ITEMS} media items",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor MediaGalleryItemDescriptionTooLong = new(
+ "DC0019",
+ "Media gallery item description length exceeded",
+ $"A media gallery items' description must contain at most {Constants.MAX_MEDIA_ITEM_DESCRIPTION_LENGTH} characters",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor InvalidSectionChildNode = new(
+ "DC0020",
+ "Invalid section child",
+ $"A section may only contain Text Display components",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor MissingSectionComponents = new(
+ "DC0021",
+ "Missing section child component",
+ $"A section must contain at least one child that is not an accessory",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor TooManySectionComponentChildren = new(
+ "DC0022",
+ "Too many section component children",
+ $"A section must contain at most {Constants.MAX_SECTION_CHILDREN} non-accessory components",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor MissingAccessory = new(
+ "DC0023",
+ "Missing accessory",
+ $"A section must contain an accessory",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor ExtraAccessory = new(
+ "DC0024",
+ "Extra accessory",
+ $"A section can only contain at most 1 accessory",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor StringSelectOptionLabelTooLong = new(
+ "DC0025",
+ "Label too long",
+ $"A string selection options' label must be at most {Constants.STRING_SELECT_OPTION_LABEL_MAX_LENGTH} characters",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+ public static readonly DiagnosticDescriptor StringSelectOptionValueTooLong = new(
+ "DC0026",
+ "Label too long",
+ $"A string selection options' value must be at most {Constants.STRING_SELECT_OPTION_VALUE_MAX_LENGTH} characters",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+ public static readonly DiagnosticDescriptor StringSelectOptionDescriptionTooLong = new(
+ "DC0027",
+ "Label too long",
+ $"A string selection options' description must be at most {Constants.MAX_MEDIA_ITEM_DESCRIPTION_LENGTH} characters",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor MissingTextInputLabel = new(
+ "DC0028",
+ "Missing label",
+ "Text input requires a 'label' attribute",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor TextInputLabelTooLong = new(
+ "DC0029",
+ "Label too long",
+ $"A text inputs' label must be at most {Constants.TEXT_INPUT_LABEL_MAX_LENGTH} characters",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor TextInputMinValueOutOfRange = new(
+ "DC0030",
+ "Min value out of range",
+ $"A text inputs' min value must be between {Constants.TEXT_INPUT_MIN_LENGTH_MIN_VALUE} and {Constants.TEXT_INPUT_MIN_LENGTH_MAX_VALUE}",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor TextInputMaxValueOutOfRange = new(
+ "DC0031",
+ "Max value out of range",
+ $"A text inputs' max value must be between {Constants.TEXT_INPUT_MIN_LENGTH_MIN_VALUE} and {Constants.TEXT_INPUT_MIN_LENGTH_MAX_VALUE}",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor TextInputValueTooLong = new(
+ "DC0032",
+ "Value is too long",
+ $"A text inputs' value must be at most {Constants.TEXT_INPUT_VALUE_MAX_LENGTH} characters",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor TextInputPlaceholderTooLong = new(
+ "DC0033",
+ "Placeholder is too long",
+ $"A text inputs' placeholder must be at most {Constants.TEXT_INPUT_PLACEHOLDER_MAX_LENGTH} characters",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor ThumbnailDescriptionTooLong = new(
+ "DC0034",
+ "Description is too long",
+ $"A thumbnails' description must be at most {Constants.THUMBNAIL_DESCRIPTION_MAX_LENGTH} characters",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor MissingThumbnailUrl = new(
+ "DC0035",
+ "Missing URL",
+ $"A thumbnail must contain a url",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor MissingButtonCustumIdOrUrl = new(
+ "DC0036",
+ "Missing custom id or URL",
+ $"A button must contain either a custom id or URL",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor InvalidEnumProperty = new(
+ "DC0037",
+ "Invalid attribute value",
+ "'{0}' is not reconized as a valid value of '{1}', accepted values are: {3}",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor PropertyMismatch = new(
+ "DC0038",
+ "Invalid type for attribute",
+ "'{0}' expects a value of type '{1}', but found '{2}'",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor DuplicateAttribute = new(
+ "DC0039",
+ "Duplicate attribute specification",
+ "'{0}' refers to the already provided attribute '{1}'",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor MissingRequiredProperty = new(
+ "DC0040",
+ "Missing required attribute",
+ "'{0}' requires the attribute '{1}' to be specified",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor StringTooShort = new(
+ "DC0041",
+ "String value is too short",
+ "'{0}' must be at least {1} characters long",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor StringTooLong = new(
+ "DC0042",
+ "String value is too long",
+ "'{0}' must be at most {1} characters long",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor InvalidPropertyValue = new(
+ "DC0043",
+ "Invalid attribute value",
+ "'{0}' is not reconized as a valid value of '{1}'",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor InvalidChildNodeType = new(
+ "DC0044",
+ "Invalid child",
+ "'{0}' cannot contain children of type '{1}'",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor InvalidChildComponentType = new(
+ "DC0045",
+ "Invalid child component",
+ "'{0}' cannot contain children of type '{1}'",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor TooManyChildren = new(
+ "DC0046",
+ "Too Many Children",
+ "'{0}' can only contain up to {1} children",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor TextCannotContainComponents = new(
+ "DC0047",
+ "Invalid child",
+ "Text displays cannot contain any components",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor ComponentParseError = new(
+ "DC0048",
+ "Invalid component markup",
+ "{0}",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor UnknownAttributeWarning = new(
+ "DC0049",
+ "Unknown attribute",
+ "'{0}' is not reconized as an attribute of '{1}'",
+ "Components",
+ DiagnosticSeverity.Warning,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor ButtonChildLabelError = new(
+ "DC0050",
+ "Invalid child for button",
+ "A button can only contain one text element as a child",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor ButtonDuplicateLabels = new(
+ "DC0051",
+ "Duplicate button label",
+ "A buttons' label can only be specified once!",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor PossibleInvalidChildNodeType = new(
+ "DC0052",
+ "Possible invalid child node type",
+ "'{0}' may not be a valid child of '{1}'",
+ "Components",
+ DiagnosticSeverity.Warning,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor InvalidAttributeType = new(
+ "DC0053",
+ "Invalid attribute value type",
+ "'{0}' is not assignable to '{1}'",
+ "Components",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor MissingLabelChildren = new(
+ "DC0054",
+ "Missing label text and component",
+ "Labels require some text and a component",
+ "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..5781958135
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Discord.Net.ComponentDesigner.Generator.csproj
@@ -0,0 +1,22 @@
+
+
+
+ netstandard2.0
+ false
+ enable
+ latest
+
+ true
+ true
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
diff --git a/src/Discord.Net.ComponentDesigner.Generator/InterpolationInfo.cs b/src/Discord.Net.ComponentDesigner.Generator/InterpolationInfo.cs
new file mode 100644
index 0000000000..5d3c5ee33e
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/InterpolationInfo.cs
@@ -0,0 +1,27 @@
+using Microsoft.CodeAnalysis;
+
+namespace Discord.ComponentDesigner.Generator;
+
+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/ComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs
new file mode 100644
index 0000000000..766526ebbb
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs
@@ -0,0 +1,444 @@
+using Discord.ComponentDesigner.Generator.Parser;
+using Microsoft.CodeAnalysis;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+public abstract class ComponentNode
+{
+ public abstract string FriendlyName { get; }
+
+ public abstract NodeKind Kind { get; }
+
+ public ComponentProperty? Id { get; }
+
+ public CXmlElement? Element => _cxml as CXmlElement;
+
+ public ComponentNodeContext Context { get; }
+
+ public Location Location => Context.GetLocation(_cxml);
+
+ private readonly List _properties = [];
+ private readonly HashSet _consumedProperties = [];
+
+ private ICXml _cxml;
+
+ protected ComponentNode(CXmlElement xml, ComponentNodeContext context, bool mapId = true) : this((ICXml) xml,
+ context)
+ {
+ if (mapId)
+ Id = MapProperty("id", ParseIntProperty, optional: true);
+ }
+
+ protected ComponentNode(ICXml xml, ComponentNodeContext context)
+ {
+ _cxml = xml;
+ Context = context;
+ }
+
+ public static ComponentNode? Create(ICXml? xml, ComponentNodeContext context)
+ {
+ if (xml is CXmlElement element) return Create(element, context);
+
+ if (xml is CXmlValue.Interpolation interpolated)
+ return new InterpolatedComponentNode(interpolated, context);
+
+ return null;
+ }
+
+ public static ComponentNode? Create(CXmlElement? xml, ComponentNodeContext context)
+ {
+ if (xml is null) return null;
+
+ switch (xml.Name.Value)
+ {
+ case "row" or "actionrow":
+ return new ActionRowComponentNode(xml, context);
+
+ case "button":
+ return new ButtonComponentNode(xml, context);
+
+ case "stringselect":
+ return new StringSelectComponentNode(xml, context);
+
+ case "textinput":
+ return new TextInputComponentNode(xml, context);
+
+ case "userselect":
+ return new UserSelectComponentNode(xml, context);
+
+ case "roleselect":
+ return new RoleSelectComponentNode(xml, context);
+
+ case "mentionableselect":
+ return new MentionableSelectComponentNode(xml, context);
+
+ case "channelselect":
+ return new ChannelSelectComponentNode(xml, context);
+
+ case "section":
+ return new SectionComponentNode(xml, context);
+
+ case "text" or "p":
+ return new TextDisplayComponentNode(xml, context);
+
+ case "thumbnail":
+ return new ThumbnailComponentNode(xml, context);
+
+ case "mediagallery" or "gallery":
+ return new MediaGalleryComponentNode(xml, context);
+
+ case "file":
+ return new FileComponentNode(xml, context);
+
+ case "separator" or "br":
+ return new SeparatorComponentNode(xml, context);
+
+ case "container":
+ return new ContainerComponentNode(xml, context);
+
+ case "select" or "selection":
+ var type = ((CXmlValue.Scalar?) xml.GetAttribute("type")?.Value)?.Value;
+
+ if (type is "channel") goto case "channelselect";
+ if (type is "user") goto case "userselect";
+ if (type is "role") goto case "roleselect";
+ if (type is "mentionable" or "mention") goto case "mentionableselect";
+ if (type is "string" or "str") goto case "stringselect";
+
+ goto default;
+
+ default:
+ if (TryBindCustomNode() is { } customNode) return customNode;
+
+ context.ReportDiagnostic(
+ Diagnostics.UnknownComponentType,
+ context.GetLocation(xml),
+ xml.Name.Value
+ );
+ return null;
+ }
+
+ ComponentNode? TryBindCustomNode()
+ {
+ var symbol = context
+ .LookupNode(xml.Name.Value)
+ .OfType()
+ .FirstOrDefault(IsValidUserNode);
+
+ if (symbol is null) return null;
+
+ return new CustomComponent(xml, symbol, context);
+ }
+
+ bool IsValidUserNode(ITypeSymbol symbol)
+ => symbol.TypeKind is TypeKind.Class or TypeKind.Struct &&
+ symbol.AllInterfaces.Any(x =>
+ context.KnownTypes.ICXElementType!.Equals(x, SymbolEqualityComparer.Default)
+ );
+ }
+
+ public virtual void ReportValidationErrors()
+ {
+ if (Element is not null)
+ {
+ foreach (var extraAttribute in Element.Attributes.Keys.Except(_consumedProperties))
+ {
+ Context.ReportDiagnostic(
+ Diagnostics.UnknownAttributeWarning,
+ Context.GetLocation(Element.GetAttribute(extraAttribute)!),
+ extraAttribute,
+ FriendlyName
+ );
+ }
+ }
+
+
+ foreach (var property in _properties)
+ {
+ property.Validate(Context);
+ }
+ }
+
+ public abstract string Render();
+
+ protected ComponentProperty MapProperty(
+ string name,
+ bool optional = false,
+ ParseDelegate? parser = null,
+ IReadOnlyList>? validators = null,
+ Optional defaultValue = default,
+ params IReadOnlyList aliases
+ ) => MapProperty(
+ name,
+ parser ?? ParseStringProperty,
+ optional,
+ validators,
+ defaultValue,
+ aliases
+ );
+
+ protected ComponentProperty MapProperty(
+ string name,
+ ParseDelegate parser,
+ bool optional = false,
+ IReadOnlyList>? validators = null,
+ Optional defaultValue = default,
+ params IReadOnlyList aliases
+ )
+ {
+ var property = new ComponentProperty(
+ this,
+ name,
+ GetAttribute(name, aliases),
+ aliases,
+ optional,
+ validators ?? [],
+ parser,
+ defaultValue
+ );
+
+ _properties.Add(property);
+
+ return property;
+ }
+
+ protected CXmlAttribute? GetAttribute(string name, params IEnumerable aliases)
+ {
+ if (Element is null) return null;
+
+ CXmlAttribute? attribute = null;
+
+ foreach (var term in aliases.Prepend(name))
+ {
+ if (Element.GetAttribute(term) is not { } result) continue;
+
+ if (attribute is not null)
+ {
+ Context.ReportDiagnostic(
+ Diagnostics.DuplicateAttribute,
+ Context.GetLocation(result),
+ result.Name,
+ attribute.Name
+ );
+ continue;
+ }
+
+ attribute = result;
+ }
+
+ if (attribute is not null) _consumedProperties.Add(attribute.Name.Value);
+
+ return attribute;
+ }
+
+ private ComponentPropertyValue? ValidateInterpolationType(
+ ComponentProperty property,
+ CXmlValue.Interpolation value,
+ SpecialType specialType
+ ) => ValidateInterpolationType(
+ property,
+ value,
+ (symbol) =>
+ {
+ if (symbol.SpecialType != specialType)
+ {
+ Context.ReportDiagnostic(
+ Diagnostics.PropertyMismatch,
+ Context.GetLocation(value),
+ property.Name,
+ nameof(Boolean),
+ symbol.ToDisplayString()
+ );
+ return false;
+ }
+
+ return true;
+ }
+ );
+
+ private ComponentPropertyValue? ValidateInterpolationType(
+ ComponentProperty property,
+ CXmlValue.Interpolation value,
+ Func validator
+ )
+ {
+ var interpolationInfo = Context.Interpolations[value.InterpolationIndex];
+
+ if (!validator(interpolationInfo.Type))
+ return null;
+
+ return property.CreateValue(in interpolationInfo);
+ }
+
+ protected ComponentPropertyValue? ParseIntProperty(ComponentProperty property)
+ {
+ switch (property.Value)
+ {
+ case null or CXmlValue.Invalid: return null;
+
+ case CXmlValue.Interpolation interpolation:
+ return ValidateInterpolationType(property, interpolation, SpecialType.System_Int32);
+
+ case CXmlValue.Multipart multipart:
+ throw new NotImplementedException();
+ case CXmlValue.Scalar scalar:
+ if (int.TryParse(scalar.Value, out var result))
+ return property.CreateValue(result);
+
+ Context.ReportDiagnostic(
+ Diagnostics.InvalidPropertyValue,
+ Context.GetLocation(scalar),
+ scalar.Value,
+ nameof(Int32)
+ );
+ return null;
+
+ default:
+ throw new ArgumentOutOfRangeException();
+ }
+ }
+
+ protected ComponentPropertyValue? ParseBooleanProperty(ComponentProperty property)
+ {
+ if (property is {IsSpecified: true, Value: null})
+ return property.CreateValue(true);
+
+ if (!property.IsSpecified)
+ return property.CreateValue(false);
+
+ switch (property.Value)
+ {
+ case null: return null;
+
+ case CXmlValue.Interpolation interpolation:
+ return ValidateInterpolationType(property, interpolation, SpecialType.System_Boolean);
+
+ case CXmlValue.Invalid: return null;
+
+ // multiparts are strings
+ case CXmlValue.Multipart multipart:
+ Context.ReportDiagnostic(
+ Diagnostics.PropertyMismatch,
+ Context.GetLocation(multipart),
+ property.Name,
+ nameof(Boolean),
+ typeof(string)
+ );
+ return null;
+
+ case CXmlValue.Scalar scalar:
+ var str = scalar.Value.ToLowerInvariant();
+
+ if (str is not "true" and not "false")
+ {
+ Context.ReportDiagnostic(
+ Diagnostics.PropertyMismatch,
+ Context.GetLocation(scalar),
+ property.Name,
+ nameof(Boolean),
+ typeof(string)
+ );
+ return null;
+ }
+
+ return property.CreateValue(str is "true");
+ default:
+ throw new ArgumentOutOfRangeException();
+ }
+ }
+
+ protected ComponentPropertyValue? ParseSnowflakeProperty(ComponentProperty property)
+ {
+ switch (property.Value)
+ {
+ case null: return null;
+
+ case CXmlValue.Interpolation interpolation:
+ return ValidateInterpolationType(property, interpolation, SpecialType.System_UInt64);
+ case CXmlValue.Invalid: return null;
+ case CXmlValue.Multipart multipart:
+ // TODO: we can only verify the non-interpolated parts
+ throw new NotImplementedException();
+ break;
+ case CXmlValue.Scalar scalar:
+ if (ulong.TryParse(scalar.Value, out var snowflake))
+ {
+ return property.CreateValue(snowflake);
+ }
+
+ Context.ReportDiagnostic(
+ Diagnostics.InvalidSnowflakeIdentifier,
+ Context.GetLocation(scalar),
+ scalar.Value
+ );
+
+ return null;
+ default:
+ throw new ArgumentOutOfRangeException();
+ }
+ }
+
+ protected ComponentPropertyValue? ParseEmojiProperty(ComponentProperty property)
+ {
+ // TODO
+ return ParseStringProperty(property);
+ }
+
+ protected ComponentPropertyValue? ParseStringProperty(ComponentProperty property)
+ {
+ switch (property.Value)
+ {
+ case CXmlValue.Invalid or null: return null;
+
+ case CXmlValue.Interpolation interpolation:
+ // any type automatically gets a .ToString() call, so we don't even have to check this
+ return property.CreateValue(interpolation);
+
+ case CXmlValue.Multipart multipart:
+ return property.CreateValue(multipart);
+
+ case CXmlValue.Scalar scalar:
+ return property.CreateValue(scalar.Value);
+
+ default:
+ throw new ArgumentOutOfRangeException(nameof(property.Value));
+ }
+ }
+
+ protected ComponentPropertyValue? ParseEnumProperty(ComponentProperty property) where T : struct
+ {
+ switch (property.Value)
+ {
+ case CXmlValue.Invalid or null: return null;
+
+ case CXmlValue.Interpolation interpolation:
+ // TODO: we'll have to validate against the actual api type
+ throw new NotImplementedException();
+
+ case CXmlValue.Multipart multipart:
+ // TODO: the usecase for this may not be great, but its to figure out later
+ throw new NotImplementedException();
+
+ case CXmlValue.Scalar scalar:
+ {
+ if (Enum.TryParse(scalar.Value, out var result))
+ return property.CreateValue(result);
+
+ Context.ReportDiagnostic(
+ Diagnostics.InvalidEnumProperty,
+ Context.GetLocation(scalar),
+ scalar.Value,
+ property.Name,
+ string.Join(", ", Enum.GetNames(typeof(T)))
+ );
+
+ return null;
+ }
+ default:
+ throw new ArgumentOutOfRangeException(nameof(property.Value));
+ }
+ }
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNodeContext.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNodeContext.cs
new file mode 100644
index 0000000000..99a2e3fe93
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNodeContext.cs
@@ -0,0 +1,101 @@
+using Discord.ComponentDesigner.Generator.Parser;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Text;
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+public sealed class ComponentNodeContext
+{
+ public Compilation Compilation => KnownTypes.Compilation;
+
+ public bool HasErrors => _document.HasErrors || Diagnostics.Count > 0;
+
+ public List Diagnostics { get; } = [];
+
+ public KnownTypes KnownTypes { get; }
+ public Func> LookupNode { get; }
+
+ public readonly InterpolationInfo[] Interpolations;
+
+ private readonly CXmlDoc _document;
+ private readonly Location _startLocation;
+ private readonly bool _isMultiLine;
+
+
+ public ComponentNodeContext(
+ CXmlDoc document,
+ Location startLocation,
+ bool isMultiLine,
+ InterpolationInfo[] interpolations,
+ KnownTypes knownTypes,
+ Func> lookupNode
+ )
+ {
+ _document = document;
+ _startLocation = startLocation;
+ _isMultiLine = isMultiLine;
+ Interpolations = interpolations;
+ KnownTypes = knownTypes;
+ LookupNode = lookupNode;
+ }
+
+ public void ReportDiagnostic(DiagnosticDescriptor descriptor, Location location, params object?[]? args)
+ {
+ var diagnostic = Diagnostic.Create(
+ descriptor,
+ location,
+ args
+ );
+
+ Diagnostics.Add(diagnostic);
+ }
+
+ private int GetInterpolationOffsets(int sourceOffset)
+ {
+ var result = sourceOffset;
+ for (var i = 0; i < _document.InterpolationOffsets.Count; i++)
+ {
+ if (_document.InterpolationOffsets[i] < sourceOffset)
+ result += Interpolations[i].Length;
+ }
+
+ return result;
+ }
+
+ public Location GetLocation(ICXml node) => GetLocation(node.Span);
+
+ public Location GetLocation(SourceSpan span)
+ {
+ var sourceSpan = _startLocation.GetLineSpan().Span;
+
+ var startLine = sourceSpan.Start.Line;
+ var endLine = startLine;
+
+ if (_isMultiLine)
+ {
+ startLine += span.Start.Line + 1;
+ endLine = startLine + span.LineDelta;
+ }
+
+ var startColumn = sourceSpan.Start.Character + span.Start.Column + 1;
+ var endColumn = startColumn + span.ColumnDelta;
+
+ var text = _startLocation.SourceTree!.GetText();
+
+ var startTextLine = text.Lines[startLine];
+
+ var startOffset = text.Lines[startLine].Start + startColumn;
+ var endOffset = text.Lines[endLine].Start + endColumn;
+
+ startOffset += GetInterpolationOffsets(span.Start.Offset) - span.Start.Offset;
+ endOffset += GetInterpolationOffsets(span.End.Offset + 1) - span.End.Offset - 1;
+
+ return _startLocation.SourceTree.GetLocation(new TextSpan(
+ startOffset,
+ (endOffset - startOffset)
+ ));
+ }
+}
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..bc76aceb3a
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs
@@ -0,0 +1,268 @@
+using Discord.ComponentDesigner.Generator.Parser;
+using Microsoft.CodeAnalysis;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+public abstract record ComponentPropertyValue(ComponentProperty Property)
+{
+ public sealed record Serializable(ComponentProperty Property, T Value) : ComponentPropertyValue(Property);
+
+ public sealed record Interpolated(
+ ComponentProperty Property,
+ int InterpolationId
+ ) : ComponentPropertyValue(Property);
+
+ public sealed record MultiPartInterpolation(
+ ComponentProperty Property,
+ CXmlValue.Multipart Multipart
+ ) : ComponentPropertyValue(Property);
+}
+
+public delegate ComponentPropertyValue? ParseDelegate(ComponentProperty property);
+
+public delegate void ComponentPropertyValidator(
+ ComponentNode node,
+ ComponentProperty property,
+ ComponentNodeContext context
+);
+
+public sealed record ComponentProperty(
+ ComponentNode Node,
+ string Name,
+ CXmlAttribute? Attribute,
+ IReadOnlyList Aliases,
+ bool IsOptional,
+ IReadOnlyList> Validators,
+ ParseDelegate Parser,
+ Optional DefaultValue
+) : IComponentProperty
+{
+ public bool IsSpecified => Attribute is not null;
+ public CXmlValue? Value => Attribute?.Value;
+
+ public override string ToString()
+ {
+ // TODO: render out into valid code
+
+ return Parser(this) switch
+ {
+ ComponentPropertyValue.Serializable(var _, var value) => Serialize(value),
+ ComponentPropertyValue.Interpolated(var _, var index) => $"designer.GetValue<{typeof(T)}>({index})",
+ ComponentPropertyValue.MultiPartInterpolation(var _, var multipart) => BuildMultipart(multipart),
+ _ => DefaultValue.HasValue ? Serialize(DefaultValue.Value) : "default"
+ };
+ }
+
+ public static string? BuildValue(CXmlValue? value)
+ {
+ switch (value)
+ {
+ case CXmlValue.Invalid or null: return null;
+
+ case CXmlValue.Interpolation interpolation:
+ return $"designer.GetValue<{typeof(T)}>({interpolation.InterpolationIndex})";
+
+ case CXmlValue.Multipart multipart:
+ return BuildMultipart(multipart);
+ case CXmlValue.Scalar scalar:
+ var sb = new StringBuilder();
+
+ // escape out the double quotes
+ var quoteCount = scalar.Value.Count(x => x is '"') + 1;
+
+
+ var isMultiLine = scalar.Value.Contains("\n");
+
+ if (isMultiLine)
+ {
+ sb.AppendLine();
+ quoteCount = Math.Max(3, quoteCount);
+ }
+
+ var quotes = new string('"', quoteCount);
+
+ sb.Append(quotes);
+
+ if (isMultiLine) sb.AppendLine();
+
+ sb.Append(ComponentProperty.FixValuePadding(scalar.Span.Start.Column, scalar.Value));
+
+ if (isMultiLine) sb.AppendLine();
+
+ sb.Append(quotes);
+
+ return sb.ToString();
+ default:
+ throw new ArgumentOutOfRangeException(nameof(value));
+ }
+ }
+
+ public static string BuildMultipart(CXmlValue.Multipart value)
+ => BuildMultipart(value.Values);
+
+ public static string BuildMultipart(IReadOnlyList values)
+ {
+ if (values.Count is 0) return string.Empty;
+
+ // count how many dollar signs we need
+ var bracketCount = 0;
+ var quoteCount = 0;
+ var isMultiLine = false;
+
+ foreach (var part in values)
+ {
+ if (part is not CXmlValue.Scalar scalar) continue;
+
+ isMultiLine |= scalar.Value.Contains("\n");
+
+ var localCount = Math.Max(
+ scalar.Value.Count(x => x is '{'),
+ scalar.Value.Count(x => x is '}')
+ );
+
+ bracketCount = Math.Max(bracketCount, localCount);
+ quoteCount = Math.Max(quoteCount, scalar.Value.Count(x => x is '"'));
+ }
+
+ var dollarSignCount = bracketCount + 1;
+ quoteCount++;
+
+ if (isMultiLine)
+ quoteCount = Math.Max(quoteCount, 3);
+
+ var sb = new StringBuilder();
+
+ var quotes = new string('"', quoteCount);
+ var startInterp = new string('{', dollarSignCount);
+ var endInterp = new string('}', dollarSignCount);
+
+
+ foreach (var part in values)
+ {
+ switch (part)
+ {
+ case CXmlValue.Scalar scalar:
+ sb.Append(scalar.Value);
+ break;
+ case CXmlValue.Interpolation interpolation:
+ sb.Append(startInterp)
+ .Append("designer.GetValueAsString(")
+ .Append(interpolation.InterpolationIndex)
+ .Append(')')
+ .Append(endInterp);
+ break;
+ }
+ }
+
+ var content = FixValuePadding(values[0].Span.Start.Column, sb.ToString());
+
+ sb.Clear();
+
+ if (isMultiLine) sb.AppendLine();
+
+ sb.Append(new string('$', dollarSignCount)).Append(quotes);
+
+
+ if (isMultiLine) sb.AppendLine();
+
+ sb.Append(content);
+
+ if (isMultiLine) sb.AppendLine();
+
+ return sb.Append(quotes).ToString();
+ }
+
+ public static string FixValuePadding(int startingPad, string value)
+ {
+ // find the min padding between each line
+ var split = value.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries);
+ var paddings = new int[split.Length];
+
+ var min = startingPad;
+
+ for (var i = 1; i < split.Length; i++)
+ {
+ var line = split[i];
+
+ if (string.IsNullOrWhiteSpace(line)) continue;
+
+ var lineIndex = 0;
+ for (; lineIndex < line.Length && char.IsWhiteSpace(line[lineIndex]); lineIndex++) ;
+
+ min = Math.Min(min, lineIndex);
+ }
+
+ // // remove useless previous lines
+ // for (var i = split.Length - 1; i >= 0; i--)
+ // {
+ // if (string.IsNullOrWhiteSpace(split[i])) split[i] = string.Empty;
+ //
+ // break;
+ // }
+
+ var result = string.Join(
+ "\n",
+ [
+ split[0],
+ ..split.Skip(1)
+ .Select(x => x.Length > min ? x.Substring(min) : x)
+ ]
+ );
+
+ return result.Trim(['\n', '\r', ' ', '\t']);
+ }
+
+ private string Serialize(T value)
+ {
+ return value switch
+ {
+ bool => value.ToString().ToLower(),
+ string => $"\"{value}\"",
+ _ => value?.ToString() ?? "default"
+ };
+ }
+
+ public bool TryGetScalarValue(out string result)
+ {
+ if (Value is not CXmlValue.Scalar scalar)
+ {
+ result = null!;
+ return false;
+ }
+
+ result = scalar.Value;
+ return true;
+ }
+
+ public void Validate(ComponentNodeContext context)
+ {
+ if (!IsOptional && !IsSpecified)
+ {
+ context.ReportDiagnostic(
+ Diagnostics.MissingRequiredProperty,
+ context.GetLocation(Node.Element),
+ Node.FriendlyName,
+ Name
+ );
+ }
+
+ foreach (var validator in Validators)
+ validator(Node, this, context);
+ }
+
+ public ComponentPropertyValue CreateValue(T value)
+ => new ComponentPropertyValue.Serializable(this, value);
+
+ public ComponentPropertyValue CreateValue(in InterpolationInfo info)
+ => new ComponentPropertyValue.Interpolated(this, info.Id);
+
+ public ComponentPropertyValue CreateValue(CXmlValue.Interpolation interpolation)
+ => new ComponentPropertyValue.Interpolated(this, interpolation.InterpolationIndex);
+
+ public ComponentPropertyValue CreateValue(CXmlValue.Multipart multipart)
+ => new ComponentPropertyValue.MultiPartInterpolation(this, multipart);
+}
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..f67e53ba5d
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs
@@ -0,0 +1,203 @@
+using Discord.ComponentDesigner.Generator.Parser;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+public sealed class ActionRowComponentNode : ComponentNode
+{
+ public override string FriendlyName => "Action Row";
+
+ public override NodeKind Kind => NodeKind.ActionRow;
+
+ public IReadOnlyList Components { get; }
+
+ public ActionRowComponentNode(CXmlElement xml, ComponentNodeContext context) : base(xml, context)
+ {
+ var children = new List();
+
+ foreach (var xmlChild in xml.Children)
+ {
+ ProcessChild(children, xmlChild);
+ }
+
+ Components = children;
+ }
+
+ private void ProcessChild(List children, ICXml child)
+ {
+ switch (child)
+ {
+ case CXmlElement element:
+ var component = Create(element, Context);
+
+ if (component is null) return;
+
+ if (!IsValidChild(component))
+ {
+ Context.ReportDiagnostic(
+ Diagnostics.InvalidChildComponentType,
+ Context.GetLocation(element),
+ FriendlyName,
+ component.FriendlyName
+ );
+
+ return;
+ }
+
+ children.Add(component);
+ break;
+ case CXmlValue value:
+
+ break;
+ }
+
+ void ProcessValue(CXmlValue value)
+ {
+ switch (value)
+ {
+ case CXmlValue.Scalar scalar:
+ if (string.IsNullOrWhiteSpace(scalar.Value)) break;
+
+ Context.ReportDiagnostic(
+ Diagnostics.InvalidChildNodeType,
+ Context.GetLocation(scalar),
+ FriendlyName,
+ "text"
+ );
+ break;
+
+ case CXmlValue.Interpolation interpolation:
+ var interpolationInfo = Context.Interpolations[interpolation.InterpolationIndex];
+
+ switch (IsValidChildBuilderType(interpolationInfo.Type))
+ {
+ case true:
+ children.Add(
+ new InterpolatedComponentNode(
+ interpolation,
+ Context,
+ Context.KnownTypes.IMessageComponentBuilderType!.ToString()
+ )
+ );
+ break;
+ case false:
+ Context.ReportDiagnostic(
+ Diagnostics.InvalidChildNodeType,
+ Context.GetLocation(interpolation),
+ FriendlyName,
+ interpolationInfo.Type.ToDisplayString()
+ );
+ return;
+ case null:
+ Context.ReportDiagnostic(
+ Diagnostics.PossibleInvalidChildNodeType,
+ Context.GetLocation(interpolation),
+ interpolationInfo.Type.ToDisplayString(),
+ FriendlyName
+ );
+ return;
+ }
+ break;
+ case CXmlValue.Multipart multipart:
+ foreach (var child in multipart.Values)
+ {
+ ProcessValue(child);
+ }
+
+ break;
+ }
+ }
+ }
+
+ private bool? IsValidChildBuilderType(ITypeSymbol symbol)
+ {
+ if (
+ !Context.KnownTypes.Compilation.HasImplicitConversion(
+ symbol,
+ Context.KnownTypes.IMessageComponentBuilderType
+ )
+ )
+ {
+ return false;
+ }
+
+ if (
+ Context
+ .KnownTypes
+ .IMessageComponentBuilderType?
+ .Equals(
+ symbol,
+ SymbolEqualityComparer.Default
+ ) ?? false
+ )
+ {
+ return null;
+ }
+
+ return
+ Context.Compilation.HasImplicitConversion(symbol, Context.KnownTypes.ButtonBuilderType) ||
+ Context.Compilation.HasImplicitConversion(symbol, Context.KnownTypes.SelectMenuBuilderType);
+ }
+
+ private static bool IsValidChild(ComponentNode node)
+ => node is ButtonComponentNode
+ or ChannelSelectComponentNode
+ or UserSelectComponentNode
+ or StringSelectComponentNode
+ or RoleSelectComponentNode
+ or MentionableSelectComponentNode;
+
+ public override void ReportValidationErrors()
+ {
+ base.ReportValidationErrors();
+
+ foreach (var component in Components) component.ReportValidationErrors();
+
+ if (Components.Count is 0)
+ {
+ Context.ReportDiagnostic(
+ Diagnostics.EmptyActionRow,
+ Location
+ );
+
+ return;
+ }
+
+ if (Components.Count > Constants.MAX_ACTION_ROW_COMPONENTS)
+ {
+ Context.ReportDiagnostic(
+ Diagnostics.TooManyChildrenInActionRow,
+ Location
+ );
+ }
+
+ if (Components.Count > 1)
+ {
+ // only multiple buttons are allow
+ foreach (var child in Components)
+ {
+ if (child is ButtonComponentNode) continue;
+
+ Context.ReportDiagnostic(
+ Diagnostics.ActionRowCanOnlyContainMultipleButtons,
+ child.Location,
+ child.FriendlyName
+ );
+ }
+ }
+ }
+
+ public override string Render()
+ => $"""
+ new {Context.KnownTypes.ActionRowBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}(
+ {
+ string.Join(
+ ",\n".Postfix(4),
+ Components.Select(x => x.Render().WithNewlinePadding(4))
+ )
+ }
+ )
+ """;
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/BaseSelectComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/BaseSelectComponentNode.cs
new file mode 100644
index 0000000000..f48291da79
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/BaseSelectComponentNode.cs
@@ -0,0 +1,115 @@
+using Discord.ComponentDesigner.Generator.Parser;
+using System.Collections.Generic;
+
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+public abstract class BaseSelectComponentNode : ComponentNode
+{
+ public ComponentProperty CustomId { get; }
+ public ComponentProperty Placeholder { get; }
+ public ComponentProperty MinValues { get; }
+ public ComponentProperty MaxValues { get; }
+ public ComponentProperty IsDisabled { get; }
+
+ public IReadOnlyList DefaultValues { get; }
+
+ protected BaseSelectComponentNode(
+ CXmlElement xml,
+ ComponentNodeContext context,
+ bool hasDefaultValues = true
+ ) : base(xml, context)
+ {
+ CustomId = MapProperty(
+ "customId",
+ validators: [Validators.LengthBounds(upper: Constants.CUSTOM_ID_MAX_LENGTH)]
+ );
+
+ Placeholder = MapProperty(
+ "placeholder",
+ optional: true,
+ validators: [Validators.LengthBounds(upper: Constants.PLACEHOLDER_MAX_LENGTH)]
+ );
+
+ MinValues = MapProperty(
+ "minValues",
+ optional: true,
+ parser: ParseIntProperty,
+ validators:
+ [
+ Validators.Bounds(
+ Constants.SELECT_MIN_VALUES,
+ Constants.SELECT_MAX_VALUES
+ )
+ ],
+ aliases: ["min"]
+ );
+
+ MaxValues = MapProperty(
+ "maxValues",
+ optional: true,
+ parser: ParseIntProperty,
+ validators:
+ [
+ Validators.Bounds(
+ Constants.SELECT_MIN_VALUES + 1,
+ Constants.SELECT_MAX_VALUES
+ )
+ ],
+ aliases: ["max"]
+ );
+
+ IsDisabled = MapProperty("disabled", optional: true, parser: ParseBooleanProperty);
+
+ if (!hasDefaultValues)
+ {
+ DefaultValues = [];
+ return;
+ }
+
+ var defaultValues = new List();
+
+ foreach (var child in xml.Children)
+ {
+ if (child is not CXmlElement element)
+ {
+ context.ReportDiagnostic(
+ Diagnostics.InvalidChildNodeType,
+ context.GetLocation(child),
+ FriendlyName,
+ "text"
+ );
+
+ continue;
+ }
+
+ if (element.Name.Value is not "default")
+ {
+ context.ReportDiagnostic(
+ Diagnostics.InvalidChildComponentType,
+ context.GetLocation(child),
+ FriendlyName,
+ element.Name.Value
+ );
+
+ continue;
+ }
+
+ defaultValues.Add(new SelectDefaultValue(element, context));
+ }
+
+ DefaultValues = defaultValues;
+ }
+
+ public override void ReportValidationErrors()
+ {
+ if (DefaultValues.Count > Constants.SELECT_MAX_VALUES)
+ {
+ Context.ReportDiagnostic(
+ Diagnostics.TooManyChildren,
+ Location,
+ FriendlyName,
+ Constants.SELECT_MAX_VALUES
+ );
+ }
+ }
+}
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..85952262d8
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs
@@ -0,0 +1,178 @@
+using Discord.ComponentDesigner.Generator.Parser;
+using System;
+using System.Xml;
+using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat;
+
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+public sealed class ButtonComponentNode : ComponentNode
+{
+ public override string FriendlyName => "Button";
+ public override NodeKind Kind => NodeKind.Button;
+ 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 ComponentProperty IsDisabled { get; }
+
+ private readonly CXmlValue? _buttonLabelNode;
+
+ public ButtonComponentNode(CXmlElement xml, ComponentNodeContext context) : base(xml, context)
+ {
+ Style = MapProperty(
+ "style",
+ ParseEnumProperty,
+ defaultValue: ButtonStyle.Primary,
+ optional: true
+ );
+
+ Label = MapProperty(
+ "label",
+ optional: true,
+ validators: [Validators.LengthBounds(upper: Constants.BUTTON_MAX_LABEL_LENGTH)]
+ );
+
+ Emoji = MapProperty("emoji", optional: true, parser: ParseEmojiProperty);
+
+ CustomId = MapProperty(
+ "customId",
+ optional: true,
+ validators: [Validators.LengthBounds(upper: Constants.CUSTOM_ID_MAX_LENGTH)]
+ );
+
+ SkuId = MapProperty("skuId", ParseSnowflakeProperty, optional: true, aliases: "sku");
+
+ Url = MapProperty(
+ "url",
+ optional: true,
+ validators: [Validators.LengthBounds(upper: Constants.BUTTON_URL_MAX_LENGTH)]
+ );
+
+ IsDisabled = MapProperty("disabled", ParseBooleanProperty, optional: true, defaultValue: false);
+
+ if (xml.Children.Count > 1)
+ {
+ context.ReportDiagnostic(
+ Diagnostics.ButtonChildLabelError,
+ Location
+ );
+ }
+
+ if (xml.Children.Count is not 0)
+ {
+ var childXml = xml.Children[0];
+
+ if (childXml is not CXmlValue valueNode)
+ {
+ context.ReportDiagnostic(
+ Diagnostics.InvalidChildNodeType,
+ Location,
+ FriendlyName,
+ "Element"
+ );
+ return;
+ }
+
+ _buttonLabelNode = valueNode;
+ }
+ }
+
+ public override void ReportValidationErrors()
+ {
+ base.ReportValidationErrors();
+
+ if (Label.IsSpecified && _buttonLabelNode is not null)
+ {
+ // report on both
+ Context.ReportDiagnostic(
+ Diagnostics.ButtonDuplicateLabels,
+ Context.GetLocation(Label.Attribute!.Span)
+ );
+
+ Context.ReportDiagnostic(
+ Diagnostics.ButtonDuplicateLabels,
+ Context.GetLocation(_buttonLabelNode)
+ );
+ }
+
+ if (CustomId.IsSpecified && Url.IsSpecified)
+ {
+ // report on both URL and custom id
+ Context.ReportDiagnostic(
+ Diagnostics.UrlAndCustomIdBothSpecified,
+ Context.GetLocation(CustomId.Attribute!.Span)
+ );
+
+ Context.ReportDiagnostic(
+ Diagnostics.UrlAndCustomIdBothSpecified,
+ Context.GetLocation(Url.Attribute!.Span)
+ );
+ }
+
+ if (!CustomId.IsSpecified && !Url.IsSpecified)
+ {
+ Context.ReportDiagnostic(
+ Diagnostics.MissingButtonCustumIdOrUrl,
+ Location
+ );
+ }
+
+ if (Style.TryGetScalarValue(out var scalar) && Enum.TryParse(scalar, out var style))
+ {
+ switch (style)
+ {
+ case ButtonStyle.Premium when !SkuId.IsSpecified:
+ Context.ReportDiagnostic(
+ Diagnostics.MissingRequiredProperty,
+ Location,
+ "Premium Button",
+ Style.Name
+ );
+ break;
+ case ButtonStyle.Link when !Url.IsSpecified:
+ Context.ReportDiagnostic(
+ Diagnostics.MissingRequiredProperty,
+ Location,
+ "Link Button",
+ Url.Name
+ );
+ break;
+ }
+ }
+
+ // TODO: rest of validation
+ }
+
+ private string RenderLabel()
+ {
+ if (Label.IsSpecified) return Label.ToString();
+
+ return ComponentProperty.BuildValue(_buttonLabelNode) ?? "default";
+ }
+
+ public override string Render()
+ => $"""
+ new {Context.KnownTypes.ButtonBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}(
+ label: {RenderLabel().WithNewlinePadding(4)},
+ customId: {CustomId.ToString().WithNewlinePadding(4)},
+ style: global::Discord.ButtonStyle.{Style},
+ url: {Url.ToString().WithNewlinePadding(4)},
+ emote: {Emoji.ToString().WithNewlinePadding(4)},
+ isDisabled: {IsDisabled.ToString().WithNewlinePadding(4)},
+ skuId: {SkuId.ToString().WithNewlinePadding(4)},
+ id: {Id}
+ )
+ """;
+}
+
+public enum ButtonStyle
+{
+ Primary = 1,
+ Secondary = 2,
+ Success = 3,
+ Danger = 4,
+ Link = 5,
+ Premium = 6
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ChannelSelectComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ChannelSelectComponentNode.cs
new file mode 100644
index 0000000000..0a081d2b45
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ChannelSelectComponentNode.cs
@@ -0,0 +1,30 @@
+using Discord.ComponentDesigner.Generator.Parser;
+using System.Collections.Generic;
+using System.Linq;
+using System.Xml;
+using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat;
+
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+public sealed class ChannelSelectComponentNode : BaseSelectComponentNode
+{
+ public override string FriendlyName => "Channel Select";
+ public override NodeKind Kind => NodeKind.ChannelSelect;
+
+ public ChannelSelectComponentNode(CXmlElement xml, ComponentNodeContext context) : base(xml, context)
+ {
+ }
+
+ public override string Render()
+ => $"""
+ new {Context.KnownTypes.SelectMenuBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}(
+ customId: {CustomId.ToString().WithNewlinePadding(4)},
+ placeholder: {Placeholder.ToString().WithNewlinePadding(4)},
+ maxValues: {MaxValues.ToString().WithNewlinePadding(4)},
+ minValues: {MinValues.ToString().WithNewlinePadding(4)},
+ isDisabled: {IsDisabled.ToString().WithNewlinePadding(4)},
+ type: {Context.KnownTypes.ComponentTypeEnumType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.ChannelSelect,
+ id: {Id}
+ )
+ """;
+}
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..c4112a4936
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs
@@ -0,0 +1,197 @@
+using Discord.ComponentDesigner.Generator.Parser;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+public sealed class ContainerComponentNode : ComponentNode
+{
+ public override string FriendlyName => "Container";
+ public override NodeKind Kind => NodeKind.Container;
+ public ComponentProperty AccentColor { get; }
+
+ public ComponentProperty IsSpoiler { get; }
+
+ public IReadOnlyList Components { get; }
+
+ public ContainerComponentNode(CXmlElement xml, ComponentNodeContext context) : base(xml, context)
+ {
+ AccentColor = MapProperty(
+ "accentColor",
+ optional: true,
+ // TODO: validator
+ aliases: ["color"]
+ );
+
+ IsSpoiler = MapProperty(
+ "spoiler",
+ ParseBooleanProperty,
+ optional: true
+ );
+
+ var components = new List();
+
+ foreach (var childXml in xml.Children)
+ {
+ switch (childXml)
+ {
+ case CXmlValue value:
+ ExtractChildrenFromXmlElementValue(components, value);
+ break;
+ case CXmlElement element:
+ var component = Create(element, context);
+
+ if (component is null) continue;
+
+ if (!IsValidChildType(component.Kind))
+ {
+ context.ReportDiagnostic(
+ Diagnostics.InvalidChildComponentType,
+ component.Location,
+ FriendlyName,
+ component.FriendlyName
+ );
+
+ continue;
+ }
+
+ components.Add(component);
+ break;
+ default:
+ context.ReportDiagnostic(
+ Diagnostics.InvalidChildNodeType,
+ context.GetLocation(childXml),
+ FriendlyName,
+ "text"
+ );
+ break;
+ }
+ }
+
+ Components = components;
+ }
+
+ private static bool IsValidChildType(NodeKind kind)
+ => (
+ kind & (NodeKind.Custom | NodeKind.ActionRow | NodeKind.TextDisplay | NodeKind.Section |
+ NodeKind.MediaGallery | NodeKind.Separator | NodeKind.File)
+ ) is not 0;
+
+ private void ExtractChildrenFromXmlElementValue(
+ List components,
+ CXmlValue value
+ )
+ {
+ switch (value)
+ {
+ case CXmlValue.Scalar scalar:
+ if (string.IsNullOrWhiteSpace(scalar.Value)) break;
+
+ Context.ReportDiagnostic(
+ Diagnostics.InvalidChildNodeType,
+ Context.GetLocation(scalar),
+ FriendlyName,
+ "text"
+ );
+ break;
+
+ case CXmlValue.Interpolation interpolation:
+ // verify it's a component
+ var interpolationInfo = Context.Interpolations[interpolation.InterpolationIndex];
+
+ switch (IsValidChildType(interpolationInfo.Type))
+ {
+ case true:
+ components.Add(
+ new InterpolatedComponentNode(
+ interpolation,
+ Context,
+ Context.KnownTypes.IMessageComponentBuilderType!.ToString()
+ )
+ );
+ break;
+ case false:
+ Context.ReportDiagnostic(
+ Diagnostics.InvalidChildNodeType,
+ Context.GetLocation(interpolation),
+ FriendlyName,
+ interpolationInfo.Type.ToDisplayString()
+ );
+ return;
+ case null:
+ Context.ReportDiagnostic(
+ Diagnostics.PossibleInvalidChildNodeType,
+ Context.GetLocation(interpolation),
+ interpolationInfo.Type.ToDisplayString(),
+ FriendlyName
+ );
+ return;
+ }
+
+ break;
+ case CXmlValue.Multipart multipart:
+ foreach (var child in multipart.Values)
+ {
+ ExtractChildrenFromXmlElementValue(components, child);
+ }
+
+ break;
+ }
+ }
+
+ private bool? IsValidChildType(ITypeSymbol symbol)
+ {
+ // ensure it inherits the component builder
+ if (
+ !Context.KnownTypes.Compilation.HasImplicitConversion(
+ symbol,
+ Context.KnownTypes.IMessageComponentBuilderType
+ )
+ )
+ {
+ return false;
+ }
+
+ // if it is just a builder, it may not be a valid type
+ if (
+ Context
+ .KnownTypes
+ .IMessageComponentBuilderType?
+ .Equals(
+ symbol,
+ SymbolEqualityComparer.Default
+ ) ?? false
+ )
+ {
+ return null;
+ }
+
+ return
+ Context.Compilation.HasImplicitConversion(symbol, Context.KnownTypes.ActionRowBuilderType) ||
+ Context.Compilation.HasImplicitConversion(symbol, Context.KnownTypes.TextDisplayBuilderType) ||
+ Context.Compilation.HasImplicitConversion(symbol, Context.KnownTypes.SectionBuilderType) ||
+ Context.Compilation.HasImplicitConversion(symbol, Context.KnownTypes.MediaGalleryBuilderType) ||
+ Context.Compilation.HasImplicitConversion(symbol, Context.KnownTypes.SeparatorBuilderType) ||
+ Context.Compilation.HasImplicitConversion(symbol, Context.KnownTypes.FileComponentBuilderType);
+ }
+
+ public override void ReportValidationErrors()
+ {
+ base.ReportValidationErrors();
+
+ foreach (var component in Components) component.ReportValidationErrors();
+ }
+
+ public override string Render()
+ => $"""
+ new {Context.KnownTypes.ContainerBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}(
+ {
+ string.Join(
+ ",\n".Postfix(4),
+ Components.Select(x => x.Render().WithNewlinePadding(4))
+ )
+ }
+ )
+ """;
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/CustomComponent.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/CustomComponent.cs
new file mode 100644
index 0000000000..c1de2895f0
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/CustomComponent.cs
@@ -0,0 +1,101 @@
+using Discord.ComponentDesigner.Generator.Parser;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using Microsoft.CodeAnalysis;
+
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+public sealed class CustomComponent : ComponentNode
+{
+ private sealed record CustomComponentProp(
+ IPropertySymbol Symbol,
+ CXmlAttribute? Attribute
+ )
+ {
+ public bool IsOptional => !Symbol.IsRequired;
+ public ITypeSymbol PropertyType => Symbol.Type;
+ }
+
+ public override string FriendlyName => _symbol.Name;
+ public override NodeKind Kind => NodeKind.Custom;
+
+ private readonly ITypeSymbol _symbol;
+ private readonly Dictionary _properties;
+
+ public CustomComponent(CXmlElement element, ITypeSymbol symbol, ComponentNodeContext context) : base(element,
+ context)
+ {
+ _symbol = symbol;
+
+ _properties = new Dictionary();
+
+ foreach (var propertySymbol in symbol.GetMembers().OfType())
+ {
+ if (propertySymbol.ExplicitInterfaceImplementations.Length is not 0) continue;
+
+ if (propertySymbol.DeclaredAccessibility is not Accessibility.Public) continue;
+
+ var isOptional = !propertySymbol.IsRequired;
+
+ _properties[propertySymbol.Name] = new(
+ propertySymbol,
+ GetAttribute(propertySymbol.Name)
+ );
+ }
+ }
+
+ private string BuildInstantiation()
+ {
+ var sb = new StringBuilder($"new {_symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}()");
+
+ var values = new List();
+
+ foreach (var prop in _properties)
+ {
+ if (prop.Value.Attribute is not null)
+ {
+ values.Add($"{prop.Key} = {MapPropertyValue(prop.Value, prop.Value.Attribute.Value)}");
+ }
+ }
+
+ if (values.Count is 0) return sb.ToString();
+
+ sb.AppendLine().AppendLine("{");
+
+ sb.Append(string.Join(",\n".Postfix(4), values.Select(x => x.Prefix(4))));
+
+ sb.AppendLine().Append("}");
+
+ return sb.ToString();
+ }
+
+ private string MapPropertyValue(CustomComponentProp prop, CXmlValue? value)
+ {
+ switch (value)
+ {
+ case CXmlValue.Interpolation interpolation:
+ // easiest case
+ var interpolationInfo = Context.Interpolations[interpolation.InterpolationIndex];
+
+ if (!Context.Compilation.HasImplicitConversion(interpolationInfo.Type, prop.PropertyType))
+ {
+ Context.ReportDiagnostic(
+ Diagnostics.InvalidAttributeType,
+ Context.GetLocation(interpolation),
+ interpolationInfo.Type.ToDisplayString(),
+ prop.PropertyType.ToDisplayString()
+ );
+ }
+
+ return
+ $"designer.GetValue<{prop.PropertyType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}>({interpolation.InterpolationIndex})";
+
+ default:
+ return "default";
+ }
+ }
+
+ public override string Render()
+ => $"{BuildInstantiation()}.Render()";
+}
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..46814423bf
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs
@@ -0,0 +1,29 @@
+using Discord.ComponentDesigner.Generator.Parser;
+using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat;
+
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+public sealed class FileComponentNode : ComponentNode
+{
+ public override string FriendlyName => "File";
+ public override NodeKind Kind => NodeKind.File;
+
+ public ComponentProperty Url { get; }
+
+ public ComponentProperty IsSpoiler { get; }
+
+ public FileComponentNode(CXmlElement xml, ComponentNodeContext context, bool mapId = true) : base(xml, context, mapId)
+ {
+ Url = MapProperty("url");
+ IsSpoiler = MapProperty("spoiler", ParseBooleanProperty, optional: true);
+ }
+
+ public override string Render()
+ => $"""
+ new {Context.KnownTypes.FileComponentBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}(
+ media: new {Context.KnownTypes.UnfurledMediaItemPropertiesType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({Url}),
+ isSpoiler: {IsSpoiler},
+ id: {Id}
+ )
+ """;
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterpolatedComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterpolatedComponentNode.cs
new file mode 100644
index 0000000000..583c2aa862
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterpolatedComponentNode.cs
@@ -0,0 +1,49 @@
+using Discord.ComponentDesigner.Generator.Parser;
+using Microsoft.CodeAnalysis;
+
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+public sealed class InterpolatedComponentNode : ComponentNode
+{
+ public override NodeKind Kind { get; }
+
+ public InterpolationInfo InterpolationInfo { get; }
+
+ private readonly CXmlValue.Interpolation _value;
+ private readonly string _preferredType;
+
+ public InterpolatedComponentNode(
+ CXmlValue.Interpolation xml,
+ ComponentNodeContext context,
+ NodeKind kind,
+ string? preferredType = null
+ ) : base(xml, context)
+ {
+ _value = xml;
+
+ InterpolationInfo = context.Interpolations[_value.InterpolationIndex];
+
+ _preferredType = preferredType ??
+ InterpolationInfo.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+
+ Kind = NodeKind.Interpolated | kind;
+ }
+
+ public InterpolatedComponentNode(
+ CXmlValue.Interpolation xml,
+ ComponentNodeContext context,
+ string? preferredType = null
+ ) : this(
+ xml,
+ context,
+ context.Interpolations[xml.InterpolationIndex].Type.ToNodeKind(context.KnownTypes),
+ preferredType
+ )
+ {
+ }
+
+ public override string FriendlyName => $"Interpolation[{_value.InterpolationIndex}]";
+
+ public override string Render()
+ => $"designer.GetValue<{_preferredType}>({_value.InterpolationIndex})";
+}
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..74d86c3b6b
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs
@@ -0,0 +1,122 @@
+using Discord.ComponentDesigner.Generator.Parser;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+public sealed class LabelComponentNode : ComponentNode
+{
+ public override string FriendlyName => "Label";
+
+ public override NodeKind Kind => NodeKind.Label;
+
+ public ComponentProperty Description { get; }
+
+ public ComponentNode? Component { get; }
+ public IReadOnlyList LabelValues { get; }
+
+ public LabelComponentNode(CXmlElement xml, ComponentNodeContext context) : base(xml, context)
+ {
+ Description = MapProperty("description", optional: true);
+
+ var values = new List();
+ LabelValues = values;
+
+ if (xml.Children.Count is 0)
+ {
+ context.ReportDiagnostic(
+ Diagnostics.MissingLabelChildren,
+ context.GetLocation(xml)
+ );
+
+ return;
+ }
+
+ var lastChild = xml.Children.Last();
+
+ var lastChildComponent = Create(lastChild, context);
+
+ if (lastChildComponent is null)
+ {
+ context.ReportDiagnostic(
+ Diagnostics.InvalidChildComponentType,
+ context.GetLocation(lastChild),
+ FriendlyName,
+ lastChild.GetType().Name
+ );
+
+ return;
+ }
+
+ Component = lastChildComponent;
+
+ if (!IsValidLabelChild(Component.Kind))
+ {
+ context.ReportDiagnostic(
+ Diagnostics.InvalidChildComponentType,
+ context.GetLocation(lastChild),
+ FriendlyName,
+ Component.FriendlyName
+ );
+
+ return;
+ }
+
+
+ foreach (var child in xml.Children.Take(xml.Children.Count - 1))
+ {
+ // we don't allow any elements
+ if (child is CXmlElement element)
+ {
+ context.ReportDiagnostic(
+ Diagnostics.InvalidChildComponentType,
+ context.GetLocation(child),
+ FriendlyName,
+ element.Name.Value
+ );
+
+ continue;
+ }
+
+ if (child is not CXmlValue value)
+ {
+ context.ReportDiagnostic(
+ Diagnostics.InvalidChildComponentType,
+ context.GetLocation(child),
+ FriendlyName,
+ child.GetType().Name
+ );
+
+ continue;
+ }
+
+ values.Add(value);
+ }
+ }
+
+ private static bool IsValidLabelChild(NodeKind kind)
+ => (
+ kind & (
+ NodeKind.TextInput |
+ NodeKind.StringSelect
+ )
+ ) is not 0;
+
+ private string BuildLabelValues()
+ {
+ if (LabelValues.Count is 1)
+ return ComponentProperty.BuildValue(LabelValues[0]) ?? "default";
+
+ return ComponentProperty.BuildMultipart(LabelValues);
+ }
+
+ public override string Render()
+ => $"""
+ new LabelBuilder(
+ label: {BuildLabelValues().WithNewlinePadding(4)},
+ component: {Component?.Render().WithNewlinePadding(4)},
+ description: {Description.ToString().WithNewlinePadding(4)},
+ id: {Id}
+ )
+ """;
+}
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..a59e2f4819
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs
@@ -0,0 +1,120 @@
+using Discord.ComponentDesigner.Generator.Parser;
+using System.Collections.Generic;
+using System.Linq;
+using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat;
+
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+public sealed class MediaGalleryComponentNode : ComponentNode
+{
+ public override string FriendlyName => "Media Gallery";
+
+ public override NodeKind Kind => NodeKind.MediaGallery;
+
+ public IReadOnlyList Items { get; }
+
+ public MediaGalleryComponentNode(CXmlElement xml, ComponentNodeContext context, bool mapId = true) : base(xml,
+ context, mapId)
+ {
+ var items = new List();
+
+ foreach (var childXml in xml.Children)
+ {
+ if (childXml is not CXmlElement element)
+ {
+ context.ReportDiagnostic(
+ Diagnostics.InvalidChildNodeType,
+ context.GetLocation(childXml),
+ FriendlyName,
+ "text"
+ );
+
+ continue;
+ }
+
+ if (element.Name.Value is not "item")
+ {
+ context.ReportDiagnostic(
+ Diagnostics.InvalidChildComponentType,
+ context.GetLocation(childXml),
+ FriendlyName,
+ element.Name.Value
+ );
+ }
+
+ items.Add(new MediaGalleryItem(element, context));
+ }
+
+ Items = items;
+ }
+
+ public override void ReportValidationErrors()
+ {
+ base.ReportValidationErrors();
+
+ foreach (var item in Items) item.ReportValidationErrors();
+
+ if (Items.Count is 0)
+ {
+ Context.ReportDiagnostic(
+ Diagnostics.EmptyMediaGallery,
+ Location
+ );
+ }
+
+ if (Items.Count > Constants.MAX_MEDIA_ITEMS)
+ {
+ Context.ReportDiagnostic(
+ Diagnostics.TooManyMediaGalleryItems,
+ Location
+ );
+ }
+ }
+
+ public override string Render()
+ => $"""
+ new {Context.KnownTypes.MediaGalleryBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}(
+ {
+ string.Join(
+ ",\n".Postfix(4),
+ Items.Select(x => x.Render().WithNewlinePadding(4))
+ )
+ }
+ )
+ """;
+}
+
+public sealed class MediaGalleryItem : ComponentNode
+{
+ public override string FriendlyName => "Media Gallery Item";
+ public override NodeKind Kind => NodeKind.MediaGalleryItem;
+ public ComponentProperty Url { get; }
+
+ public ComponentProperty Description { get; }
+
+ public ComponentProperty IsSpoiler { get; }
+
+ public MediaGalleryItem(CXmlElement xml, ComponentNodeContext context, bool mapId = true) : base(xml, context,
+ mapId)
+ {
+ Url = MapProperty("url");
+
+ Description = MapProperty(
+ "description",
+ optional: true,
+ validators: [Validators.LengthBounds(upper: Constants.MAX_MEDIA_ITEM_DESCRIPTION_LENGTH)]
+ );
+
+ IsSpoiler = MapProperty("spoiler", ParseBooleanProperty, optional: true);
+ }
+
+
+ public override string Render()
+ => $"""
+ new {Context.KnownTypes.MediaGalleryItemPropertiesType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}(
+ media: new {Context.KnownTypes.UnfurledMediaItemPropertiesType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({Url}),
+ description: {Description.ToString().WithNewlinePadding(4)},
+ isSpoiler: {IsSpoiler}
+ )
+ """;
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MentionableSelectComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MentionableSelectComponentNode.cs
new file mode 100644
index 0000000000..a87362570c
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MentionableSelectComponentNode.cs
@@ -0,0 +1,28 @@
+using Discord.ComponentDesigner.Generator.Parser;
+using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat;
+
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+public sealed class MentionableSelectComponentNode : BaseSelectComponentNode
+{
+ public override string FriendlyName => "Mentionable Select";
+
+ public override NodeKind Kind => NodeKind.MentionableSelect;
+
+ public MentionableSelectComponentNode(CXmlElement xml, ComponentNodeContext context) : base(xml, context)
+ {
+ }
+
+ public override string Render()
+ => $"""
+ new {Context.KnownTypes.SelectMenuBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}(
+ customId: {CustomId.ToString().WithNewlinePadding(4)},
+ placeholder: {Placeholder.ToString().WithNewlinePadding(4)},
+ maxValues: {MaxValues.ToString().WithNewlinePadding(4)},
+ minValues: {MinValues.ToString().WithNewlinePadding(4)},
+ isDisabled: {IsDisabled.ToString().WithNewlinePadding(4)},
+ type: {Context.KnownTypes.ComponentTypeEnumType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.MentionableSelect,
+ id: {Id}
+ )
+ """;
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/RoleSelectComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/RoleSelectComponentNode.cs
new file mode 100644
index 0000000000..e36dec49b8
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/RoleSelectComponentNode.cs
@@ -0,0 +1,27 @@
+using Discord.ComponentDesigner.Generator.Parser;
+using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat;
+
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+public sealed class RoleSelectComponentNode : BaseSelectComponentNode
+{
+ public override string FriendlyName => "Role Select";
+ public override NodeKind Kind => NodeKind.RoleSelect;
+
+ public RoleSelectComponentNode(CXmlElement xml, ComponentNodeContext context) : base(xml, context)
+ {
+ }
+
+ public override string Render()
+ => $"""
+ new {Context.KnownTypes.SelectMenuBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}(
+ customId: {CustomId},
+ placeholder: {Placeholder},
+ maxValues: {MaxValues},
+ minValues: {MinValues},
+ isDisabled: {IsDisabled},
+ type: {Context.KnownTypes.ComponentTypeEnumType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.RoleSelect,
+ id: {Id}
+ )
+ """;
+}
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..d2fe633083
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentNode.cs
@@ -0,0 +1,244 @@
+using Discord.ComponentDesigner.Generator.Parser;
+using System.Collections.Generic;
+using System.Linq;
+using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat;
+
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+public sealed class SectionComponentNode : ComponentNode
+{
+ public override string FriendlyName => "Section";
+
+ public override NodeKind Kind => NodeKind.Section;
+
+ public ComponentNode? Accessory { get; }
+
+ public IReadOnlyList Components { get; }
+
+ public SectionComponentNode(CXmlElement xml, ComponentNodeContext context, bool mapId = true) : base(xml, context,
+ mapId)
+ {
+ var components = new List();
+ ComponentNode? accessory = null;
+
+ foreach (var childXml in xml.Children)
+ {
+ ProcessChildren(components, childXml, ref accessory);
+ }
+
+ Accessory = accessory;
+ Components = components;
+ }
+
+ private void ProcessChildren(List children, ICXml xml, ref ComponentNode? accessory)
+ {
+ switch (xml)
+ {
+ case CXmlElement element:
+ if (element.Name.Value is "accessory")
+ {
+ if (accessory is not null)
+ {
+ Context.ReportDiagnostic(
+ Diagnostics.ExtraAccessory,
+ Context.GetLocation(element)
+ );
+
+ return;
+ }
+
+ switch (element.Children.Count)
+ {
+ case 0:
+ Context.ReportDiagnostic(
+ Diagnostics.MissingAccessory,
+ Context.GetLocation(element)
+ );
+ break;
+ case > 1:
+ var head = element.Children[1];
+ var tail = element.Children.Last();
+
+ Context.ReportDiagnostic(
+ Diagnostics.ExtraAccessory,
+ Context.GetLocation((head.Span.Start, tail.Span.End))
+ );
+
+ break;
+ default:
+ accessory = Create(element.Children[0], Context);
+ if (accessory is null) return;
+
+ if (!IsValidAccessoryComponent(accessory.Kind))
+ {
+ Context.ReportDiagnostic(
+ Diagnostics.InvalidChildComponentType,
+ accessory.Location,
+ "accessory",
+ accessory.FriendlyName
+ );
+ }
+
+ break;
+ }
+
+ return;
+ }
+
+ var component = Create(element, Context);
+
+ if (component is null) return;
+
+ if (!IsValidChildComponent(component.Kind))
+ {
+ Context.ReportDiagnostic(
+ Diagnostics.InvalidChildComponentType,
+ component.Location,
+ "accessory",
+ component.FriendlyName
+ );
+
+ return;
+ }
+
+ children.Add(component);
+ break;
+
+ case CXmlValue value:
+ ProcessValue(value);
+ break;
+ }
+
+ void ProcessValue(CXmlValue value)
+ {
+ switch (value)
+ {
+ case CXmlValue.Scalar scalar:
+ if (string.IsNullOrWhiteSpace(scalar.Value)) return;
+
+ Context.ReportDiagnostic(
+ Diagnostics.InvalidChildNodeType,
+ Context.GetLocation(scalar),
+ FriendlyName,
+ "text"
+ );
+ return;
+ case CXmlValue.Interpolation interpolation:
+ var interpolationInfo = Context.Interpolations[interpolation.InterpolationIndex];
+
+ var kind = interpolationInfo.Type.ToNodeKind(Context.KnownTypes);
+
+ if (
+ kind is NodeKind.Unknown ||
+ !Context.KnownTypes.Compilation.HasImplicitConversion(
+ interpolationInfo.Type,
+ Context.KnownTypes.IMessageComponentBuilderType
+ ) ||
+ !IsValidChildComponent(kind)
+ )
+ {
+ Context.ReportDiagnostic(
+ Diagnostics.InvalidChildNodeType,
+ Context.GetLocation(interpolation),
+ FriendlyName,
+ interpolationInfo.Type.ToDisplayString()
+ );
+ return;
+ }
+
+ if (kind is NodeKind.AnyComponent)
+ {
+ Context.ReportDiagnostic(
+ Diagnostics.PossibleInvalidChildNodeType,
+ Context.GetLocation(interpolation),
+ interpolationInfo.Type.ToDisplayString(),
+ FriendlyName
+ );
+ }
+
+ children.Add(
+ new InterpolatedComponentNode(
+ interpolation,
+ Context,
+ Context.KnownTypes.IMessageComponentBuilderType!.ToString()
+ )
+ );
+
+ return;
+ case CXmlValue.Multipart multipart:
+ foreach (var part in multipart.Values)
+ {
+ ProcessValue(part);
+ }
+
+ return;
+ }
+ }
+ }
+
+ private static bool IsValidAccessoryComponent(NodeKind kind)
+ => kind.HasFlag(NodeKind.Button) || kind.HasFlag(NodeKind.Thumbnail);
+
+ private static bool IsValidChildComponent(NodeKind kind)
+ => kind.HasFlag(NodeKind.TextDisplay);
+
+ public override void ReportValidationErrors()
+ {
+ base.ReportValidationErrors();
+
+ Accessory?.ReportValidationErrors();
+ foreach (var component in Components) component.ReportValidationErrors();
+
+ if (Accessory is null)
+ {
+ Context.ReportDiagnostic(
+ Diagnostics.MissingAccessory,
+ Location
+ );
+ }
+
+ if (
+ Accessory is not null and not ButtonComponentNode and not ThumbnailComponentNode
+ )
+ {
+ Context.ReportDiagnostic(
+ Diagnostics.InvalidChildComponentType,
+ Accessory.Location,
+ "accessory",
+ Accessory.FriendlyName
+ );
+ }
+
+ if (Components.Count is 0)
+ {
+ Context.ReportDiagnostic(
+ Diagnostics.MissingSectionComponents,
+ Location
+ );
+ }
+
+ if (Components.Count > Constants.MAX_SECTION_CHILDREN)
+ {
+ Context.ReportDiagnostic(
+ Diagnostics.TooManySectionComponentChildren,
+ Location
+ );
+ }
+ }
+
+ public override string Render()
+ => $"""
+ new {Context.KnownTypes.SectionBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}(
+ accessory: {Accessory?.Render().WithNewlinePadding(4) ?? "null"},
+ components:
+ [
+ {
+ string.Join(
+ ",\n".Postfix(8),
+ Components.Select(x => x.Render().WithNewlinePadding(8))
+ )
+ }
+ ]
+ )
+ """;
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectDefaultValue.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectDefaultValue.cs
new file mode 100644
index 0000000000..33c54fe134
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectDefaultValue.cs
@@ -0,0 +1,44 @@
+using Discord.ComponentDesigner.Generator.Parser;
+using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat;
+
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+public sealed class SelectDefaultValue : ComponentNode
+{
+ public override string FriendlyName => "Select Default Value";
+
+ public override NodeKind Kind => NodeKind.SelectDefault;
+
+ public new ComponentProperty Id { get; }
+
+ public ComponentProperty Type { get; }
+
+ public SelectDefaultValue(CXmlElement xml, ComponentNodeContext context) : base(xml, context, mapId: false)
+ {
+ Id = MapProperty(
+ "id",
+ ParseSnowflakeProperty
+ );
+
+ Type = MapProperty(
+ "type",
+ ParseEnumProperty,
+ optional: true
+ );
+ }
+
+ public override string Render()
+ => $"""
+ new {Context.KnownTypes.SelectMenuDefaultValueType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}(
+ id: {Id},
+ type: {Context.KnownTypes.SelectDefaultValueTypeEnumType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.{Type}
+ )
+ """;
+}
+
+public enum SelectDefaultValueType
+{
+ User,
+ Role,
+ Channel
+}
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..7ecf5af91b
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs
@@ -0,0 +1,45 @@
+using Discord.ComponentDesigner.Generator.Parser;
+using System.Xml;
+using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat;
+
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+public sealed class SeparatorComponentNode : ComponentNode
+{
+ public override string FriendlyName => "Separator";
+ public override NodeKind Kind => NodeKind.Separator;
+ public ComponentProperty IsDivider { get; }
+
+ public ComponentProperty Spacing { get; }
+
+ public SeparatorComponentNode(CXmlElement xml, ComponentNodeContext context) : base(xml, context)
+ {
+ IsDivider = MapProperty(
+ "divider",
+ ParseBooleanProperty,
+ optional: true,
+ defaultValue: true
+ );
+
+ Spacing = MapProperty(
+ "spacing",
+ ParseEnumProperty,
+ optional: true,
+ defaultValue: SeparatorSpacing.Small
+ );
+ }
+
+ public override string Render()
+ => $"""
+ new {Context.KnownTypes.SeparatorBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}(
+ isDivider: {IsDivider},
+ spacing: {Context.KnownTypes.SeparatorSpacingSizeType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.{Spacing}
+ )
+ """;
+}
+
+public enum SeparatorSpacing
+{
+ Small = 1,
+ Large = 2
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/StringSelectComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/StringSelectComponentNode.cs
new file mode 100644
index 0000000000..04657b52fd
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/StringSelectComponentNode.cs
@@ -0,0 +1,124 @@
+using Discord.ComponentDesigner.Generator.Parser;
+using System.Collections.Generic;
+using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat;
+
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+public sealed class StringSelectComponentNode : BaseSelectComponentNode
+{
+ public override string FriendlyName => "String Select";
+ public override NodeKind Kind => NodeKind.StringSelect;
+ public IReadOnlyList Options { get; }
+
+ public StringSelectComponentNode(CXmlElement xml, ComponentNodeContext context) : base(
+ xml,
+ context,
+ hasDefaultValues: false
+ )
+ {
+ var options = new List();
+
+ foreach (var childXml in xml.Children)
+ {
+ if (childXml is not CXmlElement element)
+ {
+ context.ReportDiagnostic(
+ Diagnostics.InvalidChildNodeType,
+ context.GetLocation(childXml),
+ FriendlyName,
+ "text"
+ );
+
+ continue;
+ }
+
+ if (element.Name.Value is not "option")
+ {
+ context.ReportDiagnostic(
+ Diagnostics.InvalidChildComponentType,
+ context.GetLocation(childXml),
+ FriendlyName,
+ element.Name.Value
+ );
+
+ continue;
+ }
+
+ options.Add(new SelectOption(element, context));
+ }
+
+ Options = options;
+ }
+
+ public override void ReportValidationErrors()
+ {
+ base.ReportValidationErrors();
+
+ foreach (var option in Options) option.ReportValidationErrors();
+ }
+
+ public override string Render()
+ => $"""
+ new {Context.KnownTypes.SelectMenuBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}(
+ customId: {CustomId.ToString().WithNewlinePadding(4)},
+ placeholder: {Placeholder.ToString().WithNewlinePadding(4)},
+ maxValues: {MaxValues.ToString().WithNewlinePadding(4)},
+ minValues: {MinValues.ToString().WithNewlinePadding(4)},
+ isDisabled: {IsDisabled.ToString().WithNewlinePadding(4)},
+ type: {Context.KnownTypes.ComponentTypeEnumType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.StringSelect
+ )
+ """;
+}
+
+public sealed class SelectOption : ComponentNode
+{
+ public override string FriendlyName => "Select Option";
+
+ public override NodeKind Kind => NodeKind.SelectOption;
+
+ public ComponentProperty Label { get; }
+
+ public ComponentProperty Value { get; }
+
+ public ComponentProperty Description { get; }
+
+ public ComponentProperty Emoji { get; }
+
+ public ComponentProperty IsDefault { get; }
+
+ public SelectOption(CXmlElement xml, ComponentNodeContext context) : base(xml, context, mapId: false)
+ {
+ Label = MapProperty(
+ "label",
+ validators: [Validators.LengthBounds(upper: Constants.STRING_SELECT_OPTION_LABEL_MAX_LENGTH)]
+ );
+
+ Value = MapProperty(
+ "value",
+ validators: [Validators.LengthBounds(upper: Constants.STRING_SELECT_OPTION_VALUE_MAX_LENGTH)]
+ );
+
+ Description = MapProperty(
+ "description",
+ optional: true,
+ validators: [Validators.LengthBounds(upper: Constants.STRING_SELECT_OPTION_DESCRIPTION_MAX_LENGTH)]
+ );
+
+ Emoji = MapProperty(
+ "emoji",
+ optional: true,
+ parser: ParseEmojiProperty
+ );
+
+ IsDefault = MapProperty(
+ "default",
+ ParseBooleanProperty,
+ optional: true
+ );
+ }
+
+ public override string Render()
+ {
+ throw new System.NotImplementedException();
+ }
+}
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..4e6d031947
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs
@@ -0,0 +1,38 @@
+using Discord.ComponentDesigner.Generator.Parser;
+using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat;
+
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+public sealed class TextDisplayComponentNode : ComponentNode
+{
+ public override string FriendlyName => "Text Display";
+ public override NodeKind Kind => NodeKind.TextDisplay;
+ public CXmlValue? Value { get; }
+
+ public TextDisplayComponentNode(CXmlElement xml, ComponentNodeContext context) : base(xml, context)
+ {
+ if (xml.Children.Count is 0) return;
+
+ if (xml.Children.Count > 1 || xml.Children[0] is not CXmlValue value)
+ {
+ context.ReportDiagnostic(
+ Diagnostics.TextCannotContainComponents,
+ Location
+ );
+
+ return;
+ }
+
+ Value = value;
+ }
+
+ private string RenderContent() => ComponentProperty.BuildValue(Value) ?? "default";
+
+ public override string Render()
+ => $"""
+ new {Context.KnownTypes.TextDisplayBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}(
+ content: {RenderContent().WithNewlinePadding(4)},
+ id: {Id}
+ )
+ """;
+}
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..d877f48f87
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs
@@ -0,0 +1,23 @@
+using Discord.ComponentDesigner.Generator.Parser;
+
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+public sealed class TextInputComponentNode : ComponentNode
+{
+ public override string FriendlyName => "Text Input";
+ public override NodeKind Kind => NodeKind.TextInput;
+ public TextInputComponentNode(CXmlElement xml, ComponentNodeContext context) : base(xml, context)
+ {
+ }
+
+ public override string Render()
+ {
+ throw new System.NotImplementedException();
+ }
+}
+
+public enum TextInputStyle
+{
+ Snort = 1,
+ Paragraph = 2
+}
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..2aa32c15e2
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs
@@ -0,0 +1,37 @@
+using Discord.ComponentDesigner.Generator.Parser;
+using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat;
+
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+public sealed class ThumbnailComponentNode : ComponentNode
+{
+ public override string FriendlyName => "Thumbnail";
+ public override NodeKind Kind => NodeKind.Thumbnail;
+ public ComponentProperty Url { get; }
+
+ public ComponentProperty Description { get; }
+
+ public ComponentProperty IsSpoiler { get; }
+
+ public ThumbnailComponentNode(CXmlElement xml, ComponentNodeContext context) : base(xml, context)
+ {
+ Url = MapProperty("url");
+
+ Description = MapProperty(
+ "description",
+ optional: true,
+ validators: [Validators.LengthBounds(upper: Constants.THUMBNAIL_DESCRIPTION_MAX_LENGTH)]
+ );
+
+ IsSpoiler = MapProperty("spoiler", ParseBooleanProperty, optional: true);
+ }
+
+ public override string Render()
+ => $"""
+ new {Context.KnownTypes.ThumbnailBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}(
+ media: new global::Discord.UnfurledMediaItemProperties({Url.ToString().WithNewlinePadding(4)}),
+ description: {Description.ToString().WithNewlinePadding(4)},
+ isSpoiler: {IsSpoiler}
+ )
+ """;
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/UserSelectComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/UserSelectComponentNode.cs
new file mode 100644
index 0000000000..2ad578bbd2
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/UserSelectComponentNode.cs
@@ -0,0 +1,30 @@
+using Discord.ComponentDesigner.Generator.Parser;
+using System.Collections.Generic;
+using System.Linq;
+using System.Xml;
+using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat;
+
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+public sealed class UserSelectComponentNode : BaseSelectComponentNode
+{
+ public override string FriendlyName => "User Select";
+ public override NodeKind Kind => NodeKind.UserSelect;
+
+ public UserSelectComponentNode(CXmlElement xml, ComponentNodeContext context) : base(xml, context)
+ {
+ }
+
+ public override string Render()
+ => $"""
+ new {Context.KnownTypes.SelectMenuBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}(
+ customId: {CustomId.ToString().WithNewlinePadding(4)},
+ placeholder: {Placeholder.ToString().WithNewlinePadding(4)},
+ maxValues: {MaxValues.ToString().WithNewlinePadding(4)},
+ minValues: {MinValues.ToString().WithNewlinePadding(4)},
+ isDisabled: {IsDisabled.ToString().WithNewlinePadding(4)},
+ type: {Context.KnownTypes.ComponentTypeEnumType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.UserSelect,
+ id: {Id}
+ )
+ """;
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/IComponentProperty.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/IComponentProperty.cs
new file mode 100644
index 0000000000..532b88e4d7
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/IComponentProperty.cs
@@ -0,0 +1,17 @@
+using Discord.ComponentDesigner.Generator.Parser;
+using System.Collections.Generic;
+
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+public interface IComponentProperty
+{
+ string Name { get; }
+ bool IsSpecified { get; }
+
+ CXmlAttribute? Attribute { get; }
+ CXmlValue? Value { get; }
+
+ IReadOnlyList Aliases { get; }
+
+ void Validate(ComponentNodeContext context);
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/NodeKind.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/NodeKind.cs
new file mode 100644
index 0000000000..747e8a212b
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/NodeKind.cs
@@ -0,0 +1,88 @@
+using Microsoft.CodeAnalysis;
+using System;
+
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+[Flags]
+public enum NodeKind : int
+{
+ Unknown = 0,
+
+ ActionRow = 1 << 0,
+ Button = 1 << 1,
+ StringSelect = 1 << 2,
+ TextInput = 1 << 3,
+ UserSelect = 1 << 4,
+ RoleSelect = 1 << 5,
+ MentionableSelect = 1 << 6,
+ ChannelSelect = 1 << 7,
+ Section = 1 << 8,
+ TextDisplay = 1 << 9,
+ Thumbnail = 1 << 10,
+ MediaGallery = 1 << 11,
+ File = 1 << 12,
+ Separator = 1 << 13,
+ Container = 1 << 14,
+ Label = 1 << 15,
+
+ SelectDefault = 1 << 16,
+ SelectOption = 1 << 17,
+ MediaGalleryItem = 1 << 18,
+
+ SelectMenuMask = UserSelect | RoleSelect | ChannelSelect | MentionableSelect | ChannelSelect | StringSelect,
+
+ AnyComponent = int.MaxValue ^ Interpolated,
+ Any = int.MaxValue,
+
+ Custom = 1 << 30,
+ Interpolated = 1 << 31
+}
+
+public static class NodeFlagExtensions
+{
+ public static NodeKind ToNodeKind(this ITypeSymbol symbol, KnownTypes types)
+ {
+ if (types.Compilation.HasImplicitConversion(symbol, types.ActionRowBuilderType))
+ return NodeKind.ActionRow;
+
+ if (types.Compilation.HasImplicitConversion(symbol, types.ButtonBuilderType))
+ return NodeKind.Button;
+
+ if (types.Compilation.HasImplicitConversion(symbol, types.SelectMenuBuilderType))
+ return NodeKind.SelectMenuMask;
+
+ if (types.Compilation.HasImplicitConversion(symbol, types.TextInputBuilderType))
+ return NodeKind.TextInput;
+
+ if (types.Compilation.HasImplicitConversion(symbol, types.SectionBuilderType))
+ return NodeKind.Section;
+
+ if (types.Compilation.HasImplicitConversion(symbol, types.TextDisplayBuilderType))
+ return NodeKind.TextDisplay;
+
+ if (types.Compilation.HasImplicitConversion(symbol, types.ThumbnailBuilderType))
+ return NodeKind.Thumbnail;
+
+ if (types.Compilation.HasImplicitConversion(symbol, types.MediaGalleryBuilderType))
+ return NodeKind.MediaGallery;
+
+ if (types.Compilation.HasImplicitConversion(symbol, types.FileComponentBuilderType))
+ return NodeKind.File;
+
+ if (types.Compilation.HasImplicitConversion(symbol, types.SeparatorBuilderType))
+ return NodeKind.Separator;
+
+ if (types.Compilation.HasImplicitConversion(symbol, types.ContainerBuilderType))
+ return NodeKind.Container;
+
+ if (types.Compilation.HasImplicitConversion(symbol, types.IMessageComponentBuilderType))
+ return NodeKind.AnyComponent;
+
+ return NodeKind.Unknown;
+ }
+
+ public static bool IsInterpolated(this NodeKind nodeKind) => nodeKind.HasFlag(NodeKind.Interpolated);
+
+ public static bool IsSelectMenu(this NodeKind nodeKind)
+ => (nodeKind & NodeKind.SelectMenuMask) is not 0;
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.Numeric.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.Numeric.cs
new file mode 100644
index 0000000000..481a531f75
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.Numeric.cs
@@ -0,0 +1,34 @@
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+partial class Validators
+{
+ public static ComponentPropertyValidator Bounds(
+ int? lower,
+ int? upper
+ ) => (node, property, context) =>
+ {
+ if (!property.TryGetScalarValue(out var value)) return;
+
+ if (!int.TryParse(value, out var number)) return;
+
+ if (number < lower)
+ {
+ context.ReportDiagnostic(
+ Diagnostics.StringTooShort,
+ context.GetLocation(property.Value!),
+ property.Name,
+ lower.Value
+ );
+ }
+
+ if (number > upper)
+ {
+ context.ReportDiagnostic(
+ Diagnostics.StringTooLong,
+ context.GetLocation(property.Value!),
+ property.Name,
+ upper.Value
+ );
+ }
+ };
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.StringLength.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.StringLength.cs
new file mode 100644
index 0000000000..b2555d63fe
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.StringLength.cs
@@ -0,0 +1,32 @@
+namespace Discord.ComponentDesigner.Generator.Nodes;
+
+public static partial class Validators
+{
+ public static ComponentPropertyValidator LengthBounds(
+ int? lower = null,
+ int? upper = null
+ ) => (node, property, context) =>
+ {
+ if (!property.TryGetScalarValue(out var value)) return;
+
+ if (lower.HasValue && value.Length < lower.Value)
+ {
+ context.ReportDiagnostic(
+ Diagnostics.StringTooShort,
+ context.GetLocation(property.Value!),
+ property.Name,
+ lower.Value
+ );
+ }
+
+ if (upper.HasValue && value.Length > upper.Value)
+ {
+ context.ReportDiagnostic(
+ Diagnostics.StringTooLong,
+ context.GetLocation(property.Value!),
+ property.Name,
+ upper.Value
+ );
+ }
+ };
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlAttribute.cs b/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlAttribute.cs
new file mode 100644
index 0000000000..7e151131b6
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlAttribute.cs
@@ -0,0 +1,14 @@
+using System.Collections.Generic;
+
+namespace Discord.ComponentDesigner.Generator.Parser;
+
+public sealed record CXmlAttribute(
+ SourceSpan Span,
+ CXmlValue.Scalar Name,
+ SourceSpan NameSpan,
+ CXmlValue? Value,
+ params IReadOnlyList Diagnostics
+) : ICXml
+{
+ public bool HasErrors => Diagnostics.Count > 0 || (Value?.HasErrors ?? false);
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlDiagnostic.cs b/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlDiagnostic.cs
new file mode 100644
index 0000000000..dccda3de87
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlDiagnostic.cs
@@ -0,0 +1,9 @@
+using Microsoft.CodeAnalysis;
+
+namespace Discord.ComponentDesigner.Generator.Parser;
+
+public readonly record struct CXmlDiagnostic(
+ DiagnosticSeverity Severity,
+ string Message,
+ SourceSpan Span
+);
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlDoc.cs b/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlDoc.cs
new file mode 100644
index 0000000000..c4f11ba8d8
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlDoc.cs
@@ -0,0 +1,14 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Discord.ComponentDesigner.Generator.Parser;
+
+public sealed record CXmlDoc(
+ SourceSpan Span,
+ IReadOnlyList Elements,
+ IReadOnlyList InterpolationOffsets,
+ params IReadOnlyList Diagnostics
+) : ICXml
+{
+ public bool HasErrors => Diagnostics.Count > 0 || Elements.Any(x => x.HasErrors);
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlElement.cs b/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlElement.cs
new file mode 100644
index 0000000000..19de4ce9fb
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlElement.cs
@@ -0,0 +1,21 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Discord.ComponentDesigner.Generator.Parser;
+
+public sealed record CXmlElement(
+ SourceSpan Span,
+ CXmlValue.Scalar Name,
+ IReadOnlyDictionary Attributes,
+ IReadOnlyList Children,
+ params IReadOnlyList Diagnostics
+) : ICXml
+{
+ public bool HasErrors
+ => Diagnostics.Count > 0 ||
+ Children.Any(x => x.HasErrors) ||
+ Attributes.Values.Any(x => x.HasErrors);
+
+ public CXmlAttribute? GetAttribute(string name)
+ => Attributes.TryGetValue(name, out var attribute) ? attribute : null;
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlTriviaToken.cs b/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlTriviaToken.cs
new file mode 100644
index 0000000000..7a5efea0b7
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlTriviaToken.cs
@@ -0,0 +1,37 @@
+using System;
+
+namespace Discord.ComponentDesigner.Generator.Parser;
+
+public enum CXmlTokenKind
+{
+ ElementStart,
+ ElementEnd,
+ Equals
+}
+
+public readonly record struct CXmlToken(
+ CXmlTokenKind Kind,
+ int LeadingTriviaLength,
+ int TrailingTriviaLength,
+ int Width
+);
+
+public readonly record struct CXmlTriviaToken(
+ TriviaKind Kind,
+ SourceSpan Span,
+ string Value
+);
+
+public readonly record struct TriviaTokenSpan(
+ int Start,
+ int Count
+);
+
+public enum TriviaKind
+{
+ CommentStart,
+ CommentText,
+ CommentEnd,
+ Newline,
+ Whitespace
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlValue.cs b/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlValue.cs
new file mode 100644
index 0000000000..1955eb133b
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlValue.cs
@@ -0,0 +1,36 @@
+using System.Collections.Generic;
+
+namespace Discord.ComponentDesigner.Generator.Parser;
+
+public abstract record CXmlValue(
+ SourceSpan Span,
+ params IReadOnlyList Diagnostics
+) : ICXml
+{
+ public bool HasErrors => Diagnostics.Count > 0;
+
+ public sealed record Invalid(
+ SourceSpan Span,
+ params IReadOnlyList Diagnostics
+ ) : CXmlValue(Span, Diagnostics);
+
+ public sealed record Scalar(
+ SourceSpan Span,
+ string Value,
+ char? QuoteChar = null,
+ params IReadOnlyList Diagnostics
+ ) : CXmlValue(Span, Diagnostics);
+
+ public sealed record Interpolation(
+ SourceSpan Span,
+ int InterpolationIndex,
+ params IReadOnlyList Diagnostics
+ ) : CXmlValue(Span, Diagnostics);
+
+ public sealed record Multipart(
+ SourceSpan Span,
+ IReadOnlyList Values,
+ char? QuoteChar = null,
+ params IReadOnlyList Diagnostics
+ ) : CXmlValue(Span, Diagnostics);
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parser/ComponentParser.cs b/src/Discord.Net.ComponentDesigner.Generator/Parser/ComponentParser.cs
new file mode 100644
index 0000000000..aecce3e510
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Parser/ComponentParser.cs
@@ -0,0 +1,1105 @@
+using Microsoft.CodeAnalysis;
+using System;
+using System.Collections.Generic;
+
+namespace Discord.ComponentDesigner.Generator.Parser;
+
+public sealed class ComponentParser
+{
+ private const string COMMENT_START = "";
+
+ private const char NULL_CHAR = '\0';
+ private const char NEWLINE_CHAR = '\n';
+ private const char CARRAGE_RETURN_CHAR = '\r';
+
+ private const char UNDERSCORE_CHAR = '_';
+ private const char HYPHEN_CHAR = '-';
+ private const char PERIOD_CHAR = '-';
+
+ private const char TAG_OPEN_CHAR = '<';
+ private const char TAG_CLOSE_CHAR = '>';
+ private const char FORWARD_SLASH_CHAR = '/';
+ private const char BACK_SLASH_CHAR = '\\';
+
+ private const char EQUALS_CHAR = '=';
+ private const char QUOTE_CAHR = '\'';
+ private const char DOUBLE_QUOTE_CAHR = '"';
+
+ ///
+ /// the raw source, in its entirety.
+ ///
+ private readonly string _source;
+
+ ///
+ /// the slices that make up the source, as presented by the source generator,
+ /// with each slice representing the boundary between interpolations.
+ ///
+ private readonly string[] _sourceSlices;
+
+ ///
+ /// The source length of each interpolation, as described by the C# source.
+ ///
+ private readonly int[] _interpolationLengths;
+
+ ///
+ /// The offsets of the interpolations, accumulated over the entire source.
+ ///
+ private readonly int[] _interpolationOffsets;
+
+ ///
+ /// A flag-set, containing which interpolations have been processed by the
+ /// parser, since each interpolation has a width of zero within the
+ /// .
+ ///
+ private readonly bool[] _handledInterpolations;
+
+ ///
+ /// if the parser is at the end of the source; otherwise
+ /// .
+ ///
+ private bool IsEOF => _position >= _source.Length;
+
+ ///
+ /// Gets the current character the parser is parsing; if
+ /// the parser is at or past the end of the .
+ ///
+ private char Current => IsEOF ? NULL_CHAR : _source[_position];
+
+ ///
+ /// Gets the next character the parser will parse; if
+ /// the next character to be parsed is at or past the end of the .
+ ///
+ private char Next => _position + 1 >= _source.Length ? NULL_CHAR : _source[_position + 1];
+
+ ///
+ /// Gets the previous character the parser has parsed; if
+ /// the previous character doesn't exist within the bounds of the .
+ ///
+ private char Previous => _position == 0 || _position > _source.Length ? NULL_CHAR : _source[_position - 1];
+
+ ///
+ /// Gets the current location the parser is at as a .
+ ///
+ private SourceLocation CurrentLocation => new(_line, _column, _position);
+
+
+ ///
+ /// The current position (offset) the parser is at.
+ ///
+ private int _position;
+
+ ///
+ /// The current zero-based line the parser is at.
+ ///
+ private int _line;
+
+ ///
+ /// The current zero-based column the parser is at.
+ ///
+ private int _column;
+
+ ///
+ /// A collection of diagnostics that the parser has reported.
+ ///
+ private readonly List _diagnostics;
+
+ ///
+ /// A collection of trivia tokens parsed so far, ordered by appearance
+ ///
+ private readonly List _trivia;
+
+ private ComponentParser(string[] slices, int[] interpolationLengths)
+ {
+ _source = string.Join(string.Empty, slices);
+ _sourceSlices = slices;
+ _interpolationLengths = interpolationLengths;
+
+ _diagnostics = [];
+
+ _trivia = [];
+
+ _interpolationOffsets = new int[slices.Length - 1];
+ _handledInterpolations = new bool[interpolationLengths.Length];
+
+ for (int i = 0, offset = 0; i < slices.Length - 1; i++)
+ {
+ _interpolationOffsets[i] = offset + slices[i].Length;
+ offset += slices[i].Length;
+ }
+ }
+
+ ///
+ /// Parses a given the slices that make up the cxml, and the interpolation lengths.
+ ///
+ /// The string slices that make up the cxml.
+ /// The length of each interpolation, as defined in C# source.
+ ///
+ public static CXmlDoc Parse(string[] slices, int[] interpolationLengths)
+ {
+ var parser = new ComponentParser(slices, interpolationLengths);
+ var elements = new List();
+
+ while (parser.IsElement())
+ {
+ elements.Add(parser.ParseElement());
+ }
+
+ return new CXmlDoc(
+ (default, parser.CurrentLocation),
+ elements,
+ parser._interpolationOffsets,
+ parser._diagnostics
+ );
+ }
+
+ ///
+ /// Reports an error as a using the as
+ /// the diagnostics position.
+ ///
+ /// The error message to report.
+ /// The that represents the error reported.
+ private CXmlDiagnostic ReportError(string message)
+ => ReportDiagnostic(DiagnosticSeverity.Error, message, (CurrentLocation, CurrentLocation));
+
+ ///
+ /// Reports an error as a at the specified .
+ ///
+ /// The error message to report.
+ /// The span describing where the error is.
+ /// The that represents the error reported.
+ private CXmlDiagnostic ReportError(string message, SourceSpan span)
+ => ReportDiagnostic(DiagnosticSeverity.Error, message, span);
+
+ ///
+ /// Reports a given the ,
+ /// , and .
+ ///
+ /// The severity of the diagnostic.
+ /// The human-readable message to report.
+ /// The span describing where the diagnostic is.
+ /// The that represents the error reported.
+ private CXmlDiagnostic ReportDiagnostic(DiagnosticSeverity severity, string message, SourceSpan span)
+ {
+ var diagnostic = new CXmlDiagnostic(severity, message, span);
+ _diagnostics.Add(diagnostic);
+ return diagnostic;
+ }
+
+ ///
+ /// Determines whether the current location contains the start of an element.
+ ///
+ ///
+ /// if the current location contains the start of an
+ /// element; otherwise .
+ ///
+ private bool IsElement()
+ {
+ if (Current is TAG_OPEN_CHAR) return true;
+
+ // we might have some trivia before
+ var location = CurrentLocation;
+ SkipWhitespace();
+ var isStartElement = Current is TAG_OPEN_CHAR;
+ Rollback(location);
+
+ return isStartElement;
+ }
+
+ ///
+ /// Parses a from the at the given and
+ /// advances the parse state.
+ ///
+ ///
+ /// A representing the element parsed at the current .
+ ///
+ ///
+ /// No valid element exists within the current parse state.
+ ///
+ private CXmlElement ParseElement()
+ {
+ SkipWhitespace();
+
+ var startLocation = CurrentLocation;
+ Eat(TAG_OPEN_CHAR);
+
+ var tagName = ParseTagName();
+
+ var attributes = new List();
+ var children = new List();
+ var diagnostics = new List();
+
+ // check for attributes
+ if (IsLikelyAttribute())
+ {
+ attributes.AddRange(ParseAttributes());
+ }
+
+ SkipWhitespace();
+
+ // element close
+ if (Current is FORWARD_SLASH_CHAR)
+ {
+ Eat(FORWARD_SLASH_CHAR);
+ SkipWhitespace();
+
+ if (Current is TAG_CLOSE_CHAR)
+ {
+ Eat(TAG_CLOSE_CHAR);
+
+ // empty element
+ return new CXmlElement(
+ (startLocation, CurrentLocation),
+ tagName,
+ BuildAttributes(),
+ children,
+ diagnostics
+ );
+ }
+
+ diagnostics.Add(
+ ReportError($"Expected '{TAG_CLOSE_CHAR}', got '{Current}'")
+ );
+ }
+ else if (Current is TAG_CLOSE_CHAR)
+ {
+ Eat(TAG_CLOSE_CHAR);
+
+ // parse children next
+ while (true)
+ {
+ var location = CurrentLocation;
+
+ SkipWhitespace();
+
+ var hasInterpolationBetweenWhiteSpace = IsInterpolationBetween(location.Offset, _position);
+
+ if (IsEOF)
+ {
+ diagnostics.Add(
+ ReportError(
+ $"Missing closing tag, expected '{tagName}', got EOF",
+ tagName.Span
+ )
+ );
+ break;
+ }
+
+ if (Current is TAG_OPEN_CHAR && !hasInterpolationBetweenWhiteSpace)
+ {
+ Eat(TAG_OPEN_CHAR);
+ SkipWhitespace();
+
+ if (Current is FORWARD_SLASH_CHAR)
+ {
+ Eat(FORWARD_SLASH_CHAR);
+ // check for our tag close
+ var tagCloseName = ParseTagName();
+
+ if (tagName.Value != tagCloseName.Value)
+ {
+ diagnostics.Add(
+ ReportError(
+ $"Missing closing tag",
+ tagName.Span
+ )
+ );
+
+ // revert back this closing tag for the parent to consume
+ Rollback(location);
+ break;
+ }
+
+ SkipWhitespace();
+ Eat(TAG_CLOSE_CHAR);
+ break;
+ }
+ else
+ {
+ Rollback(location);
+ // it's another tag, parse it as such
+ children.Add(ParseElement());
+ }
+ }
+ else
+ {
+ Rollback(location);
+ children.Add(ParseValue(ValueParsingMode.ElementValue));
+ }
+ }
+ }
+ else
+ {
+ diagnostics.Add(
+ ReportError(
+ "Missing closing tag",
+ (startLocation, CurrentLocation)
+ )
+ );
+ }
+
+ return new CXmlElement(
+ (startLocation, CurrentLocation),
+ tagName,
+ BuildAttributes(),
+ children,
+ diagnostics
+ );
+
+ IReadOnlyDictionary BuildAttributes()
+ {
+ var result = new Dictionary();
+
+ foreach (var attribute in attributes)
+ {
+ if (result.ContainsKey(attribute.Name.Value))
+ {
+ diagnostics.Add(
+ ReportError($"Duplicate attribute '{attribute.Name}'", attribute.Span)
+ );
+ continue;
+ }
+
+ result.Add(attribute.Name.Value, attribute);
+ }
+
+ return result;
+ }
+ }
+
+ ///
+ /// Parses a set of attributes at the current and advances
+ /// the parse state.
+ ///
+ ///
+ /// An enumerable representing the parsing of the attributes at the current .
+ ///
+ private IEnumerable ParseAttributes()
+ {
+ while (IsLikelyAttribute())
+ yield return ParseAttribute();
+ }
+
+ ///
+ /// Determines if the current within the is most likely
+ /// an attribute.
+ ///
+ ///
+ /// if the current parse state is most likely at an attribute; otherwise
+ /// .
+ ///
+ private bool IsLikelyAttribute()
+ {
+ SkipWhitespace();
+
+ return Current is not TAG_CLOSE_CHAR and not FORWARD_SLASH_CHAR && IsValidNameStartChar(Current);
+ }
+
+ ///
+ /// Parses a at the current , and advances
+ /// the parse state.
+ ///
+ ///
+ /// The parsed from the at the current
+ /// .
+ ///
+ private CXmlAttribute ParseAttribute()
+ {
+ SkipWhitespace();
+
+ var startLocation = CurrentLocation;
+
+ var attributeName = ParseTagName();
+ var nameEndLocation = CurrentLocation;
+
+ CXmlValue? value = null;
+
+ SkipWhitespace();
+
+ // does it have a value?
+ if (Current is EQUALS_CHAR)
+ {
+ Eat(EQUALS_CHAR);
+ value = ParseValue(ValueParsingMode.AttributeValue);
+ }
+
+ return new CXmlAttribute(
+ Span: (startLocation, value is null ? nameEndLocation : CurrentLocation),
+ Name: attributeName,
+ NameSpan: (startLocation, nameEndLocation),
+ Value: value
+ );
+ }
+
+ ///
+ /// Represents the mode on which to parse .
+ ///
+ private enum ValueParsingMode
+ {
+ ///
+ /// Parses values allowed inside of attributes.
+ ///
+ AttributeValue,
+
+ ///
+ /// Parses values allowed inside of elements.
+ ///
+ ElementValue
+ }
+
+ ///
+ /// Parses a at the current using the given
+ /// and updates the parse state.
+ ///
+ ///
+ /// This function will return a atom in cases that no value can be parsed with
+ /// the given mode.
+ ///
+ /// The mode of which to parse the value.
+ ///
+ /// The parsed from the at the current .
+ ///
+ /// Unknown/invalid .
+ private CXmlValue ParseValue(ValueParsingMode mode)
+ {
+ var diagnostics = new List();
+
+ var startLocation = CurrentLocation;
+
+ if (IsEOF)
+ {
+ diagnostics.Add(
+ ReportError("Expected value, got EOF")
+ );
+
+ return new CXmlValue.Scalar((startLocation, CurrentLocation), "", Diagnostics: diagnostics);
+ }
+
+ switch (mode)
+ {
+ case ValueParsingMode.ElementValue:
+ return ReadElementValue();
+
+ case ValueParsingMode.AttributeValue:
+ // can be quoted
+ if (Current is not QUOTE_CAHR and not DOUBLE_QUOTE_CAHR)
+ {
+ // check for string interpolation
+ if (IsAtStartOfInterpolation(out var interpolationIndex))
+ {
+ return new CXmlValue.Interpolation(
+ (startLocation, CurrentLocation),
+ interpolationIndex,
+ diagnostics
+ );
+ }
+
+ // otherwise it's an invalid attribute
+ diagnostics.Add(
+ ReportError(
+ $"Invalid attribute value: expected a quoted or interpolated string, got '{Current}'"
+ )
+ );
+
+ return new CXmlValue.Invalid(
+ (startLocation, CurrentLocation),
+ diagnostics
+ );
+ }
+
+ return ParseQuotedValue();
+
+ default: throw new NotSupportedException($"Unknown value parsing mode '{mode}'");
+ }
+
+ CXmlValue ReadElementValue()
+ {
+ var globalStartLocation = CurrentLocation;
+ var startLocation = CurrentLocation;
+ var parts = new List();
+ var diagnostics = new List();
+
+ var hasOnlyWhitespace = true;
+
+ while (true)
+ {
+ if (hasOnlyWhitespace && !char.IsWhiteSpace(Current))
+ {
+ hasOnlyWhitespace = false;
+ globalStartLocation = CurrentLocation;
+ }
+
+ while (IsAtStartOfInterpolation(out var offsetIndex))
+ {
+ // do we have any content up to this point?
+ if (startLocation.Offset != _position && !hasOnlyWhitespace)
+ {
+ parts.Add(
+ new CXmlValue.Scalar(
+ (startLocation, CurrentLocation),
+ _source.Substring(startLocation.Offset, (_position - startLocation.Offset))
+ )
+ );
+ }
+
+ parts.Add(
+ new CXmlValue.Interpolation(
+ (CurrentLocation, CurrentLocation),
+ offsetIndex
+ )
+ );
+
+ startLocation = CurrentLocation;
+ if (hasOnlyWhitespace)
+ {
+ globalStartLocation = CurrentLocation;
+ hasOnlyWhitespace = false;
+ }
+ }
+
+ if (IsEOF || Current is TAG_OPEN_CHAR) break; // we're done reading
+
+ if (Current is NEWLINE_CHAR)
+ {
+ Advance();
+ _line++;
+ _column = 0;
+
+ continue;
+ }
+
+ if (Current is CARRAGE_RETURN_CHAR)
+ {
+ Advance();
+
+ var isValidFullReturn = Current is NEWLINE_CHAR;
+
+ if (!isValidFullReturn)
+ {
+ // TODO: report incorrect newlines
+ }
+ else Advance();
+
+ _line++;
+ _column = 0;
+ continue;
+ }
+
+ Advance();
+ }
+
+ if (parts.Count is 0)
+ {
+ // basic scalar
+ return new CXmlValue.Scalar(
+ (startLocation, CurrentLocation),
+ _source.Substring(startLocation.Offset, (_position - startLocation.Offset)),
+ Diagnostics: diagnostics
+ );
+ }
+
+ if (startLocation != CurrentLocation)
+ {
+ var remainder = _source.Substring(startLocation.Offset, (_position - startLocation.Offset));
+
+ if (!string.IsNullOrWhiteSpace(remainder))
+ {
+ // add remaining
+ parts.Add(
+ new CXmlValue.Scalar(
+ (startLocation, CurrentLocation),
+ Value: remainder
+ )
+ );
+ }
+ }
+
+
+ if (parts.Count is 1) return parts[0];
+
+ return new CXmlValue.Multipart(
+ (globalStartLocation, CurrentLocation),
+ parts,
+ Diagnostics: diagnostics
+ );
+ }
+
+
+ bool IsAtStartOfInterpolation(out int interpolationIndex)
+ {
+ for (interpolationIndex = 0; interpolationIndex < _interpolationOffsets.Length; interpolationIndex++)
+ {
+ var offset = _interpolationOffsets[interpolationIndex];
+
+ if (offset > _position) break;
+
+ if (offset == _position && !_handledInterpolations[interpolationIndex])
+ return _handledInterpolations[interpolationIndex] = true;
+ }
+
+ return false;
+ }
+
+ CXmlValue ParseQuotedValue()
+ {
+ var parts = new List();
+
+ var quoteChar = Current;
+ Eat(quoteChar);
+
+ var valueStartLocation = CurrentLocation;
+
+ while (true)
+ {
+ if (IsAtStartOfInterpolation(out var interpolationIndex))
+ {
+ // is the interpolation the first part?
+ if (valueStartLocation.Offset != _position)
+ {
+ // add our current content to the parts
+ parts.Add(
+ new CXmlValue.Scalar(
+ (valueStartLocation, CurrentLocation),
+ _source.Substring(
+ valueStartLocation.Offset,
+ CurrentLocation.Offset - valueStartLocation.Offset
+ )
+ )
+ );
+ }
+
+ // add the interpolation
+ parts.Add(
+ new CXmlValue.Interpolation(
+ (CurrentLocation, CurrentLocation),
+ interpolationIndex
+ )
+ );
+
+ // reset the value start position
+ valueStartLocation = CurrentLocation;
+ continue;
+ }
+
+ if (Current == quoteChar)
+ {
+ // is it escaped?
+ if (Previous == BACK_SLASH_CHAR)
+ {
+ Advance();
+ continue;
+ }
+
+ // value has ended
+ if (parts.Count > 0)
+ {
+ parts.Add(
+ new CXmlValue.Scalar(
+ (valueStartLocation, CurrentLocation),
+ _source.Substring(
+ valueStartLocation.Offset,
+ (CurrentLocation.Offset - valueStartLocation.Offset)
+ ),
+ Diagnostics: diagnostics
+ )
+ );
+ }
+
+ Advance();
+
+ break;
+ }
+
+ if (IsEOF)
+ {
+ diagnostics.Add(
+ ReportError("Unclosed attribute value, got EOF", (startLocation, CurrentLocation))
+ );
+
+ break;
+ }
+
+ Advance();
+ }
+
+ if (parts.Count > 0)
+ {
+ return new CXmlValue.Multipart(
+ (startLocation, CurrentLocation),
+ parts,
+ quoteChar,
+ diagnostics
+ );
+ }
+
+ return new CXmlValue.Scalar(
+ (startLocation, CurrentLocation),
+ _source.Substring(
+ startLocation.Offset + 1,
+ (CurrentLocation.Offset - startLocation.Offset - 2)
+ ),
+ quoteChar,
+ diagnostics
+ );
+ }
+ }
+
+ ///
+ /// Determines whether an unhandled interpolation exists between and
+ /// .
+ ///
+ /// The lower inclusive bound.
+ /// The upper inclusive bound.
+ ///
+ /// if an unhandled interpolation exists between the specified bounds; otherwise
+ /// .
+ ///
+ private bool IsInterpolationBetween(int start, int end)
+ {
+ if (_interpolationOffsets.Length is 0) return false;
+
+ for (var i = 0; i < _interpolationOffsets.Length; i++)
+ {
+ var offset = _interpolationOffsets[i];
+
+ if (offset < start) continue;
+
+ if (_handledInterpolations[i]) continue;
+
+ return offset >= start && end >= offset;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Parses a spec-compliant element/attribute name at the current and updates the
+ /// parse state.
+ ///
+ ///
+ /// The parsed representing the tag name.
+ ///
+ private CXmlValue.Scalar ParseTagName()
+ {
+ SkipWhitespace();
+
+ var diagnostics = new List();
+ var nameStart = CurrentLocation;
+
+ if (IsEOF)
+ {
+ diagnostics.Add(ReportError("Expected tag name start, got EOF"));
+ return new CXmlValue.Scalar(
+ (CurrentLocation, CurrentLocation),
+ string.Empty,
+ Diagnostics: diagnostics
+ );
+ }
+
+ if (!IsValidNameStartChar(Current))
+ {
+ diagnostics.Add(ReportError($"Expected tag name start, got '{Current}'"));
+ Advance();
+ return new CXmlValue.Scalar(
+ (nameStart, CurrentLocation),
+ string.Empty,
+ Diagnostics: diagnostics
+ );
+ }
+
+ Advance();
+
+ while (IsValidNameRestChar(Current)) Advance();
+
+ return new CXmlValue.Scalar(
+ (nameStart, CurrentLocation),
+ _source.Substring(nameStart.Offset, (_position - nameStart.Offset))
+ );
+ }
+
+ ///
+ /// Determines whether the supplied is a valid starting character of a spec-compliant
+ /// element/attribute name.
+ ///
+ /// The character to validate.
+ ///
+ /// if the character is a valid starting name character; otherwise
+ /// .
+ ///
+ private static bool IsValidNameStartChar(char c)
+ => c is UNDERSCORE_CHAR || char.IsLetter(c);
+
+ ///
+ /// Determines whether the supplied is a valid remaining character of a spec-compliant
+ /// element/attribute name.
+ ///
+ /// The character to validate.
+ ///
+ /// if the character is a valid remaining name character; otherwise
+ /// .
+ ///
+ private static bool IsValidNameRestChar(char c)
+ => c is UNDERSCORE_CHAR or HYPHEN_CHAR or PERIOD_CHAR || char.IsLetterOrDigit(c);
+
+ ///
+ /// Parses syntax trivia at the current and advances the parse state.
+ ///
+ ///
+ /// A pointing to the syntax trivia tokens parsed.
+ ///
+ private TriviaTokenSpan ParseTrivia()
+ {
+ if (IsEOF) return default;
+
+ // have we parsed this trivia already?
+
+ var triviaStart = _trivia.Count;
+
+ if (_trivia.Count > 0)
+ {
+ var latestTrivia = _trivia[_trivia.Count - 1];
+
+ if (latestTrivia.Span.End.Offset >= _position)
+ {
+ // we have some trivia that has been parsed, update 'triviaStart' to trivia that's after
+ // our source position
+ triviaStart--;
+ for (; triviaStart >= 0; triviaStart--)
+ {
+ var trivia = _trivia[triviaStart];
+
+ if (trivia.Span.End.Offset < _position) break;
+ }
+ }
+ }
+
+ while (true)
+ {
+ var startLocation = CurrentLocation;
+
+ switch (Current)
+ {
+ case CARRAGE_RETURN_CHAR:
+ Advance(1);
+
+ if (Current is NEWLINE_CHAR)
+ {
+ Advance(1);
+ }
+
+ // todo: how do we want to handle improper returns?
+
+ _trivia.Add(
+ new CXmlTriviaToken(
+ TriviaKind.Newline,
+ (startLocation, CurrentLocation),
+ _source.Substring(startLocation.Offset, CurrentLocation.Offset - startLocation.Offset)
+ )
+ );
+
+ continue;
+
+ case NEWLINE_CHAR:
+ Advance(1);
+ _trivia.Add(
+ new CXmlTriviaToken(
+ TriviaKind.Newline,
+ (startLocation, CurrentLocation),
+ _source.Substring(startLocation.Offset, CurrentLocation.Offset - startLocation.Offset)
+ )
+ );
+ continue;
+
+ case ' ':
+ do
+ {
+ Advance();
+ } while (char.IsWhiteSpace(Current));
+
+ _trivia.Add(
+ new CXmlTriviaToken(
+ TriviaKind.Whitespace,
+ (startLocation, CurrentLocation),
+ _source.Substring(startLocation.Offset, CurrentLocation.Offset - startLocation.Offset)
+ )
+ );
+
+ continue;
+
+ default:
+ if (char.IsWhiteSpace(Current)) goto case ' ';
+
+ // check for comments
+ if (GetSlice(COMMENT_START.Length) == COMMENT_START)
+ {
+ Advance(COMMENT_START.Length);
+ _trivia.Add(
+ new CXmlTriviaToken(
+ TriviaKind.CommentStart,
+ (startLocation, CurrentLocation),
+ _source.Substring(startLocation.Offset, CurrentLocation.Offset - startLocation.Offset)
+ )
+ );
+ ParseComment();
+ continue;
+ }
+
+ goto endTrivia;
+ }
+ }
+
+ endTrivia:
+ return new(triviaStart, _trivia.Count - triviaStart);
+
+ void ParseComment()
+ {
+ var commentBodyStart = CurrentLocation;
+
+ while (Current is not NULL_CHAR)
+ {
+ if (Current is not '-')
+ {
+ Advance();
+ continue;
+ }
+
+ // check for ending comment
+ if (GetSlice(COMMENT_END.Length) == COMMENT_END)
+ {
+ _trivia.Add(
+ new CXmlTriviaToken(
+ TriviaKind.CommentText,
+ (commentBodyStart, CurrentLocation),
+ _source.Substring(commentBodyStart.Offset, CurrentLocation.Offset - commentBodyStart.Offset)
+ )
+ );
+
+ var commentEndAt = CurrentLocation;
+ Advance(COMMENT_END.Length);
+ _trivia.Add(
+ new CXmlTriviaToken(
+ TriviaKind.CommentText,
+ (commentEndAt, CurrentLocation),
+ _source.Substring(commentEndAt.Offset, CurrentLocation.Offset - commentEndAt.Offset)
+ )
+ );
+ return;
+ }
+ }
+
+ // we're missing the close tag
+ _trivia.Add(
+ new CXmlTriviaToken(
+ TriviaKind.CommentText,
+ (commentBodyStart, CurrentLocation),
+ _source.Substring(commentBodyStart.Offset, CurrentLocation.Offset - commentBodyStart.Offset)
+ )
+ );
+
+ ReportError("Missing comment close tag", (commentBodyStart, CurrentLocation));
+
+ }
+ }
+
+ ///
+ /// Skips any whitespace characters, updating the parse state in the process.
+ ///
+ private void SkipWhitespace()
+ {
+ if (IsEOF) return;
+
+ while (IsWhitespaceChar(Current))
+ {
+ if (Current is NEWLINE_CHAR)
+ {
+ Advance();
+
+ _line++;
+ _column = 0;
+ continue;
+ }
+ else if (Current is CARRAGE_RETURN_CHAR)
+ {
+ var isProperFullNewline = Next is NEWLINE_CHAR;
+ if (!isProperFullNewline)
+ {
+ // treat as a newline anyways.
+ // TODO: figure out correct behaviour
+ }
+
+ Advance(isProperFullNewline ? 2 : 1);
+ _line++;
+ _column = 0;
+ continue;
+ }
+
+ Advance();
+ }
+ }
+
+ ///
+ /// Determines whether the supplied is a whitespace character.
+ ///
+ /// The character to verify.
+ ///
+ /// if the supplied is a whitespace character; otherwise
+ /// .
+ ///
+ private static bool IsWhitespaceChar(char c)
+ => char.IsWhiteSpace(c);
+
+ ///
+ /// Advances the parser if and only if the character is equal to the supplied
+ /// ; otherwise a is raised.
+ ///
+ /// The character expected to be at the current .
+ ///
+ /// The current character was not the supplied character.
+ ///
+ private void Eat(char c)
+ {
+ if (IsEOF) throw new InvalidOperationException($"Expected '{c}', got EOF");
+
+ if (Current != c) throw new InvalidOperationException($"Expected '{c}', got {Current}");
+
+ Advance();
+ }
+
+ ///
+ /// Rolls back the parser to the specified .
+ ///
+ ///
+ /// The location on which to roll back to.
+ ///
+ private void Rollback(SourceLocation location)
+ {
+ _position = location.Offset;
+ _line = location.Line;
+ _column = location.Column;
+ }
+
+ ///
+ /// Advances the parser '' characters.
+ ///
+ /// This method does NOT update the field.
+ /// The number of characters to advance by
+ private void Advance(int c = 1)
+ {
+ _position += c;
+ _column += c;
+ }
+
+ ///
+ /// Extracts a slice representing the bounds from the with the provided
+ /// .
+ ///
+ /// The number of characters to slice.
+ ///
+ /// A string representing the characters from the current position with the size of .
+ ///
+ private string GetSlice(int count)
+ {
+ var sz = Math.Min(count, _source.Length - _position);
+ return _source.Substring(_position, sz);
+ }
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parser/ICXml.cs b/src/Discord.Net.ComponentDesigner.Generator/Parser/ICXml.cs
new file mode 100644
index 0000000000..4056ddf855
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Parser/ICXml.cs
@@ -0,0 +1,12 @@
+using System.Collections.Generic;
+
+namespace Discord.ComponentDesigner.Generator.Parser;
+
+public interface ICXml
+{
+ SourceSpan Span { get; }
+
+ IReadOnlyList Diagnostics { get; }
+
+ bool HasErrors { get; }
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parser/SourceLocation.cs b/src/Discord.Net.ComponentDesigner.Generator/Parser/SourceLocation.cs
new file mode 100644
index 0000000000..603a897dd4
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Parser/SourceLocation.cs
@@ -0,0 +1,10 @@
+namespace Discord.ComponentDesigner.Generator.Parser;
+
+public readonly record struct SourceLocation(
+ int Line,
+ int Column,
+ int Offset
+)
+{
+ public static implicit operator SourceLocation((int, int, int) tuple) => new(tuple.Item1, tuple.Item2, tuple.Item3);
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parser/SourceSpan.cs b/src/Discord.Net.ComponentDesigner.Generator/Parser/SourceSpan.cs
new file mode 100644
index 0000000000..f188166620
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Parser/SourceSpan.cs
@@ -0,0 +1,15 @@
+namespace Discord.ComponentDesigner.Generator.Parser;
+
+public readonly record struct SourceSpan(
+ SourceLocation Start,
+ SourceLocation End
+)
+{
+ public static implicit operator SourceSpan((SourceLocation, SourceLocation) tuple) => new(tuple.Item1, tuple.Item2);
+
+ public int Length => End.Offset - Start.Offset;
+
+ public int LineDelta => End.Line - Start.Line;
+ public int ColumnDelta => End.Column - Start.Column;
+
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs b/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs
new file mode 100644
index 0000000000..9a449142ed
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs
@@ -0,0 +1,283 @@
+using Discord.ComponentDesigner.Generator.Nodes;
+using Discord.ComponentDesigner.Generator.Parser;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Operations;
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Text;
+using System.Threading;
+
+namespace Discord.ComponentDesigner.Generator;
+
+[Generator]
+public sealed class SourceGenerator : IIncrementalGenerator
+{
+ private readonly record struct Target(
+ InterceptableLocation InterceptableLocation,
+ Location Location,
+ string[] Content,
+ InterpolationInfo[] Interpolations,
+ bool IsMultiLine,
+ KnownTypes KnownTypes,
+ Func> LookupNode
+ )
+ {
+ public bool Equals(Target? other)
+ => other is { } target &&
+ InterceptableLocation.Equals(target.InterceptableLocation) &&
+ Location.Equals(target.Location) &&
+ Content.SequenceEqual(target.Content) &&
+ Interpolations.SequenceEqual(target.Interpolations) &&
+ IsMultiLine == target.IsMultiLine;
+
+ public override int GetHashCode()
+ {
+ unchecked
+ {
+ var hashCode = InterceptableLocation.GetHashCode();
+ hashCode = (hashCode * 397) ^ Location.GetHashCode();
+ hashCode = (hashCode * 397) ^ Content.Aggregate(0, (a, b) => (a * 397) ^ b.GetHashCode());
+ hashCode = (hashCode * 397) ^ Interpolations.Aggregate(0, (a, b) => (a * 397) ^ b.GetHashCode());
+ hashCode = (hashCode * 397) ^ IsMultiLine.GetHashCode();
+ return hashCode;
+ }
+ }
+ }
+
+ private readonly record struct Interceptor(
+ string? Source,
+ Diagnostic[] Diagnostics
+ );
+
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ var provider = context
+ .SyntaxProvider
+ .CreateSyntaxProvider((x, _) =>
+ x is InvocationExpressionSyntax
+ {
+ Expression: MemberAccessExpressionSyntax
+ {
+ Name: {Identifier.Value: "Create" or "cx"}
+ } or IdentifierNameSyntax
+ {
+ Identifier.ValueText: "cx"
+ }
+ },
+ Transform
+ )
+ .Select(BuildInterceptor)
+ .Collect();
+
+ context.RegisterSourceOutput(provider, Generate);
+ }
+
+ private static Interceptor? BuildInterceptor(Target? rawTarget, CancellationToken token)
+ {
+ if (rawTarget is not { } target) return null;
+
+ var diagnostics = new List();
+
+ var interpolationLengths = target.Interpolations.Select(x => x.Length).ToArray();
+ var doc = ComponentParser.Parse(target.Content, interpolationLengths);
+
+ var componentContext = new ComponentNodeContext(
+ doc,
+ target.Location,
+ target.IsMultiLine,
+ target.Interpolations,
+ target.KnownTypes,
+ target.LookupNode
+ );
+
+ foreach (var parsingDiagnostic in doc.Diagnostics)
+ {
+ diagnostics.Add(
+ Diagnostic.Create(
+ Diagnostics.ComponentParseError,
+ componentContext.GetLocation(parsingDiagnostic.Span),
+ parsingDiagnostic.Message
+ )
+ );
+ }
+
+ var nodes = doc
+ .Elements
+ .Select(x => ComponentNode.Create(x, componentContext))
+ .Where(x => x is not null)
+ .ToArray();
+
+ foreach (var node in nodes)
+ {
+ node!.ReportValidationErrors();
+ }
+
+ diagnostics.AddRange(componentContext.Diagnostics);
+
+ if (componentContext.HasErrors) return new(null, [..diagnostics]);
+
+ return new Interceptor(
+ $$"""
+ [global::System.Runtime.CompilerServices.InterceptsLocation(version: {{target.InterceptableLocation.Version}}, data: "{{target.InterceptableLocation.Data}}")]
+ public static global::Discord.ComponentBuilderV2 _{{Math.Abs(target.GetHashCode())}}(
+ global::{{Constants.COMPONENT_DESIGNER_QUALIFIED_NAME}} designer
+ )
+ {
+ return new([
+ {{
+ string.Join(
+ "\n".Postfix(8),
+ nodes.Select(x => x!.Render().WithNewlinePadding(8))
+ )
+ }}
+ ]);
+ }
+ """,
+ [..diagnostics]
+ );
+ }
+
+ private void Generate(SourceProductionContext context, ImmutableArray arg2)
+ {
+ var sb = new StringBuilder();
+
+ foreach (var interceptor in arg2)
+ {
+ if (!interceptor.HasValue) continue;
+
+ foreach (var diagnostic in interceptor.Value.Diagnostics)
+ {
+ context.ReportDiagnostic(diagnostic);
+ }
+
+ if (interceptor.Value.Source is not null) sb.AppendLine(interceptor.Value.Source);
+ }
+
+ if (sb.Length is 0) return;
+
+ context.AddSource(
+ "Interceptors.g.cs",
+ $$"""
+ using Discord;
+
+ namespace System.Runtime.CompilerServices
+ {
+ [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
+ sealed file class InterceptsLocationAttribute(int version, string data) : Attribute
+ {
+ }
+ }
+
+ namespace InlineComponent
+ {
+ static file class Interceptors
+ {
+ {{sb.ToString().Replace("\n", "\n ")}}
+ }
+ }
+ """
+ );
+ }
+
+ private Target? Transform(GeneratorSyntaxContext context, CancellationToken token)
+ {
+ var operation = context.SemanticModel.GetOperation(context.Node, token);
+
+ checkOperation:
+ switch (operation)
+ {
+ case IInvalidOperation invalid:
+ operation = invalid.ChildOperations.OfType().FirstOrDefault();
+ goto checkOperation;
+ case IInvocationOperation invocation:
+ if (
+ invocation
+ .TargetMethod
+ .ContainingType
+ .ToDisplayString()
+ is "InlineComponent.InlineComponentBuilder"
+ ) break;
+ goto default;
+
+ default: return null;
+ }
+
+ if (context.Node is not InvocationExpressionSyntax invocationSyntax) return null;
+
+ if (context.SemanticModel.GetInterceptableLocation(invocationSyntax) is not { } location)
+ return null;
+
+ if (invocationSyntax.ArgumentList.Arguments.Count is not 1) return null;
+
+ var argument = invocationSyntax.ArgumentList.Arguments[0].Expression;
+
+ var content = new List();
+ var interpolations = new List();
+ var isMultiLine = false;
+
+ switch (argument)
+ {
+ case InterpolatedStringExpressionSyntax interpolated:
+ foreach (var interpolation in interpolated.Contents)
+ {
+ switch (interpolation)
+ {
+ case InterpolatedStringTextSyntax interpolatedStringTextSyntax:
+ content.Add(interpolatedStringTextSyntax.TextToken.ValueText);
+ break;
+ case InterpolationSyntax interpolationSyntax:
+ var typeInfo = ModelExtensions.GetTypeInfo(context
+ .SemanticModel, interpolationSyntax.Expression, token);
+
+ if (typeInfo.Type is null) return null;
+
+ interpolations.Add(
+ new InterpolationInfo(
+ interpolations.Count,
+ interpolationSyntax.FullSpan.Length,
+ typeInfo.Type
+ )
+ );
+ // interpolationLengths.Add(interpolationSyntax.FullSpan.Length);
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(interpolation));
+ }
+ }
+
+ if (content.Count is 0) return null;
+ isMultiLine = interpolated.StringStartToken.Kind()
+ is SyntaxKind.MultiLineRawStringLiteralToken
+ or SyntaxKind.InterpolatedMultiLineRawStringStartToken;
+ break;
+
+
+ case LiteralExpressionSyntax {Token.Value: string stringContent} literal:
+ content.Add(stringContent);
+ isMultiLine = literal.Token.Kind()
+ is SyntaxKind.MultiLineRawStringLiteralToken
+ or SyntaxKind.InterpolatedMultiLineRawStringStartToken;
+ break;
+
+ default: return null;
+ }
+
+
+ return new Target(
+ location,
+ argument.GetLocation(),
+ content.ToArray(),
+ interpolations.ToArray(),
+ isMultiLine,
+ context.SemanticModel.Compilation.GetKnownTypes(),
+ LookupNode
+ );
+
+ ImmutableArray LookupNode(string? name)
+ => context.SemanticModel.LookupNamespacesAndTypes(context.Node.SpanStart, name: name);
+ }
+}
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Utils/IsExternalInit.cs b/src/Discord.Net.ComponentDesigner.Generator/Utils/IsExternalInit.cs
new file mode 100644
index 0000000000..103d14aa61
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Utils/IsExternalInit.cs
@@ -0,0 +1,3 @@
+namespace System.Runtime.CompilerServices;
+
+internal sealed class IsExternalInit : Attribute;
diff --git a/src/Discord.Net.ComponentDesigner.Generator/Utils/KnownTypes.cs b/src/Discord.Net.ComponentDesigner.Generator/Utils/KnownTypes.cs
new file mode 100644
index 0000000000..e4401a5bc4
--- /dev/null
+++ b/src/Discord.Net.ComponentDesigner.Generator/Utils/KnownTypes.cs
@@ -0,0 +1,504 @@
+using Microsoft.CodeAnalysis;
+using System;
+using System.Collections;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Collections.ObjectModel;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+
+namespace Discord.ComponentDesigner.Generator;
+
+public class KnownTypes
+{
+ public Compilation Compilation { get; }
+
+ public KnownTypes(Compilation compilation)
+ {
+ Compilation = compilation;
+ }
+
+ public INamedTypeSymbol? ICXElementType
+ => GetOrResolveType("InlineComponent.ICXElement", ref _ICXElementType);
+
+ private Optional _ICXElementType;
+
+ public INamedTypeSymbol? ComponentTypeEnumType
+ => GetOrResolveType("Discord.ComponentType", ref _ComponentTypeEnumType);
+
+ private Optional _ComponentTypeEnumType;
+
+ public INamedTypeSymbol? IMessageComponentBuilderType
+ => GetOrResolveType("Discord.IMessageComponentBuilder", ref _IMessageComponentBuilderType);
+
+ private Optional _IMessageComponentBuilderType;
+
+ public INamedTypeSymbol? ActionRowBuilderType
+ => GetOrResolveType("Discord.ActionRowBuilder", ref _ActionRowBuilderType);
+
+ private Optional _ActionRowBuilderType;
+
+ public INamedTypeSymbol? ButtonBuilderType
+ => GetOrResolveType("Discord.ButtonBuilder", ref _ButtonBuilderType);
+
+ private Optional _ButtonBuilderType;
+
+ public INamedTypeSymbol? ComponentBuilderType
+ => GetOrResolveType("Discord.ComponentBuilder", ref _ComponentBuilderType);
+
+ private Optional _ComponentBuilderType;
+
+ public INamedTypeSymbol? ComponentBuilderV2Type
+ => GetOrResolveType("Discord.ComponentBuilderV2", ref _ComponentBuilderV2Type);
+
+ private Optional _ComponentBuilderV2Type;
+
+ public INamedTypeSymbol? ContainerBuilderType
+ => GetOrResolveType("Discord.ContainerBuilder", ref _ContainerBuilderType);
+
+ private Optional _ContainerBuilderType;
+
+ public INamedTypeSymbol? FileComponentBuilderType
+ => GetOrResolveType("Discord.FileComponentBuilder", ref _FileComponentBuilderType);
+
+ private Optional _FileComponentBuilderType;
+
+ public INamedTypeSymbol? MediaGalleryBuilderType
+ => GetOrResolveType("Discord.MediaGalleryBuilder", ref _MediaGalleryBuilderType);
+
+ private Optional _MediaGalleryBuilderType;
+
+ public INamedTypeSymbol? MediaGalleryItemPropertiesType
+ => GetOrResolveType("Discord.MediaGalleryItemProperties", ref _MediaGalleryItemPropertiesType);
+
+ private Optional _MediaGalleryItemPropertiesType;
+
+ public INamedTypeSymbol? SectionBuilderType
+ => GetOrResolveType("Discord.SectionBuilder", ref _SectionBuilderType);
+
+ private Optional _SectionBuilderType;
+
+ public INamedTypeSymbol? SelectMenuBuilderType
+ => GetOrResolveType("Discord.SelectMenuBuilder", ref _SelectMenuBuilderType);
+
+ private Optional _SelectMenuBuilderType;
+
+ public INamedTypeSymbol? SelectMenuOptionBuilderType
+ => GetOrResolveType("Discord.SelectMenuOptionBuilder", ref _SelectMenuOptionBuilderType);
+
+ private Optional _SelectMenuOptionBuilderType;
+
+ public INamedTypeSymbol? SelectMenuDefaultValueType
+ => GetOrResolveType("Discord.SelectMenuDefaultValue", ref _SelectMenuDefaultValueType);
+
+ private Optional _SelectMenuDefaultValueType;
+
+ public INamedTypeSymbol? SelectDefaultValueTypeEnumType
+ => GetOrResolveType("Discord.SelectDefaultValueType", ref _SelectDefaultValueTypeEnumType);
+
+ private Optional _SelectDefaultValueTypeEnumType;
+
+ public INamedTypeSymbol? SeparatorBuilderType
+ => GetOrResolveType("Discord.SeparatorBuilder", ref _SeparatorBuilderType);
+
+ private Optional _SeparatorBuilderType;
+
+ public INamedTypeSymbol? SeparatorSpacingSizeType
+ => GetOrResolveType("Discord.SeparatorSpacingSize", ref _SeparatorSpacingSizeType);
+
+ private Optional _SeparatorSpacingSizeType;
+
+ public INamedTypeSymbol? TextDisplayBuilderType
+ => GetOrResolveType("Discord.TextDisplayBuilder", ref _TextDisplayBuilderType);
+
+ private Optional _TextDisplayBuilderType;
+
+ public INamedTypeSymbol? TextInputBuilderType
+ => GetOrResolveType("Discord.TextInputBuilder", ref _TextInputBuilderType);
+
+ private Optional _TextInputBuilderType;
+
+ public INamedTypeSymbol? ThumbnailBuilderType
+ => GetOrResolveType("Discord.ThumbnailBuilder", ref _ThumbnailBuilderType);
+
+ private Optional _ThumbnailBuilderType;
+
+ public INamedTypeSymbol? UnfurledMediaItemPropertiesType
+ => GetOrResolveType("Discord.UnfurledMediaItemProperties", ref _UnfurledMediaItemPropertiesType);
+
+ private Optional _UnfurledMediaItemPropertiesType;
+
+
+ public HashSet? BuiltInSupportTypes { get; set; }
+
+ public INamedTypeSymbol? IListOfTType => GetOrResolveType(typeof(IList<>), ref _IListOfTType);
+ private Optional _IListOfTType;
+
+ public INamedTypeSymbol? ICollectionOfTType => GetOrResolveType(typeof(ICollection<>), ref _ICollectionOfTType);
+ private Optional _ICollectionOfTType;
+
+ public INamedTypeSymbol? IEnumerableType => GetOrResolveType(typeof(IEnumerable), ref _IEnumerableType);
+ private Optional _IEnumerableType;
+
+ public INamedTypeSymbol? IEnumerableOfTType => GetOrResolveType(typeof(IEnumerable<>), ref _IEnumerableOfTType);
+ private Optional _IEnumerableOfTType;
+
+ public INamedTypeSymbol? ListOfTType => GetOrResolveType(typeof(List<>), ref _ListOfTType);
+ private Optional _ListOfTType;
+
+ public INamedTypeSymbol? DictionaryOfTKeyTValueType =>
+ GetOrResolveType(typeof(Dictionary<,>), ref _DictionaryOfTKeyTValueType);
+
+ private Optional _DictionaryOfTKeyTValueType;
+
+ public INamedTypeSymbol? IAsyncEnumerableOfTType =>
+ GetOrResolveType("System.Collections.Generic.IAsyncEnumerable`1", ref _AsyncEnumerableOfTType);
+
+ private Optional _AsyncEnumerableOfTType;
+
+ public INamedTypeSymbol? IDictionaryOfTKeyTValueType =>
+ GetOrResolveType(typeof(IDictionary<,>), ref _IDictionaryOfTKeyTValueType);
+
+ private Optional _IDictionaryOfTKeyTValueType;
+
+ public INamedTypeSymbol? IReadonlyDictionaryOfTKeyTValueType => GetOrResolveType(typeof(IReadOnlyDictionary<,>),
+ ref _IReadonlyDictionaryOfTKeyTValueType);
+
+ private Optional _IReadonlyDictionaryOfTKeyTValueType;
+
+ public INamedTypeSymbol? ISetOfTType => GetOrResolveType(typeof(ISet<>), ref _ISetOfTType);
+ private Optional _ISetOfTType;
+
+ public INamedTypeSymbol? StackOfTType => GetOrResolveType(typeof(Stack<>), ref _StackOfTType);
+ private Optional _StackOfTType;
+
+ public INamedTypeSymbol? QueueOfTType => GetOrResolveType(typeof(Queue<>), ref _QueueOfTType);
+ private Optional _QueueOfTType;
+
+ public INamedTypeSymbol? ConcurrentStackType =>
+ GetOrResolveType(typeof(ConcurrentStack<>), ref _ConcurrentStackType);
+
+ private Optional _ConcurrentStackType;
+
+ public INamedTypeSymbol? ConcurrentQueueType =>
+ GetOrResolveType(typeof(ConcurrentQueue<>), ref _ConcurrentQueueType);
+
+ private Optional _ConcurrentQueueType;
+
+ public INamedTypeSymbol? IDictionaryType => GetOrResolveType(typeof(IDictionary), ref _IDictionaryType);
+ private Optional _IDictionaryType;
+
+ public INamedTypeSymbol? IListType => GetOrResolveType(typeof(IList), ref _IListType);
+ private Optional _IListType;
+
+ public INamedTypeSymbol? StackType => GetOrResolveType(typeof(Stack), ref _StackType);
+ private Optional _StackType;
+
+ public INamedTypeSymbol? QueueType => GetOrResolveType(typeof(Queue), ref _QueueType);
+ private Optional _QueueType;
+
+ public INamedTypeSymbol? KeyValuePair => GetOrResolveType(typeof(KeyValuePair<,>), ref _KeyValuePair);
+ private Optional _KeyValuePair;
+
+ public INamedTypeSymbol? ImmutableArrayType => GetOrResolveType(typeof(ImmutableArray<>), ref _ImmutableArrayType);
+ private Optional _ImmutableArrayType;
+
+ public INamedTypeSymbol? ImmutableListType => GetOrResolveType(typeof(ImmutableList<>), ref _ImmutableListType);
+ private Optional _ImmutableListType;
+
+ public INamedTypeSymbol? IImmutableListType => GetOrResolveType(typeof(IImmutableList<>), ref _IImmutableListType);
+ private Optional _IImmutableListType;
+
+ public INamedTypeSymbol? ImmutableStackType => GetOrResolveType(typeof(ImmutableStack<>), ref _ImmutableStackType);
+ private Optional _ImmutableStackType;
+
+ public INamedTypeSymbol? IImmutableStackType =>
+ GetOrResolveType(typeof(IImmutableStack<>), ref _IImmutableStackType);
+
+ private Optional _IImmutableStackType;
+
+ public INamedTypeSymbol? ImmutableQueueType => GetOrResolveType(typeof(ImmutableQueue<>), ref _ImmutableQueueType);
+ private Optional _ImmutableQueueType;
+
+ public INamedTypeSymbol? IImmutableQueueType =>
+ GetOrResolveType(typeof(IImmutableQueue<>), ref _IImmutableQueueType);
+
+ private Optional _IImmutableQueueType;
+
+ public INamedTypeSymbol? ImmutableSortedType =>
+ GetOrResolveType(typeof(ImmutableSortedSet<>), ref _ImmutableSortedType);
+
+ private Optional _ImmutableSortedType;
+
+ public INamedTypeSymbol? ImmutableHashSetType =>
+ GetOrResolveType(typeof(ImmutableHashSet<>), ref _ImmutableHashSetType);
+
+ private Optional _ImmutableHashSetType;
+
+ public INamedTypeSymbol? IImmutableSetType => GetOrResolveType(typeof(IImmutableSet<>), ref _IImmutableSetType);
+ private Optional _IImmutableSetType;
+
+ public INamedTypeSymbol? ImmutableDictionaryType =>
+ GetOrResolveType(typeof(ImmutableDictionary<,>), ref _ImmutableDictionaryType);
+
+ private Optional _ImmutableDictionaryType;
+
+ public INamedTypeSymbol? ImmutableSortedDictionaryType =>
+ GetOrResolveType(typeof(ImmutableSortedDictionary<,>), ref _ImmutableSortedDictionaryType);
+
+ private Optional _ImmutableSortedDictionaryType;
+
+ public INamedTypeSymbol? IImmutableDictionaryType =>
+ GetOrResolveType(typeof(IImmutableDictionary<,>), ref _IImmutableDictionaryType);
+
+ private Optional _IImmutableDictionaryType;
+
+ public INamedTypeSymbol? KeyedCollectionType =>
+ GetOrResolveType(typeof(KeyedCollection<,>), ref _KeyedCollectionType);
+
+ private Optional _KeyedCollectionType;
+
+ public INamedTypeSymbol ObjectType => _ObjectType ??= Compilation.GetSpecialType(SpecialType.System_Object);
+ private INamedTypeSymbol? _ObjectType;
+
+ public INamedTypeSymbol StringType => _StringType ??= Compilation.GetSpecialType(SpecialType.System_String);
+ private INamedTypeSymbol? _StringType;
+
+ public INamedTypeSymbol? DateTimeOffsetType => GetOrResolveType(typeof(DateTimeOffset), ref _DateTimeOffsetType);
+ private Optional _DateTimeOffsetType;
+
+ public INamedTypeSymbol? TimeSpanType => GetOrResolveType(typeof(TimeSpan), ref _TimeSpanType);
+ private Optional _TimeSpanType;
+
+ public INamedTypeSymbol? DateOnlyType => GetOrResolveType("System.DateOnly", ref _DateOnlyType);
+ private Optional _DateOnlyType;
+
+ public INamedTypeSymbol? TimeOnlyType => GetOrResolveType("System.TimeOnly", ref _TimeOnlyType);
+ private Optional _TimeOnlyType;
+
+ public INamedTypeSymbol? Int128Type => GetOrResolveType("System.Int128", ref _Int128Type);
+ private Optional _Int128Type;
+
+ public INamedTypeSymbol? UInt128Type => GetOrResolveType("System.UInt128", ref _UInt128Type);
+ private Optional _UInt128Type;
+
+ public INamedTypeSymbol? HalfType => GetOrResolveType("System.Half", ref _HalfType);
+ private Optional _HalfType;
+
+ public IArrayTypeSymbol? ByteArrayType => _ByteArrayType.HasValue
+ ? _ByteArrayType.Value
+ : (_ByteArrayType =
+ new(Compilation.CreateArrayTypeSymbol(Compilation.GetSpecialType(SpecialType.System_Byte), rank: 1))).Value;
+
+ private Optional _ByteArrayType;
+
+ public INamedTypeSymbol? MemoryByteType => _MemoryByteType.HasValue
+ ? _MemoryByteType.Value
+ : (_MemoryByteType = new(MemoryType?.Construct(Compilation.GetSpecialType(SpecialType.System_Byte)))).Value;
+
+ private Optional _MemoryByteType;
+
+ public INamedTypeSymbol? ReadOnlyMemoryByteType => _ReadOnlyMemoryByteType.HasValue
+ ? _ReadOnlyMemoryByteType.Value
+ : (_ReadOnlyMemoryByteType =
+ new(ReadOnlyMemoryType?.Construct(Compilation.GetSpecialType(SpecialType.System_Byte)))).Value;
+
+ private Optional _ReadOnlyMemoryByteType;
+
+ public INamedTypeSymbol? GuidType => GetOrResolveType(typeof(Guid), ref _GuidType);
+ private Optional _GuidType;
+
+ public INamedTypeSymbol? UriType => GetOrResolveType(typeof(Uri), ref _UriType);
+ private Optional _UriType;
+
+ public INamedTypeSymbol? VersionType => GetOrResolveType(typeof(Version), ref _VersionType);
+ private Optional