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 _VersionType; + + // Unsupported types + public INamedTypeSymbol? DelegateType => _DelegateType ??= Compilation.GetSpecialType(SpecialType.System_Delegate); + private INamedTypeSymbol? _DelegateType; + + public INamedTypeSymbol? MemberInfoType => GetOrResolveType(typeof(MemberInfo), ref _MemberInfoType); + private Optional _MemberInfoType; + + public INamedTypeSymbol? IntPtrType => GetOrResolveType(typeof(IntPtr), ref _IntPtrType); + private Optional _IntPtrType; + + public INamedTypeSymbol? UIntPtrType => GetOrResolveType(typeof(UIntPtr), ref _UIntPtrType); + private Optional _UIntPtrType; + + public INamedTypeSymbol? MemoryType => GetOrResolveType(typeof(Memory<>), ref _MemoryType); + private Optional _MemoryType; + + public INamedTypeSymbol? ReadOnlyMemoryType => GetOrResolveType(typeof(ReadOnlyMemory<>), ref _ReadOnlyMemoryType); + private Optional _ReadOnlyMemoryType; + + public bool IsImmutableEnumerableType(ITypeSymbol type, out string? factoryTypeFullName) + { + if (type is not INamedTypeSymbol { IsGenericType: true, ConstructedFrom: INamedTypeSymbol genericTypeDef }) + { + factoryTypeFullName = null; + return false; + } + + SymbolEqualityComparer cmp = SymbolEqualityComparer.Default; + if (cmp.Equals(genericTypeDef, ImmutableArrayType)) + { + factoryTypeFullName = typeof(ImmutableArray).FullName; + return true; + } + + if (cmp.Equals(genericTypeDef, ImmutableListType) || + cmp.Equals(genericTypeDef, IImmutableListType)) + { + factoryTypeFullName = typeof(ImmutableList).FullName; + return true; + } + + if (cmp.Equals(genericTypeDef, ImmutableStackType) || + cmp.Equals(genericTypeDef, IImmutableStackType)) + { + factoryTypeFullName = typeof(ImmutableStack).FullName; + return true; + } + + if (cmp.Equals(genericTypeDef, ImmutableQueueType) || + cmp.Equals(genericTypeDef, IImmutableQueueType)) + { + factoryTypeFullName = typeof(ImmutableQueue).FullName; + return true; + } + + if (cmp.Equals(genericTypeDef, ImmutableHashSetType) || + cmp.Equals(genericTypeDef, IImmutableSetType)) + { + factoryTypeFullName = typeof(ImmutableHashSet).FullName; + return true; + } + + if (cmp.Equals(genericTypeDef, ImmutableSortedType)) + { + factoryTypeFullName = typeof(ImmutableSortedSet).FullName; + return true; + } + + factoryTypeFullName = null; + return false; + } + + public bool IsImmutableDictionaryType(ITypeSymbol type, out string? factoryTypeFullName) + { + if (type is not INamedTypeSymbol {IsGenericType: true, ConstructedFrom: { } genericTypeDef}) + { + factoryTypeFullName = null; + return false; + } + + SymbolEqualityComparer cmp = SymbolEqualityComparer.Default; + + if (cmp.Equals(genericTypeDef, ImmutableDictionaryType) || + cmp.Equals(genericTypeDef, IImmutableDictionaryType)) + { + factoryTypeFullName = typeof(ImmutableDictionary).FullName; + return true; + } + + if (cmp.Equals(genericTypeDef, ImmutableSortedDictionaryType)) + { + factoryTypeFullName = typeof(ImmutableSortedDictionary).FullName; + return true; + } + + factoryTypeFullName = null; + return false; + } + + + private INamedTypeSymbol? GetOrResolveType(Type type, ref Optional field) + => GetOrResolveType(type.FullName!, ref field); + + private INamedTypeSymbol? GetOrResolveType(string fullyQualifiedName, ref Optional field) + { + if (field.HasValue) + { + return field.Value; + } + + var type = GetBestType(); + field = new(type); + return type; + + INamedTypeSymbol? GetBestType() + { + var type = Compilation.GetTypeByMetadataName(fullyQualifiedName) ?? + Compilation.Assembly.GetTypeByMetadataName(fullyQualifiedName); + + if (type is null) + { + foreach (var module in Compilation.Assembly.Modules) + { + foreach (var referencedAssembly in module.ReferencedAssemblySymbols) + { + var currentType = referencedAssembly.GetTypeByMetadataName(fullyQualifiedName); + if (currentType is null) + continue; + + var visibility = Accessibility.Public; + + ISymbol symbol = currentType; + while (symbol is not null && symbol.Kind is not SymbolKind.Namespace) + { + switch (currentType.DeclaredAccessibility) + { + case Accessibility.Private or Accessibility.NotApplicable: + visibility = Accessibility.Private; + break; + case Accessibility.Internal or Accessibility.ProtectedAndInternal: + visibility = Accessibility.Internal; + break; + } + + symbol = symbol.ContainingSymbol; + } + + switch (visibility) + { + case Accessibility.Public: + case Accessibility.Internal when referencedAssembly.GivesAccessTo(Compilation.Assembly): + break; + + default: + continue; + } + + if (type is object) + { + return null; + } + + type = currentType; + } + } + } + + return type; + } + } +} + + +public static class KnownTypesExtensions +{ + private static readonly ConditionalWeakTable _table = new(); + + public static KnownTypes GetKnownTypes(this Compilation compilation) + { + if (_table.TryGetValue(compilation, out var knownTypes)) + return knownTypes; + + knownTypes = new(compilation); + _table.Add(compilation, knownTypes); + return knownTypes; + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Utils/StringUtils.cs b/src/Discord.Net.ComponentDesigner.Generator/Utils/StringUtils.cs new file mode 100644 index 0000000000..77dbbace73 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Utils/StringUtils.cs @@ -0,0 +1,13 @@ +namespace Discord.ComponentDesigner.Generator; + +public static class StringUtils +{ + public static string Prefix(this string str, int count, char prefixChar = ' ') + => count > 0 ? $"{new string(prefixChar, count)}{str}" : str; + + public static string Postfix(this string str, int count, char prefixChar = ' ') + => count > 0 ? $"{str}{new string(prefixChar, count)}" : str; + + public static string WithNewlinePadding(this string str, int pad) + => str.Replace("\n", "\n".Postfix(pad)); +} diff --git a/src/Discord.Net.ComponentDesigner/ComponentDesigner.cs b/src/Discord.Net.ComponentDesigner/ComponentDesigner.cs new file mode 100644 index 0000000000..339b436523 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner/ComponentDesigner.cs @@ -0,0 +1,18 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace Discord; + +public static class ComponentDesigner +{ + // ReSharper disable once InconsistentNaming + public static IMessageComponentBuilder cx( + [StringSyntax("html")] DesignerInterpolationHandler designer + ) => cx(designer); + + // ReSharper disable once InconsistentNaming + public static T cx( + [StringSyntax("html")] DesignerInterpolationHandler designer + ) where T : IMessageComponentBuilder + => throw new UnreachableException(); +} diff --git a/src/Discord.Net.ComponentDesigner/DesignerInterpolationHandler.cs b/src/Discord.Net.ComponentDesigner/DesignerInterpolationHandler.cs new file mode 100644 index 0000000000..f8b4f8e83c --- /dev/null +++ b/src/Discord.Net.ComponentDesigner/DesignerInterpolationHandler.cs @@ -0,0 +1,9 @@ +using System.Runtime.CompilerServices; + +namespace Discord; + +[InterpolatedStringHandler] +public struct DesignerInterpolationHandler +{ + +} diff --git a/src/Discord.Net.ComponentDesigner/Discord.Net.ComponentDesigner.csproj b/src/Discord.Net.ComponentDesigner/Discord.Net.ComponentDesigner.csproj new file mode 100644 index 0000000000..758982234a --- /dev/null +++ b/src/Discord.Net.ComponentDesigner/Discord.Net.ComponentDesigner.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + From e9b69af1c48e4127084dfdef12292a6d954baf58 Mon Sep 17 00:00:00 2001 From: Quin Lynch <49576606+quinchs@users.noreply.github.com> Date: Sat, 6 Sep 2025 14:43:48 -0300 Subject: [PATCH 02/17] fix parser naming issues --- .../Parser/ComponentParser.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parser/ComponentParser.cs b/src/Discord.Net.ComponentDesigner.Generator/Parser/ComponentParser.cs index aecce3e510..b5d5d50219 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parser/ComponentParser.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parser/ComponentParser.cs @@ -15,7 +15,7 @@ public sealed class ComponentParser private const char UNDERSCORE_CHAR = '_'; private const char HYPHEN_CHAR = '-'; - private const char PERIOD_CHAR = '-'; + private const char PERIOD_CHAR = '.'; private const char TAG_OPEN_CHAR = '<'; private const char TAG_CLOSE_CHAR = '>'; @@ -23,8 +23,8 @@ public sealed class ComponentParser private const char BACK_SLASH_CHAR = '\\'; private const char EQUALS_CHAR = '='; - private const char QUOTE_CAHR = '\''; - private const char DOUBLE_QUOTE_CAHR = '"'; + private const char QUOTE_CHAR = '\''; + private const char DOUBLE_QUOTE_CHAR = '"'; /// /// the raw source, in its entirety. @@ -482,7 +482,7 @@ private CXmlValue ParseValue(ValueParsingMode mode) case ValueParsingMode.AttributeValue: // can be quoted - if (Current is not QUOTE_CAHR and not DOUBLE_QUOTE_CAHR) + if (Current is not QUOTE_CHAR and not DOUBLE_QUOTE_CHAR) { // check for string interpolation if (IsAtStartOfInterpolation(out var interpolationIndex)) From 3d273997eafc6db52683464cb1c5f65c6521a479 Mon Sep 17 00:00:00 2001 From: Quin Lynch <49576606+quinchs@users.noreply.github.com> Date: Sat, 6 Sep 2025 15:23:59 -0300 Subject: [PATCH 03/17] refactor value parsers --- .../Diagnostics.cs | 2 +- .../Nodes/ComponentNode.cs | 222 +-------------- .../Nodes/ComponentProperty.cs | 252 ++++++++++++++++++ .../Nodes/ComponentPropertyValidator.cs | 7 + .../Nodes/ComponentPropertyValue.cs | 252 +----------------- .../Components/BaseSelectComponentNode.cs | 6 +- .../Nodes/Components/ButtonComponentNode.cs | 13 +- .../Components/ContainerComponentNode.cs | 2 +- .../Nodes/Components/FileComponentNode.cs | 2 +- .../Components/MediaGalleryComponentNode.cs | 2 +- .../Nodes/Components/SelectDefaultValue.cs | 7 +- .../Nodes/Components/SelectOption.cs | 56 ++++ .../Components/SeparatorComponentNode.cs | 7 +- .../Components/StringSelectComponentNode.cs | 53 ---- .../Components/ThumbnailComponentNode.cs | 2 +- .../Nodes/ValueCodeGenerator.cs | 171 ++++++++++++ .../Nodes/ValueParsers/ValueParseDelegate.cs | 3 + .../Nodes/ValueParsers/ValueParsers.Bool.cs | 54 ++++ .../Nodes/ValueParsers/ValueParsers.Emoji.cs | 72 +++++ .../Nodes/ValueParsers/ValueParsers.Enum.cs | 66 +++++ .../Nodes/ValueParsers/ValueParsers.Int.cs | 39 +++ .../ValueParsers/ValueParsers.Snowflake.cs | 39 +++ .../Nodes/ValueParsers/ValueParsers.String.cs | 28 ++ .../Nodes/ValueParsers/ValueParsers.cs | 47 ++++ .../Utils/KnownTypes.cs | 20 ++ .../DesignerInterpolationHandler.cs | 21 ++ 26 files changed, 909 insertions(+), 536 deletions(-) create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValidator.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectOption.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueCodeGenerator.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParseDelegate.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Bool.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Emoji.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Enum.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Int.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Snowflake.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.String.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.cs diff --git a/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs b/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs index bcd4fdc271..ba53d291bc 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs @@ -381,7 +381,7 @@ public static class Diagnostics public static readonly DiagnosticDescriptor InvalidPropertyValue = new( "DC0043", "Invalid attribute value", - "'{0}' is not reconized as a valid value of '{1}'", + "'{0}' is not recognized as a valid value of '{1}'", "Components", DiagnosticSeverity.Error, true diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs index 766526ebbb..8e2f58b875 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs @@ -29,7 +29,7 @@ protected ComponentNode(CXmlElement xml, ComponentNodeContext context, bool mapI context) { if (mapId) - Id = MapProperty("id", ParseIntProperty, optional: true); + Id = MapProperty("id", ValueParsers.ParseIntProperty, optional: true); } protected ComponentNode(ICXml xml, ComponentNodeContext context) @@ -167,25 +167,28 @@ public virtual void ReportValidationErrors() protected ComponentProperty MapProperty( string name, bool optional = false, - ParseDelegate? parser = null, + ValueParseDelegate? parser = null, IReadOnlyList>? validators = null, Optional defaultValue = default, + ITypeSymbol? apiType = null, params IReadOnlyList aliases ) => MapProperty( name, - parser ?? ParseStringProperty, + parser ?? ValueParsers.ParseStringProperty, optional, validators, defaultValue, + apiType, aliases ); protected ComponentProperty MapProperty( string name, - ParseDelegate parser, + ValueParseDelegate parser, bool optional = false, IReadOnlyList>? validators = null, Optional defaultValue = default, + ITypeSymbol? apiType = null, params IReadOnlyList aliases ) { @@ -197,7 +200,8 @@ params IReadOnlyList aliases optional, validators ?? [], parser, - defaultValue + defaultValue, + apiType ); _properties.Add(property); @@ -233,212 +237,4 @@ params IReadOnlyList aliases 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/ComponentProperty.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs new file mode 100644 index 0000000000..fe728d9ccd --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs @@ -0,0 +1,252 @@ +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 sealed record ComponentProperty( + ComponentNode Node, + string Name, + CXmlAttribute? Attribute, + IReadOnlyList Aliases, + bool IsOptional, + IReadOnlyList> Validators, + ValueParseDelegate Parser, + Optional DefaultValue, + ITypeSymbol? ApiType = null +) : IComponentProperty +{ + public ComponentNodeContext Context => Node.Context; + 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 value) => Serialize(value), + ComponentPropertyValue.Interpolated(_, var index) => $"designer.GetValue<{typeof(T)}>({index})", + ComponentPropertyValue.MultiPartInterpolation(_, var multipart) => BuildMultipart(multipart), + ComponentPropertyValue.InlineCode(_, var code) => code, + _ => 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 DangerousCreateCode( + string code + ) => new ComponentPropertyValue.InlineCode(this, code); + + 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/ComponentPropertyValidator.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValidator.cs new file mode 100644 index 0000000000..9f58932d2e --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValidator.cs @@ -0,0 +1,7 @@ +namespace Discord.ComponentDesigner.Generator.Nodes; + +public delegate void ComponentPropertyValidator( + ComponentNode node, + ComponentProperty property, + ComponentNodeContext context +); diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs index bc76aceb3a..bf6009005a 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs @@ -1,9 +1,4 @@ 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; @@ -11,6 +6,8 @@ public abstract record ComponentPropertyValue(ComponentProperty Property) { public sealed record Serializable(ComponentProperty Property, T Value) : ComponentPropertyValue(Property); + public sealed record InlineCode(ComponentProperty Property, string Code) : ComponentPropertyValue(Property); + public sealed record Interpolated( ComponentProperty Property, int InterpolationId @@ -21,248 +18,3 @@ public sealed record MultiPartInterpolation( 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/BaseSelectComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/BaseSelectComponentNode.cs index f48291da79..f5ce2784d2 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/BaseSelectComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/BaseSelectComponentNode.cs @@ -33,7 +33,7 @@ protected BaseSelectComponentNode( MinValues = MapProperty( "minValues", optional: true, - parser: ParseIntProperty, + parser: ValueParsers.ParseIntProperty, validators: [ Validators.Bounds( @@ -47,7 +47,7 @@ protected BaseSelectComponentNode( MaxValues = MapProperty( "maxValues", optional: true, - parser: ParseIntProperty, + parser: ValueParsers.ParseIntProperty, validators: [ Validators.Bounds( @@ -58,7 +58,7 @@ protected BaseSelectComponentNode( aliases: ["max"] ); - IsDisabled = MapProperty("disabled", optional: true, parser: ParseBooleanProperty); + IsDisabled = MapProperty("disabled", optional: true, parser: ValueParsers.ParseBooleanProperty); if (!hasDefaultValues) { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs index 85952262d8..ae5d8cf690 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs @@ -23,9 +23,10 @@ public ButtonComponentNode(CXmlElement xml, ComponentNodeContext context) : base { Style = MapProperty( "style", - ParseEnumProperty, + ValueParsers.ParseEnumProperty, defaultValue: ButtonStyle.Primary, - optional: true + optional: true, + apiType: context.KnownTypes.ButtonStyleEnumType ); Label = MapProperty( @@ -34,7 +35,7 @@ public ButtonComponentNode(CXmlElement xml, ComponentNodeContext context) : base validators: [Validators.LengthBounds(upper: Constants.BUTTON_MAX_LABEL_LENGTH)] ); - Emoji = MapProperty("emoji", optional: true, parser: ParseEmojiProperty); + Emoji = MapProperty("emoji", optional: true, parser: ValueParsers.ParseEmojiProperty); CustomId = MapProperty( "customId", @@ -42,7 +43,7 @@ public ButtonComponentNode(CXmlElement xml, ComponentNodeContext context) : base validators: [Validators.LengthBounds(upper: Constants.CUSTOM_ID_MAX_LENGTH)] ); - SkuId = MapProperty("skuId", ParseSnowflakeProperty, optional: true, aliases: "sku"); + SkuId = MapProperty("skuId", ValueParsers.ParseSnowflakeProperty, optional: true, aliases: "sku"); Url = MapProperty( "url", @@ -50,7 +51,7 @@ public ButtonComponentNode(CXmlElement xml, ComponentNodeContext context) : base validators: [Validators.LengthBounds(upper: Constants.BUTTON_URL_MAX_LENGTH)] ); - IsDisabled = MapProperty("disabled", ParseBooleanProperty, optional: true, defaultValue: false); + IsDisabled = MapProperty("disabled", ValueParsers.ParseBooleanProperty, optional: true, defaultValue: false); if (xml.Children.Count > 1) { @@ -157,7 +158,7 @@ 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}, + style: {Context.KnownTypes.ButtonStyleEnumType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.{Style}, url: {Url.ToString().WithNewlinePadding(4)}, emote: {Emoji.ToString().WithNewlinePadding(4)}, isDisabled: {IsDisabled.ToString().WithNewlinePadding(4)}, diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs index c4112a4936..0654ad36e2 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs @@ -26,7 +26,7 @@ public ContainerComponentNode(CXmlElement xml, ComponentNodeContext context) : b IsSpoiler = MapProperty( "spoiler", - ParseBooleanProperty, + ValueParsers.ParseBooleanProperty, optional: true ); diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs index 46814423bf..709d48156e 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs @@ -15,7 +15,7 @@ public sealed class FileComponentNode : ComponentNode public FileComponentNode(CXmlElement xml, ComponentNodeContext context, bool mapId = true) : base(xml, context, mapId) { Url = MapProperty("url"); - IsSpoiler = MapProperty("spoiler", ParseBooleanProperty, optional: true); + IsSpoiler = MapProperty("spoiler", ValueParsers.ParseBooleanProperty, optional: true); } public override string Render() diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs index a59e2f4819..6a45ebe5de 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs @@ -105,7 +105,7 @@ public MediaGalleryItem(CXmlElement xml, ComponentNodeContext context, bool mapI validators: [Validators.LengthBounds(upper: Constants.MAX_MEDIA_ITEM_DESCRIPTION_LENGTH)] ); - IsSpoiler = MapProperty("spoiler", ParseBooleanProperty, optional: true); + IsSpoiler = MapProperty("spoiler", ValueParsers.ParseBooleanProperty, optional: true); } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectDefaultValue.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectDefaultValue.cs index 33c54fe134..828ef90f2e 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectDefaultValue.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectDefaultValue.cs @@ -17,13 +17,14 @@ public SelectDefaultValue(CXmlElement xml, ComponentNodeContext context) : base( { Id = MapProperty( "id", - ParseSnowflakeProperty + ValueParsers.ParseSnowflakeProperty ); Type = MapProperty( "type", - ParseEnumProperty, - optional: true + ValueParsers.ParseEnumProperty, + optional: true, + apiType: context.KnownTypes.SelectDefaultValueTypeEnumType ); } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectOption.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectOption.cs new file mode 100644 index 0000000000..274cff7e11 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectOption.cs @@ -0,0 +1,56 @@ +using Discord.ComponentDesigner.Generator.Parser; + +namespace Discord.ComponentDesigner.Generator.Nodes; + +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: ValueParsers.ParseEmojiProperty + ); + + IsDefault = MapProperty( + "default", + ValueParsers.ParseBooleanProperty, + optional: true + ); + } + + public override string Render() + { + throw new System.NotImplementedException(); + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs index 7ecf5af91b..48ff669477 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs @@ -16,16 +16,17 @@ public SeparatorComponentNode(CXmlElement xml, ComponentNodeContext context) : b { IsDivider = MapProperty( "divider", - ParseBooleanProperty, + ValueParsers.ParseBooleanProperty, optional: true, defaultValue: true ); Spacing = MapProperty( "spacing", - ParseEnumProperty, + ValueParsers.ParseEnumProperty, optional: true, - defaultValue: SeparatorSpacing.Small + defaultValue: SeparatorSpacing.Small, + apiType: context.KnownTypes.SeparatorSpacingSizeType ); } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/StringSelectComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/StringSelectComponentNode.cs index 04657b52fd..d4f1fcf39d 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/StringSelectComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/StringSelectComponentNode.cs @@ -69,56 +69,3 @@ public override string Render() ) """; } - -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/ThumbnailComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs index 2aa32c15e2..0fdba50a84 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs @@ -23,7 +23,7 @@ public ThumbnailComponentNode(CXmlElement xml, ComponentNodeContext context) : b validators: [Validators.LengthBounds(upper: Constants.THUMBNAIL_DESCRIPTION_MAX_LENGTH)] ); - IsSpoiler = MapProperty("spoiler", ParseBooleanProperty, optional: true); + IsSpoiler = MapProperty("spoiler", ValueParsers.ParseBooleanProperty, optional: true); } public override string Render() diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueCodeGenerator.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueCodeGenerator.cs new file mode 100644 index 0000000000..24abc148ee --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueCodeGenerator.cs @@ -0,0 +1,171 @@ +using Discord.ComponentDesigner.Generator.Parser; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.ComponentDesigner.Generator.Nodes; + +public static class ValueCodeGenerator +{ + public static string? BuildValue(CXmlValue? value, ComponentNodeContext context) + { + switch (value) + { + case CXmlValue.Invalid or null: return null; + + case CXmlValue.Interpolation interpolation: + var type = context.Interpolations[interpolation.InterpolationIndex].Type; + return $"designer.GetValue<{type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}>({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']); + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParseDelegate.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParseDelegate.cs new file mode 100644 index 0000000000..0a0a287ad3 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParseDelegate.cs @@ -0,0 +1,3 @@ +namespace Discord.ComponentDesigner.Generator.Nodes; + +public delegate ComponentPropertyValue? ValueParseDelegate(ComponentProperty property); diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Bool.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Bool.cs new file mode 100644 index 0000000000..7616ca3bb9 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Bool.cs @@ -0,0 +1,54 @@ +using Discord.ComponentDesigner.Generator.Parser; +using Microsoft.CodeAnalysis; +using System; + +namespace Discord.ComponentDesigner.Generator.Nodes; + +partial class ValueParsers +{ + public static 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 or CXmlValue.Invalid: return null; + + case CXmlValue.Interpolation interpolation: + return ValidateInterpolationType(property, interpolation, SpecialType.System_Boolean); + + // multi-parts are strings + case CXmlValue.Multipart multipart: + property.Context.ReportDiagnostic( + Diagnostics.PropertyMismatch, + property.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") + { + property.Context.ReportDiagnostic( + Diagnostics.PropertyMismatch, + property.Context.GetLocation(scalar), + property.Name, + nameof(Boolean), + typeof(string) + ); + return null; + } + + return property.CreateValue(str is "true"); + default: + throw new ArgumentOutOfRangeException(); + } + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Emoji.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Emoji.cs new file mode 100644 index 0000000000..51046c703b --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Emoji.cs @@ -0,0 +1,72 @@ +using Discord.ComponentDesigner.Generator.Parser; +using Microsoft.CodeAnalysis; +using System; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.ComponentDesigner.Generator.Nodes; + +partial class ValueParsers +{ + public static ComponentPropertyValue? ParseEmojiProperty(ComponentProperty property) + { + switch (property.Value) + { + case null or CXmlValue.Invalid: return null; + + case CXmlValue.Scalar or CXmlValue.Multipart: + return CreateDiscordParserCode(ValueCodeGenerator.BuildValue(property.Value, property.Context)); + + case CXmlValue.Interpolation interpolation: + var interpolationInfo = property.Context.Interpolations[interpolation.InterpolationIndex]; + + if ( + property.Context.Compilation.HasImplicitConversion( + interpolationInfo.Type, + property.Context.KnownTypes.IEmoteType + ) + ) + { + return property.CreateValue(in interpolationInfo); + } + + // if its a string interpolation, do the same parse + if (interpolationInfo.Type.SpecialType is SpecialType.System_String) + { + return CreateDiscordParserCode( + $"designer.GetValueAsString({interpolationInfo.Id})" + ); + } + + // otherwise, unknown way to parse it + property.Context.ReportDiagnostic( + Diagnostics.PropertyMismatch, + property.Context.GetLocation(interpolation), + property.Name, + property.Context.KnownTypes.IEmoteType!.ToDisplayString(), + interpolationInfo.Type.ToDisplayString() + ); + return null; + + default: + throw new ArgumentOutOfRangeException(); + } + + ComponentPropertyValue? CreateDiscordParserCode(string? value) + { + if (value is null) return null; + + var emoteType = + property.Context.KnownTypes.EmoteType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var emojiType = + property.Context.KnownTypes.EmojiType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + return property.DangerousCreateCode( + $""" + {emoteType}.TryParse({value}, out var emote) + ? emote + : {emojiType}.Parse({value}) + """ + ); + } + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Enum.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Enum.cs new file mode 100644 index 0000000000..b2fb6297be --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Enum.cs @@ -0,0 +1,66 @@ +using Discord.ComponentDesigner.Generator.Parser; +using Microsoft.CodeAnalysis; +using System; + +namespace Discord.ComponentDesigner.Generator.Nodes; + +partial class ValueParsers +{ + public static ComponentPropertyValue? ParseEnumProperty(ComponentProperty property) where T : struct + { + switch (property.Value) + { + case CXmlValue.Invalid or null: return null; + + case CXmlValue.Interpolation interpolation: + var interpolationInfo = property.Context.Interpolations[interpolation.InterpolationIndex]; + + if (property.ApiType is not null) + { + if (property.ApiType.Equals(interpolationInfo.Type, SymbolEqualityComparer.Default)) + { + return property.CreateValue(in interpolationInfo); + } + + // we'll use the parse method + return property.DangerousCreateCode( + $"Enum.Parse<{property.ApiType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}>(designer.GetValueAsString({interpolationInfo.Id}))" + ); + } + + property.Context.ReportDiagnostic( + Diagnostics.InvalidPropertyValue, + property.Context.GetLocation(interpolation), + interpolationInfo.Type.ToDisplayString(), + property.Name + ); + return null; + + case CXmlValue.Multipart multipart: + property.Context.ReportDiagnostic( + Diagnostics.InvalidPropertyValue, + property.Context.GetLocation(multipart), + "", + property.Name + ); + return null; + case CXmlValue.Scalar scalar: + { + if (Enum.TryParse(scalar.Value, out var result)) + return property.CreateValue(result); + + property.Context.ReportDiagnostic( + Diagnostics.InvalidEnumProperty, + property.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/ValueParsers/ValueParsers.Int.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Int.cs new file mode 100644 index 0000000000..7225d10158 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Int.cs @@ -0,0 +1,39 @@ +using Discord.ComponentDesigner.Generator.Parser; +using Microsoft.CodeAnalysis; +using System; + +namespace Discord.ComponentDesigner.Generator.Nodes; + +partial class ValueParsers +{ + public static 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: + // we'll use int.Parse + return property.DangerousCreateCode( + $"int.Parse({ValueCodeGenerator.BuildValue(multipart, property.Context)})" + ); + case CXmlValue.Scalar scalar: + if (int.TryParse(scalar.Value, out var result)) + return property.CreateValue(result); + + property.Node.Context.ReportDiagnostic( + Diagnostics.InvalidPropertyValue, + property.Node.Context.GetLocation(scalar), + scalar.Value, + nameof(Int32) + ); + return null; + + default: + throw new ArgumentOutOfRangeException(); + } + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Snowflake.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Snowflake.cs new file mode 100644 index 0000000000..0e40a501cd --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Snowflake.cs @@ -0,0 +1,39 @@ +using Discord.ComponentDesigner.Generator.Parser; +using Microsoft.CodeAnalysis; +using System; + +namespace Discord.ComponentDesigner.Generator.Nodes; + +partial class ValueParsers +{ + public static 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: + return property.DangerousCreateCode( + $"ulong.Parse({ValueCodeGenerator.BuildValue(multipart, property.Context)})" + ); + case CXmlValue.Scalar scalar: + if (ulong.TryParse(scalar.Value, out var snowflake)) + { + return property.CreateValue(snowflake); + } + + property.Context.ReportDiagnostic( + Diagnostics.InvalidSnowflakeIdentifier, + property.Context.GetLocation(scalar), + scalar.Value + ); + + return null; + default: + throw new ArgumentOutOfRangeException(); + } + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.String.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.String.cs new file mode 100644 index 0000000000..5d1bf72110 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.String.cs @@ -0,0 +1,28 @@ +using Discord.ComponentDesigner.Generator.Parser; +using System; + +namespace Discord.ComponentDesigner.Generator.Nodes; + +partial class ValueParsers +{ + public static 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)); + } + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.cs new file mode 100644 index 0000000000..a9db8ae9de --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.cs @@ -0,0 +1,47 @@ +using Discord.ComponentDesigner.Generator.Parser; +using Microsoft.CodeAnalysis; +using System; + +namespace Discord.ComponentDesigner.Generator.Nodes; + +public static partial class ValueParsers +{ + private static ComponentPropertyValue? ValidateInterpolationType( + ComponentProperty property, + CXmlValue.Interpolation value, + Func validator + ) + { + var interpolationInfo = property.Context.Interpolations[value.InterpolationIndex]; + + if (!validator(interpolationInfo.Type)) + return null; + + return property.CreateValue(in interpolationInfo); + } + + private static ComponentPropertyValue? ValidateInterpolationType( + ComponentProperty property, + CXmlValue.Interpolation value, + SpecialType specialType + ) => ValidateInterpolationType( + property, + value, + (symbol) => + { + if (symbol.SpecialType != specialType) + { + property.Context.ReportDiagnostic( + Diagnostics.PropertyMismatch, + property.Context.GetLocation(value), + property.Name, + nameof(Boolean), + symbol.ToDisplayString() + ); + return false; + } + + return true; + } + ); +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Utils/KnownTypes.cs b/src/Discord.Net.ComponentDesigner.Generator/Utils/KnownTypes.cs index e4401a5bc4..263204b323 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Utils/KnownTypes.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Utils/KnownTypes.cs @@ -24,6 +24,26 @@ public INamedTypeSymbol? ICXElementType private Optional _ICXElementType; + public INamedTypeSymbol? EmojiType + => GetOrResolveType("Discord.Emoji", ref _EmojiType); + + private Optional _EmojiType; + + public INamedTypeSymbol? IEmoteType + => GetOrResolveType("Discord.IEmote", ref _IEmoteType); + + private Optional _IEmoteType; + + public INamedTypeSymbol? EmoteType + => GetOrResolveType("Discord.Emote", ref _EmoteType); + + private Optional _EmoteType; + + public INamedTypeSymbol? ButtonStyleEnumType + => GetOrResolveType("Discord.ButtonStyle", ref _ButtonStyleEnumType); + + private Optional _ButtonStyleEnumType; + public INamedTypeSymbol? ComponentTypeEnumType => GetOrResolveType("Discord.ComponentType", ref _ComponentTypeEnumType); diff --git a/src/Discord.Net.ComponentDesigner/DesignerInterpolationHandler.cs b/src/Discord.Net.ComponentDesigner/DesignerInterpolationHandler.cs index f8b4f8e83c..91840daf47 100644 --- a/src/Discord.Net.ComponentDesigner/DesignerInterpolationHandler.cs +++ b/src/Discord.Net.ComponentDesigner/DesignerInterpolationHandler.cs @@ -5,5 +5,26 @@ namespace Discord; [InterpolatedStringHandler] public struct DesignerInterpolationHandler { + private readonly object?[] _interpolatedValues; + private int _index; + + public DesignerInterpolationHandler(int literalLength, int formattedCount) + { + _interpolatedValues = new object?[formattedCount]; + } + + public void AppendLiteral(string s) + { + + } + + public void AppendFormatted(T value) + { + _interpolatedValues[_index++] = value; + } + + public object? GetValue(int index) => _interpolatedValues[index]; + public T? GetValue(int index) => (T?)_interpolatedValues[index]; + public string? GetValueAsString(int index) => _interpolatedValues[index]?.ToString(); } From 7758447154e2326ededd5d4e4f2ef21a319ca96a Mon Sep 17 00:00:00 2001 From: Quin Lynch <49576606+quinchs@users.noreply.github.com> Date: Sat, 6 Sep 2025 15:30:26 -0300 Subject: [PATCH 04/17] fix name gating --- .../Nodes/Components/ThumbnailComponentNode.cs | 2 +- src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs index 0fdba50a84..17f4d7fe01 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs @@ -29,7 +29,7 @@ public ThumbnailComponentNode(CXmlElement xml, ComponentNodeContext context) : b public override string Render() => $""" new {Context.KnownTypes.ThumbnailBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}( - media: new global::Discord.UnfurledMediaItemProperties({Url.ToString().WithNewlinePadding(4)}), + media: {Context.KnownTypes.UnfurledMediaItemPropertiesType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({Url.ToString().WithNewlinePadding(4)}), description: {Description.ToString().WithNewlinePadding(4)}, isSpoiler: {IsSpoiler} ) diff --git a/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs b/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs index 9a449142ed..0e7340039e 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs @@ -199,7 +199,7 @@ static file class Interceptors .TargetMethod .ContainingType .ToDisplayString() - is "InlineComponent.InlineComponentBuilder" + is "Discord.ComponentDesigner" ) break; goto default; From 2bbc246f15b7100d304dcc93403930a1112c1d6e Mon Sep 17 00:00:00 2001 From: Quin Lynch <49576606+quinchs@users.noreply.github.com> Date: Sat, 6 Sep 2025 15:40:25 -0300 Subject: [PATCH 05/17] add color parser --- .../Components/ContainerComponentNode.cs | 2 +- .../Nodes/ValueParsers/ValueParsers.Color.cs | 68 +++++++++++++++++++ .../Utils/KnownTypes.cs | 5 ++ 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Color.cs diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs index 0654ad36e2..bc89d38920 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs @@ -20,7 +20,7 @@ public ContainerComponentNode(CXmlElement xml, ComponentNodeContext context) : b AccentColor = MapProperty( "accentColor", optional: true, - // TODO: validator + parser: ValueParsers.ParseColorProperty, aliases: ["color"] ); diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Color.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Color.cs new file mode 100644 index 0000000000..04a03b3d7b --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Color.cs @@ -0,0 +1,68 @@ +using Discord.ComponentDesigner.Generator.Parser; +using Microsoft.CodeAnalysis; +using System; +using System.Linq; + +namespace Discord.ComponentDesigner.Generator.Nodes; + +partial class ValueParsers +{ + public static ComponentPropertyValue? ParseColorProperty(ComponentProperty property) + { + var colorTypeName = + property.Context.KnownTypes.ColorType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + switch (property.Value) + { + case null or CXmlValue.Invalid: return null; + + case CXmlValue.Scalar scalar: + // check for field name first + var known = property.Context.KnownTypes.ColorType! + .GetMembers() + .OfType() + .FirstOrDefault(x => x.Name == scalar.Value); + + if (known is not null) + return property.DangerousCreateCode( + $"{colorTypeName}.{known.Name}" + ); + + return CreateParsedColor(ValueCodeGenerator.BuildValue(scalar, property.Context)); + + case CXmlValue.Interpolation interpolation: + var interpolationInfo = property.Context.Interpolations[interpolation.InterpolationIndex]; + + if ( + interpolationInfo.Type.Equals( + property.Context.KnownTypes.ColorType, + SymbolEqualityComparer.Default + ) + ) + { + return property.CreateValue(in interpolationInfo); + } + + if (interpolationInfo.Type.SpecialType is SpecialType.System_String) + { + return CreateParsedColor($"designer.GetValueAsString({interpolationInfo.Id})"); + } + + property.Context.ReportDiagnostic( + Diagnostics.PropertyMismatch, + property.Context.GetLocation(interpolation), + property.Name, + property.Context.KnownTypes.ColorType.ToDisplayString(), + interpolationInfo.Type.ToDisplayString() + ); + return null; + case CXmlValue.Multipart multipart: + return CreateParsedColor(ValueCodeGenerator.BuildValue(multipart, property.Context)); + + default: throw new ArgumentOutOfRangeException(); + } + + ComponentPropertyValue? CreateParsedColor(string? value) + => value is null ? null : property.DangerousCreateCode($"{colorTypeName}.Parse({value})"); + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Utils/KnownTypes.cs b/src/Discord.Net.ComponentDesigner.Generator/Utils/KnownTypes.cs index 263204b323..8aef58acd0 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Utils/KnownTypes.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Utils/KnownTypes.cs @@ -24,6 +24,11 @@ public INamedTypeSymbol? ICXElementType private Optional _ICXElementType; + public INamedTypeSymbol? ColorType + => GetOrResolveType("Discord.Color", ref _ColorType); + + private Optional _ColorType; + public INamedTypeSymbol? EmojiType => GetOrResolveType("Discord.Emoji", ref _EmojiType); From 0201e1b759996b8245b2a4752d1d67e6f747d8d2 Mon Sep 17 00:00:00 2001 From: Quin Lynch <49576606+quinchs@users.noreply.github.com> Date: Sat, 6 Sep 2025 15:42:58 -0300 Subject: [PATCH 06/17] fix diagnostic warnings treated as errors --- .../Nodes/ComponentNodeContext.cs | 3 ++- src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlDoc.cs | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNodeContext.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNodeContext.cs index 99a2e3fe93..359b5063dd 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNodeContext.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNodeContext.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Linq; namespace Discord.ComponentDesigner.Generator.Nodes; @@ -11,7 +12,7 @@ public sealed class ComponentNodeContext { public Compilation Compilation => KnownTypes.Compilation; - public bool HasErrors => _document.HasErrors || Diagnostics.Count > 0; + public bool HasErrors => _document.HasErrors || Diagnostics.Any(x => x.Severity is DiagnosticSeverity.Error); public List Diagnostics { get; } = []; diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlDoc.cs b/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlDoc.cs index c4f11ba8d8..8f9aea015a 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlDoc.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlDoc.cs @@ -1,3 +1,4 @@ +using Microsoft.CodeAnalysis; using System.Collections.Generic; using System.Linq; @@ -10,5 +11,6 @@ public sealed record CXmlDoc( params IReadOnlyList Diagnostics ) : ICXml { - public bool HasErrors => Diagnostics.Count > 0 || Elements.Any(x => x.HasErrors); + public bool HasErrors => + Diagnostics.Any(x => x.Severity is DiagnosticSeverity.Error) || Elements.Any(x => x.HasErrors); } From 83a74f5566b459e3afc93e4039744d696192db1f Mon Sep 17 00:00:00 2001 From: Quin Lynch <49576606+quinchs@users.noreply.github.com> Date: Sat, 6 Sep 2025 15:46:36 -0300 Subject: [PATCH 07/17] disable custom components --- .../Nodes/ComponentNode.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs index 8e2f58b875..b477f97e23 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs @@ -123,6 +123,9 @@ protected ComponentNode(ICXml xml, ComponentNodeContext context) ComponentNode? TryBindCustomNode() { + // TODO: Disabled, for now + return null; + var symbol = context .LookupNode(xml.Name.Value) .OfType() From 03786c67f4b11f916548fdacac012ba63670e000 Mon Sep 17 00:00:00 2001 From: Quin Lynch <49576606+quinchs@users.noreply.github.com> Date: Sun, 7 Sep 2025 20:34:21 -0300 Subject: [PATCH 08/17] start of incremental parsing --- .../Constants.cs | 2 +- .../Diagnostics.cs | 2 +- ...ord.Net.ComponentDesigner.Generator.csproj | 1 + .../InterpolationInfo.cs | 2 +- .../Nodes/ComponentNode.cs | 4 +- .../Nodes/ComponentNodeContext.cs | 4 +- .../Nodes/ComponentProperty.cs | 4 +- .../Nodes/ComponentPropertyValidator.cs | 2 +- .../Nodes/ComponentPropertyValue.cs | 4 +- .../Components/ActionRowComponentNode.cs | 4 +- .../Components/BaseSelectComponentNode.cs | 4 +- .../Nodes/Components/ButtonComponentNode.cs | 4 +- .../Components/ChannelSelectComponentNode.cs | 4 +- .../Components/ContainerComponentNode.cs | 4 +- .../Nodes/Components/CustomComponent.cs | 4 +- .../Nodes/Components/FileComponentNode.cs | 4 +- .../Components/InterpolatedComponentNode.cs | 4 +- .../Nodes/Components/LabelComponentNode.cs | 4 +- .../Components/MediaGalleryComponentNode.cs | 4 +- .../MentionableSelectComponentNode.cs | 4 +- .../Components/RoleSelectComponentNode.cs | 4 +- .../Nodes/Components/SectionComponentNode.cs | 4 +- .../Nodes/Components/SelectDefaultValue.cs | 4 +- .../Nodes/Components/SelectOption.cs | 4 +- .../Components/SeparatorComponentNode.cs | 4 +- .../Components/StringSelectComponentNode.cs | 4 +- .../Components/TextDisplayComponentNode.cs | 4 +- .../Components/TextInputComponentNode.cs | 4 +- .../Components/ThumbnailComponentNode.cs | 4 +- .../Components/UserSelectComponentNode.cs | 4 +- .../Nodes/IComponentProperty.cs | 4 +- .../Nodes/NodeKind.cs | 2 +- .../Nodes/Validators/Validators.Numeric.cs | 2 +- .../Validators/Validators.StringLength.cs | 2 +- .../Nodes/ValueCodeGenerator.cs | 4 +- .../Nodes/ValueParsers/ValueParseDelegate.cs | 2 +- .../Nodes/ValueParsers/ValueParsers.Bool.cs | 4 +- .../Nodes/ValueParsers/ValueParsers.Color.cs | 4 +- .../Nodes/ValueParsers/ValueParsers.Emoji.cs | 4 +- .../Nodes/ValueParsers/ValueParsers.Enum.cs | 4 +- .../Nodes/ValueParsers/ValueParsers.Int.cs | 4 +- .../ValueParsers/ValueParsers.Snowflake.cs | 4 +- .../Nodes/ValueParsers/ValueParsers.String.cs | 4 +- .../Nodes/ValueParsers/ValueParsers.cs | 4 +- .../Parser/CXmlTriviaToken.cs | 37 -- .../Parsing/CXSource.cs | 39 ++ .../Parsing/CXSourceReader.cs | 38 ++ .../Parsing/Lexer/CXLexer.cs | 391 ++++++++++++++++++ .../Parsing/Lexer/CXToken.cs | 19 + .../Parsing/Lexer/CXTokenFlags.cs | 10 + .../Parsing/Lexer/CXTokenKind.cs | 20 + .../{ => Parsing}/Parser/CXmlAttribute.cs | 2 +- .../{ => Parsing}/Parser/CXmlDiagnostic.cs | 2 +- .../{ => Parsing}/Parser/CXmlDoc.cs | 2 +- .../{ => Parsing}/Parser/CXmlElement.cs | 2 +- .../{ => Parsing}/Parser/CXmlValue.cs | 2 +- .../{ => Parsing}/Parser/ComponentParser.cs | 169 +------- .../{ => Parsing}/Parser/ICXml.cs | 2 +- .../{ => Parsing}/Parser/SourceLocation.cs | 2 +- .../{ => Parsing}/Parser/SourceSpan.cs | 2 +- .../Parsing/Parser2/CXAttribute.cs | 31 ++ .../Parsing/Parser2/CXBlender.cs | 142 +++++++ .../Parsing/Parser2/CXDiagnostic.cs | 10 + .../Parsing/Parser2/CXDoc.cs | 55 +++ .../Parsing/Parser2/CXElement.cs | 40 ++ .../Parsing/Parser2/CXNode.cs | 152 +++++++ .../Parsing/Parser2/CXParser.cs | 366 ++++++++++++++++ .../Parsing/Parser2/CXValue.cs | 50 +++ .../SourceGenerator.cs | 6 +- .../Utils/KnownTypes.cs | 2 +- .../Utils/StringUtils.cs | 2 +- .../Discord.Net.ComponentDesigner.csproj | 1 + 72 files changed, 1457 insertions(+), 296 deletions(-) delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlTriviaToken.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/CXSource.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/CXSourceReader.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXLexer.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXToken.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXTokenFlags.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXTokenKind.cs rename src/Discord.Net.ComponentDesigner.Generator/{ => Parsing}/Parser/CXmlAttribute.cs (85%) rename src/Discord.Net.ComponentDesigner.Generator/{ => Parsing}/Parser/CXmlDiagnostic.cs (74%) rename src/Discord.Net.ComponentDesigner.Generator/{ => Parsing}/Parser/CXmlDoc.cs (88%) rename src/Discord.Net.ComponentDesigner.Generator/{ => Parsing}/Parser/CXmlElement.cs (91%) rename src/Discord.Net.ComponentDesigner.Generator/{ => Parsing}/Parser/CXmlValue.cs (94%) rename src/Discord.Net.ComponentDesigner.Generator/{ => Parsing}/Parser/ComponentParser.cs (84%) rename src/Discord.Net.ComponentDesigner.Generator/{ => Parsing}/Parser/ICXml.cs (76%) rename src/Discord.Net.ComponentDesigner.Generator/{ => Parsing}/Parser/SourceLocation.cs (80%) rename src/Discord.Net.ComponentDesigner.Generator/{ => Parsing}/Parser/SourceSpan.cs (87%) create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXAttribute.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXBlender.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXDiagnostic.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXDoc.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXElement.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXNode.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXParser.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXValue.cs diff --git a/src/Discord.Net.ComponentDesigner.Generator/Constants.cs b/src/Discord.Net.ComponentDesigner.Generator/Constants.cs index a91d17941d..f894360f6e 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Constants.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Constants.cs @@ -1,4 +1,4 @@ -namespace Discord.ComponentDesigner.Generator; +namespace Discord.ComponentDesignerGenerator; public static class Constants { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs b/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs index ba53d291bc..2c94429575 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs @@ -1,6 +1,6 @@ using Microsoft.CodeAnalysis; -namespace Discord.ComponentDesigner.Generator; +namespace Discord.ComponentDesignerGenerator; public static class Diagnostics { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Discord.Net.ComponentDesigner.Generator.csproj b/src/Discord.Net.ComponentDesigner.Generator/Discord.Net.ComponentDesigner.Generator.csproj index 5781958135..2214de95d0 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Discord.Net.ComponentDesigner.Generator.csproj +++ b/src/Discord.Net.ComponentDesigner.Generator/Discord.Net.ComponentDesigner.Generator.csproj @@ -8,6 +8,7 @@ true true + Discord.Net.ComponentDesignerGenerator diff --git a/src/Discord.Net.ComponentDesigner.Generator/InterpolationInfo.cs b/src/Discord.Net.ComponentDesigner.Generator/InterpolationInfo.cs index 5d3c5ee33e..7535ad2e8b 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/InterpolationInfo.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/InterpolationInfo.cs @@ -1,6 +1,6 @@ using Microsoft.CodeAnalysis; -namespace Discord.ComponentDesigner.Generator; +namespace Discord.ComponentDesignerGenerator; public readonly record struct InterpolationInfo( int Id, diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs index b477f97e23..c0ca4b452d 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs @@ -1,10 +1,10 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using Microsoft.CodeAnalysis; using System; using System.Collections.Generic; using System.Linq; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public abstract class ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNodeContext.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNodeContext.cs index 359b5063dd..bbb2a2c695 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNodeContext.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNodeContext.cs @@ -1,4 +1,4 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; using System; @@ -6,7 +6,7 @@ using System.Collections.Immutable; using System.Linq; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public sealed class ComponentNodeContext { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs index fe728d9ccd..6568bc3b35 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs @@ -1,11 +1,11 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using Microsoft.CodeAnalysis; using System; using System.Collections.Generic; using System.Linq; using System.Text; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public sealed record ComponentProperty( ComponentNode Node, diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValidator.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValidator.cs index 9f58932d2e..d555bfe7dd 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValidator.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValidator.cs @@ -1,4 +1,4 @@ -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public delegate void ComponentPropertyValidator( ComponentNode node, diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs index bf6009005a..3650719921 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs @@ -1,6 +1,6 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public abstract record ComponentPropertyValue(ComponentProperty Property) { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs index f67e53ba5d..922e4c707c 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs @@ -1,9 +1,9 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using System.Collections.Generic; using System.Linq; using Microsoft.CodeAnalysis; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public sealed class ActionRowComponentNode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/BaseSelectComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/BaseSelectComponentNode.cs index f5ce2784d2..cd678b4a13 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/BaseSelectComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/BaseSelectComponentNode.cs @@ -1,7 +1,7 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using System.Collections.Generic; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public abstract class BaseSelectComponentNode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs index ae5d8cf690..a990b1cc65 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs @@ -1,9 +1,9 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using System; using System.Xml; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public sealed class ButtonComponentNode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ChannelSelectComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ChannelSelectComponentNode.cs index 0a081d2b45..0d62c54bb6 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ChannelSelectComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ChannelSelectComponentNode.cs @@ -1,10 +1,10 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using System.Collections.Generic; using System.Linq; using System.Xml; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public sealed class ChannelSelectComponentNode : BaseSelectComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs index bc89d38920..0b7c4b77a3 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs @@ -1,9 +1,9 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using System.Collections.Generic; using System.Linq; using Microsoft.CodeAnalysis; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public sealed class ContainerComponentNode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/CustomComponent.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/CustomComponent.cs index c1de2895f0..23c109bd17 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/CustomComponent.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/CustomComponent.cs @@ -1,10 +1,10 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using System.Collections.Generic; using System.Linq; using System.Text; using Microsoft.CodeAnalysis; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public sealed class CustomComponent : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs index 709d48156e..6296382640 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs @@ -1,7 +1,7 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public sealed class FileComponentNode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterpolatedComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterpolatedComponentNode.cs index 583c2aa862..402437ada3 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterpolatedComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterpolatedComponentNode.cs @@ -1,7 +1,7 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using Microsoft.CodeAnalysis; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public sealed class InterpolatedComponentNode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs index 74d86c3b6b..512ff57e5d 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs @@ -1,8 +1,8 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using System.Collections.Generic; using System.Linq; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public sealed class LabelComponentNode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs index 6a45ebe5de..e73fbbdacd 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs @@ -1,9 +1,9 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using System.Collections.Generic; using System.Linq; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public sealed class MediaGalleryComponentNode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MentionableSelectComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MentionableSelectComponentNode.cs index a87362570c..8eb0f2fa37 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MentionableSelectComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MentionableSelectComponentNode.cs @@ -1,7 +1,7 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public sealed class MentionableSelectComponentNode : BaseSelectComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/RoleSelectComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/RoleSelectComponentNode.cs index e36dec49b8..0cc7b491e0 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/RoleSelectComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/RoleSelectComponentNode.cs @@ -1,7 +1,7 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public sealed class RoleSelectComponentNode : BaseSelectComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentNode.cs index d2fe633083..10b233eea1 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentNode.cs @@ -1,9 +1,9 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using System.Collections.Generic; using System.Linq; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public sealed class SectionComponentNode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectDefaultValue.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectDefaultValue.cs index 828ef90f2e..c348877a74 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectDefaultValue.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectDefaultValue.cs @@ -1,7 +1,7 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public sealed class SelectDefaultValue : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectOption.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectOption.cs index 274cff7e11..cc5bfa3d56 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectOption.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectOption.cs @@ -1,6 +1,6 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public sealed class SelectOption : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs index 48ff669477..81ad3dcda0 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs @@ -1,8 +1,8 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using System.Xml; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public sealed class SeparatorComponentNode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/StringSelectComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/StringSelectComponentNode.cs index d4f1fcf39d..587f9b86dd 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/StringSelectComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/StringSelectComponentNode.cs @@ -1,8 +1,8 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using System.Collections.Generic; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public sealed class StringSelectComponentNode : BaseSelectComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs index 4e6d031947..1498618416 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs @@ -1,7 +1,7 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public sealed class TextDisplayComponentNode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs index d877f48f87..40136000d7 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs @@ -1,6 +1,6 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public sealed class TextInputComponentNode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs index 17f4d7fe01..7a285b399c 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs @@ -1,7 +1,7 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public sealed class ThumbnailComponentNode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/UserSelectComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/UserSelectComponentNode.cs index 2ad578bbd2..cb60311211 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/UserSelectComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/UserSelectComponentNode.cs @@ -1,10 +1,10 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using System.Collections.Generic; using System.Linq; using System.Xml; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public sealed class UserSelectComponentNode : BaseSelectComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/IComponentProperty.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/IComponentProperty.cs index 532b88e4d7..be15d84b59 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/IComponentProperty.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/IComponentProperty.cs @@ -1,7 +1,7 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using System.Collections.Generic; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public interface IComponentProperty { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/NodeKind.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/NodeKind.cs index 747e8a212b..1da746faaf 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/NodeKind.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/NodeKind.cs @@ -1,7 +1,7 @@ using Microsoft.CodeAnalysis; using System; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; [Flags] public enum NodeKind : int diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.Numeric.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.Numeric.cs index 481a531f75..fdd0252600 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.Numeric.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.Numeric.cs @@ -1,4 +1,4 @@ -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; partial class Validators { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.StringLength.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.StringLength.cs index b2555d63fe..0fd7be4b1c 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.StringLength.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.StringLength.cs @@ -1,4 +1,4 @@ -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public static partial class Validators { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueCodeGenerator.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueCodeGenerator.cs index 24abc148ee..c846d25ddc 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueCodeGenerator.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueCodeGenerator.cs @@ -1,11 +1,11 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using System; using System.Collections.Generic; using System.Linq; using System.Text; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public static class ValueCodeGenerator { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParseDelegate.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParseDelegate.cs index 0a0a287ad3..81f9646ce2 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParseDelegate.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParseDelegate.cs @@ -1,3 +1,3 @@ -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public delegate ComponentPropertyValue? ValueParseDelegate(ComponentProperty property); diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Bool.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Bool.cs index 7616ca3bb9..2406b0b887 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Bool.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Bool.cs @@ -1,8 +1,8 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using Microsoft.CodeAnalysis; using System; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; partial class ValueParsers { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Color.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Color.cs index 04a03b3d7b..d32b345db1 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Color.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Color.cs @@ -1,9 +1,9 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using Microsoft.CodeAnalysis; using System; using System.Linq; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; partial class ValueParsers { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Emoji.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Emoji.cs index 51046c703b..0aeb40c4a8 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Emoji.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Emoji.cs @@ -1,9 +1,9 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using Microsoft.CodeAnalysis; using System; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; partial class ValueParsers { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Enum.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Enum.cs index b2fb6297be..0bdc504755 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Enum.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Enum.cs @@ -1,8 +1,8 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using Microsoft.CodeAnalysis; using System; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; partial class ValueParsers { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Int.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Int.cs index 7225d10158..42bd4eea32 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Int.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Int.cs @@ -1,8 +1,8 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using Microsoft.CodeAnalysis; using System; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; partial class ValueParsers { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Snowflake.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Snowflake.cs index 0e40a501cd..869945f9e0 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Snowflake.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Snowflake.cs @@ -1,8 +1,8 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using Microsoft.CodeAnalysis; using System; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; partial class ValueParsers { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.String.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.String.cs index 5d1bf72110..2bf606efd6 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.String.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.String.cs @@ -1,7 +1,7 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using System; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; partial class ValueParsers { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.cs index a9db8ae9de..97e312e2d0 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.cs @@ -1,8 +1,8 @@ -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Parser; using Microsoft.CodeAnalysis; using System; -namespace Discord.ComponentDesigner.Generator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes; public static partial class ValueParsers { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlTriviaToken.cs b/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlTriviaToken.cs deleted file mode 100644 index 7a5efea0b7..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlTriviaToken.cs +++ /dev/null @@ -1,37 +0,0 @@ -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/Parsing/CXSource.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXSource.cs new file mode 100644 index 0000000000..48246edb0c --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXSource.cs @@ -0,0 +1,39 @@ +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; + +namespace Discord.ComponentDesignerGenerator.Parser; + +public sealed class CXSource +{ + public string Value { get; } + + public char this[int index] => Value[index - SourceSpan.Start]; + + public readonly TextSpan[] Interpolations; + public int Length => Value.Length; + + public readonly TextSpan SourceSpan; + + public CXSource(TextSpan sourceSpan, string content, TextSpan[] interpolations) + { + SourceSpan = sourceSpan; + Value = content; + Interpolations = interpolations; + } + + public bool IsAtInterpolation(int index) + { + for (var i = 0; i < Interpolations.Length; i++) + if (Interpolations[i].Contains(index)) + return true; + + return false; + } + + public string GetValue(TextSpan span) + { + var start = span.Start - SourceSpan.Start; + + return Value.Substring(start, span.Length); + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXSourceReader.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXSourceReader.cs new file mode 100644 index 0000000000..bc03f17d4e --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXSourceReader.cs @@ -0,0 +1,38 @@ +namespace Discord.ComponentDesignerGenerator.Parser; + +public sealed class CXSourceReader +{ + public char this[int index] + => index < 0 || index >= Source.Length + ? CXLexer.NULL_CHAR + : Source.Value[index]; + + public bool IsEOF => Position >= Source.Length; + + public char Current => this[Position]; + + public char Next => this[Position + 1]; + + public char Previous => this[Position - 1]; + + public bool IsInInterpolation => Source.IsAtInterpolation(Position); + + + public int Position { get; set; } + public CXSource Source { get; } + + + public CXSourceReader(CXSource source) + { + Source = source; + Position = source.SourceSpan.Start; + } + + public void Advance(int count = 1) + { + for (var i = 0; i < count; i++) + { + Position++; + } + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXLexer.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXLexer.cs new file mode 100644 index 0000000000..240a6ade8c --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXLexer.cs @@ -0,0 +1,391 @@ +using Microsoft.CodeAnalysis.Text; +using System; + +namespace Discord.ComponentDesignerGenerator.Parser; + +public sealed class CXLexer +{ + private ref struct TokenInfo + { + public int Start; + public int End; + + public CXTokenKind Kind; + public CXTokenFlags Flags; + + public int LeadingTriviaLength; + public int TrailingTriviaLength; + } + + public enum LexMode + { + Default, + StringLiteral, + Identifier, + ElementValue, + Attribute + } + + private struct State + { + public int NextInterpolationIndex; + public int InterpolationIndex; + public char? QuoteChar; + } + + public const string COMMENT_START = ""; + + public const char NULL_CHAR = '\0'; + public const char NEWLINE_CHAR = '\n'; + public const char CARRAGE_RETURN_CHAR = '\r'; + + public const char UNDERSCORE_CHAR = '_'; + public const char HYPHEN_CHAR = '-'; + public const char PERIOD_CHAR = '.'; + + public const char LESS_THAN_CHAR = '<'; + public const char GREATER_THAN_CHAR = '>'; + public const char FORWARD_SLASH_CHAR = '/'; + public const char BACK_SLASH_CHAR = '\\'; + + public const char EQUALS_CHAR = '='; + public const char QUOTE_CHAR = '\''; + public const char DOUBLE_QUOTE_CHAR = '"'; + + public CXSourceReader Reader { get; } + + public int? InterpolationIndex { get; private set; } + + public TextSpan? CurrentInterpolationSpan + { + get + { + ref var interpolationIndex = ref _state.InterpolationIndex; + + // there's no next interpolation + if (Reader.Source.Interpolations.Length <= interpolationIndex) return null; + + for (; interpolationIndex < Reader.Source.Interpolations.Length; interpolationIndex++) + { + var interpolationSpan = Reader.Source.Interpolations[interpolationIndex]; + + if (interpolationSpan.End < Reader.Position) continue; + + // either we're in the interpolation or it's ahead of us + if (interpolationSpan.Contains(Reader.Position)) return interpolationSpan; + + // it's ahead of us + break; + } + + return null; + } + } + + public TextSpan? NextInterpolationSpan + { + get + { + ref var interpolationIndex = ref _state.NextInterpolationIndex; + + // there's no next interpolation + if (Reader.Source.Interpolations.Length <= interpolationIndex) return null; + + // check if it's ahead of us + TextSpan? interpolationSpan = null; + + for (; interpolationIndex < Reader.Source.Interpolations.Length; interpolationIndex++) + { + interpolationSpan = Reader.Source.Interpolations[interpolationIndex]; + if (interpolationSpan.Value.Start > Reader.Position) break; + } + + return interpolationSpan; + } + } + + private readonly bool[] _handledInterpolations; + + public LexMode Mode { get; set; } + private State _state; + + public CXLexer(CXSourceReader reader) + { + Reader = reader; + _handledInterpolations = new bool[reader.Source.Interpolations.Length]; + Mode = LexMode.Default; + _state = default; + } + + private void UpdateInterpolationState() + { + ref var interpolationIndex = ref _state.NextInterpolationIndex; + if (Reader.Source.Interpolations.Length > interpolationIndex) + { + var interpolation = Reader.Source.Interpolations[interpolationIndex]; + + if (Reader.Position > interpolation.End) interpolationIndex++; + } + } + + public CXToken Next() + { + InterpolationIndex = null; + + var info = default(TokenInfo); + + GetTrivia(isTrailing: false, ref info.LeadingTriviaLength); + + info.Start = Reader.Position; + + Scan(ref info); + + info.End = Reader.Position; + + GetTrivia(isTrailing: true, ref info.TrailingTriviaLength); + + return new CXToken( + info.Kind, + new TextSpan(info.Start, info.End - info.Start), + info.LeadingTriviaLength, + info.TrailingTriviaLength, + info.Flags + ); + } + + private void Scan(ref TokenInfo info) + { + switch (Mode) + { + case LexMode.StringLiteral: + LexStringLiteral(ref info); + return; + case LexMode.Identifier when TryScanIdentifier(ref info): + return; + case LexMode.ElementValue when TryScanElementValue(ref info): + return; + } + + if (TryScanInterpolation(ref info)) return; + + switch (Reader.Current) + { + case LESS_THAN_CHAR: + Reader.Advance(); + if (Reader.Current is FORWARD_SLASH_CHAR) + { + info.Kind = CXTokenKind.LessThanForwardSlash; + Reader.Advance(); + return; + } + info.Kind = CXTokenKind.LessThan; + return; + case FORWARD_SLASH_CHAR when Reader.Next is GREATER_THAN_CHAR: + Reader.Advance(2); + info.Kind = CXTokenKind.ForwardSlashGreaterThan; + return; + case GREATER_THAN_CHAR: + info.Kind = CXTokenKind.GreaterThan; + Reader.Advance(); + return; + case EQUALS_CHAR when Mode == LexMode.Attribute: + info.Kind = CXTokenKind.Equals; + Reader.Advance(); + return; + case NULL_CHAR: + if (Reader.IsEOF) + { + info.Kind = CXTokenKind.EOF; + return; + } + + goto default; + + default: + if (Mode == LexMode.Attribute && TryScanAttributeValue(ref info)) return; + + info.Kind = CXTokenKind.Invalid; + return; + } + } + + private bool TryScanElementValue(ref TokenInfo info) + { + var interpolationUpperBounds = NextInterpolationSpan?.Start ?? Reader.Source.Length; + + var start = Reader.Position; + + for (; Reader.Position < interpolationUpperBounds; Reader.Advance()) + { + switch (Reader.Current) + { + case NULL_CHAR + or LESS_THAN_CHAR: + goto end; + } + } + + end: + if (Reader.Position != start) + { + info.Kind = CXTokenKind.Text; + return true; + } + + return false; + } + + private void LexStringLiteral(ref TokenInfo info) + { + if (_state.QuoteChar is null) + { + // bad state + throw new InvalidOperationException("Missing closing char for string literal"); + } + + if (Reader.IsEOF) + { + // TODO: unclosed string literal + info.Kind = CXTokenKind.EOF; + return; + } + + var interpolationUpperBounds = NextInterpolationSpan?.Start ?? Reader.Source.Length; + + if (Reader.Position >= interpolationUpperBounds) + { + if (!TryScanInterpolation(ref info)) + { + // TODO: handle + } + + return; + } + + if (Reader.Current == _state.QuoteChar) + { + Reader.Advance(); + + info.Kind = CXTokenKind.StringLiteralEnd; + Mode = LexMode.Default; + _state.QuoteChar = null; + + return; + } + + for (; Reader.Position < interpolationUpperBounds; Reader.Advance()) + { + if (_state.QuoteChar == Reader.Current) + { + // is it escaped? + if (Reader.Previous is FORWARD_SLASH_CHAR) + { + // allow + continue; + } + + // we've reached the end + info.Kind = CXTokenKind.Text; + return; + } + } + } + + private bool TryScanAttributeValue(ref TokenInfo info) + { + if (Mode is LexMode.StringLiteral) return false; + + if (Reader.Current is not QUOTE_CHAR and not DOUBLE_QUOTE_CHAR) + { + // interpolations only + return TryScanInterpolation(ref info); + } + + _state.QuoteChar = Reader.Current; + Reader.Advance(); + info.Kind = CXTokenKind.StringLiteralStart; + Mode = LexMode.StringLiteral; + return true; + } + + private bool TryScanIdentifier(ref TokenInfo info) + { + var upperBounds = NextInterpolationSpan?.Start ?? Reader.Source.Length; + + if (!IsValidIdentifierStartChar(Reader.Current) || Reader.Position >= upperBounds) + return false; + + do + { + Reader.Advance(); + } while (IsValidIdentifierChar(Reader.Current) && Reader.Position < upperBounds); + + info.Kind = CXTokenKind.Identifier; + return true; + + + static bool IsValidIdentifierChar(char c) + => c is UNDERSCORE_CHAR or HYPHEN_CHAR or PERIOD_CHAR || char.IsLetterOrDigit(c); + + static bool IsValidIdentifierStartChar(char c) + => c is UNDERSCORE_CHAR || char.IsLetter(c); + } + + private bool TryScanInterpolation(ref TokenInfo info) + { + if (CurrentInterpolationSpan is { } span) + { + info.Kind = CXTokenKind.Interpolation; + Reader.Advance( + span.End - Reader.Position + ); + InterpolationIndex = _state.InterpolationIndex; + return true; + } + + return false; + } + + private void GetTrivia(bool isTrailing, ref int trivia) + { + if (Mode is LexMode.StringLiteral) return; + + for (;; trivia++, Reader.Advance()) + { + start: + + var current = Reader.Current; + + if (CurrentInterpolationSpan is not null) return; + + if (IsWhitespace(current)) continue; + + if (current is CARRAGE_RETURN_CHAR && Reader.Next is NEWLINE_CHAR) + { + trivia += 2; + Reader.Advance(2); + + if (isTrailing) break; + + goto start; + } + + if (current is NEWLINE_CHAR) + { + if (isTrailing) + { + trivia++; + break; + } + + continue; + } + + return; + } + } + + private static bool IsWhitespace(char ch) + => char.IsWhiteSpace(ch); +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXToken.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXToken.cs new file mode 100644 index 0000000000..c75ab8354e --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXToken.cs @@ -0,0 +1,19 @@ +using Microsoft.CodeAnalysis.Text; + +namespace Discord.ComponentDesignerGenerator.Parser; + +public readonly record struct CXToken( + CXTokenKind Kind, + TextSpan Span, + int LeadingTriviaLength, + int TrailingTriviaLength, + CXTokenFlags Flags +) +{ + public int AbsoluteStart => Span.Start - LeadingTriviaLength; + public int AbsoluteEnd => Span.End + TrailingTriviaLength; + + public int AbsoluteWidth => AbsoluteEnd - AbsoluteStart; + + public TextSpan FullSpan => new(AbsoluteStart, AbsoluteWidth); +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXTokenFlags.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXTokenFlags.cs new file mode 100644 index 0000000000..f5f2c0b146 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXTokenFlags.cs @@ -0,0 +1,10 @@ +using System; + +namespace Discord.ComponentDesignerGenerator.Parser; + +[Flags] +public enum CXTokenFlags : byte +{ + None = 0, + HasErrors = 1 << 0 +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXTokenKind.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXTokenKind.cs new file mode 100644 index 0000000000..c546b1925e --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXTokenKind.cs @@ -0,0 +1,20 @@ +namespace Discord.ComponentDesignerGenerator.Parser; + +public enum CXTokenKind : byte +{ + Invalid, + EOF, + + LessThan, + GreaterThan, + ForwardSlashGreaterThan, + LessThanForwardSlash, + Equals, + + Text, + Interpolation, + StringLiteralStart, + StringLiteralEnd, + + Identifier, +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlAttribute.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlAttribute.cs similarity index 85% rename from src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlAttribute.cs rename to src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlAttribute.cs index 7e151131b6..9e8fca6b72 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlAttribute.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlAttribute.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace Discord.ComponentDesigner.Generator.Parser; +namespace Discord.ComponentDesignerGenerator.Parser; public sealed record CXmlAttribute( SourceSpan Span, diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlDiagnostic.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlDiagnostic.cs similarity index 74% rename from src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlDiagnostic.cs rename to src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlDiagnostic.cs index dccda3de87..2802ba747b 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlDiagnostic.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlDiagnostic.cs @@ -1,6 +1,6 @@ using Microsoft.CodeAnalysis; -namespace Discord.ComponentDesigner.Generator.Parser; +namespace Discord.ComponentDesignerGenerator.Parser; public readonly record struct CXmlDiagnostic( DiagnosticSeverity Severity, diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlDoc.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlDoc.cs similarity index 88% rename from src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlDoc.cs rename to src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlDoc.cs index 8f9aea015a..9a80722b3d 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlDoc.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlDoc.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Linq; -namespace Discord.ComponentDesigner.Generator.Parser; +namespace Discord.ComponentDesignerGenerator.Parser; public sealed record CXmlDoc( SourceSpan Span, diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlElement.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlElement.cs similarity index 91% rename from src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlElement.cs rename to src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlElement.cs index 19de4ce9fb..704c8c78cc 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlElement.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlElement.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Linq; -namespace Discord.ComponentDesigner.Generator.Parser; +namespace Discord.ComponentDesignerGenerator.Parser; public sealed record CXmlElement( SourceSpan Span, diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlValue.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlValue.cs similarity index 94% rename from src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlValue.cs rename to src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlValue.cs index 1955eb133b..2badd67aad 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parser/CXmlValue.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlValue.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace Discord.ComponentDesigner.Generator.Parser; +namespace Discord.ComponentDesignerGenerator.Parser; public abstract record CXmlValue( SourceSpan Span, diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parser/ComponentParser.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/ComponentParser.cs similarity index 84% rename from src/Discord.Net.ComponentDesigner.Generator/Parser/ComponentParser.cs rename to src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/ComponentParser.cs index b5d5d50219..a3dc685e16 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parser/ComponentParser.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/ComponentParser.cs @@ -2,7 +2,7 @@ using System; using System.Collections.Generic; -namespace Discord.ComponentDesigner.Generator.Parser; +namespace Discord.ComponentDesignerGenerator.Parser; public sealed class ComponentParser { @@ -104,11 +104,6 @@ public sealed class ComponentParser /// 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); @@ -117,8 +112,6 @@ private ComponentParser(string[] slices, int[] interpolationLengths) _diagnostics = []; - _trivia = []; - _interpolationOffsets = new int[slices.Length - 1]; _handledInterpolations = new bool[interpolationLengths.Length]; @@ -841,166 +834,6 @@ private static bool IsValidNameStartChar(char c) 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. /// diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parser/ICXml.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/ICXml.cs similarity index 76% rename from src/Discord.Net.ComponentDesigner.Generator/Parser/ICXml.cs rename to src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/ICXml.cs index 4056ddf855..6850f67820 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parser/ICXml.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/ICXml.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace Discord.ComponentDesigner.Generator.Parser; +namespace Discord.ComponentDesignerGenerator.Parser; public interface ICXml { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parser/SourceLocation.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/SourceLocation.cs similarity index 80% rename from src/Discord.Net.ComponentDesigner.Generator/Parser/SourceLocation.cs rename to src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/SourceLocation.cs index 603a897dd4..72adac3da5 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parser/SourceLocation.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/SourceLocation.cs @@ -1,4 +1,4 @@ -namespace Discord.ComponentDesigner.Generator.Parser; +namespace Discord.ComponentDesignerGenerator.Parser; public readonly record struct SourceLocation( int Line, diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parser/SourceSpan.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/SourceSpan.cs similarity index 87% rename from src/Discord.Net.ComponentDesigner.Generator/Parser/SourceSpan.cs rename to src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/SourceSpan.cs index f188166620..b4aaef8f94 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parser/SourceSpan.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/SourceSpan.cs @@ -1,4 +1,4 @@ -namespace Discord.ComponentDesigner.Generator.Parser; +namespace Discord.ComponentDesignerGenerator.Parser; public readonly record struct SourceSpan( SourceLocation Start, diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXAttribute.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXAttribute.cs new file mode 100644 index 0000000000..fd3c289d4c --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXAttribute.cs @@ -0,0 +1,31 @@ +using Microsoft.CodeAnalysis.Text; + +namespace Discord.ComponentDesignerGenerator.Parser; + +public sealed class CXAttribute : CXNode +{ + public CXToken Identifier { get; } + + public CXToken? EqualsToken { get; } + + public CXValue? Value { get; } + + public CXAttribute( + CXToken identifier, + CXToken? equalsToken, + CXValue? value + ) + { + Slot(Identifier = identifier); + Slot(EqualsToken = equalsToken); + Slot(Value = value); + } + + public override void IncrementalParse(ParseSlot slot, TextChange change) + { + if (slot == Identifier) + { + + } + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXBlender.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXBlender.cs new file mode 100644 index 0000000000..01737f6e05 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXBlender.cs @@ -0,0 +1,142 @@ +// using Microsoft.CodeAnalysis.Text; +// using System.Collections.Generic; +// +// namespace Discord.ComponentDesignerGenerator.Parser; +// +// public sealed class CXBlender +// { +// public int CurrentSourcePosition => _lexer.Reader.Position; +// +// private readonly CXLexer _lexer; +// private readonly CXDoc _doc; +// private readonly Queue _changes; +// private readonly List _nodes; +// private readonly List _tokens; +// +// private int _changeDelta; +// private int _docTokenIndex; +// +// public CXBlender( +// CXLexer lexer, +// CXDoc doc, +// IEnumerable changes, +// List nodes, +// List tokens +// ) +// { +// _lexer = lexer; +// _doc = doc; +// _changes = new(changes); +// _nodes = nodes; +// _tokens = tokens; +// } +// +// public CXToken GetToken(int index) +// { +// if (_tokens.Count > index) +// return _tokens[index]; +// +// while (_tokens.Count <= index) +// { +// var token = NextToken(); +// +// if (token.Kind is CXTokenKind.EOF) return token; +// } +// +// return _tokens[index]; +// } +// +// public CXToken NextToken() +// { +// SkipPastChanges(); +// +// while (true) +// { +// while (_changeDelta < 0 && _docTokenIndex < _doc.Tokens.Count) +// { +// var oldToken = _doc.Tokens[_docTokenIndex++]; +// _changeDelta += oldToken.AbsoluteWidth; +// } +// +// if (_changeDelta > 0) return LexNewToken(); +// +// if (TryReuseToken(out var token)) return token; +// +// if (_doc.Tokens.Count <= _docTokenIndex) return LexNewToken(); +// +// _changeDelta += _doc.Tokens[_docTokenIndex++].AbsoluteWidth; +// } +// +// bool TryReuseToken(out CXToken token) +// { +// if (_docTokenIndex >= _doc.Tokens.Count) +// { +// token = default; +// return false; +// } +// +// token = _doc.Tokens[_docTokenIndex]; +// +// if (!CanReuse(token)) return false; +// +// _lexer.Reader.Advance(token.AbsoluteWidth); +// _tokens.Add(token); +// return true; +// } +// } +// +// private CXToken LexNewToken() +// { +// while (true) +// { +// var token = _lexer.Next(); +// +// if(token.Kind is CXTokenKind.Invalid) continue; +// +// _tokens.Add(token); +// _changeDelta += token.AbsoluteWidth; +// return token; +// } +// } +// +// private bool CanReuse(CXToken token) +// { +// if (token.Span.Length is 0) return false; +// +// if (IntersectsNextChange(token.Span)) return false; +// +// return true; +// } +// +// private bool IntersectsNextChange(TextSpan span) +// { +// if (_changes.Count is 0) return false; +// +// return span.IntersectsWith(_changes.Peek().Span); +// } +// +// private void SkipPastChanges() +// { +// while (_changes.Count is not 0) +// { +// var change = _changes.Peek(); +// +// if (change.Span.Start + change.NewLength > CurrentSourcePosition) +// break; +// +// _changes.Dequeue(); +// +// _changeDelta += change.NewLength - change.Span.Length; +// +// while (_docTokenIndex < _doc.Tokens.Count) +// { +// var token = _doc.Tokens[_docTokenIndex]; +// +// if (token.AbsoluteStart >= change.Span.Start) +// break; +// +// _docTokenIndex++; +// } +// } +// } +// } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXDiagnostic.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXDiagnostic.cs new file mode 100644 index 0000000000..ca2a9471a7 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXDiagnostic.cs @@ -0,0 +1,10 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Discord.ComponentDesignerGenerator.Parser; + +public readonly record struct CXDiagnostic( + DiagnosticSeverity Severity, + string Message, + TextSpan Span +); diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXDoc.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXDoc.cs new file mode 100644 index 0000000000..aeb690616a --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXDoc.cs @@ -0,0 +1,55 @@ +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord.ComponentDesignerGenerator.Parser; + +public sealed class CXDoc : CXNode +{ + private readonly CXParser _parser; + private readonly IReadOnlyList _rootElements; + + public CXDoc( + CXParser parser, + IReadOnlyList rootElements + ) + { + _parser = parser; + Slot(_rootElements = rootElements); + } + + public void ApplyChanges(IEnumerable changes) + { + // find out largest node that encapsolates the change + + foreach (var change in changes) + { + _parser.TextChange = change; + var owner = FindOwningNode(change.Span, out var slot); + } + } + + private CXNode FindOwningNode(TextSpan span, out ParseSlot slot) + { + CXNode current = this; + slot = default; + + search: + for (var i = 0; i < current.Slots.Count; i++) + { + slot = current.Slots[i]; + + if (!slot.FullSpan.Contains(span)) continue; + + if (slot.Node is null) break; + + current = slot.Node; + goto search; + } + + return current; + } + + public string GetTokenValue(CXToken token) => _parser.Source.GetValue(token.Span); + public string GetTokenValueWithTrivia(CXToken token) => _parser.Source.GetValue(token.FullSpan); +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXElement.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXElement.cs new file mode 100644 index 0000000000..0711f9d736 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXElement.cs @@ -0,0 +1,40 @@ +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; + +namespace Discord.ComponentDesignerGenerator.Parser; + +public sealed class CXElement : CXNode +{ + public CXToken ElementStartOpenToken { get; } + public CXToken ElementStartNameToken { get; } + public IReadOnlyList Attributes { get; } + + public CXToken ElementStartCloseToken { get; } + + public IReadOnlyList Children { get; } + + public CXToken? ElementEndOpenToken { get; } + public CXToken? ElementEndNameToken { get; } + public CXToken? ElementEndCloseToken { get; } + + public CXElement( + CXToken elementStartOpenToken, + CXToken elementStartNameToken, + IReadOnlyList attributes, + CXToken elementStartCloseToken, + IEnumerable? children = null, + CXToken? elementEndOpenToken = null, + CXToken? elementEndNameToken = null, + CXToken? elementEndCloseToken = null + ) + { + Slot(ElementStartOpenToken = elementStartOpenToken); + Slot(ElementStartNameToken = elementStartNameToken); + Slot(Attributes = attributes); + Slot(ElementStartCloseToken = elementStartCloseToken); + Slot(Children = [..children ?? []]); + Slot(ElementEndOpenToken = elementEndOpenToken); + Slot(ElementEndNameToken = elementEndNameToken); + Slot(ElementEndCloseToken = elementEndCloseToken); + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXNode.cs new file mode 100644 index 0000000000..993dcd8b5f --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXNode.cs @@ -0,0 +1,152 @@ +using Microsoft.CodeAnalysis.Text; +using System; +using System.Collections.Generic; + +namespace Discord.ComponentDesignerGenerator.Parser; + +public abstract class CXNode +{ + public readonly struct ParseSlot + { + public TextSpan FullSpan => Node?.FullSpan ?? Token?.FullSpan ?? default; + + public readonly CXNode? Node; + public readonly CXToken? Token; + + public ParseSlot(CXNode node) + { + Node = node; + } + + public ParseSlot(CXToken token) + { + Token = token; + } + + public static bool operator ==(ParseSlot slot, CXNode node) + => slot.Node == node; + + public static bool operator !=(ParseSlot slot, CXNode node) + => slot.Node != node; + + public static bool operator ==(ParseSlot slot, CXToken token) + => slot.Token == token; + + public static bool operator !=(ParseSlot slot, CXToken token) + => slot.Token != token; + } + + public CXNode? Parent { get; set; } + public int Width { get; private set; } + + public List Diagnostics { get; } + + public CXDoc Document + { + get => this is CXDoc doc + ? doc + : _doc ??= Parent?._doc ?? throw new InvalidOperationException(); + set => _doc = value; + } + + protected CXParser Parser => Document.Parser; + + public CXToken FirstTerminal + { + get + { + if (_slots.Count is 0) return default; + + return _slots[0] switch + { + {Node: { } node} => node.FirstTerminal, + {Token: { } token} => token, + _ => throw new InvalidOperationException() + }; + } + } + + public CXToken LastTerminal + { + get + { + if (_slots.Count is 0) return default; + + return _slots[_slots.Count - 1] switch + { + {Node: { } node} => node.LastTerminal, + {Token: { } token} => token, + _ => throw new InvalidOperationException() + }; + } + } + + public TextSpan FullSpan => new(Offset, Width); + + public int Offset => _offset ??= ComputeOffset(); + + private int? _offset; + + private CXDoc _doc; + + public IReadOnlyList Slots => _slots; + + private readonly List _slots; + private int _parentSlotIndex = -1; + + public CXNode() + { + Diagnostics = []; + _slots = []; + } + + private int ComputeOffset() + { + if (Parent is null) return 0; + + var parentOffset = Parent.Offset; + + return _parentSlotIndex switch + { + -1 => throw new InvalidOperationException(), + 0 => parentOffset, + _ => Parent._slots[_parentSlotIndex - 1] switch + { + {Node: { } sibling} => sibling.Offset + sibling.Width, + {Token: { } token} => token.AbsoluteEnd, + _ => throw new InvalidOperationException() + } + }; + } + + protected void Slot(CXNode? node) + { + if (node is null) return; + + Width += node.Width; + + node.Parent = this; + node._parentSlotIndex = _slots.Count; + _slots.Add(new(node)); + } + + protected void Slot(CXToken? token) + { + if (token is null) return; + + _slots.Add(new(token.Value)); + Width += token.Value.AbsoluteWidth; + } + + protected void Slot(IEnumerable tokens) + { + foreach (var token in tokens) Slot(token); + } + + protected void Slot(IEnumerable nodes) + { + foreach (var node in nodes) Slot(node); + } + + public abstract void IncrementalParse(ParseSlot slot, TextChange change); +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXParser.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXParser.cs new file mode 100644 index 0000000000..d8ad20fe11 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXParser.cs @@ -0,0 +1,366 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Discord.ComponentDesignerGenerator.Parser; + +public sealed class CXParser +{ + public CXSource Source { get; } + public CXToken CurrentToken => Lex(_tokenIndex); + public CXToken NextToken => Lex(_tokenIndex + 1); + + private readonly List _tokens; + private readonly CXLexer _lexer; + private int _tokenIndex; + + private readonly CXSourceReader _reader; + + private readonly List _diagnostics; + + public TextChange? TextChange { get; set; } + + public CXParser(CXSource source) + { + Source = source; + _reader = new CXSourceReader(source); + _lexer = new CXLexer(_reader); + _tokens = []; + _diagnostics = []; + } + + public static CXDoc Parse(CXSource source) + { + var elements = new List(); + + var parser = new CXParser(source); + + while (parser.CurrentToken.Kind is not CXTokenKind.EOF and not CXTokenKind.Invalid) + { + elements.Add(parser.ParseElement()); + } + + return new CXDoc(parser, elements); + } + + private CXElement ParseElement() + { + var start = Expect(CXTokenKind.LessThan); + + var identifier = ParseIdentifier(); + + var attributes = ParseAttributes().ToList(); + + switch (CurrentToken.Kind) + { + case CXTokenKind.GreaterThan: + var end = Eat(); + // parse children + var children = ParseElementChildren().ToList(); + + ParseClosingElement( + out var endStart, + out var endIdent, + out var endClose + ); + + return new CXElement( + start, + identifier, + attributes, + end, + children, + endStart, + endIdent, + endClose + ); + case CXTokenKind.ForwardSlashGreaterThan: + return new CXElement( + start, + identifier, + attributes, + Eat() + ); + default: + throw new InvalidOperationException("Unexpected token"); + } + + void ParseClosingElement( + out CXToken elementEndStart, + out CXToken elementEndIdent, + out CXToken elementEndClose) + { + elementEndStart = Expect(CXTokenKind.LessThan); + elementEndIdent = Expect(CXTokenKind.Identifier); + elementEndClose = Expect(CXTokenKind.ForwardSlashGreaterThan); + + // TODO: verify identifier match + } + + IEnumerable ParseElementChildren() + { + // valid children are: + // - other elements + // - interpolations + // - text + var oldMode = _lexer.Mode; + _lexer.Mode = CXLexer.LexMode.ElementValue; + + try + { + while (true) + { + switch (CurrentToken.Kind) + { + case CXTokenKind.Interpolation: + yield return new CXValue.Interpolation( + Eat(), + _lexer.InterpolationIndex!.Value + ); + break; + case CXTokenKind.Text: + yield return new CXValue.Scalar(Eat()); + break; + case CXTokenKind.LessThan: + // new element + yield return ParseElement(); + break; + case CXTokenKind.LessThanForwardSlash: + yield break; + + case CXTokenKind.EOF or CXTokenKind.Invalid: break; + + default: + _diagnostics.Add( + new CXDiagnostic( + DiagnosticSeverity.Error, + $"Unexpected element child type '{CurrentToken.Kind}'", + CurrentToken.Span + ) + ); + break; + } + } + } + finally + { + _lexer.Mode = oldMode; + } + } + } + + private IEnumerable ParseAttributes() + { + // expect identifiers + var oldMode = _lexer.Mode; + _lexer.Mode = CXLexer.LexMode.Identifier; + try + { + while (CurrentToken.Kind is CXTokenKind.Identifier) + yield return ParseAttribute(); + } + finally + { + _lexer.Mode = oldMode; + } + } + + private CXAttribute ParseAttribute() + { + var oldMode = _lexer.Mode; + _lexer.Mode = CXLexer.LexMode.Attribute; + + try + { + var identifier = ParseIdentifier(); + + if (!Eat(CXTokenKind.Equals, out var equalsToken)) + { + return new CXAttribute( + identifier, + null, + null + ); + } + + // parse attribute values + var value = ParseAttributeValue(); + + return new CXAttribute( + identifier, + equalsToken, + value + ); + } + finally + { + _lexer.Mode = oldMode; + } + } + + private CXValue ParseAttributeValue() + { + switch (CurrentToken.Kind) + { + case CXTokenKind.Interpolation: + return new CXValue.Interpolation( + CurrentToken, + _lexer.InterpolationIndex!.Value + ); + case CXTokenKind.StringLiteralStart: + return ParseStringLiteral(); + default: + _diagnostics.Add( + new CXDiagnostic( + DiagnosticSeverity.Error, + $"Unexpected attribute valid start, expected interpolation or string literal, got '{CurrentToken.Kind}'", + CurrentToken.Span + ) + ); + return new CXValue.Invalid(); + } + } + + private CXValue ParseStringLiteral() + { + var tokens = new List(); + + var start = Expect(CXTokenKind.StringLiteralStart); + + while (CurrentToken.Kind is not CXTokenKind.StringLiteralEnd) + { + switch (CurrentToken.Kind) + { + case CXTokenKind.Text: + case CXTokenKind.Interpolation: + tokens.Add(Eat()); + continue; + + case CXTokenKind.Invalid or CXTokenKind.EOF: break; + + default: + _diagnostics.Add( + new CXDiagnostic( + DiagnosticSeverity.Error, + $"Unexpected string literal token '{CurrentToken.Kind}'", + CurrentToken.Span + ) + ); + goto end; + } + } + + end: + var end = Expect(CXTokenKind.StringLiteralEnd); + + return new CXValue.StringLiteral( + start, + tokens, + end + ); + } + + private CXToken ParseIdentifier() + { + var oldMode = _lexer.Mode; + _lexer.Mode = CXLexer.LexMode.Identifier; + + try + { + var token = Expect(CXTokenKind.Identifier); + + _lexer.Mode = oldMode; + + return token; + } + finally + { + _lexer.Mode = oldMode; + } + } + + private CXToken Eat() + { + var token = CurrentToken; + _tokenIndex++; + return token; + } + + private bool Eat(CXTokenKind kind, out CXToken token) + { + token = CurrentToken; + + if (token.Kind == kind) + { + _tokenIndex++; + return true; + } + + return false; + } + + private CXToken Expect(CXTokenKind kind) + { + var token = CurrentToken; + + if (token.Kind != kind) + { + token = token with {Flags = CXTokenFlags.HasErrors}; + _diagnostics.Add( + new CXDiagnostic( + DiagnosticSeverity.Error, + $"Unexpected token, expected '{kind}', got '{token.Kind}'", + token.Span + ) + ); + } + + _tokenIndex++; + return token; + } + + private CXToken Lex(int index) + { + CXToken token; + + while (_tokens.Count <= index) + { + token = _lexer.Next(); + + if (token.Kind is CXTokenKind.EOF) return token; + + _tokens.Add(token); + } + + token = _tokens[index]; + + ValidateChanges(); + + return token; + + void ValidateChanges() + { + if (!TextChange.HasValue) return; + + var span = TextChange.Value.Span; + + if (span.OverlapsWith(token.Span)) + { + // we need to re-lex + _reader.Position = token.AbsoluteStart; + _tokens[index] = token = _lexer.Next(); + } + } + + // CXToken ActuallyLex() + // { + // // are we in a change + // if (NextChange is null) return _lexer.Next(); + // + // var changeSpan = NextChange.Value.Span; + // + // } + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXValue.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXValue.cs new file mode 100644 index 0000000000..125ec0a006 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXValue.cs @@ -0,0 +1,50 @@ +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; + +namespace Discord.ComponentDesignerGenerator.Parser; + +public abstract class CXValue : CXNode +{ + public sealed class Invalid : CXValue; + + public sealed class StringLiteral : CXValue + { + public CXToken StartToken { get; } + public IReadOnlyList Tokens { get; } + public CXToken EndToken { get; } + + public StringLiteral( + CXToken start, + List tokens, + CXToken end + ) + { + Slot(StartToken = start); + Slot(Tokens = tokens); + Slot(EndToken = end); + } + } + + public sealed class Interpolation : CXValue + { + public CXToken Token { get; } + public int InterpolationIndex { get; } + + public Interpolation(CXToken token, int interpolationIndex) + { + Slot(Token = token); + InterpolationIndex = interpolationIndex; + } + } + + public sealed class Scalar : CXValue + { + public string Value => Document.GetTokenValue(Token); + public CXToken Token { get; } + + public Scalar(CXToken token) + { + Slot(Token = token); + } + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs b/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs index 0e7340039e..dacfff1acf 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs @@ -1,5 +1,5 @@ -using Discord.ComponentDesigner.Generator.Nodes; -using Discord.ComponentDesigner.Generator.Parser; +using Discord.ComponentDesignerGenerator.Nodes; +using Discord.ComponentDesignerGenerator.Parser; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -11,7 +11,7 @@ using System.Text; using System.Threading; -namespace Discord.ComponentDesigner.Generator; +namespace Discord.ComponentDesignerGenerator; [Generator] public sealed class SourceGenerator : IIncrementalGenerator diff --git a/src/Discord.Net.ComponentDesigner.Generator/Utils/KnownTypes.cs b/src/Discord.Net.ComponentDesigner.Generator/Utils/KnownTypes.cs index 8aef58acd0..92bd902100 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Utils/KnownTypes.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Utils/KnownTypes.cs @@ -8,7 +8,7 @@ using System.Reflection; using System.Runtime.CompilerServices; -namespace Discord.ComponentDesigner.Generator; +namespace Discord.ComponentDesignerGenerator; public class KnownTypes { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Utils/StringUtils.cs b/src/Discord.Net.ComponentDesigner.Generator/Utils/StringUtils.cs index 77dbbace73..ed6140bde1 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Utils/StringUtils.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Utils/StringUtils.cs @@ -1,4 +1,4 @@ -namespace Discord.ComponentDesigner.Generator; +namespace Discord.ComponentDesignerGenerator; public static class StringUtils { diff --git a/src/Discord.Net.ComponentDesigner/Discord.Net.ComponentDesigner.csproj b/src/Discord.Net.ComponentDesigner/Discord.Net.ComponentDesigner.csproj index 758982234a..6220b1239f 100644 --- a/src/Discord.Net.ComponentDesigner/Discord.Net.ComponentDesigner.csproj +++ b/src/Discord.Net.ComponentDesigner/Discord.Net.ComponentDesigner.csproj @@ -4,6 +4,7 @@ net8.0 enable enable + Discord From 04372608db2726a27ea6579c7eee1a3319162986 Mon Sep 17 00:00:00 2001 From: Quin Lynch <49576606+quinchs@users.noreply.github.com> Date: Mon, 8 Sep 2025 19:41:49 -0300 Subject: [PATCH 09/17] continue incremental parsing --- .../Generator/SourceManager.cs | 122 ++++++++ .../Parsing/Parser2/CXAttribute.cs | 20 +- .../Parsing/Parser2/CXBlender.cs | 278 +++++++++--------- .../Parsing/Parser2/CXDoc.cs | 21 +- .../Parsing/Parser2/CXElement.cs | 5 + .../Parsing/Parser2/CXNode.cs | 54 +++- .../Parsing/Parser2/CXParser.cs | 58 ++-- .../Parsing/Parser2/CXValue.cs | 5 + src/Discord.Net.Core/Discord.Net.Core.csproj | 4 +- src/Discord.Net.Core/packages.lock.json | 14 - 10 files changed, 379 insertions(+), 202 deletions(-) create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Generator/SourceManager.cs diff --git a/src/Discord.Net.ComponentDesigner.Generator/Generator/SourceManager.cs b/src/Discord.Net.ComponentDesigner.Generator/Generator/SourceManager.cs new file mode 100644 index 0000000000..7c0aa3e3db --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Generator/SourceManager.cs @@ -0,0 +1,122 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; + +namespace Discord.ComponentDesignerGenerator; + +public sealed record Target( + InterceptableLocation InterceptLocation, + InvocationExpressionSyntax InvocationSyntax, + ExpressionSyntax ArgumentExpressionSyntax, + IOperation Operation, + Compilation Compilation +); + +public sealed class SourceManager +{ + public SourceManager(IncrementalGeneratorInitializationContext context) + { + context + .SyntaxProvider + .CreateSyntaxProvider( + IsComponentDesignerCall, + MapPossibleComponentDesignerCall + ) + .Collect(); + } + + private static void ProcessTargetsUpdate(ImmutableArray targets, CancellationToken token) + { + foreach (var target in targets) + { + if(target is null) continue; + + + } + } + + + private static Target? MapPossibleComponentDesignerCall(GeneratorSyntaxContext context, CancellationToken token) + { + if ( + !TryGetValidDesignerCall( + out var operation, + out var invocationSyntax, + out var interceptLocation, + out var argumentSyntax + ) + ) return null; + + return new Target( + interceptLocation, + invocationSyntax, + argumentSyntax, + operation, + context.SemanticModel.Compilation + ); + + + bool TryGetValidDesignerCall( + out IOperation operation, + out InvocationExpressionSyntax invocationSyntax, + out InterceptableLocation interceptLocation, + out ExpressionSyntax argumentExpressionSyntax + ) + { + operation = context.SemanticModel.GetOperation(context.Node, token)!; + interceptLocation = null!; + argumentExpressionSyntax = null!; + invocationSyntax = null!; + + checkOperation: + switch (operation) + { + case IInvalidOperation invalid: + operation = invalid.ChildOperations.OfType().FirstOrDefault()!; + goto checkOperation; + case IInvocationOperation invocation: + if ( + invocation + .TargetMethod + .ContainingType + .ToDisplayString() + is "Discord.ComponentDesigner" + ) break; + goto default; + + default: return false; + } + + if (context.Node is not InvocationExpressionSyntax syntax) return false; + + invocationSyntax = syntax; + + if (context.SemanticModel.GetInterceptableLocation(invocationSyntax) is not { } location) + return false; + + interceptLocation = location; + + if (invocationSyntax.ArgumentList.Arguments.Count is not 1) return false; + + argumentExpressionSyntax = invocationSyntax.ArgumentList.Arguments[0].Expression; + + return true; + } + } + + private static bool IsComponentDesignerCall(SyntaxNode node, CancellationToken token) + => node is InvocationExpressionSyntax + { + Expression: MemberAccessExpressionSyntax + { + Name: {Identifier.Value: "Create" or "cx"} + } or IdentifierNameSyntax + { + Identifier.ValueText: "cx" + } + }; +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXAttribute.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXAttribute.cs index fd3c289d4c..ecf1aca89f 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXAttribute.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXAttribute.cs @@ -4,7 +4,7 @@ namespace Discord.ComponentDesignerGenerator.Parser; public sealed class CXAttribute : CXNode { - public CXToken Identifier { get; } + public CXToken Identifier { get; private set; } public CXToken? EqualsToken { get; } @@ -23,9 +23,25 @@ public CXAttribute( public override void IncrementalParse(ParseSlot slot, TextChange change) { - if (slot == Identifier) + var oldMode = Parser.Lexer.Mode; + Parser.Lexer.Mode = CXLexer.LexMode.Attribute; + + try { + if (slot == Identifier) + { + UpdateSlot(slot, Identifier = Parser.ParseIdentifier()); + } + // if (slot == EqualsToken) + // { + // + // } + } + finally + { + Parser.Lexer.Mode = oldMode; } + } } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXBlender.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXBlender.cs index 01737f6e05..32bcd9f130 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXBlender.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXBlender.cs @@ -1,142 +1,136 @@ -// using Microsoft.CodeAnalysis.Text; -// using System.Collections.Generic; -// -// namespace Discord.ComponentDesignerGenerator.Parser; -// -// public sealed class CXBlender -// { -// public int CurrentSourcePosition => _lexer.Reader.Position; -// -// private readonly CXLexer _lexer; -// private readonly CXDoc _doc; -// private readonly Queue _changes; -// private readonly List _nodes; -// private readonly List _tokens; -// -// private int _changeDelta; -// private int _docTokenIndex; -// -// public CXBlender( -// CXLexer lexer, -// CXDoc doc, -// IEnumerable changes, -// List nodes, -// List tokens -// ) -// { -// _lexer = lexer; -// _doc = doc; -// _changes = new(changes); -// _nodes = nodes; -// _tokens = tokens; -// } -// -// public CXToken GetToken(int index) -// { -// if (_tokens.Count > index) -// return _tokens[index]; -// -// while (_tokens.Count <= index) -// { -// var token = NextToken(); -// -// if (token.Kind is CXTokenKind.EOF) return token; -// } -// -// return _tokens[index]; -// } -// -// public CXToken NextToken() -// { -// SkipPastChanges(); -// -// while (true) -// { -// while (_changeDelta < 0 && _docTokenIndex < _doc.Tokens.Count) -// { -// var oldToken = _doc.Tokens[_docTokenIndex++]; -// _changeDelta += oldToken.AbsoluteWidth; -// } -// -// if (_changeDelta > 0) return LexNewToken(); -// -// if (TryReuseToken(out var token)) return token; -// -// if (_doc.Tokens.Count <= _docTokenIndex) return LexNewToken(); -// -// _changeDelta += _doc.Tokens[_docTokenIndex++].AbsoluteWidth; -// } -// -// bool TryReuseToken(out CXToken token) -// { -// if (_docTokenIndex >= _doc.Tokens.Count) -// { -// token = default; -// return false; -// } -// -// token = _doc.Tokens[_docTokenIndex]; -// -// if (!CanReuse(token)) return false; -// -// _lexer.Reader.Advance(token.AbsoluteWidth); -// _tokens.Add(token); -// return true; -// } -// } -// -// private CXToken LexNewToken() -// { -// while (true) -// { -// var token = _lexer.Next(); -// -// if(token.Kind is CXTokenKind.Invalid) continue; -// -// _tokens.Add(token); -// _changeDelta += token.AbsoluteWidth; -// return token; -// } -// } -// -// private bool CanReuse(CXToken token) -// { -// if (token.Span.Length is 0) return false; -// -// if (IntersectsNextChange(token.Span)) return false; -// -// return true; -// } -// -// private bool IntersectsNextChange(TextSpan span) -// { -// if (_changes.Count is 0) return false; -// -// return span.IntersectsWith(_changes.Peek().Span); -// } -// -// private void SkipPastChanges() -// { -// while (_changes.Count is not 0) -// { -// var change = _changes.Peek(); -// -// if (change.Span.Start + change.NewLength > CurrentSourcePosition) -// break; -// -// _changes.Dequeue(); -// -// _changeDelta += change.NewLength - change.Span.Length; -// -// while (_docTokenIndex < _doc.Tokens.Count) -// { -// var token = _doc.Tokens[_docTokenIndex]; -// -// if (token.AbsoluteStart >= change.Span.Start) -// break; -// -// _docTokenIndex++; -// } -// } -// } -// } +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; + +namespace Discord.ComponentDesignerGenerator.Parser; + +public sealed class CXBlender +{ + private int CurrentSourcePosition => _lexer.Reader.Position; + + private readonly CXDoc _document; + private readonly Queue _changes; + + private readonly List _tokens; + + private int _docTokenIndex; + + private int _changeDelta; + + private CXLexer _lexer; + + public CXBlender( + CXDoc document, + IReadOnlyList changes + ) + { + _document = document; + _changes = new(changes); + } + + public CXToken GetToken(int index) + { + while (_tokens.Count <= index) + { + var token = NextToken(); + + if (token.Kind is CXTokenKind.EOF) return token; + } + + return _tokens[index]; + } + + public CXToken NextToken() + { + SkipPastChanges(); + + while (true) + { + while (_changeDelta < 0 && _docTokenIndex < _document.Tokens.Count) + { + var oldToken = _document.Tokens[_docTokenIndex++]; + _changeDelta += oldToken.AbsoluteWidth; + } + + if (_changeDelta > 0) + return LexNewToken(); + + if (TryReuseToken(out var token)) return token; + + if (_document.Tokens.Count <= _docTokenIndex) return LexNewToken(); + + _changeDelta += _document.Tokens[_docTokenIndex++].AbsoluteWidth; + } + + bool TryReuseToken(out CXToken token) + { + if (_docTokenIndex >= _document.Tokens.Count) + { + token = default; + return false; + } + + token = _document.Tokens[_docTokenIndex]; + + if (!CanReuse(token)) return false; + + _docTokenIndex++; + _lexer.Reader.Advance(token.AbsoluteWidth); + + _tokens.Add(token); + return true; + } + } + + private CXToken LexNewToken() + { + var token = _lexer.Next(); + + _tokens.Add(token); + _changeDelta += token.AbsoluteWidth; + + return token; + } + + private void SkipPastChanges() + { + while (_changes.Count > 0) + { + var change = _changes.Peek(); + var newLength = change.NewText?.Length ?? 0; + + if (change.Span.Start + newLength > CurrentSourcePosition) + break; + + _changes.Dequeue(); + + _changeDelta += newLength - change.Span.Length; + + // update the cursor to the new change + while (_docTokenIndex < _document.Tokens.Count) + { + var token = _document.Tokens[_docTokenIndex]; + + if (token.AbsoluteStart >= change.Span.Start) + break; + + _docTokenIndex++; + } + } + } + + private bool CanReuse(CXToken token) + { + if (token.AbsoluteWidth is 0) return false; + + if (IntersectsNextChange(token.Span)) return false; + + return true; + } + + private bool IntersectsNextChange(TextSpan span) + { + if (_changes.Count is 0) return false; + + return span.IntersectsWith(_changes.Peek().Span); + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXDoc.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXDoc.cs index aeb690616a..5b155e9f09 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXDoc.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXDoc.cs @@ -6,7 +6,10 @@ namespace Discord.ComponentDesignerGenerator.Parser; public sealed class CXDoc : CXNode { - private readonly CXParser _parser; + public override CXParser Parser { get; } + + public IReadOnlyList Tokens { get; } + private readonly IReadOnlyList _rootElements; public CXDoc( @@ -14,18 +17,19 @@ public CXDoc( IReadOnlyList rootElements ) { - _parser = parser; + Parser = parser; Slot(_rootElements = rootElements); } public void ApplyChanges(IEnumerable changes) { // find out largest node that encapsolates the change - foreach (var change in changes) { - _parser.TextChange = change; + Parser.TextChange = change; var owner = FindOwningNode(change.Span, out var slot); + + owner.IncrementalParse(slot, change); } } @@ -50,6 +54,11 @@ private CXNode FindOwningNode(TextSpan span, out ParseSlot slot) return current; } - public string GetTokenValue(CXToken token) => _parser.Source.GetValue(token.Span); - public string GetTokenValueWithTrivia(CXToken token) => _parser.Source.GetValue(token.FullSpan); + public override void IncrementalParse(ParseSlot slot, TextChange change) + { + + } + + public string GetTokenValue(CXToken token) => Parser.Source.GetValue(token.Span); + public string GetTokenValueWithTrivia(CXToken token) => Parser.Source.GetValue(token.FullSpan); } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXElement.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXElement.cs index 0711f9d736..2879588f17 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXElement.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXElement.cs @@ -37,4 +37,9 @@ public CXElement( Slot(ElementEndNameToken = elementEndNameToken); Slot(ElementEndCloseToken = elementEndCloseToken); } + + public override void IncrementalParse(ParseSlot slot, TextChange change) + { + + } } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXNode.cs index 993dcd8b5f..963b129b37 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXNode.cs @@ -6,20 +6,24 @@ namespace Discord.ComponentDesignerGenerator.Parser; public abstract class CXNode { - public readonly struct ParseSlot + public readonly struct ParseSlot : IEquatable { public TextSpan FullSpan => Node?.FullSpan ?? Token?.FullSpan ?? default; + public readonly int Id; + public readonly CXNode? Node; public readonly CXToken? Token; - public ParseSlot(CXNode node) + public ParseSlot(int id, CXNode node) { + Id = id; Node = node; } - public ParseSlot(CXToken token) + public ParseSlot(int id, CXToken token) { + Id = id; Token = token; } @@ -34,6 +38,26 @@ public ParseSlot(CXToken token) public static bool operator !=(ParseSlot slot, CXToken token) => slot.Token != token; + + public static bool operator ==(ParseSlot slot, CXToken? token) + => slot.Token == token; + + public static bool operator !=(ParseSlot slot, CXToken? token) + => slot.Token != token; + + public bool Equals(ParseSlot other) + => Equals(Node, other.Node) && Nullable.Equals(Token, other.Token); + + public override bool Equals(object? obj) + => obj is ParseSlot other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + return ((Node != null ? Node.GetHashCode() : 0) * 397) ^ Token.GetHashCode(); + } + } } public CXNode? Parent { get; set; } @@ -45,11 +69,11 @@ public CXDoc Document { get => this is CXDoc doc ? doc - : _doc ??= Parent?._doc ?? throw new InvalidOperationException(); + : _doc ??= Parent?.Document ?? throw new InvalidOperationException(); set => _doc = value; } - protected CXParser Parser => Document.Parser; + public virtual CXParser Parser => Document.Parser; public CXToken FirstTerminal { @@ -127,14 +151,14 @@ protected void Slot(CXNode? node) node.Parent = this; node._parentSlotIndex = _slots.Count; - _slots.Add(new(node)); + _slots.Add(new(_slots.Count, node)); } protected void Slot(CXToken? token) { if (token is null) return; - _slots.Add(new(token.Value)); + _slots.Add(new(_slots.Count, token.Value)); Width += token.Value.AbsoluteWidth; } @@ -149,4 +173,20 @@ protected void Slot(IEnumerable nodes) } public abstract void IncrementalParse(ParseSlot slot, TextChange change); + + public void UpdateSlot(ParseSlot slot, CXToken token) + { + _slots[slot.Id] = new(slot.Id, token); + + // do we have to update the widths? + + } + + public void UpdateSlot(ParseSlot slot, CXNode token) + { + _slots[slot.Id] = new(slot.Id, token); + + // do we have to update the widths? + + } } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXParser.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXParser.cs index d8ad20fe11..4b35a963f6 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXParser.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXParser.cs @@ -11,9 +11,9 @@ public sealed class CXParser public CXSource Source { get; } public CXToken CurrentToken => Lex(_tokenIndex); public CXToken NextToken => Lex(_tokenIndex + 1); + public CXLexer Lexer { get; } private readonly List _tokens; - private readonly CXLexer _lexer; private int _tokenIndex; private readonly CXSourceReader _reader; @@ -26,7 +26,7 @@ public CXParser(CXSource source) { Source = source; _reader = new CXSourceReader(source); - _lexer = new CXLexer(_reader); + Lexer = new CXLexer(_reader); _tokens = []; _diagnostics = []; } @@ -45,7 +45,7 @@ public static CXDoc Parse(CXSource source) return new CXDoc(parser, elements); } - private CXElement ParseElement() + internal CXElement ParseElement() { var start = Expect(CXTokenKind.LessThan); @@ -105,8 +105,8 @@ IEnumerable ParseElementChildren() // - other elements // - interpolations // - text - var oldMode = _lexer.Mode; - _lexer.Mode = CXLexer.LexMode.ElementValue; + var oldMode = Lexer.Mode; + Lexer.Mode = CXLexer.LexMode.ElementValue; try { @@ -117,7 +117,7 @@ IEnumerable ParseElementChildren() case CXTokenKind.Interpolation: yield return new CXValue.Interpolation( Eat(), - _lexer.InterpolationIndex!.Value + Lexer.InterpolationIndex!.Value ); break; case CXTokenKind.Text: @@ -146,16 +146,16 @@ IEnumerable ParseElementChildren() } finally { - _lexer.Mode = oldMode; + Lexer.Mode = oldMode; } } } - private IEnumerable ParseAttributes() + internal IEnumerable ParseAttributes() { // expect identifiers - var oldMode = _lexer.Mode; - _lexer.Mode = CXLexer.LexMode.Identifier; + var oldMode = Lexer.Mode; + Lexer.Mode = CXLexer.LexMode.Identifier; try { while (CurrentToken.Kind is CXTokenKind.Identifier) @@ -163,14 +163,14 @@ private IEnumerable ParseAttributes() } finally { - _lexer.Mode = oldMode; + Lexer.Mode = oldMode; } } - private CXAttribute ParseAttribute() + internal CXAttribute ParseAttribute() { - var oldMode = _lexer.Mode; - _lexer.Mode = CXLexer.LexMode.Attribute; + var oldMode = Lexer.Mode; + Lexer.Mode = CXLexer.LexMode.Attribute; try { @@ -196,18 +196,18 @@ private CXAttribute ParseAttribute() } finally { - _lexer.Mode = oldMode; + Lexer.Mode = oldMode; } } - private CXValue ParseAttributeValue() + internal CXValue ParseAttributeValue() { switch (CurrentToken.Kind) { case CXTokenKind.Interpolation: return new CXValue.Interpolation( CurrentToken, - _lexer.InterpolationIndex!.Value + Lexer.InterpolationIndex!.Value ); case CXTokenKind.StringLiteralStart: return ParseStringLiteral(); @@ -223,7 +223,7 @@ private CXValue ParseAttributeValue() } } - private CXValue ParseStringLiteral() + internal CXValue ParseStringLiteral() { var tokens = new List(); @@ -262,33 +262,33 @@ private CXValue ParseStringLiteral() ); } - private CXToken ParseIdentifier() + internal CXToken ParseIdentifier() { - var oldMode = _lexer.Mode; - _lexer.Mode = CXLexer.LexMode.Identifier; + var oldMode = Lexer.Mode; + Lexer.Mode = CXLexer.LexMode.Identifier; try { var token = Expect(CXTokenKind.Identifier); - _lexer.Mode = oldMode; + Lexer.Mode = oldMode; return token; } finally { - _lexer.Mode = oldMode; + Lexer.Mode = oldMode; } } - private CXToken Eat() + internal CXToken Eat() { var token = CurrentToken; _tokenIndex++; return token; } - private bool Eat(CXTokenKind kind, out CXToken token) + internal bool Eat(CXTokenKind kind, out CXToken token) { token = CurrentToken; @@ -301,7 +301,7 @@ private bool Eat(CXTokenKind kind, out CXToken token) return false; } - private CXToken Expect(CXTokenKind kind) + internal CXToken Expect(CXTokenKind kind) { var token = CurrentToken; @@ -321,13 +321,13 @@ private CXToken Expect(CXTokenKind kind) return token; } - private CXToken Lex(int index) + internal CXToken Lex(int index) { CXToken token; while (_tokens.Count <= index) { - token = _lexer.Next(); + token = Lexer.Next(); if (token.Kind is CXTokenKind.EOF) return token; @@ -350,7 +350,7 @@ void ValidateChanges() { // we need to re-lex _reader.Position = token.AbsoluteStart; - _tokens[index] = token = _lexer.Next(); + _tokens[index] = token = Lexer.Next(); } } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXValue.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXValue.cs index 125ec0a006..dd33721d93 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXValue.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXValue.cs @@ -5,6 +5,11 @@ namespace Discord.ComponentDesignerGenerator.Parser; public abstract class CXValue : CXNode { + public override void IncrementalParse(ParseSlot slot, TextChange change) + { + + } + public sealed class Invalid : CXValue; public sealed class StringLiteral : CXValue diff --git a/src/Discord.Net.Core/Discord.Net.Core.csproj b/src/Discord.Net.Core/Discord.Net.Core.csproj index dcfe178a22..5d64fd6b8b 100644 --- a/src/Discord.Net.Core/Discord.Net.Core.csproj +++ b/src/Discord.Net.Core/Discord.Net.Core.csproj @@ -7,7 +7,7 @@ The core components for the Discord.Net library. net9.0;net8.0;net6.0;net5.0;net461;netstandard2.0;netstandard2.1 5 - True + false false true @@ -25,4 +25,4 @@ - \ No newline at end of file + diff --git a/src/Discord.Net.Core/packages.lock.json b/src/Discord.Net.Core/packages.lock.json index 2a777794c2..a924fa1912 100644 --- a/src/Discord.Net.Core/packages.lock.json +++ b/src/Discord.Net.Core/packages.lock.json @@ -8,15 +8,6 @@ "resolved": "4.0.8", "contentHash": "vNi4NMG0CcJyjXxiNDcQ21FwV/whM9o9OEZKD+oP7tuxAqFEzX/x5OhC3OZJqW/w+8GOtCmJPBquYgMWgz0rfQ==" }, - "Microsoft.NETFramework.ReferenceAssemblies": { - "type": "Direct", - "requested": "[1.0.3, )", - "resolved": "1.0.3", - "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", - "dependencies": { - "Microsoft.NETFramework.ReferenceAssemblies.net461": "1.0.3" - } - }, "Newtonsoft.Json": { "type": "Direct", "requested": "[13.0.3, )", @@ -56,11 +47,6 @@ "System.Threading.Tasks.Extensions": "4.5.4" } }, - "Microsoft.NETFramework.ReferenceAssemblies.net461": { - "type": "Transitive", - "resolved": "1.0.3", - "contentHash": "AmOJZwCqnOCNp6PPcf9joyogScWLtwy0M1WkqfEQ0M9nYwyDD7EX9ZjscKS5iYnyvteX7kzSKFCKt9I9dXA6mA==" - }, "System.Buffers": { "type": "Transitive", "resolved": "4.5.1", From e64fe8e7270631fd021e5b8a23811effc123a811 Mon Sep 17 00:00:00 2001 From: Quin Lynch <49576606+quinchs@users.noreply.github.com> Date: Tue, 9 Sep 2025 02:49:02 -0300 Subject: [PATCH 10/17] semi-working incremental parsing --- .../Generator/CXGraphManager.cs | 131 +++++++ .../Generator/SourceManager.cs | 167 ++++++++- .../Parsing/CXSourceReader.cs | 11 +- .../Parsing/Lexer/CXLexer.cs | 69 ++-- .../Parsing/Parser2/CXAttribute.cs | 24 -- .../Parsing/Parser2/CXBlender.cs | 58 +-- .../Parsing/Parser2/CXCollection.cs | 24 ++ .../Parsing/Parser2/CXDoc.cs | 69 +++- .../Parsing/Parser2/CXElement.cs | 16 +- .../Parsing/Parser2/CXNode.cs | 74 +++- .../Parsing/Parser2/CXParser.cs | 263 +++++++++----- .../Parsing/Parser2/CXValue.cs | 5 - .../Parsing/Parser2/Cursor.cs | 339 ++++++++++++++++++ .../SourceGenerator.cs | 4 + .../ComponentDesigner.cs | 2 +- .../Discord.Net.ComponentDesigner.csproj | 19 +- .../StringSyntax.cs | 69 ++++ 17 files changed, 1104 insertions(+), 240 deletions(-) create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Generator/CXGraphManager.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXCollection.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/Cursor.cs create mode 100644 src/Discord.Net.ComponentDesigner/StringSyntax.cs diff --git a/src/Discord.Net.ComponentDesigner.Generator/Generator/CXGraphManager.cs b/src/Discord.Net.ComponentDesigner.Generator/Generator/CXGraphManager.cs new file mode 100644 index 0000000000..37a69353fc --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Generator/CXGraphManager.cs @@ -0,0 +1,131 @@ +using Discord.ComponentDesignerGenerator.Parser; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Diagnostics; +using System.Linq; +using System.Text; + +namespace Discord.ComponentDesignerGenerator; + +public sealed class CXGraphManager +{ + public InterceptableLocation InterceptLocation => _target.InterceptLocation; + public InvocationExpressionSyntax InvocationSyntax => _target.InvocationSyntax; + public ExpressionSyntax ArgumentExpressionSyntax => _target.ArgumentExpressionSyntax; + public IOperation Operation => _target.Operation; + public Compilation Compilation => _target.Compilation; + + public string CXDesigner => _target.CXDesigner; + public DesignerInterpolationInfo[] InterpolationInfos => _target.Interpolations; + + public TextSpan CXDesignerSpan => _target.CXDesignerSpan; + + public CXParser Parser => _document.Parser; + + private readonly SourceManager _manager; + + private CXDoc _document; + private Target _target; + private string _key; + + private string _basicCXSource; + + public CXGraphManager( + SourceManager manager, + string key, + Target target, + CXDoc document + ) + { + _manager = manager; + _target = target; + _document = document; + _key = key; + + _basicCXSource = GetCXWithoutInterpolations( + CXDesignerSpan.Start, + CXDesigner, + InterpolationInfos + ); + } + + public static CXGraphManager Create(SourceManager manager, string key, Target target) + { + var source = new CXSource( + target.CXDesignerSpan, + target.CXDesigner, + target.Interpolations.Select(x => x.Span).ToArray() + ); + + return new CXGraphManager(manager, key, target, CXParser.Parse(source)); + } + + public void OnUpdate(string key, Target target) + { + /* + * TODO: + * There are 2 modes of incremental updating: re-parse and re-gen, + * + * Reparsing: + * This requires incremental parsing and then re-generating the updated nodes that were parsed, we can + * re-use old gen information + * + * Regenerating + * Caused mostly by interpolation types changing, the actual values don't matter since it doesn't change + * out emitted code + */ + + var newSource = GetCXWithoutInterpolations( + target.ArgumentExpressionSyntax.SpanStart, + target.CXDesigner, + target.Interpolations + ); + + if (newSource != _basicCXSource) + { + // we're gonna need to reparse + DoReparse(target); + } + + _target = target; + _key = key; + } + + private void DoReparse(Target target) + { + Debug.Assert(_document is not null); + + var source = new CXSource( + target.CXDesignerSpan, + target.CXDesigner, + target.Interpolations.Select(x => x.Span).ToArray() + ); + + _document!.ApplyChanges( + source, + [ + ..target + .SyntaxTree + .GetChanges(_target.SyntaxTree) + .Where(x => CXDesignerSpan.Contains(x.Span)) + ] + ); + } + + private static string GetCXWithoutInterpolations(int offset, string cx, DesignerInterpolationInfo[] interpolations) + { + if (interpolations.Length is 0) return cx; + + var builder = new StringBuilder(cx); + + for (var i = 0; i < interpolations.Length; i++) + { + var interpolation = interpolations[i]; + builder.Remove(interpolation.Span.Start - offset, interpolation.Span.Length); + } + + return builder.ToString(); + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Generator/SourceManager.cs b/src/Discord.Net.ComponentDesigner.Generator/Generator/SourceManager.cs index 7c0aa3e3db..195b263a65 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Generator/SourceManager.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Generator/SourceManager.cs @@ -2,6 +2,8 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Operations; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Threading; @@ -13,29 +15,131 @@ public sealed record Target( InvocationExpressionSyntax InvocationSyntax, ExpressionSyntax ArgumentExpressionSyntax, IOperation Operation, - Compilation Compilation + Compilation Compilation, + string? ParentKey, + string CXDesigner, + TextSpan CXDesignerSpan, + DesignerInterpolationInfo[] Interpolations +) +{ + public SyntaxTree SyntaxTree => InvocationSyntax.SyntaxTree; +} + +public sealed record DesignerInterpolationInfo( + TextSpan Span, + ITypeSymbol? Symbol ); public sealed class SourceManager { + private readonly Dictionary _cache = []; + public SourceManager(IncrementalGeneratorInitializationContext context) { - context + var provider = context .SyntaxProvider .CreateSyntaxProvider( IsComponentDesignerCall, MapPossibleComponentDesignerCall ) .Collect(); + + context.RegisterSourceOutput( + provider + .Combine(provider.Select(GetKeysAndUpdateCachedEntries)) + .SelectMany(MapManagers), + Generate + ); + } + + private void Generate(SourceProductionContext arg1, CXGraphManager arg2) + { + } + + private IEnumerable MapManagers( + (ImmutableArray targets, ImmutableArray keys) tuple, + CancellationToken token + ) + { + var (targets, keys) = tuple; + + for (var i = 0; i < targets.Length; i++) + { + var target = targets[i]; + var key = keys[i]; + + if (target is null || key is null) continue; + + // TODO: handle key updates + + if (_cache.TryGetValue(key, out var manager)) + { + manager.OnUpdate(key, target); + } + else + { + manager = _cache[key] = CXGraphManager.Create( + this, + key, + target + ); + } + + yield return manager; + } } + private ImmutableArray GetKeysAndUpdateCachedEntries(ImmutableArray target, + CancellationToken token) + { + var result = new string?[target.Length]; + + var map = new Dictionary(); + var globalCount = 0; + + for (var i = 0; i < target.Length; i++) + { + var targetItem = target[i]; + + if (targetItem is null) continue; + + string key; + if (targetItem.ParentKey is null) + { + key = $":{globalCount++}"; + } + else + { + map.TryGetValue(targetItem.ParentKey, out var index); + + key = $"{targetItem.ParentKey}:{index}"; + map[targetItem.ParentKey] = index + 1; + } + + result[i] = key; + } + + foreach (var key in _cache.Keys.Except(result)) + { + if (key is not null) _cache.Remove(key); + } + + return [..result]; + } + + private static void OnTargetUpdated(Target? target, CancellationToken token) + { + if (target is null) return; + + //target.Compilation.SyntaxTrees + } + + private static void ProcessTargetsUpdate(ImmutableArray targets, CancellationToken token) { foreach (var target in targets) { - if(target is null) continue; - - + if (target is null) continue; } } @@ -51,14 +155,65 @@ out var argumentSyntax ) ) return null; + if ( + !TryGetCXDesigner( + argumentSyntax, + context.SemanticModel, + out var cxDesigner, + out var span, + out var interpolationInfos + ) + ) return null; + + return new Target( interceptLocation, invocationSyntax, argumentSyntax, operation, - context.SemanticModel.Compilation + context.SemanticModel.Compilation, + context.SemanticModel + .GetEnclosingSymbol(invocationSyntax.SpanStart, token) + ?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + cxDesigner, + span, + interpolationInfos ); + static bool TryGetCXDesigner( + ExpressionSyntax expression, + SemanticModel semanticModel, + out string content, + out TextSpan span, + out DesignerInterpolationInfo[] interpolations + ) + { + switch (expression) + { + case LiteralExpressionSyntax {Token.Value: string literalContent} literal: + content = literalContent; + interpolations = []; + span = literal.Token.Span; + return true; + + case InterpolatedStringExpressionSyntax interpolated: + content = interpolated.Contents.ToString(); + interpolations = interpolated.Contents + .OfType() + .Select(x => new DesignerInterpolationInfo( + x.FullSpan, + semanticModel.GetTypeInfo(x.Expression).Type + )) + .ToArray(); + span = interpolated.Contents.Span; + return true; + default: + content = string.Empty; + span = default; + interpolations = []; + return false; + } + } bool TryGetValidDesignerCall( out IOperation operation, diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXSourceReader.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXSourceReader.cs index bc03f17d4e..d0c1d01965 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXSourceReader.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXSourceReader.cs @@ -3,11 +3,11 @@ public sealed class CXSourceReader { public char this[int index] - => index < 0 || index >= Source.Length - ? CXLexer.NULL_CHAR - : Source.Value[index]; + => Source.SourceSpan.Contains(index) + ? Source[index] + : CXLexer.NULL_CHAR; - public bool IsEOF => Position >= Source.Length; + public bool IsEOF => Position >= Source.SourceSpan.End; public char Current => this[Position]; @@ -19,8 +19,7 @@ public char this[int index] public int Position { get; set; } - public CXSource Source { get; } - + public CXSource Source { get; set; } public CXSourceReader(CXSource source) { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXLexer.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXLexer.cs index 240a6ade8c..a0e288e5f5 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXLexer.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXLexer.cs @@ -26,13 +26,6 @@ public enum LexMode Attribute } - private struct State - { - public int NextInterpolationIndex; - public int InterpolationIndex; - public char? QuoteChar; - } - public const string COMMENT_START = ""; @@ -61,14 +54,12 @@ public TextSpan? CurrentInterpolationSpan { get { - ref var interpolationIndex = ref _state.InterpolationIndex; - // there's no next interpolation - if (Reader.Source.Interpolations.Length <= interpolationIndex) return null; + if (Reader.Source.Interpolations.Length <= _interpolationIndex) return null; - for (; interpolationIndex < Reader.Source.Interpolations.Length; interpolationIndex++) + for (; _interpolationIndex < Reader.Source.Interpolations.Length; _interpolationIndex++) { - var interpolationSpan = Reader.Source.Interpolations[interpolationIndex]; + var interpolationSpan = Reader.Source.Interpolations[_interpolationIndex]; if (interpolationSpan.End < Reader.Position) continue; @@ -87,17 +78,15 @@ public TextSpan? NextInterpolationSpan { get { - ref var interpolationIndex = ref _state.NextInterpolationIndex; - // there's no next interpolation - if (Reader.Source.Interpolations.Length <= interpolationIndex) return null; + if (Reader.Source.Interpolations.Length <= _nextInterpolationIndex) return null; // check if it's ahead of us TextSpan? interpolationSpan = null; - for (; interpolationIndex < Reader.Source.Interpolations.Length; interpolationIndex++) + for (; _nextInterpolationIndex < Reader.Source.Interpolations.Length; _nextInterpolationIndex++) { - interpolationSpan = Reader.Source.Interpolations[interpolationIndex]; + interpolationSpan = Reader.Source.Interpolations[_nextInterpolationIndex]; if (interpolationSpan.Value.Start > Reader.Position) break; } @@ -108,27 +97,40 @@ public TextSpan? NextInterpolationSpan private readonly bool[] _handledInterpolations; public LexMode Mode { get; set; } - private State _state; + + + public char? QuoteChar; + + private int _nextInterpolationIndex; + private int _interpolationIndex; public CXLexer(CXSourceReader reader) { Reader = reader; _handledInterpolations = new bool[reader.Source.Interpolations.Length]; Mode = LexMode.Default; - _state = default; } - private void UpdateInterpolationState() + public readonly struct ModeSentinel(CXLexer? lexer) : IDisposable { - ref var interpolationIndex = ref _state.NextInterpolationIndex; - if (Reader.Source.Interpolations.Length > interpolationIndex) + private readonly LexMode _mode = lexer?.Mode ?? LexMode.Default; + public void Dispose() { - var interpolation = Reader.Source.Interpolations[interpolationIndex]; + if (lexer is null) return; - if (Reader.Position > interpolation.End) interpolationIndex++; + lexer.Mode = _mode; } } + public ModeSentinel SetMode(LexMode mode) + { + if (mode == Mode) return default; + + var sentinel = new ModeSentinel(this); + Mode = mode; + return sentinel; + } + public CXToken Next() { InterpolationIndex = null; @@ -212,7 +214,7 @@ private void Scan(ref TokenInfo info) private bool TryScanElementValue(ref TokenInfo info) { - var interpolationUpperBounds = NextInterpolationSpan?.Start ?? Reader.Source.Length; + var interpolationUpperBounds = NextInterpolationSpan?.Start ?? Reader.Source.SourceSpan.End; var start = Reader.Position; @@ -238,7 +240,7 @@ private bool TryScanElementValue(ref TokenInfo info) private void LexStringLiteral(ref TokenInfo info) { - if (_state.QuoteChar is null) + if (QuoteChar is null) { // bad state throw new InvalidOperationException("Missing closing char for string literal"); @@ -251,7 +253,7 @@ private void LexStringLiteral(ref TokenInfo info) return; } - var interpolationUpperBounds = NextInterpolationSpan?.Start ?? Reader.Source.Length; + var interpolationUpperBounds = NextInterpolationSpan?.Start ?? Reader.Source.SourceSpan.End; if (Reader.Position >= interpolationUpperBounds) { @@ -263,20 +265,19 @@ private void LexStringLiteral(ref TokenInfo info) return; } - if (Reader.Current == _state.QuoteChar) + if (Reader.Current == QuoteChar) { Reader.Advance(); info.Kind = CXTokenKind.StringLiteralEnd; - Mode = LexMode.Default; - _state.QuoteChar = null; + QuoteChar = null; return; } for (; Reader.Position < interpolationUpperBounds; Reader.Advance()) { - if (_state.QuoteChar == Reader.Current) + if (QuoteChar == Reader.Current) { // is it escaped? if (Reader.Previous is FORWARD_SLASH_CHAR) @@ -302,7 +303,7 @@ private bool TryScanAttributeValue(ref TokenInfo info) return TryScanInterpolation(ref info); } - _state.QuoteChar = Reader.Current; + QuoteChar = Reader.Current; Reader.Advance(); info.Kind = CXTokenKind.StringLiteralStart; Mode = LexMode.StringLiteral; @@ -311,7 +312,7 @@ private bool TryScanAttributeValue(ref TokenInfo info) private bool TryScanIdentifier(ref TokenInfo info) { - var upperBounds = NextInterpolationSpan?.Start ?? Reader.Source.Length; + var upperBounds = NextInterpolationSpan?.Start ?? Reader.Source.SourceSpan.End; if (!IsValidIdentifierStartChar(Reader.Current) || Reader.Position >= upperBounds) return false; @@ -340,7 +341,7 @@ private bool TryScanInterpolation(ref TokenInfo info) Reader.Advance( span.End - Reader.Position ); - InterpolationIndex = _state.InterpolationIndex; + InterpolationIndex = _interpolationIndex; return true; } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXAttribute.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXAttribute.cs index ecf1aca89f..b6cf7aeb89 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXAttribute.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXAttribute.cs @@ -20,28 +20,4 @@ public CXAttribute( Slot(EqualsToken = equalsToken); Slot(Value = value); } - - public override void IncrementalParse(ParseSlot slot, TextChange change) - { - var oldMode = Parser.Lexer.Mode; - Parser.Lexer.Mode = CXLexer.LexMode.Attribute; - - try - { - if (slot == Identifier) - { - UpdateSlot(slot, Identifier = Parser.ParseIdentifier()); - } - - // if (slot == EqualsToken) - // { - // - // } - } - finally - { - Parser.Lexer.Mode = oldMode; - } - - } } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXBlender.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXBlender.cs index 32bcd9f130..d486d68b39 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXBlender.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXBlender.cs @@ -5,26 +5,34 @@ namespace Discord.ComponentDesignerGenerator.Parser; public sealed class CXBlender { - private int CurrentSourcePosition => _lexer.Reader.Position; + public Queue Changes { get; } - private readonly CXDoc _document; - private readonly Queue _changes; + private int CurrentSourcePosition => Lexer.Reader.Position; + public CXLexer Lexer => Document.Parser.Lexer; + + public CXDoc Document { get; } private readonly List _tokens; private int _docTokenIndex; private int _changeDelta; - - private CXLexer _lexer; - + public CXBlender( - CXDoc document, - IReadOnlyList changes + CXDoc document ) { - _document = document; - _changes = new(changes); + _tokens = []; + Document = document; + Changes = []; + } + + public void Reset() + { + _tokens.Clear(); + _docTokenIndex = 0; + _changeDelta = 0; + Changes.Clear(); } public CXToken GetToken(int index) @@ -45,9 +53,9 @@ public CXToken NextToken() while (true) { - while (_changeDelta < 0 && _docTokenIndex < _document.Tokens.Count) + while (_changeDelta < 0 && _docTokenIndex < Document.Tokens.Count) { - var oldToken = _document.Tokens[_docTokenIndex++]; + var oldToken = Document.Tokens[_docTokenIndex++]; _changeDelta += oldToken.AbsoluteWidth; } @@ -56,25 +64,25 @@ public CXToken NextToken() if (TryReuseToken(out var token)) return token; - if (_document.Tokens.Count <= _docTokenIndex) return LexNewToken(); + if (Document.Tokens.Count <= _docTokenIndex) return LexNewToken(); - _changeDelta += _document.Tokens[_docTokenIndex++].AbsoluteWidth; + _changeDelta += Document.Tokens[_docTokenIndex++].AbsoluteWidth; } bool TryReuseToken(out CXToken token) { - if (_docTokenIndex >= _document.Tokens.Count) + if (_docTokenIndex >= Document.Tokens.Count) { token = default; return false; } - token = _document.Tokens[_docTokenIndex]; + token = Document.Tokens[_docTokenIndex]; if (!CanReuse(token)) return false; _docTokenIndex++; - _lexer.Reader.Advance(token.AbsoluteWidth); + Lexer.Reader.Advance(token.AbsoluteWidth); _tokens.Add(token); return true; @@ -83,7 +91,7 @@ bool TryReuseToken(out CXToken token) private CXToken LexNewToken() { - var token = _lexer.Next(); + var token = Lexer.Next(); _tokens.Add(token); _changeDelta += token.AbsoluteWidth; @@ -93,22 +101,22 @@ private CXToken LexNewToken() private void SkipPastChanges() { - while (_changes.Count > 0) + while (Changes.Count > 0) { - var change = _changes.Peek(); + var change = Changes.Peek(); var newLength = change.NewText?.Length ?? 0; if (change.Span.Start + newLength > CurrentSourcePosition) break; - _changes.Dequeue(); + Changes.Dequeue(); _changeDelta += newLength - change.Span.Length; // update the cursor to the new change - while (_docTokenIndex < _document.Tokens.Count) + while (_docTokenIndex < Document.Tokens.Count) { - var token = _document.Tokens[_docTokenIndex]; + var token = Document.Tokens[_docTokenIndex]; if (token.AbsoluteStart >= change.Span.Start) break; @@ -129,8 +137,8 @@ private bool CanReuse(CXToken token) private bool IntersectsNextChange(TextSpan span) { - if (_changes.Count is 0) return false; + if (Changes.Count is 0) return false; - return span.IntersectsWith(_changes.Peek().Span); + return span.IntersectsWith(Changes.Peek().Span); } } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXCollection.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXCollection.cs new file mode 100644 index 0000000000..ccca70f5c0 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXCollection.cs @@ -0,0 +1,24 @@ +using Microsoft.CodeAnalysis.Text; +using System.Collections; +using System.Collections.Generic; + +namespace Discord.ComponentDesignerGenerator.Parser; + +public sealed class CXCollection : CXNode, IReadOnlyList + where T : CXNode +{ + public T this[int index] => _items[index]; + + public int Count => _items.Count; + + private readonly List _items; + + public CXCollection(params IEnumerable items) + { + Slot(_items = [..items]); + } + + public IEnumerator GetEnumerator() => _items.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable) _items).GetEnumerator(); +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXDoc.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXDoc.cs index 5b155e9f09..aa298a70be 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXDoc.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXDoc.cs @@ -1,12 +1,13 @@ using Microsoft.CodeAnalysis.Text; using System.Collections.Generic; using System.Collections.Immutable; +using System.Linq; namespace Discord.ComponentDesignerGenerator.Parser; public sealed class CXDoc : CXNode { - public override CXParser Parser { get; } + public override CXParser Parser { get; } public IReadOnlyList Tokens { get; } @@ -14,26 +15,62 @@ public sealed class CXDoc : CXNode public CXDoc( CXParser parser, - IReadOnlyList rootElements + IReadOnlyList rootElements, + IReadOnlyList tokens ) { + Tokens = tokens; Parser = parser; Slot(_rootElements = rootElements); } - public void ApplyChanges(IEnumerable changes) + public void ApplyChanges( + CXSource source, + IReadOnlyList changes + ) + { + var blender = new CXBlender2(Parser.Lexer, this, changes.Select(x => (TextChangeRange)x)); + + Parser.Source = source; + Parser.Reset(); + Parser.RootBlender = blender; + var x = Parser.ParseElement(); + } + + public bool TryFindToken(int position, out CXToken token) { - // find out largest node that encapsolates the change - foreach (var change in changes) + if (!FullSpan.Contains(position)) + { + token = default; + return false; + } + + CXNode? current = this; + + while (current is not null) { - Parser.TextChange = change; - var owner = FindOwningNode(change.Span, out var slot); + for (var i = 0; i < current.Slots.Count; i++) + { + var slot = current.Slots[i]; - owner.IncrementalParse(slot, change); + if (!slot.FullSpan.Contains(position)) continue; + + if (slot.Token.HasValue) + { + token = slot.Token.Value; + return true; + } + + current = slot.Node; + break; + } } + + token = default; + return false; } - private CXNode FindOwningNode(TextSpan span, out ParseSlot slot) + public CXNode FindOwningNode(TextSpan span, out ParseSlot slot) { CXNode current = this; slot = default; @@ -43,7 +80,10 @@ private CXNode FindOwningNode(TextSpan span, out ParseSlot slot) { slot = current.Slots[i]; - if (!slot.FullSpan.Contains(span)) continue; + if ( + // the end is exclusive, since its char-based + !(span.Start >= slot.FullSpan.Start && span.End < slot.FullSpan.End) + ) continue; if (slot.Node is null) break; @@ -51,12 +91,11 @@ private CXNode FindOwningNode(TextSpan span, out ParseSlot slot) goto search; } - return current; - } - - public override void IncrementalParse(ParseSlot slot, TextChange change) - { + // // we only want the top most container + // while (current.Parent is not null && current.FullSpan == current.Parent.FullSpan) + // current = current.Parent; + return current; } public string GetTokenValue(CXToken token) => Parser.Source.GetValue(token.Span); diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXElement.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXElement.cs index 2879588f17..766d7d6611 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXElement.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXElement.cs @@ -1,4 +1,5 @@ using Microsoft.CodeAnalysis.Text; +using System; using System.Collections.Generic; namespace Discord.ComponentDesignerGenerator.Parser; @@ -7,11 +8,11 @@ public sealed class CXElement : CXNode { public CXToken ElementStartOpenToken { get; } public CXToken ElementStartNameToken { get; } - public IReadOnlyList Attributes { get; } + public CXCollection Attributes { get; } public CXToken ElementStartCloseToken { get; } - public IReadOnlyList Children { get; } + public CXCollection Children { get; } public CXToken? ElementEndOpenToken { get; } public CXToken? ElementEndNameToken { get; } @@ -20,9 +21,9 @@ public sealed class CXElement : CXNode public CXElement( CXToken elementStartOpenToken, CXToken elementStartNameToken, - IReadOnlyList attributes, + CXCollection attributes, CXToken elementStartCloseToken, - IEnumerable? children = null, + CXCollection children, CXToken? elementEndOpenToken = null, CXToken? elementEndNameToken = null, CXToken? elementEndCloseToken = null @@ -32,14 +33,9 @@ public CXElement( Slot(ElementStartNameToken = elementStartNameToken); Slot(Attributes = attributes); Slot(ElementStartCloseToken = elementStartCloseToken); - Slot(Children = [..children ?? []]); + Slot(Children = children); Slot(ElementEndOpenToken = elementEndOpenToken); Slot(ElementEndNameToken = elementEndNameToken); Slot(ElementEndCloseToken = elementEndCloseToken); } - - public override void IncrementalParse(ParseSlot slot, TextChange change) - { - - } } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXNode.cs index 963b129b37..30db956a5c 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXNode.cs @@ -1,6 +1,7 @@ using Microsoft.CodeAnalysis.Text; using System; using System.Collections.Generic; +using System.Linq; namespace Discord.ComponentDesignerGenerator.Parser; @@ -107,7 +108,10 @@ public CXToken LastTerminal public TextSpan FullSpan => new(Offset, Width); - public int Offset => _offset ??= ComputeOffset(); + // TODO: + // this could be cached, a caveat though is if we incrementally parse, we need to update the + // offset/width of any nodes right of the change + public int Offset => ComputeOffset(); private int? _offset; @@ -116,7 +120,6 @@ public CXToken LastTerminal public IReadOnlyList Slots => _slots; private readonly List _slots; - private int _parentSlotIndex = -1; public CXNode() { @@ -124,17 +127,29 @@ public CXNode() _slots = []; } + public int GetParentSlotIndex() + { + if (Parent is null) return -1; + + for (var i = 0; i < Parent._slots.Count; i++) + if (Parent._slots[i] == this) + return i; + + return -1; + } + private int ComputeOffset() { - if (Parent is null) return 0; + if (Parent is null) return Document.Parser.Source.SourceSpan.Start; var parentOffset = Parent.Offset; + var parentSlotIndex = GetParentSlotIndex(); - return _parentSlotIndex switch + return parentSlotIndex switch { -1 => throw new InvalidOperationException(), 0 => parentOffset, - _ => Parent._slots[_parentSlotIndex - 1] switch + _ => Parent._slots[parentSlotIndex - 1] switch { {Node: { } sibling} => sibling.Offset + sibling.Width, {Token: { } token} => token.AbsoluteEnd, @@ -143,6 +158,35 @@ private int ComputeOffset() }; } + protected bool IsGraphChild(CXNode node) => IsGraphChild(node, out _); + + protected bool IsGraphChild(CXNode node, out int index) + { + index = -1; + + if (node.Parent != this) return false; + + index = node.GetParentSlotIndex(); + + return index >= 0 && index < _slots.Count && _slots.ElementAt(index) == node; + } + + + protected void UpdateSlot(CXNode old, CXNode @new) + { + if (!IsGraphChild(old, out var slotIndex)) return; + + _slots[slotIndex] = new(slotIndex, @new); + } + + protected void RemoveSlot(CXNode node) + { + if (!IsGraphChild(node, out var index)) return; + + _slots.RemoveAt(index); + } + + protected void Slot(CXCollection? node) where T : CXNode => Slot((CXNode?)node); protected void Slot(CXNode? node) { if (node is null) return; @@ -150,7 +194,6 @@ protected void Slot(CXNode? node) Width += node.Width; node.Parent = this; - node._parentSlotIndex = _slots.Count; _slots.Add(new(_slots.Count, node)); } @@ -172,21 +215,14 @@ protected void Slot(IEnumerable nodes) foreach (var node in nodes) Slot(node); } - public abstract void IncrementalParse(ParseSlot slot, TextChange change); + public virtual void IncrementalParse(TextChange change) => Parent?.IncrementalParse(change); - public void UpdateSlot(ParseSlot slot, CXToken token) + protected void UpdateSelf(CXNode? node) { - _slots[slot.Id] = new(slot.Id, token); - - // do we have to update the widths? - + // TODO: update the parents slot + OnDescendantUpdated(this, node); } - public void UpdateSlot(ParseSlot slot, CXNode token) - { - _slots[slot.Id] = new(slot.Id, token); - - // do we have to update the widths? - - } + protected virtual void OnDescendantUpdated(CXNode? old, CXNode? descendant) + => Parent?.OnDescendantUpdated(old, descendant); } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXParser.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXParser.cs index 4b35a963f6..b7ebd95abb 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXParser.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXParser.cs @@ -2,35 +2,65 @@ using Microsoft.CodeAnalysis.Text; using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; namespace Discord.ComponentDesignerGenerator.Parser; public sealed class CXParser { - public CXSource Source { get; } + public CXSource Source + { + get => _source; + set + { + _source = value; + Reader.Source = value; + } + } + public CXToken CurrentToken => Lex(_tokenIndex); public CXToken NextToken => Lex(_tokenIndex + 1); + + public CXNode? CurrentNode => (_currentBlendedNode ??= GetCurrentBlendedNode())?.Node; + public CXLexer Lexer { get; } private readonly List _tokens; private int _tokenIndex; - private readonly CXSourceReader _reader; + private readonly List _blendedTokens; + + public CXSourceReader Reader { get; } private readonly List _diagnostics; - public TextChange? TextChange { get; set; } + public bool IsIncremental => RootBlender.HasValue; + + public CXBlender2? RootBlender { get; set; } + private BlendedNode? _currentBlendedNode; + + + private CXSource _source; public CXParser(CXSource source) { - Source = source; - _reader = new CXSourceReader(source); - Lexer = new CXLexer(_reader); + _source = source; + Reader = new CXSourceReader(source); + Lexer = new CXLexer(Reader); _tokens = []; + _blendedTokens = []; _diagnostics = []; } + public void Reset() + { + _tokens.Clear(); + _diagnostics.Clear(); + Reader.Position = Source.SourceSpan.Start; + _tokenIndex = 0; + } + public static CXDoc Parse(CXSource source) { var elements = new List(); @@ -42,23 +72,25 @@ public static CXDoc Parse(CXSource source) elements.Add(parser.ParseElement()); } - return new CXDoc(parser, elements); + return new CXDoc(parser, elements, [..parser._tokens]); } internal CXElement ParseElement() { + if (IsIncremental && CurrentNode is CXElement element) return element; + var start = Expect(CXTokenKind.LessThan); var identifier = ParseIdentifier(); - var attributes = ParseAttributes().ToList(); + var attributes = ParseAttributes(); switch (CurrentToken.Kind) { case CXTokenKind.GreaterThan: var end = Eat(); // parse children - var children = ParseElementChildren().ToList(); + var children = ParseElementChildren(); ParseClosingElement( out var endStart, @@ -81,7 +113,8 @@ out var endClose start, identifier, attributes, - Eat() + Eat(), + new() ); default: throw new InvalidOperationException("Unexpected token"); @@ -93,82 +126,97 @@ void ParseClosingElement( out CXToken elementEndClose) { elementEndStart = Expect(CXTokenKind.LessThan); - elementEndIdent = Expect(CXTokenKind.Identifier); + elementEndIdent = ParseIdentifier(); elementEndClose = Expect(CXTokenKind.ForwardSlashGreaterThan); // TODO: verify identifier match } - IEnumerable ParseElementChildren() + CXCollection ParseElementChildren() { + if (IsIncremental && CurrentNode is CXCollection incrementalChildren) return incrementalChildren; + // valid children are: // - other elements // - interpolations // - text - var oldMode = Lexer.Mode; - Lexer.Mode = CXLexer.LexMode.ElementValue; + var children = new List(); - try + using (Lexer.SetMode(CXLexer.LexMode.ElementValue)) { - while (true) - { - switch (CurrentToken.Kind) - { - case CXTokenKind.Interpolation: - yield return new CXValue.Interpolation( - Eat(), - Lexer.InterpolationIndex!.Value - ); - break; - case CXTokenKind.Text: - yield return new CXValue.Scalar(Eat()); - break; - case CXTokenKind.LessThan: - // new element - yield return ParseElement(); - break; - case CXTokenKind.LessThanForwardSlash: - yield break; - - case CXTokenKind.EOF or CXTokenKind.Invalid: break; - - default: - _diagnostics.Add( - new CXDiagnostic( - DiagnosticSeverity.Error, - $"Unexpected element child type '{CurrentToken.Kind}'", - CurrentToken.Span - ) - ); - break; - } - } + while (TryParseElementChild(out var child)) + children.Add(child); + } + + return new CXCollection(children); + } + + bool TryParseElementChild(out CXNode node) + { + if (IsIncremental && CurrentNode is CXValue or CXElement) + { + node = CurrentNode; + return true; } - finally + + switch (CurrentToken.Kind) { - Lexer.Mode = oldMode; + case CXTokenKind.Interpolation: + node = new CXValue.Interpolation( + Eat(), + Lexer.InterpolationIndex!.Value + ); + return true; + case CXTokenKind.Text: + node = new CXValue.Scalar(Eat()); + return true; + case CXTokenKind.LessThan: + // new element + node = ParseElement(); + return true; + + case CXTokenKind.LessThanForwardSlash: + case CXTokenKind.EOF: + case CXTokenKind.Invalid: + node = null!; + return false; + + default: + _diagnostics.Add( + new CXDiagnostic( + DiagnosticSeverity.Error, + $"Unexpected element child type '{CurrentToken.Kind}'", + CurrentToken.Span + ) + ); + goto case CXTokenKind.Invalid; } } } - internal IEnumerable ParseAttributes() + internal CXCollection ParseAttributes() { - // expect identifiers - var oldMode = Lexer.Mode; - Lexer.Mode = CXLexer.LexMode.Identifier; - try + if (IsIncremental && CurrentNode is CXCollection incrementalNode) return incrementalNode; + + var attributes = new List(); + + using (Lexer.SetMode(CXLexer.LexMode.Identifier)) { while (CurrentToken.Kind is CXTokenKind.Identifier) - yield return ParseAttribute(); - } - finally - { - Lexer.Mode = oldMode; + attributes.Add(ParseAttribute()); } + + return new CXCollection(attributes); } internal CXAttribute ParseAttribute() { + if (IsIncremental && CurrentNode is CXAttribute attribute) + { + EatNode(); + return attribute; + } + var oldMode = Lexer.Mode; Lexer.Mode = CXLexer.LexMode.Attribute; @@ -202,6 +250,8 @@ internal CXAttribute ParseAttribute() internal CXValue ParseAttributeValue() { + if (IsIncremental && CurrentNode is CXValue value) return value; + switch (CurrentToken.Kind) { case CXTokenKind.Interpolation: @@ -225,10 +275,17 @@ internal CXValue ParseAttributeValue() internal CXValue ParseStringLiteral() { + if (IsIncremental && CurrentNode is CXValue value) return value; + var tokens = new List(); + var quoteToken = CurrentToken.Kind; + var start = Expect(CXTokenKind.StringLiteralStart); + using var _ = Lexer.SetMode(CXLexer.LexMode.StringLiteral); + Lexer.QuoteChar = Reader[start.Span.Start]; + while (CurrentToken.Kind is not CXTokenKind.StringLiteralEnd) { switch (CurrentToken.Kind) @@ -238,7 +295,7 @@ internal CXValue ParseStringLiteral() tokens.Add(Eat()); continue; - case CXTokenKind.Invalid or CXTokenKind.EOF: break; + case CXTokenKind.Invalid or CXTokenKind.EOF: goto end; default: _diagnostics.Add( @@ -301,6 +358,33 @@ internal bool Eat(CXTokenKind kind, out CXToken token) return false; } + internal CXToken Expect(params ReadOnlySpan kinds) + { + var current = CurrentToken; + + switch (kinds.Length) + { + case 0: throw new InvalidOperationException("Missing expected token"); + case 1: return Expect(kinds[0]); + default: + foreach (var kind in kinds) + { + if (current.Kind == kind) return Eat(); + } + + _diagnostics.Add( + new CXDiagnostic( + DiagnosticSeverity.Error, + $"Unexpected token, expected one of '{string.Join(", ", kinds.ToArray())}', got '{current.Kind}'", + current.Span + ) + ); + break; + } + + return current; + } + internal CXToken Expect(CXTokenKind kind) { var token = CurrentToken; @@ -321,46 +405,53 @@ internal CXToken Expect(CXTokenKind kind) return token; } + private BlendedNode? GetCurrentBlendedNode() + => RootBlender.HasValue + ? (_tokenIndex is 0 ? RootBlender.Value : _blendedTokens[_blendedTokens.Count - 1].Blender).ReadNode() + : null; + + private CXNode? EatNode() + { + var node = _currentBlendedNode?.Node; + + if (node is null) return null; + + _blendedTokens.Add(_currentBlendedNode!.Value); + + _tokenIndex += 2; // we add 2 to cause a new lex + + _currentBlendedNode = null; + + return node; + } + internal CXToken Lex(int index) { - CXToken token; + if (RootBlender.HasValue) return FetchBlended(); while (_tokens.Count <= index) { - token = Lexer.Next(); + var token = Lexer.Next(); if (token.Kind is CXTokenKind.EOF) return token; _tokens.Add(token); } - token = _tokens[index]; - - ValidateChanges(); - - return token; + return _tokens[index]; - void ValidateChanges() + CXToken FetchBlended() { - if (!TextChange.HasValue) return; - - var span = TextChange.Value.Span; - - if (span.OverlapsWith(token.Span)) + while (_blendedTokens.Count <= index) { - // we need to re-lex - _reader.Position = token.AbsoluteStart; - _tokens[index] = token = Lexer.Next(); + var blender = _blendedTokens.Count is 0 + ? RootBlender!.Value + : _blendedTokens[_blendedTokens.Count - 1].Blender; + + _blendedTokens.Add(blender.ReadToken()); } - } - // CXToken ActuallyLex() - // { - // // are we in a change - // if (NextChange is null) return _lexer.Next(); - // - // var changeSpan = NextChange.Value.Span; - // - // } + return _blendedTokens[index].Token!.Value; + } } } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXValue.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXValue.cs index dd33721d93..125ec0a006 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXValue.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXValue.cs @@ -5,11 +5,6 @@ namespace Discord.ComponentDesignerGenerator.Parser; public abstract class CXValue : CXNode { - public override void IncrementalParse(ParseSlot slot, TextChange change) - { - - } - public sealed class Invalid : CXValue; public sealed class StringLiteral : CXValue diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/Cursor.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/Cursor.cs new file mode 100644 index 0000000000..034a647c8f --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/Cursor.cs @@ -0,0 +1,339 @@ +using Microsoft.CodeAnalysis.Text; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; + +namespace Discord.ComponentDesignerGenerator.Parser; + +public readonly struct NodeOrToken +{ + public bool HasValue => Node is not null || Token is not null; + + public TextSpan FullSpan => Node?.FullSpan ?? Token!.Value.FullSpan; + + public readonly CXNode? Parent; + + public readonly CXNode? Node; + + public readonly CXToken? Token; + + public NodeOrToken(CXNode node) + { + Node = node; + Parent = node.Parent; + } + + public NodeOrToken(CXNode parent, CXToken token) + { + Token = token; + Parent = parent; + } + + public static NodeOrToken FromSlot(CXNode.ParseSlot slot, CXNode parent) + => slot switch + { + {Token: { } token} => new(parent, token), + {Node: { } node} => new(node), + _ => throw new InvalidOperationException() + }; +} + +public readonly record struct BlendedNode( + CXNode? Node, + CXToken? Token, + CXBlender2 Blender +); + +public readonly struct CXBlender2 +{ + private readonly CXLexer _lexer; + private readonly ImmutableStack _changes; + + private readonly int _newPosition; + private readonly int _changeDelta; + + private readonly Cursor _cursor; + + + public CXBlender2( + CXLexer lexer, + CXDoc document, + IEnumerable changes, + Cursor? cursor = null + ) + { + _lexer = lexer; + _newPosition = _lexer.Reader.Source.SourceSpan.Start; + _changes = ImmutableStack.Empty.Push( + GetAffectedRange(document, TextChangeRange.Collapse(changes)) + ); + + _cursor = cursor ?? Cursor.FromRoot(document).MoveToFirstChild(); + } + + private CXBlender2( + CXLexer lexer, + Cursor cursor, + ImmutableStack changes, + int newPosition, + int changeDelta + ) + { + _lexer = lexer; + _cursor = cursor; + _changes = changes; + _newPosition = newPosition; + _changeDelta = changeDelta; + } + + private static TextChangeRange GetAffectedRange(CXDoc doc, TextChangeRange range) + { + return range; + + // // clamp the range to the end of the doc + // var start = Math.Max(Math.Min(range.Span.Start, doc.FullSpan.End - 1), 0); + // + // if (!doc.TryFindToken(start, out var token)) return range; + // + // start = Math.Max(0, token.Span.Start - 1); + // + // var span = TextSpan.FromBounds(start, range.Span.End); + // var length = range.NewLength + (range.Span.Start - start); + // return new(span, length); + } + + public BlendedNode ReadNode() => ReadNodeOrToken(asToken: false); + public BlendedNode ReadToken() => ReadNodeOrToken(asToken: true); + + private BlendedNode ReadNodeOrToken(bool asToken) + => new Reader(this).ReadNodeOrToken(asToken); + + public struct Reader + { + private Cursor _oldCursor; + private ImmutableStack _changes; + private int _newPosition; + private int _changeDelta; + + private readonly CXLexer _lexer; + + public Reader(CXBlender2 blender) + { + _lexer = blender._lexer; + _oldCursor = blender._cursor; + _changes = blender._changes; + _newPosition = blender._newPosition; + _changeDelta = blender._changeDelta; + } + + public BlendedNode ReadNodeOrToken(bool asToken) + { + while (true) + { + if (_oldCursor.IsDone) return ReadNewToken(); + + if (_changeDelta < 0) SkipOldToken(); + else if (_changeDelta > 0) return ReadNewToken(); + else + { + if (TryTakeOldNodeOrToken(asToken, out var blendedNode)) return blendedNode; + + if (_oldCursor.Current.Node is not null) + _oldCursor = _oldCursor.MoveToFirstChild(); + else + SkipOldToken(); + } + } + } + + private void SkipOldToken() + { + _oldCursor = _oldCursor.MoveToFirstToken(); + + var current = _oldCursor.Current; + + _changeDelta += current.FullSpan.Length; + + _oldCursor = Cursor.MoveToNextSibling(_oldCursor); + + SkipPastChanges(); + } + + private void SkipPastChanges() + { + var oldPosition = _oldCursor.Current.FullSpan.Start; + + while (!_changes.IsEmpty && oldPosition >= _changes.Peek().Span.End) + { + var change = _changes.Peek(); + + _changes = _changes.Pop(); + _changeDelta += change.NewLength - change.Span.Length; + } + } + + private BlendedNode ReadNewToken() + { + var token = LexNewToken(); + + _newPosition += token.FullSpan.Length; + _changeDelta -= token.FullSpan.Length; + + SkipPastChanges(); + + return CreateBlendedNode(token: token); + } + + private CXToken LexNewToken() + { + _lexer.Reader.Position = _newPosition; + return _lexer.Next(); + } + + private bool TryTakeOldNodeOrToken( + bool asToken, + out BlendedNode blendedNode + ) + { + if (asToken) _oldCursor = _oldCursor.MoveToFirstToken(); + + var current = _oldCursor.Current; + + if (!CanReuse(current)) + { + blendedNode = default; + return false; + } + + _newPosition += current.FullSpan.Length; + _oldCursor = Cursor.MoveToNextSibling(_oldCursor); + + blendedNode = CreateBlendedNode( + node: current.Node, + token: current.Token + ); + return true; + } + + private bool CanReuse(NodeOrToken value) + { + if (!value.HasValue) return false; + + if (value.FullSpan.IsEmpty) return false; + + if (IntersectsNextChange(value.FullSpan)) return false; + + // TODO: more riggerous checking; no error nodes/tokens, etc + + return true; + } + + private bool IntersectsNextChange(TextSpan span) + => !_changes.IsEmpty && span.IntersectsWith(_changes.Peek().Span); + + private BlendedNode CreateBlendedNode(CXNode? node = null, CXToken? token = null) + { + Debug.Assert(node is not null || token.HasValue); + + return new( + node, + token, + new CXBlender2( + _lexer, + _oldCursor, + _changes, + _newPosition, + _changeDelta + ) + ); + } + } +} + +public readonly struct Cursor +{ + public readonly NodeOrToken Current; + private readonly int _index; + + public Cursor(NodeOrToken current, int index) + { + Current = current; + _index = index; + } + + public static Cursor FromRoot(CXDoc doc) => new(new(doc), 0); + + public bool IsDone => Current.Token?.Kind is CXTokenKind.EOF or CXTokenKind.Invalid; + + public static bool IsNonZeroWidthOrIsEOF(CXNode.ParseSlot value) + => value.Token?.Kind is CXTokenKind.EOF || !value.FullSpan.IsEmpty; + + public static bool IsNonZeroWidthOrIsEOF(NodeOrToken value) + => value.Token?.Kind is CXTokenKind.EOF || !value.FullSpan.IsEmpty; + + private Cursor TryFindNextNonZeroWidthOrIsEOFSibling() + { + if (Current.Parent is not null) + { + for (var i = _index + 1; i < Current.Parent.Slots.Count; i++) + { + var sibling = Current.Parent.Slots[i]; + + if (IsNonZeroWidthOrIsEOF(sibling)) return new Cursor(NodeOrToken.FromSlot(sibling, Current.Parent), i); + } + } + + return default; + } + + private Cursor MoveToParent() + { + if (Current.Parent is null) return this; + + var parent = Current.Parent; + return new(new(parent), parent.GetParentSlotIndex()); + } + + public static Cursor MoveToNextSibling(Cursor cursor) + { + while (cursor.Current.Parent is not null) + { + var next = cursor.TryFindNextNonZeroWidthOrIsEOFSibling(); + + if (next.Current.HasValue) + return next; + + cursor = cursor.MoveToParent(); + } + + return default; + } + + public Cursor MoveToFirstChild() + { + if (Current.Node is not {Slots.Count: > 0} node) return default; + + for (var i = 0; i < node.Slots.Count; i++) + { + var child = node.Slots[i]; + if (IsNonZeroWidthOrIsEOF(child)) return new Cursor(NodeOrToken.FromSlot(child, node), i); + } + + return default; + } + + public Cursor MoveToFirstToken() + { + var cursor = this; + + if (!cursor.IsDone) + { + for (var current = cursor.Current; !current.Token.HasValue; current = cursor.Current) + cursor = cursor.MoveToFirstChild(); + } + + return cursor; + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs b/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs index dacfff1acf..da01fa9702 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs @@ -53,8 +53,12 @@ private readonly record struct Interceptor( Diagnostic[] Diagnostics ); + public void Initialize(IncrementalGeneratorInitializationContext context) { + var manager = new SourceManager(context); + return; + var provider = context .SyntaxProvider .CreateSyntaxProvider((x, _) => diff --git a/src/Discord.Net.ComponentDesigner/ComponentDesigner.cs b/src/Discord.Net.ComponentDesigner/ComponentDesigner.cs index 339b436523..ca83d57799 100644 --- a/src/Discord.Net.ComponentDesigner/ComponentDesigner.cs +++ b/src/Discord.Net.ComponentDesigner/ComponentDesigner.cs @@ -14,5 +14,5 @@ public static IMessageComponentBuilder cx( public static T cx( [StringSyntax("html")] DesignerInterpolationHandler designer ) where T : IMessageComponentBuilder - => throw new UnreachableException(); + => throw new InvalidOperationException(); } diff --git a/src/Discord.Net.ComponentDesigner/Discord.Net.ComponentDesigner.csproj b/src/Discord.Net.ComponentDesigner/Discord.Net.ComponentDesigner.csproj index 6220b1239f..2f89e1ab6d 100644 --- a/src/Discord.Net.ComponentDesigner/Discord.Net.ComponentDesigner.csproj +++ b/src/Discord.Net.ComponentDesigner/Discord.Net.ComponentDesigner.csproj @@ -1,14 +1,15 @@  - - net8.0 - enable - enable - Discord - + + net9.0;net8.0;net6.0; + enable + enable + latest + Discord + - - - + + + diff --git a/src/Discord.Net.ComponentDesigner/StringSyntax.cs b/src/Discord.Net.ComponentDesigner/StringSyntax.cs new file mode 100644 index 0000000000..77d0706381 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner/StringSyntax.cs @@ -0,0 +1,69 @@ +#if NET6_0 +namespace System.Diagnostics.CodeAnalysis +{ + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = + false, Inherited = false)] + internal sealed class StringSyntaxAttribute : Attribute + { + /// Initializes the with the identifier of the syntax used. + /// The syntax identifier. + public StringSyntaxAttribute(string syntax) + { + Syntax = syntax; + Arguments = Array.Empty(); + } + + /// Initializes the with the identifier of the syntax used. + /// The syntax identifier. + /// Optional arguments associated with the specific syntax employed. + public StringSyntaxAttribute(string syntax, params object?[] arguments) + { + Syntax = syntax; + Arguments = arguments; + } + + /// Gets the identifier of the syntax used. + public string Syntax { get; } + + /// Optional arguments associated with the specific syntax employed. + public object?[] Arguments { get; } + + /// The syntax identifier for strings containing composite formats for string formatting. + public const string CompositeFormat = nameof(CompositeFormat); + + /// The syntax identifier for strings containing date format specifiers. + public const string DateOnlyFormat = nameof(DateOnlyFormat); + + /// The syntax identifier for strings containing date and time format specifiers. + public const string DateTimeFormat = nameof(DateTimeFormat); + + /// The syntax identifier for strings containing format specifiers. + public const string EnumFormat = nameof(EnumFormat); + + /// The syntax identifier for strings containing format specifiers. + public const string GuidFormat = nameof(GuidFormat); + + /// The syntax identifier for strings containing JavaScript Object Notation (JSON). + public const string Json = nameof(Json); + + /// The syntax identifier for strings containing numeric format specifiers. + public const string NumericFormat = nameof(NumericFormat); + + /// The syntax identifier for strings containing regular expressions. + public const string Regex = nameof(Regex); + + /// The syntax identifier for strings containing time format specifiers. + public const string TimeOnlyFormat = nameof(TimeOnlyFormat); + + /// The syntax identifier for strings containing format specifiers. + public const string TimeSpanFormat = nameof(TimeSpanFormat); + + /// The syntax identifier for strings containing URIs. + public const string Uri = nameof(Uri); + + /// The syntax identifier for strings containing XML. + public const string Xml = nameof(Xml); + } +} + +#endif From ff730711a1b086f4f84c49e02aea564e3ee4d173 Mon Sep 17 00:00:00 2001 From: Quin Lynch <49576606+quinchs@users.noreply.github.com> Date: Tue, 9 Sep 2025 03:24:34 -0300 Subject: [PATCH 11/17] ok it basically is incremental --- .../Parsing/Parser2/BlendedNode.cs | 7 + .../Parsing/Parser2/CXBlender.cs | 242 ++++++++----- .../Parsing/Parser2/CXCursor.cs | 91 +++++ .../Parsing/Parser2/CXDoc.cs | 31 +- .../Parsing/Parser2/CXNode.cs | 4 +- .../Parsing/Parser2/CXParser.cs | 11 +- .../Parsing/Parser2/Cursor.cs | 339 ------------------ .../Parser2/IncrementalParseContext.cs | 9 + .../Parsing/Parser2/NodeOrToken.cs | 37 ++ 9 files changed, 336 insertions(+), 435 deletions(-) create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/BlendedNode.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXCursor.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/Cursor.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/IncrementalParseContext.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/NodeOrToken.cs diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/BlendedNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/BlendedNode.cs new file mode 100644 index 0000000000..e11feddba3 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/BlendedNode.cs @@ -0,0 +1,7 @@ +namespace Discord.ComponentDesignerGenerator.Parser; + +public readonly record struct BlendedNode( + CXNode? Node, + CXToken? Token, + CXBlender Blender +); diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXBlender.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXBlender.cs index d486d68b39..7eaec53407 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXBlender.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXBlender.cs @@ -1,144 +1,212 @@ using Microsoft.CodeAnalysis.Text; using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; namespace Discord.ComponentDesignerGenerator.Parser; -public sealed class CXBlender +public readonly struct CXBlender { - public Queue Changes { get; } + private readonly CXLexer _lexer; + private readonly ImmutableStack _changes; - private int CurrentSourcePosition => Lexer.Reader.Position; - public CXLexer Lexer => Document.Parser.Lexer; + private readonly int _newPosition; + private readonly int _changeDelta; - public CXDoc Document { get; } + private readonly CXCursor _cursor; - private readonly List _tokens; - private int _docTokenIndex; - - private int _changeDelta; - public CXBlender( - CXDoc document + CXLexer lexer, + CXDoc document, + IEnumerable changes, + CXCursor? cursor = null ) { - _tokens = []; - Document = document; - Changes = []; + _lexer = lexer; + + _newPosition = _lexer.Reader.Source.SourceSpan.Start; + + _changes = [..changes]; + + _cursor = cursor ?? CXCursor.FromRoot(document).MoveToFirstChild(); } - public void Reset() + private CXBlender( + CXLexer lexer, + CXCursor cursor, + ImmutableStack changes, + int newPosition, + int changeDelta + ) { - _tokens.Clear(); - _docTokenIndex = 0; - _changeDelta = 0; - Changes.Clear(); + _lexer = lexer; + _cursor = cursor; + _changes = changes; + _newPosition = newPosition; + _changeDelta = changeDelta; } - public CXToken GetToken(int index) + public static TextChangeRange GetAffectedRange(CXDoc doc, TextChangeRange range) { - while (_tokens.Count <= index) - { - var token = NextToken(); + return range; + + // // clamp the range to the end of the doc + // var start = Math.Max(Math.Min(range.Span.Start, doc.FullSpan.End - 1), 0); + // + // if (!doc.TryFindToken(start, out var token)) return range; + // + // start = Math.Max(0, token.Span.Start - 1); + // + // var span = TextSpan.FromBounds(start, range.Span.End); + // var length = range.NewLength + (range.Span.Start - start); + // return new(span, length); + } - if (token.Kind is CXTokenKind.EOF) return token; - } + public BlendedNode ReadNode() => ReadNodeOrToken(asToken: false); + public BlendedNode ReadToken() => ReadNodeOrToken(asToken: true); - return _tokens[index]; - } + private BlendedNode ReadNodeOrToken(bool asToken) + => new Reader(this).ReadNodeOrToken(asToken); - public CXToken NextToken() + public struct Reader { - SkipPastChanges(); + private CXCursor _oldCursor; + private ImmutableStack _changes; + private int _newPosition; + private int _changeDelta; + + private readonly CXLexer _lexer; + + public Reader(CXBlender blender) + { + _lexer = blender._lexer; + _oldCursor = blender._cursor; + _changes = blender._changes; + _newPosition = blender._newPosition; + _changeDelta = blender._changeDelta; + } - while (true) + public BlendedNode ReadNodeOrToken(bool asToken) { - while (_changeDelta < 0 && _docTokenIndex < Document.Tokens.Count) + while (true) { - var oldToken = Document.Tokens[_docTokenIndex++]; - _changeDelta += oldToken.AbsoluteWidth; + if (_oldCursor.IsDone) return ReadNewToken(); + + if (_changeDelta < 0) SkipOldToken(); + else if (_changeDelta > 0) return ReadNewToken(); + else + { + if (TryTakeOldNodeOrToken(asToken, out var blendedNode)) return blendedNode; + + if (_oldCursor.Current.Node is not null) + _oldCursor = _oldCursor.MoveToFirstChild(); + else + SkipOldToken(); + } } + } + + private void SkipOldToken() + { + _oldCursor = _oldCursor.MoveToFirstToken(); - if (_changeDelta > 0) - return LexNewToken(); + var current = _oldCursor.Current; - if (TryReuseToken(out var token)) return token; + _changeDelta += current.FullSpan.Length; - if (Document.Tokens.Count <= _docTokenIndex) return LexNewToken(); + _oldCursor = CXCursor.MoveToNextSibling(_oldCursor); - _changeDelta += Document.Tokens[_docTokenIndex++].AbsoluteWidth; + SkipPastChanges(); } - bool TryReuseToken(out CXToken token) + private void SkipPastChanges() { - if (_docTokenIndex >= Document.Tokens.Count) + var oldPosition = _oldCursor.Current.FullSpan.Start; + + while (!_changes.IsEmpty && oldPosition >= _changes.Peek().Span.End) { - token = default; - return false; + var change = _changes.Peek(); + + _changes = _changes.Pop(); + _changeDelta += change.NewLength - change.Span.Length; } + } - token = Document.Tokens[_docTokenIndex]; + private BlendedNode ReadNewToken() + { + var token = LexNewToken(); - if (!CanReuse(token)) return false; + _newPosition += token.FullSpan.Length; + _changeDelta -= token.FullSpan.Length; - _docTokenIndex++; - Lexer.Reader.Advance(token.AbsoluteWidth); + SkipPastChanges(); - _tokens.Add(token); - return true; + return CreateBlendedNode(token: token); } - } - private CXToken LexNewToken() - { - var token = Lexer.Next(); - - _tokens.Add(token); - _changeDelta += token.AbsoluteWidth; - - return token; - } - - private void SkipPastChanges() - { - while (Changes.Count > 0) + private CXToken LexNewToken() { - var change = Changes.Peek(); - var newLength = change.NewText?.Length ?? 0; - - if (change.Span.Start + newLength > CurrentSourcePosition) - break; + _lexer.Reader.Position = _newPosition; + return _lexer.Next(); + } - Changes.Dequeue(); + private bool TryTakeOldNodeOrToken( + bool asToken, + out BlendedNode blendedNode + ) + { + if (asToken) _oldCursor = _oldCursor.MoveToFirstToken(); - _changeDelta += newLength - change.Span.Length; + var current = _oldCursor.Current; - // update the cursor to the new change - while (_docTokenIndex < Document.Tokens.Count) + if (!CanReuse(current)) { - var token = Document.Tokens[_docTokenIndex]; + blendedNode = default; + return false; + } - if (token.AbsoluteStart >= change.Span.Start) - break; + _newPosition += current.FullSpan.Length; + _oldCursor = CXCursor.MoveToNextSibling(_oldCursor); - _docTokenIndex++; - } + blendedNode = CreateBlendedNode( + node: current.Node, + token: current.Token + ); + return true; } - } - private bool CanReuse(CXToken token) - { - if (token.AbsoluteWidth is 0) return false; + private bool CanReuse(NodeOrToken value) + { + if (!value.HasValue) return false; - if (IntersectsNextChange(token.Span)) return false; + if (value.FullSpan.IsEmpty) return false; - return true; - } + if (IntersectsNextChange(value.FullSpan)) return false; - private bool IntersectsNextChange(TextSpan span) - { - if (Changes.Count is 0) return false; + // TODO: more riggerous checking; no error nodes/tokens, etc - return span.IntersectsWith(Changes.Peek().Span); + return true; + } + + private bool IntersectsNextChange(TextSpan span) + => !_changes.IsEmpty && span.IntersectsWith(_changes.Peek().Span); + + private BlendedNode CreateBlendedNode(CXNode? node = null, CXToken? token = null) + { + Debug.Assert(node is not null || token.HasValue); + + return new( + node, + token, + new CXBlender( + _lexer, + _oldCursor, + _changes, + _newPosition, + _changeDelta + ) + ); + } } } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXCursor.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXCursor.cs new file mode 100644 index 0000000000..8cde3db98b --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXCursor.cs @@ -0,0 +1,91 @@ +using System.Collections; + +namespace Discord.ComponentDesignerGenerator.Parser; + +public readonly struct CXCursor +{ + public readonly NodeOrToken Current; + private readonly int _index; + + public CXCursor(NodeOrToken current, int index) + { + Current = current; + _index = index; + } + + public static CXCursor FromRoot(CXDoc doc) => new(new(doc), 0); + + public bool IsDone => !Current.HasValue || Current.Token?.Kind is CXTokenKind.EOF or CXTokenKind.Invalid; + + public static bool IsNonZeroWidthOrIsEOF(CXNode.ParseSlot value) + => value.Token?.Kind is CXTokenKind.EOF || !value.FullSpan.IsEmpty; + + + private CXCursor TryFindNextNonZeroWidthOrIsEOFSibling() + { + if (Current.Parent is not null) + { + for (var i = _index + 1; i < Current.Parent.Slots.Count; i++) + { + var sibling = Current.Parent.Slots[i]; + + if (IsNonZeroWidthOrIsEOF(sibling)) + return new CXCursor(NodeOrToken.FromSlot(sibling, Current.Parent), i); + } + } + + return default; + } + + private CXCursor MoveToParent() + { + if (Current.Parent is null) return this; + + var parent = Current.Parent; + return new(new(parent), parent.GetParentSlotIndex()); + } + + public static CXCursor MoveToNextSibling(CXCursor cursor) + { + while (cursor.Current.Parent is not null) + { + var next = cursor.TryFindNextNonZeroWidthOrIsEOFSibling(); + + if (next.Current.HasValue) + return next; + + cursor = cursor.MoveToParent(); + } + + return default; + } + + public CXCursor MoveToFirstChild() + { + if (Current.Node is not {Slots.Count: > 0} node) return default; + + for (var i = 0; i < node.Slots.Count; i++) + { + var child = node.Slots[i]; + if (IsNonZeroWidthOrIsEOF(child)) return new CXCursor(NodeOrToken.FromSlot(child, node), i); + } + + return default; + } + + public CXCursor MoveToFirstToken() + { + var cursor = this; + + if (!cursor.IsDone) + { + for ( + var current = cursor.Current; + current is {HasValue: true, Token: null}; + current = cursor.Current + ) cursor = cursor.MoveToFirstChild(); + } + + return cursor; + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXDoc.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXDoc.cs index aa298a70be..4004d23cf8 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXDoc.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXDoc.cs @@ -11,7 +11,7 @@ public sealed class CXDoc : CXNode public IReadOnlyList Tokens { get; } - private readonly IReadOnlyList _rootElements; + public IReadOnlyList RootElements { get; private set; } public CXDoc( CXParser parser, @@ -21,7 +21,7 @@ IReadOnlyList tokens { Tokens = tokens; Parser = parser; - Slot(_rootElements = rootElements); + Slot(RootElements = rootElements); } public void ApplyChanges( @@ -29,12 +29,35 @@ public void ApplyChanges( IReadOnlyList changes ) { - var blender = new CXBlender2(Parser.Lexer, this, changes.Select(x => (TextChangeRange)x)); + var affectedRange = CXBlender.GetAffectedRange( + this, + TextChangeRange.Collapse(changes.Select(x => (TextChangeRange)x)) + ); + + var blender = new CXBlender(Parser.Lexer, this, [affectedRange]); Parser.Source = source; Parser.Reset(); Parser.RootBlender = blender; - var x = Parser.ParseElement(); + + var context = new IncrementalParseContext(changes, affectedRange); + + var owner = FindOwningNode(affectedRange.Span, out _); + + owner.IncrementalParse(context); + } + + public override void IncrementalParse(IncrementalParseContext context) + { + var children = new List(); + + while (Parser.CurrentToken.Kind is not CXTokenKind.EOF and not CXTokenKind.Invalid) + { + children.Add(Parser.ParseElement()); + } + + ClearSlots(); + Slot(RootElements = children); } public bool TryFindToken(int position, out CXToken token) diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXNode.cs index 30db956a5c..5c23887810 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXNode.cs @@ -127,6 +127,8 @@ public CXNode() _slots = []; } + protected void ClearSlots() => _slots.Clear(); + public int GetParentSlotIndex() { if (Parent is null) return -1; @@ -215,7 +217,7 @@ protected void Slot(IEnumerable nodes) foreach (var node in nodes) Slot(node); } - public virtual void IncrementalParse(TextChange change) => Parent?.IncrementalParse(change); + public virtual void IncrementalParse(IncrementalParseContext change) => Parent?.IncrementalParse(change); protected void UpdateSelf(CXNode? node) { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXParser.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXParser.cs index b7ebd95abb..b5af80e039 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXParser.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXParser.cs @@ -37,7 +37,7 @@ public CXSource Source public bool IsIncremental => RootBlender.HasValue; - public CXBlender2? RootBlender { get; set; } + public CXBlender? RootBlender { get; set; } private BlendedNode? _currentBlendedNode; @@ -433,9 +433,9 @@ internal CXToken Lex(int index) { var token = Lexer.Next(); - if (token.Kind is CXTokenKind.EOF) return token; - _tokens.Add(token); + + if (token.Kind is CXTokenKind.EOF) return token; } return _tokens[index]; @@ -448,7 +448,10 @@ CXToken FetchBlended() ? RootBlender!.Value : _blendedTokens[_blendedTokens.Count - 1].Blender; - _blendedTokens.Add(blender.ReadToken()); + var node = blender.ReadToken(); + _blendedTokens.Add(node); + + if (node.Token?.Kind is CXTokenKind.EOF) return node.Token.Value; } return _blendedTokens[index].Token!.Value; diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/Cursor.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/Cursor.cs deleted file mode 100644 index 034a647c8f..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/Cursor.cs +++ /dev/null @@ -1,339 +0,0 @@ -using Microsoft.CodeAnalysis.Text; -using System; -using System.Collections; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Diagnostics; - -namespace Discord.ComponentDesignerGenerator.Parser; - -public readonly struct NodeOrToken -{ - public bool HasValue => Node is not null || Token is not null; - - public TextSpan FullSpan => Node?.FullSpan ?? Token!.Value.FullSpan; - - public readonly CXNode? Parent; - - public readonly CXNode? Node; - - public readonly CXToken? Token; - - public NodeOrToken(CXNode node) - { - Node = node; - Parent = node.Parent; - } - - public NodeOrToken(CXNode parent, CXToken token) - { - Token = token; - Parent = parent; - } - - public static NodeOrToken FromSlot(CXNode.ParseSlot slot, CXNode parent) - => slot switch - { - {Token: { } token} => new(parent, token), - {Node: { } node} => new(node), - _ => throw new InvalidOperationException() - }; -} - -public readonly record struct BlendedNode( - CXNode? Node, - CXToken? Token, - CXBlender2 Blender -); - -public readonly struct CXBlender2 -{ - private readonly CXLexer _lexer; - private readonly ImmutableStack _changes; - - private readonly int _newPosition; - private readonly int _changeDelta; - - private readonly Cursor _cursor; - - - public CXBlender2( - CXLexer lexer, - CXDoc document, - IEnumerable changes, - Cursor? cursor = null - ) - { - _lexer = lexer; - _newPosition = _lexer.Reader.Source.SourceSpan.Start; - _changes = ImmutableStack.Empty.Push( - GetAffectedRange(document, TextChangeRange.Collapse(changes)) - ); - - _cursor = cursor ?? Cursor.FromRoot(document).MoveToFirstChild(); - } - - private CXBlender2( - CXLexer lexer, - Cursor cursor, - ImmutableStack changes, - int newPosition, - int changeDelta - ) - { - _lexer = lexer; - _cursor = cursor; - _changes = changes; - _newPosition = newPosition; - _changeDelta = changeDelta; - } - - private static TextChangeRange GetAffectedRange(CXDoc doc, TextChangeRange range) - { - return range; - - // // clamp the range to the end of the doc - // var start = Math.Max(Math.Min(range.Span.Start, doc.FullSpan.End - 1), 0); - // - // if (!doc.TryFindToken(start, out var token)) return range; - // - // start = Math.Max(0, token.Span.Start - 1); - // - // var span = TextSpan.FromBounds(start, range.Span.End); - // var length = range.NewLength + (range.Span.Start - start); - // return new(span, length); - } - - public BlendedNode ReadNode() => ReadNodeOrToken(asToken: false); - public BlendedNode ReadToken() => ReadNodeOrToken(asToken: true); - - private BlendedNode ReadNodeOrToken(bool asToken) - => new Reader(this).ReadNodeOrToken(asToken); - - public struct Reader - { - private Cursor _oldCursor; - private ImmutableStack _changes; - private int _newPosition; - private int _changeDelta; - - private readonly CXLexer _lexer; - - public Reader(CXBlender2 blender) - { - _lexer = blender._lexer; - _oldCursor = blender._cursor; - _changes = blender._changes; - _newPosition = blender._newPosition; - _changeDelta = blender._changeDelta; - } - - public BlendedNode ReadNodeOrToken(bool asToken) - { - while (true) - { - if (_oldCursor.IsDone) return ReadNewToken(); - - if (_changeDelta < 0) SkipOldToken(); - else if (_changeDelta > 0) return ReadNewToken(); - else - { - if (TryTakeOldNodeOrToken(asToken, out var blendedNode)) return blendedNode; - - if (_oldCursor.Current.Node is not null) - _oldCursor = _oldCursor.MoveToFirstChild(); - else - SkipOldToken(); - } - } - } - - private void SkipOldToken() - { - _oldCursor = _oldCursor.MoveToFirstToken(); - - var current = _oldCursor.Current; - - _changeDelta += current.FullSpan.Length; - - _oldCursor = Cursor.MoveToNextSibling(_oldCursor); - - SkipPastChanges(); - } - - private void SkipPastChanges() - { - var oldPosition = _oldCursor.Current.FullSpan.Start; - - while (!_changes.IsEmpty && oldPosition >= _changes.Peek().Span.End) - { - var change = _changes.Peek(); - - _changes = _changes.Pop(); - _changeDelta += change.NewLength - change.Span.Length; - } - } - - private BlendedNode ReadNewToken() - { - var token = LexNewToken(); - - _newPosition += token.FullSpan.Length; - _changeDelta -= token.FullSpan.Length; - - SkipPastChanges(); - - return CreateBlendedNode(token: token); - } - - private CXToken LexNewToken() - { - _lexer.Reader.Position = _newPosition; - return _lexer.Next(); - } - - private bool TryTakeOldNodeOrToken( - bool asToken, - out BlendedNode blendedNode - ) - { - if (asToken) _oldCursor = _oldCursor.MoveToFirstToken(); - - var current = _oldCursor.Current; - - if (!CanReuse(current)) - { - blendedNode = default; - return false; - } - - _newPosition += current.FullSpan.Length; - _oldCursor = Cursor.MoveToNextSibling(_oldCursor); - - blendedNode = CreateBlendedNode( - node: current.Node, - token: current.Token - ); - return true; - } - - private bool CanReuse(NodeOrToken value) - { - if (!value.HasValue) return false; - - if (value.FullSpan.IsEmpty) return false; - - if (IntersectsNextChange(value.FullSpan)) return false; - - // TODO: more riggerous checking; no error nodes/tokens, etc - - return true; - } - - private bool IntersectsNextChange(TextSpan span) - => !_changes.IsEmpty && span.IntersectsWith(_changes.Peek().Span); - - private BlendedNode CreateBlendedNode(CXNode? node = null, CXToken? token = null) - { - Debug.Assert(node is not null || token.HasValue); - - return new( - node, - token, - new CXBlender2( - _lexer, - _oldCursor, - _changes, - _newPosition, - _changeDelta - ) - ); - } - } -} - -public readonly struct Cursor -{ - public readonly NodeOrToken Current; - private readonly int _index; - - public Cursor(NodeOrToken current, int index) - { - Current = current; - _index = index; - } - - public static Cursor FromRoot(CXDoc doc) => new(new(doc), 0); - - public bool IsDone => Current.Token?.Kind is CXTokenKind.EOF or CXTokenKind.Invalid; - - public static bool IsNonZeroWidthOrIsEOF(CXNode.ParseSlot value) - => value.Token?.Kind is CXTokenKind.EOF || !value.FullSpan.IsEmpty; - - public static bool IsNonZeroWidthOrIsEOF(NodeOrToken value) - => value.Token?.Kind is CXTokenKind.EOF || !value.FullSpan.IsEmpty; - - private Cursor TryFindNextNonZeroWidthOrIsEOFSibling() - { - if (Current.Parent is not null) - { - for (var i = _index + 1; i < Current.Parent.Slots.Count; i++) - { - var sibling = Current.Parent.Slots[i]; - - if (IsNonZeroWidthOrIsEOF(sibling)) return new Cursor(NodeOrToken.FromSlot(sibling, Current.Parent), i); - } - } - - return default; - } - - private Cursor MoveToParent() - { - if (Current.Parent is null) return this; - - var parent = Current.Parent; - return new(new(parent), parent.GetParentSlotIndex()); - } - - public static Cursor MoveToNextSibling(Cursor cursor) - { - while (cursor.Current.Parent is not null) - { - var next = cursor.TryFindNextNonZeroWidthOrIsEOFSibling(); - - if (next.Current.HasValue) - return next; - - cursor = cursor.MoveToParent(); - } - - return default; - } - - public Cursor MoveToFirstChild() - { - if (Current.Node is not {Slots.Count: > 0} node) return default; - - for (var i = 0; i < node.Slots.Count; i++) - { - var child = node.Slots[i]; - if (IsNonZeroWidthOrIsEOF(child)) return new Cursor(NodeOrToken.FromSlot(child, node), i); - } - - return default; - } - - public Cursor MoveToFirstToken() - { - var cursor = this; - - if (!cursor.IsDone) - { - for (var current = cursor.Current; !current.Token.HasValue; current = cursor.Current) - cursor = cursor.MoveToFirstChild(); - } - - return cursor; - } -} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/IncrementalParseContext.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/IncrementalParseContext.cs new file mode 100644 index 0000000000..d5c98d5096 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/IncrementalParseContext.cs @@ -0,0 +1,9 @@ +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; + +namespace Discord.ComponentDesignerGenerator.Parser; + +public readonly record struct IncrementalParseContext( + IReadOnlyList Changes, + TextChangeRange AffectedRange +); diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/NodeOrToken.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/NodeOrToken.cs new file mode 100644 index 0000000000..640e0a56c6 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/NodeOrToken.cs @@ -0,0 +1,37 @@ +using Microsoft.CodeAnalysis.Text; +using System; + +namespace Discord.ComponentDesignerGenerator.Parser; + +public readonly struct NodeOrToken +{ + public bool HasValue => Node is not null || Token is not null; + + public TextSpan FullSpan => Node?.FullSpan ?? Token?.FullSpan ?? default; + + public readonly CXNode? Parent; + + public readonly CXNode? Node; + + public readonly CXToken? Token; + + public NodeOrToken(CXNode node) + { + Node = node; + Parent = node.Parent; + } + + public NodeOrToken(CXNode parent, CXToken token) + { + Token = token; + Parent = parent; + } + + public static NodeOrToken FromSlot(CXNode.ParseSlot slot, CXNode parent) + => slot switch + { + {Token: { } token} => new(parent, token), + {Node: { } node} => new(node), + _ => throw new InvalidOperationException() + }; +} From 8f31b86b852b95e6a709edfdedb64c400e294dec Mon Sep 17 00:00:00 2001 From: Quin Lynch <49576606+quinchs@users.noreply.github.com> Date: Tue, 9 Sep 2025 03:26:49 -0300 Subject: [PATCH 12/17] undo edits to core --- src/Discord.Net.Core/Discord.Net.Core.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Discord.Net.Core/Discord.Net.Core.csproj b/src/Discord.Net.Core/Discord.Net.Core.csproj index 5d64fd6b8b..c51e74ecf3 100644 --- a/src/Discord.Net.Core/Discord.Net.Core.csproj +++ b/src/Discord.Net.Core/Discord.Net.Core.csproj @@ -7,7 +7,7 @@ The core components for the Discord.Net library. net9.0;net8.0;net6.0;net5.0;net461;netstandard2.0;netstandard2.1 5 - + True false false true From 596293f9e69fc46f65f00c1f3216e8475935818e Mon Sep 17 00:00:00 2001 From: Quin Lynch <49576606+quinchs@users.noreply.github.com> Date: Tue, 9 Sep 2025 03:45:59 -0300 Subject: [PATCH 13/17] it might be a good idea to consume nodes --- .../Parsing/Parser2/CXParser.cs | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXParser.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXParser.cs index b5af80e039..65ef1855bb 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXParser.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXParser.cs @@ -77,7 +77,11 @@ public static CXDoc Parse(CXSource source) internal CXElement ParseElement() { - if (IsIncremental && CurrentNode is CXElement element) return element; + if (IsIncremental && CurrentNode is CXElement element) + { + EatNode(); + return element; + } var start = Expect(CXTokenKind.LessThan); @@ -134,7 +138,11 @@ void ParseClosingElement( CXCollection ParseElementChildren() { - if (IsIncremental && CurrentNode is CXCollection incrementalChildren) return incrementalChildren; + if (IsIncremental && CurrentNode is CXCollection incrementalChildren) + { + EatNode(); + return incrementalChildren; + } // valid children are: // - other elements @@ -156,6 +164,7 @@ bool TryParseElementChild(out CXNode node) if (IsIncremental && CurrentNode is CXValue or CXElement) { node = CurrentNode; + EatNode(); return true; } @@ -196,7 +205,11 @@ bool TryParseElementChild(out CXNode node) internal CXCollection ParseAttributes() { - if (IsIncremental && CurrentNode is CXCollection incrementalNode) return incrementalNode; + if (IsIncremental && CurrentNode is CXCollection incrementalNode) + { + EatNode(); + return incrementalNode; + } var attributes = new List(); @@ -250,7 +263,11 @@ internal CXAttribute ParseAttribute() internal CXValue ParseAttributeValue() { - if (IsIncremental && CurrentNode is CXValue value) return value; + if (IsIncremental && CurrentNode is CXValue value) + { + EatNode(); + return value; + } switch (CurrentToken.Kind) { @@ -275,7 +292,11 @@ internal CXValue ParseAttributeValue() internal CXValue ParseStringLiteral() { - if (IsIncremental && CurrentNode is CXValue value) return value; + if (IsIncremental && CurrentNode is CXValue value) + { + EatNode(); + return value; + } var tokens = new List(); From 239545fb70890f7b879435c3c207f3b26074b182 Mon Sep 17 00:00:00 2001 From: Quin Lynch <49576606+quinchs@users.noreply.github.com> Date: Thu, 18 Sep 2025 00:35:14 -0300 Subject: [PATCH 14/17] proper incremental parsing this time --- Discord.Net.targets | 8 +- .../Diagnostics.cs | 475 +-------- .../Generator/CXGraph.cs | 142 +++ .../Generator/CXGraphManager.cs | 87 +- .../Generator/RenderedInterceptor.cs | 27 + .../Generator/SourceManager.cs | 277 ------ .../Nodes/ComponentContext.cs | 40 + .../Nodes/ComponentNode.cs | 256 +---- .../Nodes/ComponentNodeContext.cs | 102 -- .../Nodes/ComponentProperty.cs | 270 +---- .../Nodes/ComponentPropertyValidator.cs | 7 - .../Nodes/ComponentPropertyValue.cs | 21 +- .../Nodes/ComponentState.cs | 74 ++ .../Components/ActionRowComponentNode.cs | 203 ---- .../Components/BaseSelectComponentNode.cs | 115 --- .../Nodes/Components/ButtonComponentNode.cs | 221 ++--- .../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/RowComponentNode.cs | 26 + .../Nodes/Components/SectionComponentNode.cs | 244 ----- .../Nodes/Components/SelectDefaultValue.cs | 45 - .../Nodes/Components/SelectOption.cs | 56 -- .../Components/SeparatorComponentNode.cs | 46 - .../Components/StringSelectComponentNode.cs | 71 -- .../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/Renderers/Renderers.cs | 189 ++++ .../Nodes/Validators/Validators.Numeric.cs | 34 - .../Validators/Validators.StringLength.cs | 32 - .../Nodes/Validators/Validators.cs | 151 +++ .../Nodes/ValueCodeGenerator.cs | 171 ---- .../Nodes/ValueParsers/ValueParseDelegate.cs | 3 - .../Nodes/ValueParsers/ValueParsers.Bool.cs | 54 - .../Nodes/ValueParsers/ValueParsers.Color.cs | 68 -- .../Nodes/ValueParsers/ValueParsers.Emoji.cs | 72 -- .../Nodes/ValueParsers/ValueParsers.Enum.cs | 66 -- .../Nodes/ValueParsers/ValueParsers.Int.cs | 39 - .../ValueParsers/ValueParsers.Snowflake.cs | 39 - .../Nodes/ValueParsers/ValueParsers.String.cs | 28 - .../Nodes/ValueParsers/ValueParsers.cs | 47 - .../Parsing/{Parser2 => }/CXDiagnostic.cs | 0 .../Parsing/{Parser2 => }/CXParser.cs | 177 ++-- .../Parsing/CXTreeWalker.cs | 31 + .../Parsing/ICXNode.cs | 24 + .../{Parser2 => Incremental}/BlendedNode.cs | 5 +- .../Parsing/Incremental/CXBlender.cs | 300 ++++++ .../IncrementalParseContext.cs | 0 .../Incremental/IncrementalParseResult.cs | 11 + .../Parsing/Lexer/CXLexer.cs | 25 +- .../Parsing/Lexer/CXToken.cs | 72 +- .../Parsing/Lexer/CXTokenFlags.cs | 2 +- .../Parsing/{Parser2 => Nodes}/CXAttribute.cs | 0 .../{Parser2 => Nodes}/CXCollection.cs | 4 +- .../Parsing/Nodes/CXDoc.cs | 127 +++ .../Parsing/{Parser2 => Nodes}/CXElement.cs | 2 + .../Parsing/Nodes/CXNode.ParseSlot.cs | 36 + .../Parsing/Nodes/CXNode.cs | 308 ++++++ .../Parsing/{Parser2 => Nodes}/CXValue.cs | 0 .../Parsing/Parser/CXmlAttribute.cs | 14 - .../Parsing/Parser/CXmlDiagnostic.cs | 9 - .../Parsing/Parser/CXmlDoc.cs | 16 - .../Parsing/Parser/CXmlElement.cs | 21 - .../Parsing/Parser/CXmlValue.cs | 36 - .../Parsing/Parser/ComponentParser.cs | 938 ------------------ .../Parsing/Parser/ICXml.cs | 12 - .../Parsing/Parser/SourceLocation.cs | 10 - .../Parsing/Parser/SourceSpan.cs | 15 - .../Parsing/Parser2/CXBlender.cs | 212 ---- .../Parsing/Parser2/CXCursor.cs | 91 -- .../Parsing/Parser2/CXDoc.cs | 126 --- .../Parsing/Parser2/CXNode.cs | 230 ----- .../Parsing/Parser2/NodeOrToken.cs | 37 - .../SourceGenerator.cs | 458 +++++---- .../Utils/IsExternalInit.cs | 3 + src/Discord.Net.Core/Discord.Net.Core.csproj | 1 + 86 files changed, 2177 insertions(+), 5918 deletions(-) create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Generator/CXGraph.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Generator/RenderedInterceptor.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Generator/SourceManager.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentContext.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNodeContext.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValidator.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentState.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/BaseSelectComponentNode.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ChannelSelectComponentNode.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/CustomComponent.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterpolatedComponentNode.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MentionableSelectComponentNode.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/RoleSelectComponentNode.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/RowComponentNode.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentNode.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectDefaultValue.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectOption.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/StringSelectComponentNode.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/UserSelectComponentNode.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/IComponentProperty.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/NodeKind.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.Numeric.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.StringLength.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueCodeGenerator.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParseDelegate.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Bool.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Color.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Emoji.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Enum.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Int.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Snowflake.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.String.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.cs rename src/Discord.Net.ComponentDesigner.Generator/Parsing/{Parser2 => }/CXDiagnostic.cs (100%) rename src/Discord.Net.ComponentDesigner.Generator/Parsing/{Parser2 => }/CXParser.cs (70%) create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/CXTreeWalker.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/ICXNode.cs rename src/Discord.Net.ComponentDesigner.Generator/Parsing/{Parser2 => Incremental}/BlendedNode.cs (63%) create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/CXBlender.cs rename src/Discord.Net.ComponentDesigner.Generator/Parsing/{Parser2 => Incremental}/IncrementalParseContext.cs (100%) create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/IncrementalParseResult.cs rename src/Discord.Net.ComponentDesigner.Generator/Parsing/{Parser2 => Nodes}/CXAttribute.cs (100%) rename src/Discord.Net.ComponentDesigner.Generator/Parsing/{Parser2 => Nodes}/CXCollection.cs (87%) create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXDoc.cs rename src/Discord.Net.ComponentDesigner.Generator/Parsing/{Parser2 => Nodes}/CXElement.cs (95%) create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXNode.ParseSlot.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXNode.cs rename src/Discord.Net.ComponentDesigner.Generator/Parsing/{Parser2 => Nodes}/CXValue.cs (100%) delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlAttribute.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlDiagnostic.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlDoc.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlElement.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlValue.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/ComponentParser.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/ICXml.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/SourceLocation.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/SourceSpan.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXBlender.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXCursor.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXDoc.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXNode.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/NodeOrToken.cs diff --git a/Discord.Net.targets b/Discord.Net.targets index 7f9836d47f..2c4bbc351f 100644 --- a/Discord.Net.targets +++ b/Discord.Net.targets @@ -28,10 +28,10 @@ $(NoWarn);CS1573;CS1591 true true - true + true - - + + - + \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs b/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs index 2c94429575..f81e63a347 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs @@ -2,485 +2,66 @@ namespace Discord.ComponentDesignerGenerator; -public static class Diagnostics +public static partial class Diagnostics { - public static readonly DiagnosticDescriptor UnknownComponentType = new( + public static readonly DiagnosticDescriptor ParseError = new( + "DCP001", + "CX Parsing error", + "{}", + "Component Parser (CX)", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor InvalidEnumVariant = new( "DC0001", - "Unknown component type", - "Unknown component '{0}'", + "Invalid enum variant", + "'{0}' is not a valid variant of '{1}'; valid values are '{2}'", "Components", DiagnosticSeverity.Error, true ); - public static readonly DiagnosticDescriptor EmptyActionRow = new( + public static readonly DiagnosticDescriptor TypeMismatch = new( "DC0002", - "Action row empty", - "An action row must contain at least one child", + "Type mismatch", + "'{0}' is not of expected type '{1}'", "Components", DiagnosticSeverity.Error, true ); - public static readonly DiagnosticDescriptor TooManyChildrenInActionRow = new( + public static readonly DiagnosticDescriptor OutOfRange = new( "DC0003", - "Too many children in action row", - "An action row can contain up to 5 buttons OR 1 select menu", + "Type mismatch", + "'{0}' must be {1} in length", "Components", DiagnosticSeverity.Error, true ); - public static readonly DiagnosticDescriptor ActionRowCanOnlyContainMultipleButtons = new( + public static readonly DiagnosticDescriptor UnknownComponent = 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", + "Unknown component", + "'{0}' is not a known component", "Components", DiagnosticSeverity.Error, true ); - public static readonly DiagnosticDescriptor ButtonLabelMaxLengthExceeded = new( + public static readonly DiagnosticDescriptor ButtonCustomIdUrlConflict = new( "DC0005", - "Button label too long", - "A buttons label may only be at most 80 characters long", + "Invalid button", + "Buttons cannot contain both a 'url' and a 'customid'", "Components", DiagnosticSeverity.Error, true ); - public static readonly DiagnosticDescriptor InvalidSnowflakeIdentifier = new( + public static readonly DiagnosticDescriptor ButtonCustomIdOrUrlMissing = 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 recognized 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", + "Invalid button", + "A button must specify either a 'customId' or a 'url'", "Components", DiagnosticSeverity.Error, true diff --git a/src/Discord.Net.ComponentDesigner.Generator/Generator/CXGraph.cs b/src/Discord.Net.ComponentDesigner.Generator/Generator/CXGraph.cs new file mode 100644 index 0000000000..f30fe57014 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Generator/CXGraph.cs @@ -0,0 +1,142 @@ +using Discord.ComponentDesignerGenerator.Nodes; +using Discord.ComponentDesignerGenerator.Parser; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.ComponentDesignerGenerator; + +public sealed class CXGraph +{ + public CXDoc Document { get; } + public CXGraphManager Manager { get; } + + public List Roots { get; } + + public Dictionary NodeCacheMap { get; private set; } + public Dictionary PropertyCacheMap { get; } + + public CXGraph(CXDoc document, CXGraphManager manager) + { + Document = document; + Manager = manager; + Roots = []; + NodeCacheMap = []; + PropertyCacheMap = []; + } + + public void Update( + CXDoc doc, + IReadOnlyList reusedNodes + ) + { + var context = new ComponentContext(this); + + var map = new Dictionary(); + + // update the properties map + foreach (var cxNode in PropertyCacheMap.Keys.Except(reusedNodes.OfType())) + { + PropertyCacheMap.Remove(cxNode); + } + + Roots.Clear(); + Roots.AddRange( + doc.RootElements.Select(x => CreateNode(null, x)).Where(x => x is not null)! + ); + + NodeCacheMap.Clear(); + NodeCacheMap = map; + + return; + + Node? CreateNode(Node? parent, CXNode cxNode) + { + if (reusedNodes.Contains(cxNode) && NodeCacheMap.TryGetValue(cxNode, out var existing)) + return map[cxNode] = existing with {Parent = parent}; + + switch (cxNode) + { + case CXElement element: + if (!ComponentNode.TryGetNode(element.Identifier, out var componentNode)) + { + context.AddDiagnostic( + Diagnostics.UnknownComponent, + element, + element.Identifier + ); + + return null; + } + + var children = new List(); + + var state = componentNode.Create(element, children); + + if (state is null) return null; + + var node = state.OwningNode = new Node( + componentNode, + state, + parent, + [], + this + ); + + map[element] = node; + + node.Children.AddRange( + children.Select(x => CreateNode(node, x)).Where(x => x is not null)! + ); + + return node; + default: return null; + } + } + } + + public static CXGraph Create(CXDoc doc, CXGraphManager manager) + { + var graph = new CXGraph(doc, manager); + + graph.Update(doc, []); + + return graph; + } + + public void Validate(ComponentContext? context = null) + { + context ??= new ComponentContext(this); + + foreach (var node in Roots) node.Validate(context); + } + + public string Render(ComponentContext? context = null) + { + context ??= new ComponentContext(this); + + return string.Join(",\n", Roots.Select(x => x.Inner.Render(x.State, context))); + } + + public sealed record Node( + ComponentNode Inner, + ComponentState State, + Node? Parent, + List Children, + CXGraph Graph + ) + { + private string? _render; + + public string Render(ComponentContext context) + => _render ??= Inner.Render(State, context); + + public void Validate(ComponentContext context) + { + Inner.Validate(State, context); + + foreach (var child in Children) child.Validate(context); + } + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Generator/CXGraphManager.cs b/src/Discord.Net.ComponentDesigner.Generator/Generator/CXGraphManager.cs index 37a69353fc..2cb5766cd8 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Generator/CXGraphManager.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Generator/CXGraphManager.cs @@ -1,8 +1,10 @@ -using Discord.ComponentDesignerGenerator.Parser; +using Discord.ComponentDesignerGenerator.Nodes; +using Discord.ComponentDesignerGenerator.Parser; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Text; @@ -11,6 +13,7 @@ namespace Discord.ComponentDesignerGenerator; public sealed class CXGraphManager { + public SyntaxTree SyntaxTree => InvocationSyntax.SyntaxTree; public InterceptableLocation InterceptLocation => _target.InterceptLocation; public InvocationExpressionSyntax InvocationSyntax => _target.InvocationSyntax; public ExpressionSyntax ArgumentExpressionSyntax => _target.ArgumentExpressionSyntax; @@ -24,7 +27,7 @@ public sealed class CXGraphManager public CXParser Parser => _document.Parser; - private readonly SourceManager _manager; + private readonly SourceGenerator _generator; private CXDoc _document; private Target _target; @@ -32,14 +35,16 @@ public sealed class CXGraphManager private string _basicCXSource; + private CXGraph _graph; + public CXGraphManager( - SourceManager manager, + SourceGenerator generator, string key, Target target, CXDoc document ) { - _manager = manager; + _generator = generator; _target = target; _document = document; _key = key; @@ -49,9 +54,11 @@ CXDoc document CXDesigner, InterpolationInfos ); + + _graph = CXGraph.Create(_document, this); } - public static CXGraphManager Create(SourceManager manager, string key, Target target) + public static CXGraphManager Create(SourceGenerator generator, string key, Target target) { var source = new CXSource( target.CXDesignerSpan, @@ -59,7 +66,7 @@ public static CXGraphManager Create(SourceManager manager, string key, Target ta target.Interpolations.Select(x => x.Span).ToArray() ); - return new CXGraphManager(manager, key, target, CXParser.Parse(source)); + return new CXGraphManager(generator, key, target, CXParser.Parse(source)); } public void OnUpdate(string key, Target target) @@ -75,17 +82,23 @@ public void OnUpdate(string key, Target target) * Regenerating * Caused mostly by interpolation types changing, the actual values don't matter since it doesn't change * out emitted code + * + * Some key things to note: + * A fast-path is possible for regenerating, if an interpolations content (source code) has changed, we + * can skip reparse and regeneration, and simply update any diagnostics' text spans. + * If an interpolations type has changed, we re-run the validator wrapping the interpolation, and regenerate + * our emitted source. */ - var newSource = GetCXWithoutInterpolations( + var newCXWithoutInterpolations = GetCXWithoutInterpolations( target.ArgumentExpressionSyntax.SpanStart, target.CXDesigner, target.Interpolations ); - if (newSource != _basicCXSource) + if (newCXWithoutInterpolations != _basicCXSource) { - // we're gonna need to reparse + // we're going to need to reparse, the underlying CX structure changed DoReparse(target); } @@ -103,18 +116,58 @@ private void DoReparse(Target target) target.Interpolations.Select(x => x.Span).ToArray() ); - _document!.ApplyChanges( + var changes = target + .SyntaxTree + .GetChanges(_target.SyntaxTree) + .Where(x => CXDesignerSpan.Contains(x.Span)) + .ToArray(); + + var result = _document!.ApplyChanges( source, - [ - ..target - .SyntaxTree - .GetChanges(_target.SyntaxTree) - .Where(x => CXDesignerSpan.Contains(x.Span)) - ] + changes + ); + + _graph.Update(_document, result.ReusedNodes); + } + + public RenderedInterceptor Render() + { + var diagnostics = new List( + _document + .Diagnostics + .Select(x => Diagnostic.Create( + Diagnostics.ParseError, + SyntaxTree.GetLocation(x.Span), + x.Message + ) + ) + ); + + if (diagnostics.Count > 0) + { + return new(InterceptLocation, string.Empty, [..diagnostics]); + } + + var context = new ComponentContext(_graph) {Diagnostics = diagnostics}; + + _graph.Validate(context); + + var source = context.HasErrors + ? string.Empty + : _graph.Render(context); + + return new( + this.InterceptLocation, + _graph.Render(), + [..diagnostics] ); } - private static string GetCXWithoutInterpolations(int offset, string cx, DesignerInterpolationInfo[] interpolations) + private static string GetCXWithoutInterpolations( + int offset, + string cx, + DesignerInterpolationInfo[] interpolations + ) { if (interpolations.Length is 0) return cx; diff --git a/src/Discord.Net.ComponentDesigner.Generator/Generator/RenderedInterceptor.cs b/src/Discord.Net.ComponentDesigner.Generator/Generator/RenderedInterceptor.cs new file mode 100644 index 0000000000..b34dc57b9d --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Generator/RenderedInterceptor.cs @@ -0,0 +1,27 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.ComponentDesignerGenerator; + +public readonly record struct RenderedInterceptor( + InterceptableLocation Location, + string Source, + ImmutableArray Diagnostics +) +{ + public bool Equals(RenderedInterceptor other) + => Location.Equals(other.Location) && Source == other.Source && Diagnostics.SequenceEqual(other.Diagnostics); + + public override int GetHashCode() + { + unchecked + { + var hashCode = Location.GetHashCode(); + hashCode = (hashCode * 397) ^ Source.GetHashCode(); + hashCode = (hashCode * 397) ^ Diagnostics.Aggregate(0, (a, b) => (a * 397) ^ b.GetHashCode()); + return hashCode; + } + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Generator/SourceManager.cs b/src/Discord.Net.ComponentDesigner.Generator/Generator/SourceManager.cs deleted file mode 100644 index 195b263a65..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Generator/SourceManager.cs +++ /dev/null @@ -1,277 +0,0 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Operations; -using Microsoft.CodeAnalysis.Text; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Threading; - -namespace Discord.ComponentDesignerGenerator; - -public sealed record Target( - InterceptableLocation InterceptLocation, - InvocationExpressionSyntax InvocationSyntax, - ExpressionSyntax ArgumentExpressionSyntax, - IOperation Operation, - Compilation Compilation, - string? ParentKey, - string CXDesigner, - TextSpan CXDesignerSpan, - DesignerInterpolationInfo[] Interpolations -) -{ - public SyntaxTree SyntaxTree => InvocationSyntax.SyntaxTree; -} - -public sealed record DesignerInterpolationInfo( - TextSpan Span, - ITypeSymbol? Symbol -); - -public sealed class SourceManager -{ - private readonly Dictionary _cache = []; - - public SourceManager(IncrementalGeneratorInitializationContext context) - { - var provider = context - .SyntaxProvider - .CreateSyntaxProvider( - IsComponentDesignerCall, - MapPossibleComponentDesignerCall - ) - .Collect(); - - context.RegisterSourceOutput( - provider - .Combine(provider.Select(GetKeysAndUpdateCachedEntries)) - .SelectMany(MapManagers), - Generate - ); - } - - private void Generate(SourceProductionContext arg1, CXGraphManager arg2) - { - } - - private IEnumerable MapManagers( - (ImmutableArray targets, ImmutableArray keys) tuple, - CancellationToken token - ) - { - var (targets, keys) = tuple; - - for (var i = 0; i < targets.Length; i++) - { - var target = targets[i]; - var key = keys[i]; - - if (target is null || key is null) continue; - - // TODO: handle key updates - - if (_cache.TryGetValue(key, out var manager)) - { - manager.OnUpdate(key, target); - } - else - { - manager = _cache[key] = CXGraphManager.Create( - this, - key, - target - ); - } - - yield return manager; - } - } - - private ImmutableArray GetKeysAndUpdateCachedEntries(ImmutableArray target, - CancellationToken token) - { - var result = new string?[target.Length]; - - var map = new Dictionary(); - var globalCount = 0; - - for (var i = 0; i < target.Length; i++) - { - var targetItem = target[i]; - - if (targetItem is null) continue; - - string key; - if (targetItem.ParentKey is null) - { - key = $":{globalCount++}"; - } - else - { - map.TryGetValue(targetItem.ParentKey, out var index); - - key = $"{targetItem.ParentKey}:{index}"; - map[targetItem.ParentKey] = index + 1; - } - - result[i] = key; - } - - foreach (var key in _cache.Keys.Except(result)) - { - if (key is not null) _cache.Remove(key); - } - - return [..result]; - } - - private static void OnTargetUpdated(Target? target, CancellationToken token) - { - if (target is null) return; - - //target.Compilation.SyntaxTrees - } - - - private static void ProcessTargetsUpdate(ImmutableArray targets, CancellationToken token) - { - foreach (var target in targets) - { - if (target is null) continue; - } - } - - - private static Target? MapPossibleComponentDesignerCall(GeneratorSyntaxContext context, CancellationToken token) - { - if ( - !TryGetValidDesignerCall( - out var operation, - out var invocationSyntax, - out var interceptLocation, - out var argumentSyntax - ) - ) return null; - - if ( - !TryGetCXDesigner( - argumentSyntax, - context.SemanticModel, - out var cxDesigner, - out var span, - out var interpolationInfos - ) - ) return null; - - - return new Target( - interceptLocation, - invocationSyntax, - argumentSyntax, - operation, - context.SemanticModel.Compilation, - context.SemanticModel - .GetEnclosingSymbol(invocationSyntax.SpanStart, token) - ?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), - cxDesigner, - span, - interpolationInfos - ); - - static bool TryGetCXDesigner( - ExpressionSyntax expression, - SemanticModel semanticModel, - out string content, - out TextSpan span, - out DesignerInterpolationInfo[] interpolations - ) - { - switch (expression) - { - case LiteralExpressionSyntax {Token.Value: string literalContent} literal: - content = literalContent; - interpolations = []; - span = literal.Token.Span; - return true; - - case InterpolatedStringExpressionSyntax interpolated: - content = interpolated.Contents.ToString(); - interpolations = interpolated.Contents - .OfType() - .Select(x => new DesignerInterpolationInfo( - x.FullSpan, - semanticModel.GetTypeInfo(x.Expression).Type - )) - .ToArray(); - span = interpolated.Contents.Span; - return true; - default: - content = string.Empty; - span = default; - interpolations = []; - return false; - } - } - - bool TryGetValidDesignerCall( - out IOperation operation, - out InvocationExpressionSyntax invocationSyntax, - out InterceptableLocation interceptLocation, - out ExpressionSyntax argumentExpressionSyntax - ) - { - operation = context.SemanticModel.GetOperation(context.Node, token)!; - interceptLocation = null!; - argumentExpressionSyntax = null!; - invocationSyntax = null!; - - checkOperation: - switch (operation) - { - case IInvalidOperation invalid: - operation = invalid.ChildOperations.OfType().FirstOrDefault()!; - goto checkOperation; - case IInvocationOperation invocation: - if ( - invocation - .TargetMethod - .ContainingType - .ToDisplayString() - is "Discord.ComponentDesigner" - ) break; - goto default; - - default: return false; - } - - if (context.Node is not InvocationExpressionSyntax syntax) return false; - - invocationSyntax = syntax; - - if (context.SemanticModel.GetInterceptableLocation(invocationSyntax) is not { } location) - return false; - - interceptLocation = location; - - if (invocationSyntax.ArgumentList.Arguments.Count is not 1) return false; - - argumentExpressionSyntax = invocationSyntax.ArgumentList.Arguments[0].Expression; - - return true; - } - } - - private static bool IsComponentDesignerCall(SyntaxNode node, CancellationToken token) - => node is InvocationExpressionSyntax - { - Expression: MemberAccessExpressionSyntax - { - Name: {Identifier.Value: "Create" or "cx"} - } or IdentifierNameSyntax - { - Identifier.ValueText: "cx" - } - }; -} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentContext.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentContext.cs new file mode 100644 index 0000000000..0aa156af8c --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentContext.cs @@ -0,0 +1,40 @@ +using Discord.ComponentDesignerGenerator.Parser; +using Microsoft.CodeAnalysis; +using System.Collections.Generic; +using System.Linq; + +namespace Discord.ComponentDesignerGenerator.Nodes; + +public sealed class ComponentContext +{ + public KnownTypes KnownTypes => Compilation.GetKnownTypes(); + public Compilation Compilation => _graph.Manager.Compilation; + + public bool HasErrors => Diagnostics.Any(x => x.Severity is DiagnosticSeverity.Error); + + public List Diagnostics { get; init; } = []; + + private readonly CXGraph _graph; + + public ComponentContext(CXGraph graph) + { + _graph = graph; + } + + public Location GetLocation(ICXNode node) + => _graph.Manager.SyntaxTree.GetLocation(node.Span); + + public void AddDiagnostic(DiagnosticDescriptor descriptor, ICXNode node, params object?[]? args) + => AddDiagnostic(Diagnostic.Create(descriptor, GetLocation(node), args)); + + + public DesignerInterpolationInfo GetInterpolationInfo(CXValue.Interpolation interpolation) + => GetInterpolationInfo(interpolation.InterpolationIndex); + + public DesignerInterpolationInfo GetInterpolationInfo(int index) => _graph.Manager.InterpolationInfos[index]; + + public void AddDiagnostic(Diagnostic diagnostics) + { + Diagnostics.Add(diagnostics); + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs index c0ca4b452d..020b472663 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs @@ -1,243 +1,67 @@ -using Discord.ComponentDesignerGenerator.Parser; -using Microsoft.CodeAnalysis; +using Discord.ComponentDesignerGenerator.Parser; using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; namespace Discord.ComponentDesignerGenerator.Nodes; -public abstract class ComponentNode +public abstract class ComponentNode : ComponentNode where TState : ComponentState, IEquatable, new() { - 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", ValueParsers.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); + public abstract string Render(TState state); - case "stringselect": - return new StringSelectComponentNode(xml, context); + public virtual void UpdateState(ref TState state) { } - case "textinput": - return new TextInputComponentNode(xml, context); + public sealed override void UpdateState(ref ComponentState state) + => UpdateState(ref Unsafe.As(ref state)); - case "userselect": - return new UserSelectComponentNode(xml, context); + public override ComponentState? Create(ICXNode source, List children) + => new TState() {Source = source}; - case "roleselect": - return new RoleSelectComponentNode(xml, context); + public sealed override string Render(ComponentState state, ComponentContext context) + => Render((TState)state); - case "mentionableselect": - return new MentionableSelectComponentNode(xml, context); + public virtual void Validate(TState state, ComponentContext 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; + public sealed override void Validate(ComponentState state, ComponentContext context) + => Validate((TState)state, context); +} - 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"; +public abstract class ComponentNode +{ + public abstract string Name { get; } + public virtual IReadOnlyList Aliases { get; } = []; - goto default; + public virtual bool HasChildren => false; - default: - if (TryBindCustomNode() is { } customNode) return customNode; + public virtual IReadOnlyList Properties { get; } = []; - context.ReportDiagnostic( - Diagnostics.UnknownComponentType, - context.GetLocation(xml), - xml.Name.Value - ); - return null; - } + public virtual void Validate(ComponentState state, ComponentContext context) { } - ComponentNode? TryBindCustomNode() - { - // TODO: Disabled, for now - return null; + public abstract string Render(ComponentState state, ComponentContext context); - var symbol = context - .LookupNode(xml.Name.Value) - .OfType() - .FirstOrDefault(IsValidUserNode); + public virtual void UpdateState(ref ComponentState state) { } - if (symbol is null) return null; + public virtual ComponentState? Create(ICXNode source, List children) + => new() {Source = source}; - 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) - ); - } + private static readonly Dictionary _nodes; - public virtual void ReportValidationErrors() + static ComponentNode() { - 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); - } + _nodes = typeof(ComponentNode) + .Assembly + .GetTypes() + .Where(x => !x.IsAbstract && typeof(ComponentNode).IsAssignableFrom(x)) + .Select(x => (ComponentNode)Activator.CreateInstance(x)!) + .SelectMany(x => x + .Aliases + .Prepend(x.Name) + .Select(y => new KeyValuePair(y, x))) + .ToDictionary(x => x.Key, x => x.Value); } - public abstract string Render(); - - protected ComponentProperty MapProperty( - string name, - bool optional = false, - ValueParseDelegate? parser = null, - IReadOnlyList>? validators = null, - Optional defaultValue = default, - ITypeSymbol? apiType = null, - params IReadOnlyList aliases - ) => MapProperty( - name, - parser ?? ValueParsers.ParseStringProperty, - optional, - validators, - defaultValue, - apiType, - aliases - ); - - protected ComponentProperty MapProperty( - string name, - ValueParseDelegate parser, - bool optional = false, - IReadOnlyList>? validators = null, - Optional defaultValue = default, - ITypeSymbol? apiType = null, - params IReadOnlyList aliases - ) - { - var property = new ComponentProperty( - this, - name, - GetAttribute(name, aliases), - aliases, - optional, - validators ?? [], - parser, - defaultValue, - apiType - ); - - _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; - } + public static bool TryGetNode(string name, out ComponentNode node) + => _nodes.TryGetValue(name, out node); } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNodeContext.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNodeContext.cs deleted file mode 100644 index bbb2a2c695..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNodeContext.cs +++ /dev/null @@ -1,102 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Text; -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; - -namespace Discord.ComponentDesignerGenerator.Nodes; - -public sealed class ComponentNodeContext -{ - public Compilation Compilation => KnownTypes.Compilation; - - public bool HasErrors => _document.HasErrors || Diagnostics.Any(x => x.Severity is DiagnosticSeverity.Error); - - 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/ComponentProperty.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs index 6568bc3b35..eca70838b7 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs @@ -1,252 +1,36 @@ using Discord.ComponentDesignerGenerator.Parser; -using Microsoft.CodeAnalysis; -using System; using System.Collections.Generic; -using System.Linq; -using System.Text; namespace Discord.ComponentDesignerGenerator.Nodes; -public sealed record ComponentProperty( - ComponentNode Node, - string Name, - CXmlAttribute? Attribute, - IReadOnlyList Aliases, - bool IsOptional, - IReadOnlyList> Validators, - ValueParseDelegate Parser, - Optional DefaultValue, - ITypeSymbol? ApiType = null -) : IComponentProperty -{ - public ComponentNodeContext Context => Node.Context; - 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 value) => Serialize(value), - ComponentPropertyValue.Interpolated(_, var index) => $"designer.GetValue<{typeof(T)}>({index})", - ComponentPropertyValue.MultiPartInterpolation(_, var multipart) => BuildMultipart(multipart), - ComponentPropertyValue.InlineCode(_, var code) => code, - _ => 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; - } - } +public delegate void PropertyValidator(ComponentContext context, ComponentPropertyValue value); +public delegate string PropertyRenderer(ComponentContext context, ComponentPropertyValue value); - 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) +public sealed class ComponentProperty +{ + public string Name { get; } + public IReadOnlyList Aliases { get; } + + public bool IsOptional { get; } + public string DotnetPropertyName { get; } + public PropertyRenderer Renderer { get; } + + public IReadOnlyList Validators { get; } + + public ComponentProperty( + string name, + bool isOptional = false, + IEnumerable? aliases = null, + IEnumerable? validators = null, + PropertyRenderer? renderer = null, + string? dotnetPropertyName = null + ) { - if (!IsOptional && !IsSpecified) - { - context.ReportDiagnostic( - Diagnostics.MissingRequiredProperty, - context.GetLocation(Node.Element), - Node.FriendlyName, - Name - ); - } - - foreach (var validator in Validators) - validator(Node, this, context); + Name = name; + Aliases = [..aliases ?? []]; + IsOptional = isOptional; + DotnetPropertyName = dotnetPropertyName ?? name; + Renderer = renderer ?? Renderers.CreateDefault(this); + Validators = [..validators ?? []]; } - - public ComponentPropertyValue DangerousCreateCode( - string code - ) => new ComponentPropertyValue.InlineCode(this, code); - - 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/ComponentPropertyValidator.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValidator.cs deleted file mode 100644 index d555bfe7dd..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValidator.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Discord.ComponentDesignerGenerator.Nodes; - -public delegate void ComponentPropertyValidator( - ComponentNode node, - ComponentProperty property, - ComponentNodeContext context -); diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs index 3650719921..ed0f58124f 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs @@ -1,20 +1,19 @@ using Discord.ComponentDesignerGenerator.Parser; +using Microsoft.CodeAnalysis; +using System.Collections.Generic; namespace Discord.ComponentDesignerGenerator.Nodes; -public abstract record ComponentPropertyValue(ComponentProperty Property) +public sealed record ComponentPropertyValue( + ComponentProperty Property, + CXAttribute? Attribute +) { - public sealed record Serializable(ComponentProperty Property, T Value) : ComponentPropertyValue(Property); + public CXValue? Value => Attribute?.Value; - public sealed record InlineCode(ComponentProperty Property, string Code) : ComponentPropertyValue(Property); + public bool IsSpecified => Attribute is not null; - public sealed record Interpolated( - ComponentProperty Property, - int InterpolationId - ) : ComponentPropertyValue(Property); + private readonly List _diagnostics = []; - public sealed record MultiPartInterpolation( - ComponentProperty Property, - CXmlValue.Multipart Multipart - ) : ComponentPropertyValue(Property); + public void AddDiagnostic(Diagnostic diagnostic) => _diagnostics.Add(diagnostic); } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentState.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentState.cs new file mode 100644 index 0000000000..ab73b0fbe9 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentState.cs @@ -0,0 +1,74 @@ +using Discord.ComponentDesignerGenerator.Parser; +using Microsoft.CodeAnalysis; +using System.Collections.Generic; +using System.Linq; + +namespace Discord.ComponentDesignerGenerator.Nodes; + +public class ComponentState +{ + public CXGraph.Node? OwningNode { get; set; } + public required ICXNode Source { get; init; } + + public bool HasChildren => OwningNode?.Children.Count > 0; + + public bool IsElement => Source is CXElement; + + private readonly Dictionary _properties = []; + + public ComponentPropertyValue? GetProperty(ComponentProperty property) + { + if (!IsElement) return null; + + if (_properties.TryGetValue(property, out var value)) return value; + + var attribute = ((CXElement)Source) + .Attributes + .FirstOrDefault(x => + property.Name == x.Identifier.Value || property.Aliases.Contains(x.Identifier.Value) + ); + + ComponentPropertyValue? propertyValue; + + if (attribute is null) + { + propertyValue = new(property, attribute); + } + else if (OwningNode is null || !OwningNode.Graph.PropertyCacheMap.TryGetValue(attribute, out propertyValue)) + { + propertyValue = new(property, attribute); + + if (OwningNode is not null) OwningNode.Graph.PropertyCacheMap[attribute] = propertyValue; + } + + return _properties[property] = propertyValue; + } + + public string RenderProperties(ComponentNode node, ComponentContext context) + { + // TODO: correct handling? + if (Source is not CXElement element) return string.Empty; + + var values = new List(); + + foreach (var property in node.Properties) + { + var propertyValue = GetProperty(property); + + if (propertyValue?.Value is not null) + values.Add($"{property.DotnetPropertyName}: {property.Renderer(context, propertyValue)}"); + } + + return string.Join(",\n", values); + } + + public string RenderChildren(ComponentContext context) + { + if (OwningNode is null || !HasChildren) return string.Empty; + + return string.Join( + ",\n", + OwningNode.Children.Select(x => x.Render(context)) + ); + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs deleted file mode 100644 index 922e4c707c..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs +++ /dev/null @@ -1,203 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using System.Collections.Generic; -using System.Linq; -using Microsoft.CodeAnalysis; - -namespace Discord.ComponentDesignerGenerator.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 deleted file mode 100644 index cd678b4a13..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/BaseSelectComponentNode.cs +++ /dev/null @@ -1,115 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using System.Collections.Generic; - -namespace Discord.ComponentDesignerGenerator.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: ValueParsers.ParseIntProperty, - validators: - [ - Validators.Bounds( - Constants.SELECT_MIN_VALUES, - Constants.SELECT_MAX_VALUES - ) - ], - aliases: ["min"] - ); - - MaxValues = MapProperty( - "maxValues", - optional: true, - parser: ValueParsers.ParseIntProperty, - validators: - [ - Validators.Bounds( - Constants.SELECT_MIN_VALUES + 1, - Constants.SELECT_MAX_VALUES - ) - ], - aliases: ["max"] - ); - - IsDisabled = MapProperty("disabled", optional: true, parser: ValueParsers.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 index a990b1cc65..9835f24455 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs @@ -1,179 +1,92 @@ using Discord.ComponentDesignerGenerator.Parser; -using System; -using System.Xml; +using Microsoft.CodeAnalysis; +using System.Collections.Generic; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesignerGenerator.Nodes; +namespace Discord.ComponentDesignerGenerator.Nodes.Components; 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; } + public const string BUTTON_STYLE_ENUM = "Discord.ButtonStyle"; + public override string Name => "button"; - private readonly CXmlValue? _buttonLabelNode; + public override IReadOnlyList Properties { get; } - public ButtonComponentNode(CXmlElement xml, ComponentNodeContext context) : base(xml, context) - { - Style = MapProperty( - "style", - ValueParsers.ParseEnumProperty, - defaultValue: ButtonStyle.Primary, - optional: true, - apiType: context.KnownTypes.ButtonStyleEnumType - ); - - Label = MapProperty( - "label", - optional: true, - validators: [Validators.LengthBounds(upper: Constants.BUTTON_MAX_LABEL_LENGTH)] - ); - - Emoji = MapProperty("emoji", optional: true, parser: ValueParsers.ParseEmojiProperty); - - CustomId = MapProperty( - "customId", - optional: true, - validators: [Validators.LengthBounds(upper: Constants.CUSTOM_ID_MAX_LENGTH)] - ); - - SkuId = MapProperty("skuId", ValueParsers.ParseSnowflakeProperty, optional: true, aliases: "sku"); + public ComponentProperty Style { get; } + public ComponentProperty Label { get; } + public ComponentProperty Emoji { get; } + public ComponentProperty CustomId { get; } + public ComponentProperty SkuId { get; } + public ComponentProperty Url { get; } - Url = MapProperty( - "url", - optional: true, - validators: [Validators.LengthBounds(upper: Constants.BUTTON_URL_MAX_LENGTH)] - ); - - IsDisabled = MapProperty("disabled", ValueParsers.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 ButtonComponentNode() + { + Properties = + [ + Style = new ComponentProperty( + "style", + isOptional: true, + validators: [Validators.EnumVariant(BUTTON_STYLE_ENUM)], + renderer: Renderers.RenderEnum(BUTTON_STYLE_ENUM) + ), + Label = new ComponentProperty( + "label", + isOptional: true, + validators: [Validators.Range(upper: Constants.BUTTON_MAX_LABEL_LENGTH)], + renderer: Renderers.String + ), + Emoji = new ComponentProperty( + "emoji", + isOptional: true, + aliases: ["emote"], + validators: [Validators.Emote] + ), + CustomId = new( + "customId", + isOptional: true, + validators: [Validators.Range(upper: Constants.CUSTOM_ID_MAX_LENGTH)] + ), + SkuId = new( + "skuId", + aliases: ["sku"], + isOptional: true, + validators: [Validators.Snowflake] + ), + Url = new( + "url", + isOptional: true, + validators: [Validators.Range(upper: Constants.BUTTON_URL_MAX_LENGTH)] + ) + ]; } - public override void ReportValidationErrors() + public override void Validate(ComponentState state, ComponentContext context) { - base.ReportValidationErrors(); - - if (Label.IsSpecified && _buttonLabelNode is not null) + if (state.GetProperty(Url)!.IsSpecified && state.GetProperty(CustomId)!.IsSpecified) { - // report on both - Context.ReportDiagnostic( - Diagnostics.ButtonDuplicateLabels, - Context.GetLocation(Label.Attribute!.Span) - ); - - Context.ReportDiagnostic( - Diagnostics.ButtonDuplicateLabels, - Context.GetLocation(_buttonLabelNode) + context.AddDiagnostic( + Diagnostic.Create( + Diagnostics.ButtonCustomIdUrlConflict, + context.GetLocation(state.Source) + ) ); } - if (CustomId.IsSpecified && Url.IsSpecified) + if (!state.GetProperty(Url)!.IsSpecified && !state.GetProperty(CustomId)!.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) + context.AddDiagnostic( + Diagnostic.Create( + Diagnostics.ButtonCustomIdOrUrlMissing, + context.GetLocation(state.Source) + ) ); } - - 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() + public override string Render(ComponentState state, ComponentContext context) => $""" - new {Context.KnownTypes.ButtonBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}( - label: {RenderLabel().WithNewlinePadding(4)}, - customId: {CustomId.ToString().WithNewlinePadding(4)}, - style: {Context.KnownTypes.ButtonStyleEnumType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.{Style}, - url: {Url.ToString().WithNewlinePadding(4)}, - emote: {Emoji.ToString().WithNewlinePadding(4)}, - isDisabled: {IsDisabled.ToString().WithNewlinePadding(4)}, - skuId: {SkuId.ToString().WithNewlinePadding(4)}, - id: {Id} + new {context.KnownTypes.ButtonBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}( + {state.RenderProperties(this, context).WithNewlinePadding(4)} ) """; } - -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 deleted file mode 100644 index 0d62c54bb6..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ChannelSelectComponentNode.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using System.Collections.Generic; -using System.Linq; -using System.Xml; -using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; - -namespace Discord.ComponentDesignerGenerator.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 deleted file mode 100644 index 0b7c4b77a3..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs +++ /dev/null @@ -1,197 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using System.Collections.Generic; -using System.Linq; -using Microsoft.CodeAnalysis; - -namespace Discord.ComponentDesignerGenerator.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, - parser: ValueParsers.ParseColorProperty, - aliases: ["color"] - ); - - IsSpoiler = MapProperty( - "spoiler", - ValueParsers.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 deleted file mode 100644 index 23c109bd17..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/CustomComponent.cs +++ /dev/null @@ -1,101 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using Microsoft.CodeAnalysis; - -namespace Discord.ComponentDesignerGenerator.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 deleted file mode 100644 index 6296382640..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; - -namespace Discord.ComponentDesignerGenerator.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", ValueParsers.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 deleted file mode 100644 index 402437ada3..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterpolatedComponentNode.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using Microsoft.CodeAnalysis; - -namespace Discord.ComponentDesignerGenerator.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 deleted file mode 100644 index 512ff57e5d..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs +++ /dev/null @@ -1,122 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using System.Collections.Generic; -using System.Linq; - -namespace Discord.ComponentDesignerGenerator.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 deleted file mode 100644 index e73fbbdacd..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs +++ /dev/null @@ -1,120 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using System.Collections.Generic; -using System.Linq; -using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; - -namespace Discord.ComponentDesignerGenerator.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", ValueParsers.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 deleted file mode 100644 index 8eb0f2fa37..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MentionableSelectComponentNode.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; - -namespace Discord.ComponentDesignerGenerator.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 deleted file mode 100644 index 0cc7b491e0..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/RoleSelectComponentNode.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; - -namespace Discord.ComponentDesignerGenerator.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/RowComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/RowComponentNode.cs new file mode 100644 index 0000000000..7e7e5211ff --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/RowComponentNode.cs @@ -0,0 +1,26 @@ +using Discord.ComponentDesignerGenerator.Parser; +using System.Collections.Generic; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.ComponentDesignerGenerator.Nodes.Components; + +public sealed class RowComponentNode : ComponentNode +{ + public override string Name => "row"; + + public override ComponentState? Create(ICXNode source, List children) + { + if (source is not CXElement element) return null; + + children.AddRange(element.Children); + + return base.Create(source, children); + } + + public override string Render(ComponentState state, ComponentContext context) + => $""" + new {context.KnownTypes.ActionRowBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}( + {state.RenderChildren(context).WithNewlinePadding(4)} + ) + """; +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentNode.cs deleted file mode 100644 index 10b233eea1..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentNode.cs +++ /dev/null @@ -1,244 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using System.Collections.Generic; -using System.Linq; -using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; - -namespace Discord.ComponentDesignerGenerator.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 deleted file mode 100644 index c348877a74..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectDefaultValue.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; - -namespace Discord.ComponentDesignerGenerator.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", - ValueParsers.ParseSnowflakeProperty - ); - - Type = MapProperty( - "type", - ValueParsers.ParseEnumProperty, - optional: true, - apiType: context.KnownTypes.SelectDefaultValueTypeEnumType - ); - } - - 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/SelectOption.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectOption.cs deleted file mode 100644 index cc5bfa3d56..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectOption.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; - -namespace Discord.ComponentDesignerGenerator.Nodes; - -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: ValueParsers.ParseEmojiProperty - ); - - IsDefault = MapProperty( - "default", - ValueParsers.ParseBooleanProperty, - optional: true - ); - } - - public override string Render() - { - throw new System.NotImplementedException(); - } -} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs deleted file mode 100644 index 81ad3dcda0..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using System.Xml; -using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; - -namespace Discord.ComponentDesignerGenerator.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", - ValueParsers.ParseBooleanProperty, - optional: true, - defaultValue: true - ); - - Spacing = MapProperty( - "spacing", - ValueParsers.ParseEnumProperty, - optional: true, - defaultValue: SeparatorSpacing.Small, - apiType: context.KnownTypes.SeparatorSpacingSizeType - ); - } - - 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 deleted file mode 100644 index 587f9b86dd..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/StringSelectComponentNode.cs +++ /dev/null @@ -1,71 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using System.Collections.Generic; -using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; - -namespace Discord.ComponentDesignerGenerator.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 - ) - """; -} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs deleted file mode 100644 index 1498618416..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; - -namespace Discord.ComponentDesignerGenerator.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 deleted file mode 100644 index 40136000d7..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; - -namespace Discord.ComponentDesignerGenerator.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 deleted file mode 100644 index 7a285b399c..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; - -namespace Discord.ComponentDesignerGenerator.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", ValueParsers.ParseBooleanProperty, optional: true); - } - - public override string Render() - => $""" - new {Context.KnownTypes.ThumbnailBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}( - media: {Context.KnownTypes.UnfurledMediaItemPropertiesType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({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 deleted file mode 100644 index cb60311211..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/UserSelectComponentNode.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using System.Collections.Generic; -using System.Linq; -using System.Xml; -using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; - -namespace Discord.ComponentDesignerGenerator.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 deleted file mode 100644 index be15d84b59..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/IComponentProperty.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using System.Collections.Generic; - -namespace Discord.ComponentDesignerGenerator.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 deleted file mode 100644 index 1da746faaf..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/NodeKind.cs +++ /dev/null @@ -1,88 +0,0 @@ -using Microsoft.CodeAnalysis; -using System; - -namespace Discord.ComponentDesignerGenerator.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/Renderers/Renderers.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs new file mode 100644 index 0000000000..b327200ca6 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs @@ -0,0 +1,189 @@ +using Discord.ComponentDesignerGenerator.Parser; +using Microsoft.CodeAnalysis; +using System; +using System.Linq; +using System.Text; + +namespace Discord.ComponentDesignerGenerator.Nodes; + +public static class Renderers +{ + public static PropertyRenderer CreateDefault(ComponentProperty property) + { + return (context, value) => + { + return string.Empty; + }; + } + + public static string String(ComponentContext context, ComponentPropertyValue propertyValue) + { + switch (propertyValue.Value) + { + default: + case null or CXValue.Invalid: return "string.Empty"; + + case CXValue.StringLiteral literal: + { + var sb = new StringBuilder(); + //var value = scalar.Value; + + var parts = literal.Tokens + .Where(x => x.Kind is CXTokenKind.Text) + .Select(x => x.Value) + .ToArray(); + + var quoteCount = parts.Select(x => x.Count(x => x is '"')).Max() + 1; + + var dollars = new string( + '$', + parts.Select(GetInterpolationDollarRequirement).Max() + + ( + literal.Tokens.Any(x => x.Kind is CXTokenKind.Interpolation) + ? 1 + : 0 + ) + ); + + var startInterpolation = dollars.Length > 0 + ? new string('{', dollars.Length) + : string.Empty; + + var endInterpolation = dollars.Length > 0 + ? new string('}', dollars.Length) + : string.Empty; + + var isMultiline = parts.Any(x => x.Contains('\n')); + + if (isMultiline) + { + sb.AppendLine(); + quoteCount = Math.Max(quoteCount, 3); + } + + var quotes = new string('"', quoteCount); + + sb.Append(dollars).Append(quotes); + + if (isMultiline) sb.AppendLine(); + + foreach (var token in literal.Tokens) + { + switch (token.Kind) + { + case CXTokenKind.Text: + sb.Append(token.Value); + break; + case CXTokenKind.Interpolation: + var index = Array.IndexOf(literal.Document.InterpolationTokens, token); + + // TODO: handle better + if (index is -1) throw new InvalidOperationException(); + + sb.Append(startInterpolation).Append($"designer.GetValueAsString({index})") + .Append(endInterpolation); + break; + + default: continue; + } + } + + if (isMultiline) sb.AppendLine(); + sb.Append(quotes); + + return sb.ToString(); + } + case CXValue.Scalar scalar: + { + var sb = new StringBuilder(); + var value = scalar.Value; + + var quoteCount = value.Count(x => x is '"') + 1; + + var isMultiline = value.Contains('\n'); + + if (isMultiline) + { + sb.AppendLine(); + quoteCount = Math.Max(quoteCount, 3); + } + + var quotes = new string('"', quoteCount); + + sb.Append(quotes); + + if (isMultiline) sb.AppendLine(); + + sb.Append(value); + + if (isMultiline) sb.AppendLine(); + sb.Append(quotes); + + return sb.ToString(); + } + } + + static int GetInterpolationDollarRequirement(string part) + { + var result = 0; + + var count = 0; + char? last = null; + + foreach (var ch in part) + { + if (ch is '{' or '}') + { + if (last is null) + { + last = ch; + count = 1; + continue; + } + + if (last == ch) + { + count++; + continue; + } + } + + if (count > 0) + { + result = Math.Max(result, count); + last = null; + count = 0; + } + } + + return result; + } + } + + + public static PropertyRenderer RenderEnum(string fullyQualifiedName) + { + ITypeSymbol? symbol = null; + IFieldSymbol[]? variants = null; + + return (context, value) => + { + if (symbol is null || variants is null) + { + symbol = context.Compilation.GetTypeByMetadataName(fullyQualifiedName); + + if (symbol is null) throw new InvalidOperationException($"Unknown type '{fullyQualifiedName}'"); + + if (symbol.TypeKind is not TypeKind.Enum) + throw new InvalidOperationException($"'{symbol}' is not an enum type."); + + variants = symbol + .GetMembers() + .OfType() + .ToArray(); + } + + return string.Empty; + }; + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.Numeric.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.Numeric.cs deleted file mode 100644 index fdd0252600..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.Numeric.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace Discord.ComponentDesignerGenerator.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 deleted file mode 100644 index 0fd7be4b1c..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.StringLength.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace Discord.ComponentDesignerGenerator.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/Nodes/Validators/Validators.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.cs new file mode 100644 index 0000000000..6684c8f2a6 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.cs @@ -0,0 +1,151 @@ +using Discord.ComponentDesignerGenerator.Parser; +using Microsoft.CodeAnalysis; +using System; +using System.Diagnostics; +using System.Linq; + +namespace Discord.ComponentDesignerGenerator.Nodes; + +public static class Validators +{ + public static void Snowflake(ComponentContext context, ComponentPropertyValue propertyValue) + { + switch (propertyValue.Value) + { + case null or CXValue.Invalid: return; + + case CXValue.Scalar scalar: + if (!ulong.TryParse(scalar.Value, out _)) + { + context.AddDiagnostic( + Diagnostics.TypeMismatch, + scalar, + scalar.Value, + "Snowflake" + ); + } + + return; + case CXValue.Interpolation interpolation: + var symbol = context.GetInterpolationInfo(interpolation).Symbol; + + if ( + symbol?.SpecialType is not SpecialType.System_UInt64 + ) + { + context.AddDiagnostic( + Diagnostics.TypeMismatch, + interpolation, + symbol, + "Snowflake" + ); + } + + return; + } + } + + public static void Emote(ComponentContext context, ComponentPropertyValue propertyValue) + { + switch (propertyValue.Value) + { + case null or CXValue.Invalid: return; + + case CXValue.Scalar scalar: + + return; + } + } + + public static PropertyValidator Range(int? lower = null, int? upper = null) + { + Debug.Assert(lower.HasValue || upper.HasValue); + + var bounds = (lower, upper) switch + { + (not null, null) => $"at least {lower}", + (null, not null) => $"at most {lower}", + (not null, not null) => $"between {lower} and {upper}", + _ => string.Empty + }; + + return (context, propertyValue) => + { + switch (propertyValue.Value) + { + case null or CXValue.Invalid: return; + + case CXValue.Scalar {Value.Length: { } length}: + if ( + length > upper || length < lower + ) + { + context.AddDiagnostic(Diagnostics.OutOfRange, propertyValue.Value, propertyValue.Property.Name, bounds); + } + + return; + } + }; + } + + public static PropertyValidator EnumVariant(string fullyQualifiedName) + { + ITypeSymbol? symbol = null; + IFieldSymbol[]? variants = null; + + return (context, propertyValue) => + { + if (symbol is null || variants is null) + { + symbol = context.Compilation.GetTypeByMetadataName(fullyQualifiedName); + + if (symbol is null) throw new InvalidOperationException($"Unknown type '{fullyQualifiedName}'"); + + if (symbol.TypeKind is not TypeKind.Enum) + throw new InvalidOperationException($"'{symbol}' is not an enum type."); + + variants = symbol + .GetMembers() + .OfType() + .ToArray(); + } + + switch (propertyValue.Value) + { + case null or CXValue.Invalid: return; + + case CXValue.Scalar scalar: + if (variants.All(x => + !string.Equals(x.Name, scalar.Value, StringComparison.InvariantCultureIgnoreCase))) + { + context.AddDiagnostic( + Diagnostics.InvalidEnumVariant, + scalar, + scalar.Value, + string.Join(", ", variants.Select(x => x.Name)) + ); + } + + return; + case CXValue.Interpolation interpolation: + // verify the value is the correct type + var interpolationInfo = context.GetInterpolationInfo(interpolation); + + if ( + interpolationInfo.Symbol is not null && + !symbol.Equals(interpolationInfo.Symbol, SymbolEqualityComparer.Default) + ) + { + context.AddDiagnostic( + Diagnostics.TypeMismatch, + interpolation, + interpolationInfo.Symbol, + symbol + ); + } + + return; + } + }; + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueCodeGenerator.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueCodeGenerator.cs deleted file mode 100644 index c846d25ddc..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueCodeGenerator.cs +++ /dev/null @@ -1,171 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; - -namespace Discord.ComponentDesignerGenerator.Nodes; - -public static class ValueCodeGenerator -{ - public static string? BuildValue(CXmlValue? value, ComponentNodeContext context) - { - switch (value) - { - case CXmlValue.Invalid or null: return null; - - case CXmlValue.Interpolation interpolation: - var type = context.Interpolations[interpolation.InterpolationIndex].Type; - return $"designer.GetValue<{type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}>({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']); - } -} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParseDelegate.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParseDelegate.cs deleted file mode 100644 index 81f9646ce2..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParseDelegate.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Discord.ComponentDesignerGenerator.Nodes; - -public delegate ComponentPropertyValue? ValueParseDelegate(ComponentProperty property); diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Bool.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Bool.cs deleted file mode 100644 index 2406b0b887..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Bool.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using Microsoft.CodeAnalysis; -using System; - -namespace Discord.ComponentDesignerGenerator.Nodes; - -partial class ValueParsers -{ - public static 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 or CXmlValue.Invalid: return null; - - case CXmlValue.Interpolation interpolation: - return ValidateInterpolationType(property, interpolation, SpecialType.System_Boolean); - - // multi-parts are strings - case CXmlValue.Multipart multipart: - property.Context.ReportDiagnostic( - Diagnostics.PropertyMismatch, - property.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") - { - property.Context.ReportDiagnostic( - Diagnostics.PropertyMismatch, - property.Context.GetLocation(scalar), - property.Name, - nameof(Boolean), - typeof(string) - ); - return null; - } - - return property.CreateValue(str is "true"); - default: - throw new ArgumentOutOfRangeException(); - } - } -} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Color.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Color.cs deleted file mode 100644 index d32b345db1..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Color.cs +++ /dev/null @@ -1,68 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using Microsoft.CodeAnalysis; -using System; -using System.Linq; - -namespace Discord.ComponentDesignerGenerator.Nodes; - -partial class ValueParsers -{ - public static ComponentPropertyValue? ParseColorProperty(ComponentProperty property) - { - var colorTypeName = - property.Context.KnownTypes.ColorType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - - switch (property.Value) - { - case null or CXmlValue.Invalid: return null; - - case CXmlValue.Scalar scalar: - // check for field name first - var known = property.Context.KnownTypes.ColorType! - .GetMembers() - .OfType() - .FirstOrDefault(x => x.Name == scalar.Value); - - if (known is not null) - return property.DangerousCreateCode( - $"{colorTypeName}.{known.Name}" - ); - - return CreateParsedColor(ValueCodeGenerator.BuildValue(scalar, property.Context)); - - case CXmlValue.Interpolation interpolation: - var interpolationInfo = property.Context.Interpolations[interpolation.InterpolationIndex]; - - if ( - interpolationInfo.Type.Equals( - property.Context.KnownTypes.ColorType, - SymbolEqualityComparer.Default - ) - ) - { - return property.CreateValue(in interpolationInfo); - } - - if (interpolationInfo.Type.SpecialType is SpecialType.System_String) - { - return CreateParsedColor($"designer.GetValueAsString({interpolationInfo.Id})"); - } - - property.Context.ReportDiagnostic( - Diagnostics.PropertyMismatch, - property.Context.GetLocation(interpolation), - property.Name, - property.Context.KnownTypes.ColorType.ToDisplayString(), - interpolationInfo.Type.ToDisplayString() - ); - return null; - case CXmlValue.Multipart multipart: - return CreateParsedColor(ValueCodeGenerator.BuildValue(multipart, property.Context)); - - default: throw new ArgumentOutOfRangeException(); - } - - ComponentPropertyValue? CreateParsedColor(string? value) - => value is null ? null : property.DangerousCreateCode($"{colorTypeName}.Parse({value})"); - } -} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Emoji.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Emoji.cs deleted file mode 100644 index 0aeb40c4a8..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Emoji.cs +++ /dev/null @@ -1,72 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using Microsoft.CodeAnalysis; -using System; -using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; - -namespace Discord.ComponentDesignerGenerator.Nodes; - -partial class ValueParsers -{ - public static ComponentPropertyValue? ParseEmojiProperty(ComponentProperty property) - { - switch (property.Value) - { - case null or CXmlValue.Invalid: return null; - - case CXmlValue.Scalar or CXmlValue.Multipart: - return CreateDiscordParserCode(ValueCodeGenerator.BuildValue(property.Value, property.Context)); - - case CXmlValue.Interpolation interpolation: - var interpolationInfo = property.Context.Interpolations[interpolation.InterpolationIndex]; - - if ( - property.Context.Compilation.HasImplicitConversion( - interpolationInfo.Type, - property.Context.KnownTypes.IEmoteType - ) - ) - { - return property.CreateValue(in interpolationInfo); - } - - // if its a string interpolation, do the same parse - if (interpolationInfo.Type.SpecialType is SpecialType.System_String) - { - return CreateDiscordParserCode( - $"designer.GetValueAsString({interpolationInfo.Id})" - ); - } - - // otherwise, unknown way to parse it - property.Context.ReportDiagnostic( - Diagnostics.PropertyMismatch, - property.Context.GetLocation(interpolation), - property.Name, - property.Context.KnownTypes.IEmoteType!.ToDisplayString(), - interpolationInfo.Type.ToDisplayString() - ); - return null; - - default: - throw new ArgumentOutOfRangeException(); - } - - ComponentPropertyValue? CreateDiscordParserCode(string? value) - { - if (value is null) return null; - - var emoteType = - property.Context.KnownTypes.EmoteType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - var emojiType = - property.Context.KnownTypes.EmojiType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - - return property.DangerousCreateCode( - $""" - {emoteType}.TryParse({value}, out var emote) - ? emote - : {emojiType}.Parse({value}) - """ - ); - } - } -} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Enum.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Enum.cs deleted file mode 100644 index 0bdc504755..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Enum.cs +++ /dev/null @@ -1,66 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using Microsoft.CodeAnalysis; -using System; - -namespace Discord.ComponentDesignerGenerator.Nodes; - -partial class ValueParsers -{ - public static ComponentPropertyValue? ParseEnumProperty(ComponentProperty property) where T : struct - { - switch (property.Value) - { - case CXmlValue.Invalid or null: return null; - - case CXmlValue.Interpolation interpolation: - var interpolationInfo = property.Context.Interpolations[interpolation.InterpolationIndex]; - - if (property.ApiType is not null) - { - if (property.ApiType.Equals(interpolationInfo.Type, SymbolEqualityComparer.Default)) - { - return property.CreateValue(in interpolationInfo); - } - - // we'll use the parse method - return property.DangerousCreateCode( - $"Enum.Parse<{property.ApiType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}>(designer.GetValueAsString({interpolationInfo.Id}))" - ); - } - - property.Context.ReportDiagnostic( - Diagnostics.InvalidPropertyValue, - property.Context.GetLocation(interpolation), - interpolationInfo.Type.ToDisplayString(), - property.Name - ); - return null; - - case CXmlValue.Multipart multipart: - property.Context.ReportDiagnostic( - Diagnostics.InvalidPropertyValue, - property.Context.GetLocation(multipart), - "", - property.Name - ); - return null; - case CXmlValue.Scalar scalar: - { - if (Enum.TryParse(scalar.Value, out var result)) - return property.CreateValue(result); - - property.Context.ReportDiagnostic( - Diagnostics.InvalidEnumProperty, - property.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/ValueParsers/ValueParsers.Int.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Int.cs deleted file mode 100644 index 42bd4eea32..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Int.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using Microsoft.CodeAnalysis; -using System; - -namespace Discord.ComponentDesignerGenerator.Nodes; - -partial class ValueParsers -{ - public static 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: - // we'll use int.Parse - return property.DangerousCreateCode( - $"int.Parse({ValueCodeGenerator.BuildValue(multipart, property.Context)})" - ); - case CXmlValue.Scalar scalar: - if (int.TryParse(scalar.Value, out var result)) - return property.CreateValue(result); - - property.Node.Context.ReportDiagnostic( - Diagnostics.InvalidPropertyValue, - property.Node.Context.GetLocation(scalar), - scalar.Value, - nameof(Int32) - ); - return null; - - default: - throw new ArgumentOutOfRangeException(); - } - } -} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Snowflake.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Snowflake.cs deleted file mode 100644 index 869945f9e0..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.Snowflake.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using Microsoft.CodeAnalysis; -using System; - -namespace Discord.ComponentDesignerGenerator.Nodes; - -partial class ValueParsers -{ - public static 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: - return property.DangerousCreateCode( - $"ulong.Parse({ValueCodeGenerator.BuildValue(multipart, property.Context)})" - ); - case CXmlValue.Scalar scalar: - if (ulong.TryParse(scalar.Value, out var snowflake)) - { - return property.CreateValue(snowflake); - } - - property.Context.ReportDiagnostic( - Diagnostics.InvalidSnowflakeIdentifier, - property.Context.GetLocation(scalar), - scalar.Value - ); - - return null; - default: - throw new ArgumentOutOfRangeException(); - } - } -} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.String.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.String.cs deleted file mode 100644 index 2bf606efd6..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.String.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using System; - -namespace Discord.ComponentDesignerGenerator.Nodes; - -partial class ValueParsers -{ - public static 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)); - } - } -} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.cs deleted file mode 100644 index 97e312e2d0..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ValueParsers/ValueParsers.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using Microsoft.CodeAnalysis; -using System; - -namespace Discord.ComponentDesignerGenerator.Nodes; - -public static partial class ValueParsers -{ - private static ComponentPropertyValue? ValidateInterpolationType( - ComponentProperty property, - CXmlValue.Interpolation value, - Func validator - ) - { - var interpolationInfo = property.Context.Interpolations[value.InterpolationIndex]; - - if (!validator(interpolationInfo.Type)) - return null; - - return property.CreateValue(in interpolationInfo); - } - - private static ComponentPropertyValue? ValidateInterpolationType( - ComponentProperty property, - CXmlValue.Interpolation value, - SpecialType specialType - ) => ValidateInterpolationType( - property, - value, - (symbol) => - { - if (symbol.SpecialType != specialType) - { - property.Context.ReportDiagnostic( - Diagnostics.PropertyMismatch, - property.Context.GetLocation(value), - property.Name, - nameof(Boolean), - symbol.ToDisplayString() - ); - return false; - } - - return true; - } - ); -} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXDiagnostic.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXDiagnostic.cs similarity index 100% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXDiagnostic.cs rename to src/Discord.Net.ComponentDesigner.Generator/Parsing/CXDiagnostic.cs diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXParser.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXParser.cs similarity index 70% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXParser.cs rename to src/Discord.Net.ComponentDesigner.Generator/Parsing/CXParser.cs index 65ef1855bb..7682c974ff 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXParser.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXParser.cs @@ -22,25 +22,30 @@ public CXSource Source public CXToken CurrentToken => Lex(_tokenIndex); public CXToken NextToken => Lex(_tokenIndex + 1); - public CXNode? CurrentNode => (_currentBlendedNode ??= GetCurrentBlendedNode())?.Node; + public ICXNode? CurrentNode + => (_currentBlendedNode ??= GetCurrentBlendedNode())?.Value; public CXLexer Lexer { get; } private readonly List _tokens; private int _tokenIndex; - private readonly List _blendedTokens; + public IReadOnlyList BlendedNodes => + [ + .._blendedNodes + .Select(x => x.Value) + .Where(x => x is not null)! + ]; - public CXSourceReader Reader { get; } + private readonly List _blendedNodes; - private readonly List _diagnostics; + public CXSourceReader Reader { get; } - public bool IsIncremental => RootBlender.HasValue; + public bool IsIncremental => Blender is not null; - public CXBlender? RootBlender { get; set; } + public CXBlender? Blender { get; set; } private BlendedNode? _currentBlendedNode; - private CXSource _source; public CXParser(CXSource source) @@ -49,16 +54,16 @@ public CXParser(CXSource source) Reader = new CXSourceReader(source); Lexer = new CXLexer(Reader); _tokens = []; - _blendedTokens = []; - _diagnostics = []; + _blendedNodes = []; } public void Reset() { _tokens.Clear(); - _diagnostics.Clear(); Reader.Position = Source.SourceSpan.Start; _tokenIndex = 0; + Lexer.Reset(); + _currentBlendedNode = null; } public static CXDoc Parse(CXSource source) @@ -83,6 +88,8 @@ internal CXElement ParseElement() return element; } + var diagnostics = new List(); + var start = Expect(CXTokenKind.LessThan); var identifier = ParseIdentifier(); @@ -111,7 +118,7 @@ out var endClose endStart, endIdent, endClose - ); + ) {Diagnostics = diagnostics}; case CXTokenKind.ForwardSlashGreaterThan: return new CXElement( start, @@ -129,11 +136,18 @@ void ParseClosingElement( out CXToken elementEndIdent, out CXToken elementEndClose) { - elementEndStart = Expect(CXTokenKind.LessThan); + var sentinel = _tokenIndex; + + elementEndStart = Expect(CXTokenKind.LessThanForwardSlash); elementEndIdent = ParseIdentifier(); - elementEndClose = Expect(CXTokenKind.ForwardSlashGreaterThan); + elementEndClose = Expect(CXTokenKind.GreaterThan); - // TODO: verify identifier match + if (elementEndIdent.Value != identifier.Value) + { + diagnostics.Add(CreateError("Missing closing tag", identifier.Span)); + // rollback + _tokenIndex = sentinel; + } } CXCollection ParseElementChildren() @@ -149,22 +163,22 @@ CXCollection ParseElementChildren() // - interpolations // - text var children = new List(); + var diagnostics = new List(); using (Lexer.SetMode(CXLexer.LexMode.ElementValue)) { - while (TryParseElementChild(out var child)) + while (TryParseElementChild(diagnostics, out var child)) children.Add(child); } - return new CXCollection(children); + return new CXCollection(children) {Diagnostics = diagnostics}; } - bool TryParseElementChild(out CXNode node) + bool TryParseElementChild(List diagnostics, out CXNode node) { if (IsIncremental && CurrentNode is CXValue or CXElement) { - node = CurrentNode; - EatNode(); + node = EatNode()!; return true; } @@ -191,7 +205,7 @@ bool TryParseElementChild(out CXNode node) return false; default: - _diagnostics.Add( + diagnostics.Add( new CXDiagnostic( DiagnosticSeverity.Error, $"Unexpected element child type '{CurrentToken.Kind}'", @@ -230,10 +244,7 @@ internal CXAttribute ParseAttribute() return attribute; } - var oldMode = Lexer.Mode; - Lexer.Mode = CXLexer.LexMode.Attribute; - - try + using (Lexer.SetMode(CXLexer.LexMode.Attribute)) { var identifier = ParseIdentifier(); @@ -255,10 +266,6 @@ internal CXAttribute ParseAttribute() value ); } - finally - { - Lexer.Mode = oldMode; - } } internal CXValue ParseAttributeValue() @@ -279,14 +286,17 @@ internal CXValue ParseAttributeValue() case CXTokenKind.StringLiteralStart: return ParseStringLiteral(); default: - _diagnostics.Add( - new CXDiagnostic( - DiagnosticSeverity.Error, - $"Unexpected attribute valid start, expected interpolation or string literal, got '{CurrentToken.Kind}'", - CurrentToken.Span - ) - ); - return new CXValue.Invalid(); + return new CXValue.Invalid() + { + Diagnostics = + [ + new CXDiagnostic( + DiagnosticSeverity.Error, + $"Unexpected attribute valid start, expected interpolation or string literal, got '{CurrentToken.Kind}'", + CurrentToken.Span + ) + ] + }; } } @@ -298,6 +308,8 @@ internal CXValue ParseStringLiteral() return value; } + var diagnostics = new List(); + var tokens = new List(); var quoteToken = CurrentToken.Kind; @@ -319,7 +331,7 @@ internal CXValue ParseStringLiteral() case CXTokenKind.Invalid or CXTokenKind.EOF: goto end; default: - _diagnostics.Add( + diagnostics.Add( new CXDiagnostic( DiagnosticSeverity.Error, $"Unexpected string literal token '{CurrentToken.Kind}'", @@ -337,25 +349,14 @@ internal CXValue ParseStringLiteral() start, tokens, end - ); + ) {Diagnostics = diagnostics}; } internal CXToken ParseIdentifier() { - var oldMode = Lexer.Mode; - Lexer.Mode = CXLexer.LexMode.Identifier; - - try - { - var token = Expect(CXTokenKind.Identifier); - - Lexer.Mode = oldMode; - - return token; - } - finally + using (Lexer.SetMode(CXLexer.LexMode.Identifier)) { - Lexer.Mode = oldMode; + return Expect(CXTokenKind.Identifier); } } @@ -393,9 +394,14 @@ internal CXToken Expect(params ReadOnlySpan kinds) if (current.Kind == kind) return Eat(); } - _diagnostics.Add( - new CXDiagnostic( - DiagnosticSeverity.Error, + return new CXToken( + kinds[0], + new TextSpan(current.Span.Start, 0), + current.LeadingTriviaLength, + 0, + Flags: CXTokenFlags.Missing, + Value: string.Empty, + CreateError( $"Unexpected token, expected one of '{string.Join(", ", kinds.ToArray())}', got '{current.Kind}'", current.Span ) @@ -412,13 +418,14 @@ internal CXToken Expect(CXTokenKind kind) if (token.Kind != kind) { - token = token with {Flags = CXTokenFlags.HasErrors}; - _diagnostics.Add( - new CXDiagnostic( - DiagnosticSeverity.Error, - $"Unexpected token, expected '{kind}', got '{token.Kind}'", - token.Span - ) + return new CXToken( + kind, + new TextSpan(token.Span.Start, 0), + token.LeadingTriviaLength, + 0, + Flags: CXTokenFlags.Missing, + Value: string.Empty, + CreateError($"Unexpected token, expected '{kind}', got '{token.Kind}'", token.Span) ); } @@ -427,28 +434,27 @@ internal CXToken Expect(CXTokenKind kind) } private BlendedNode? GetCurrentBlendedNode() - => RootBlender.HasValue - ? (_tokenIndex is 0 ? RootBlender.Value : _blendedTokens[_blendedTokens.Count - 1].Blender).ReadNode() - : null; + => Blender?.NextNode( + _tokenIndex is 0 ? Blender.StartingCursor : _blendedNodes[_tokenIndex - 1].Cursor + ); private CXNode? EatNode() { - var node = _currentBlendedNode?.Node; - - if (node is null) return null; + if (_currentBlendedNode?.Value is not CXNode node) return null; - _blendedTokens.Add(_currentBlendedNode!.Value); + _blendedNodes.Add(_currentBlendedNode!.Value); - _tokenIndex += 2; // we add 2 to cause a new lex + _tokenIndex++; _currentBlendedNode = null; + node.ResetCachedState(); return node; } internal CXToken Lex(int index) { - if (RootBlender.HasValue) return FetchBlended(); + if (Blender is not null) return FetchBlended(); while (_tokens.Count <= index) { @@ -463,19 +469,34 @@ internal CXToken Lex(int index) CXToken FetchBlended() { - while (_blendedTokens.Count <= index) + while (_blendedNodes.Count <= index) { - var blender = _blendedTokens.Count is 0 - ? RootBlender!.Value - : _blendedTokens[_blendedTokens.Count - 1].Blender; + var cursor = _blendedNodes.Count is 0 + ? Blender.StartingCursor + : _blendedNodes[_blendedNodes.Count - 1].Cursor; + + var node = Blender.NextToken(cursor); - var node = blender.ReadToken(); - _blendedTokens.Add(node); + _blendedNodes.Add(node); + _currentBlendedNode = null; - if (node.Token?.Kind is CXTokenKind.EOF) return node.Token.Value; + if (node.Value is CXToken {Kind: CXTokenKind.EOF} eof) return eof; } - return _blendedTokens[index].Token!.Value; + return (CXToken)_blendedNodes[index].Value; } } + + private CXDiagnostic CreateError(string message) + => CreateError(message, new(Reader.Position, 1)); + + private CXDiagnostic CreateError(string message, TextSpan span) + => CreateDiagnostic(DiagnosticSeverity.Error, message, span); + + private static CXDiagnostic CreateDiagnostic(DiagnosticSeverity severity, string message, TextSpan span) + => new( + severity, + message, + span + ); } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXTreeWalker.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXTreeWalker.cs new file mode 100644 index 0000000000..deb5626d58 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXTreeWalker.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; + +namespace Discord.ComponentDesignerGenerator.Parser; + +public class CXTreeWalker(CXDoc doc) +{ + public ICXNode? Current => IsAtEnd ? null : _graph[Position]; + + private readonly List _graph = doc.GetFlatGraph(); + + public int Position { get; set; } + public bool IsAtEnd => Position >= _graph.Count || Position < 0; + + public CXNode? NextNode() + { + if (IsAtEnd) return null; + + while (!IsAtEnd && Current is not CXNode) Position++; + + return (CXNode?)Current; + } + + public CXToken? NextToken() + { + if (IsAtEnd) return null; + + while (!IsAtEnd && Current is not CXToken) Position++; + + return (CXToken?)Current; + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/ICXNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/ICXNode.cs new file mode 100644 index 0000000000..68706780d3 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/ICXNode.cs @@ -0,0 +1,24 @@ +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; + +namespace Discord.ComponentDesignerGenerator.Parser; + +public interface ICXNode +{ + TextSpan FullSpan { get; } + TextSpan Span { get; } + + int Width { get; } + + int GraphWidth { get; } + + bool HasErrors { get; } + + IReadOnlyList Diagnostics { get; } + + CXNode? Parent { get; internal set; } + + IReadOnlyList Slots { get; } + + void ResetCachedState(); +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/BlendedNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/BlendedNode.cs similarity index 63% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/BlendedNode.cs rename to src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/BlendedNode.cs index e11feddba3..39145b781e 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/BlendedNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/BlendedNode.cs @@ -1,7 +1,6 @@ namespace Discord.ComponentDesignerGenerator.Parser; public readonly record struct BlendedNode( - CXNode? Node, - CXToken? Token, - CXBlender Blender + ICXNode Value, + CXBlender.Cursor Cursor ); diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/CXBlender.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/CXBlender.cs new file mode 100644 index 0000000000..d28655edf0 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/CXBlender.cs @@ -0,0 +1,300 @@ +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord.ComponentDesignerGenerator.Parser; + +public sealed class CXBlender +{ + public readonly record struct Cursor( + int NewPosition, + int ChangeDelta, + int Index, + ImmutableStack Changes + ) + { + public bool IsInvalid => Index is -1; + + public static readonly Cursor Invalid = new( + -1, + -1, + -1, + ImmutableStack.Empty + ); + + public Cursor Invalidate() => this with {Index = -1}; + + public Cursor WithChangedNode(ICXNode node) + => new Cursor( + NewPosition: NewPosition + node.FullSpan.Length, + ChangeDelta: ChangeDelta - node.FullSpan.Length, + Index: Index + node.GraphWidth, + Changes: Changes + ); + + public BlendedNode BlendChangedNode(ICXNode node) + => new( + node, + WithChangedNode(node) + ); + } + + public readonly Cursor StartingCursor; + + private ICXNode? this[in Cursor cursor] => + cursor.Index >= 0 && cursor.Index < _graph.Count ? _graph[cursor.Index] : null; + + private readonly CXLexer _lexer; + + private readonly IReadOnlyList _graph; + + + public CXBlender( + CXLexer lexer, + CXDoc document, + TextChangeRange changeRange + ) + { + _lexer = lexer; + + _graph = document.GetFlatGraph(); + + StartingCursor = new( + document.FullSpan.Start, + 0, + 0, + ImmutableStack + .Empty + .Push(changeRange) + ); + } + + private void MoveToFirstToken(ref Cursor cursor) + { + if (cursor.Index >= _graph.Count) return; + + var index = cursor.Index; + + while (index < _graph.Count && _graph[index] is not CXToken) + index++; + + cursor = cursor with {Index = index}; + } + + private void MoveToNextSibling(ref Cursor cursor) + { + while (this[cursor]?.Parent is not null) + { + var tempCursor = cursor; + FindNextNonZeroWidthOrIsEOFSibling(ref cursor); + + if (cursor.IsInvalid) + { + MoveToParent(ref tempCursor); + cursor = tempCursor; + } + else return; + } + + cursor = cursor.Invalidate(); + } + + private void MoveToParent(ref Cursor cursor) + { + var current = this[cursor]; + + if (current?.Parent is null) return; + + var index = current.Parent.GetIndexOfSlot(current); + + if (index is -1) return; + + var delta = 1; + + for (var i = index - 1; i >= 0; i--) + delta += current.Parent.Slots[i].Value.GraphWidth + 1; + + cursor = cursor with {Index = cursor.Index - delta}; + } + + private void FindNextNonZeroWidthOrIsEOFSibling(ref Cursor cursor) + { + var current = this[cursor]; + + if (current?.Parent is { } parent) + { + var index = parent.GetIndexOfSlot(current); + + for ( + int slotIndex = index + 1, + cursorIndex = cursor.Index + current.GraphWidth + 1; + slotIndex < parent.Slots.Count; + cursorIndex += parent.Slots[slotIndex++].Value.GraphWidth + 1 + ) + { + var sibling = parent.Slots[slotIndex]; + + if (IsNonZeroWidthOrIsEOF(sibling.Value)) + { + cursor = cursor with {Index = cursorIndex}; + return; + } + } + } + + cursor = cursor.Invalidate(); + } + + private void MoveToFirstChild(ref Cursor cursor) + { + var current = this[cursor]; + + if (current is null || current.Slots.Count is 0) + { + cursor = cursor.Invalidate(); + return; + } + + for ( + int childIndex = 0, childGraphIndex = cursor.Index + 1; + childIndex < current.Slots.Count; + childGraphIndex += current.Slots[childIndex++].Value.GraphWidth + 1 + ) + { + var child = current.Slots[childIndex]; + if (IsNonZeroWidthOrIsEOF(child.Value)) + { + cursor = cursor with {Index = childGraphIndex}; + return; + } + } + + cursor = cursor.Invalidate(); + } + + private static bool IsNonZeroWidthOrIsEOF(ICXNode node) + => !node.FullSpan.IsEmpty || node is CXToken {Kind: CXTokenKind.EOF}; + + private bool IsCompletedCursor(in Cursor cursor) + => this[cursor] is null or CXToken {Kind: CXTokenKind.EOF or CXTokenKind.Invalid}; + + public BlendedNode NextToken(Cursor cursor) => Next(asToken: true, cursor); + public BlendedNode NextNode(Cursor cursor) => Next(asToken: false, cursor); + + public BlendedNode Next(bool asToken, Cursor cursor) + { + while (true) + { + if (IsCompletedCursor(cursor)) return ReadNewToken(cursor); + + if (cursor.ChangeDelta < 0) SkipOldToken(ref cursor); + else if (cursor.ChangeDelta > 0) return ReadNewToken(cursor); + else + { + if (TryTakeOldNodeOrToken(asToken, cursor, out var node)) return node; + + if (this[cursor] is CXNode) + MoveToFirstChild(ref cursor); + else + SkipOldToken(ref cursor); + } + } + } + + private void SkipOldToken(ref Cursor cursor) + { + MoveToFirstToken(ref cursor); + + var current = this[cursor]; + + if (current is null) return; + + cursor = cursor with {ChangeDelta = cursor.ChangeDelta + current.FullSpan.Length}; + + MoveToNextSibling(ref cursor); + + SkipPastChanges(ref cursor); + } + + private void SkipPastChanges(ref Cursor cursor) + { + if (this[cursor] is not { } current) return; + + while ( + !cursor.Changes.IsEmpty && + current.FullSpan.Start >= cursor.Changes.Peek().Span.End + ) + { + var change = cursor.Changes.Peek(); + cursor = cursor with + { + ChangeDelta = cursor.ChangeDelta + (change.NewLength - change.Span.Length), + Changes = cursor.Changes.Pop() + }; + } + } + + private bool TryTakeOldNodeOrToken( + bool asToken, + Cursor cursor, + out BlendedNode blendedNode) + { + if (asToken) MoveToFirstToken(ref cursor); + + var current = this[cursor]; + + if (!CanReuse(current, cursor) || current is null) + { + blendedNode = default; + return false; + } + + MoveToNextSibling(ref cursor); + + blendedNode = new( + current, + cursor with {NewPosition = cursor.NewPosition + current.FullSpan.Length,} + ); + return true; + } + + private bool CanReuse(ICXNode? node, Cursor cursor) + { + if (node is null) return false; + + if (node.FullSpan.IsEmpty) return false; + + if (IntersectsChange(node.FullSpan, cursor)) return false; + + if (node.HasErrors) return false; + + return true; + } + + private static bool IntersectsChange(TextSpan span, Cursor cursor) + => !cursor.Changes.IsEmpty && span.IntersectsWith(cursor.Changes.Peek().Span); + + private BlendedNode ReadNewToken(Cursor cursor) + { + var token = LexNewToken(cursor); + + cursor = cursor with + { + NewPosition = cursor.NewPosition + token.FullSpan.Length, + ChangeDelta = cursor.ChangeDelta - token.FullSpan.Length, + }; + + SkipPastChanges(ref cursor); + + return new( + token, + cursor + ); + } + + private CXToken LexNewToken(Cursor cursor) + { + _lexer.Reader.Position = cursor.NewPosition; + return _lexer.Next(); + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/IncrementalParseContext.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/IncrementalParseContext.cs similarity index 100% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/IncrementalParseContext.cs rename to src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/IncrementalParseContext.cs diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/IncrementalParseResult.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/IncrementalParseResult.cs new file mode 100644 index 0000000000..2942acc45f --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/IncrementalParseResult.cs @@ -0,0 +1,11 @@ +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; + +namespace Discord.ComponentDesignerGenerator.Parser; + +public readonly record struct IncrementalParseResult( + IReadOnlyList ReusedNodes, + IReadOnlyList NewNodes, + IReadOnlyList Changes, + TextChangeRange AppliedRange +); diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXLexer.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXLexer.cs index a0e288e5f5..3df6d51cdc 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXLexer.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXLexer.cs @@ -94,10 +94,9 @@ public TextSpan? NextInterpolationSpan } } - private readonly bool[] _handledInterpolations; - public LexMode Mode { get; set; } + public CXToken[] InterpolationMap; public char? QuoteChar; @@ -107,8 +106,13 @@ public TextSpan? NextInterpolationSpan public CXLexer(CXSourceReader reader) { Reader = reader; - _handledInterpolations = new bool[reader.Source.Interpolations.Length]; Mode = LexMode.Default; + InterpolationMap = new CXToken[Reader.Source.Interpolations.Length]; + } + + public void Reset() + { + InterpolationMap = new CXToken[Reader.Source.Interpolations.Length]; } public readonly struct ModeSentinel(CXLexer? lexer) : IDisposable @@ -147,13 +151,21 @@ public CXToken Next() GetTrivia(isTrailing: true, ref info.TrailingTriviaLength); - return new CXToken( + var span = new TextSpan(info.Start, info.End - info.Start); + + var token = new CXToken( info.Kind, - new TextSpan(info.Start, info.End - info.Start), + span, info.LeadingTriviaLength, info.TrailingTriviaLength, - info.Flags + info.Flags, + Reader.Source.GetValue(span) ); + + if (info.Kind is CXTokenKind.Interpolation) + InterpolationMap[_interpolationIndex] = token; + + return token; } private void Scan(ref TokenInfo info) @@ -342,6 +354,7 @@ private bool TryScanInterpolation(ref TokenInfo info) span.End - Reader.Position ); InterpolationIndex = _interpolationIndex; + return true; } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXToken.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXToken.cs index c75ab8354e..b3f55aa2db 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXToken.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXToken.cs @@ -1,19 +1,81 @@ -using Microsoft.CodeAnalysis.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; namespace Discord.ComponentDesignerGenerator.Parser; -public readonly record struct CXToken( +public sealed record CXToken( CXTokenKind Kind, TextSpan Span, int LeadingTriviaLength, int TrailingTriviaLength, - CXTokenFlags Flags -) + CXTokenFlags Flags, + string Value, + params IReadOnlyList Diagnostics +) : ICXNode { + public CXNode? Parent { get; set; } + + public bool HasErrors + => _hasErrors ??= ( + Kind is CXTokenKind.Invalid || + Diagnostics.Any(x => x.Severity is DiagnosticSeverity.Error) || + (Flags & CXTokenFlags.Missing) != 0 + ); + + public bool IsMissing => (Flags & CXTokenFlags.Missing) != 0; + + public bool IsZeroWidth => Span.IsEmpty; + + public bool IsInvalid => Kind is CXTokenKind.Invalid; + public int AbsoluteStart => Span.Start - LeadingTriviaLength; public int AbsoluteEnd => Span.End + TrailingTriviaLength; - public int AbsoluteWidth => AbsoluteEnd - AbsoluteStart; + public int AbsoluteWidth => AbsoluteEnd - AbsoluteStart; public TextSpan FullSpan => new(AbsoluteStart, AbsoluteWidth); + + public int Width => FullSpan.Length; + + int ICXNode.GraphWidth => 0; + IReadOnlyList ICXNode.Slots => []; + + private bool? _hasErrors; + + public void ResetCachedState() + { + _hasErrors = null; + } + + public bool Equals(CXToken? other) + { + if (other is null) return false; + + if (ReferenceEquals(this, other)) return true; + + return + Kind == other.Kind && + Span.Equals(other.Span) && + LeadingTriviaLength == other.LeadingTriviaLength && + TrailingTriviaLength == other.TrailingTriviaLength && + Flags == other.Flags && + Diagnostics.SequenceEqual(other.Diagnostics); + } + + public override int GetHashCode() + { + unchecked + { + var hashCode = Diagnostics.Aggregate(0, (a, b) => (a * 397) ^ b.GetHashCode()); + hashCode = (hashCode * 397) ^ (int)Kind; + hashCode = (hashCode * 397) ^ Span.GetHashCode(); + hashCode = (hashCode * 397) ^ LeadingTriviaLength; + hashCode = (hashCode * 397) ^ TrailingTriviaLength; + hashCode = (hashCode * 397) ^ (int)Flags; + return hashCode; + } + } } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXTokenFlags.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXTokenFlags.cs index f5f2c0b146..6007c9a9d5 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXTokenFlags.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXTokenFlags.cs @@ -6,5 +6,5 @@ namespace Discord.ComponentDesignerGenerator.Parser; public enum CXTokenFlags : byte { None = 0, - HasErrors = 1 << 0 + Missing = 1 << 0 } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXAttribute.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXAttribute.cs similarity index 100% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXAttribute.cs rename to src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXAttribute.cs diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXCollection.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXCollection.cs similarity index 87% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXCollection.cs rename to src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXCollection.cs index ccca70f5c0..d265c11cb5 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXCollection.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXCollection.cs @@ -5,7 +5,7 @@ namespace Discord.ComponentDesignerGenerator.Parser; public sealed class CXCollection : CXNode, IReadOnlyList - where T : CXNode + where T : ICXNode { public T this[int index] => _items[index]; @@ -15,7 +15,7 @@ public sealed class CXCollection : CXNode, IReadOnlyList public CXCollection(params IEnumerable items) { - Slot(_items = [..items]); + Slot((IEnumerable)(_items = [..items])); } public IEnumerator GetEnumerator() => _items.GetEnumerator(); diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXDoc.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXDoc.cs new file mode 100644 index 0000000000..895416c8f3 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXDoc.cs @@ -0,0 +1,127 @@ +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.ComponentDesignerGenerator.Parser; + +public sealed class CXDoc : CXNode +{ + public override CXParser Parser { get; } + + public IReadOnlyList Tokens { get; } + + public IReadOnlyList RootElements { get; private set; } + + public readonly CXToken[] InterpolationTokens; + + public CXDoc( + CXParser parser, + IReadOnlyList rootElements, + IReadOnlyList tokens + ) + { + Tokens = tokens; + Parser = parser; + Slot(RootElements = rootElements); + InterpolationTokens = parser.Lexer.InterpolationMap; + } + + public IncrementalParseResult ApplyChanges( + CXSource source, + IReadOnlyList changes + ) + { + var affectedRange = TextChangeRange.Collapse(changes.Select(x => (TextChangeRange)x)); + + var blender = new CXBlender(Parser.Lexer, this, affectedRange); + + Parser.Source = source; + Parser.Reset(); + Parser.Blender = blender; + + var context = new IncrementalParseContext(changes, affectedRange); + + var owner = FindOwningNode(affectedRange.Span, out _); + + owner.IncrementalParse(context); + + var reusedNodes = new List(); + var flatGraph = GetFlatGraph(); + + foreach (var reusedNode in Parser.BlendedNodes) + { + reusedNodes.Add(reusedNode); + + if(reusedNode is not CXNode concreteNode) continue; + + // add descendants to reused collection + reusedNodes.AddRange(concreteNode.Descendants); + } + + return new( + reusedNodes, + [..GetFlatGraph().Except(Parser.BlendedNodes)], + changes, + affectedRange + ); + } + + public override void IncrementalParse(IncrementalParseContext context) + { + var children = new List(); + + while (Parser.CurrentToken.Kind is not CXTokenKind.EOF and not CXTokenKind.Invalid) + { + children.Add(Parser.ParseElement()); + } + + ClearSlots(); + Slot(RootElements = children); + } + + public string GetTokenValue(CXToken token) => Parser.Source.GetValue(token.Span); + public string GetTokenValueWithTrivia(CXToken token) => Parser.Source.GetValue(token.FullSpan); + + public List GetFlatGraph() + { + var result = new List(); + + var stack = new Stack<(ICXNode Node, int SlotIndex)>([(this, 0)]); + + while (stack.Count > 0) + { + var (node, index) = stack.Pop(); + + if (node is CXToken token) + { + result.Add(token); + continue; + } + + if (node is CXNode concreteNode) + { + if(index is 0) result.Add(node); + + if (concreteNode.Slots.Count > index) + { + // enqueue self + stack.Push( + (concreteNode, index + 1) + ); + + // enqueue child + stack.Push( + (concreteNode.Slots[index].Value, 0) + ); + + continue; + } + + // we do nothing + } + } + + return result; + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXElement.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXElement.cs similarity index 95% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXElement.cs rename to src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXElement.cs index 766d7d6611..92081f8f40 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXElement.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXElement.cs @@ -6,6 +6,8 @@ namespace Discord.ComponentDesignerGenerator.Parser; public sealed class CXElement : CXNode { + public string Identifier => ElementStartNameToken.Value; + public CXToken ElementStartOpenToken { get; } public CXToken ElementStartNameToken { get; } public CXCollection Attributes { get; } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXNode.ParseSlot.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXNode.ParseSlot.cs new file mode 100644 index 0000000000..f52cff1444 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXNode.ParseSlot.cs @@ -0,0 +1,36 @@ +using Microsoft.CodeAnalysis.Text; +using System; + +namespace Discord.ComponentDesignerGenerator.Parser; + +partial class CXNode +{ + public readonly struct ParseSlot : IEquatable + { + public ICXNode Value { get; } + public TextSpan FullSpan => Value.FullSpan; + + public readonly int Id; + + public ParseSlot(int id, ICXNode node) + { + Id = id; + Value = node; + } + + public static bool operator ==(ParseSlot slot, ICXNode node) + => slot.Value == node; + + public static bool operator !=(ParseSlot slot, ICXNode node) + => slot.Value != node; + + public bool Equals(ParseSlot other) + => Equals(Value, other.Value); + + public override bool Equals(object? obj) + => obj is ParseSlot other && Equals(other); + + public override int GetHashCode() + => Value.GetHashCode(); + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXNode.cs new file mode 100644 index 0000000000..dc8ea69acf --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXNode.cs @@ -0,0 +1,308 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Discord.ComponentDesignerGenerator.Parser; + +public abstract partial class CXNode : ICXNode +{ + public CXNode? Parent { get; set; } + + public int Width { get; private set; } + + public int GraphWidth + => _graphWidth ??= ( + _slots.Count > 0 + ? _slots.Count + _slots.Sum(node => node.Value.GraphWidth) + : 0 + ); + + public IReadOnlyList Diagnostics + { + get => + [ + .._diagnostics + .Concat(Slots.SelectMany(x => x.Value.Diagnostics)) + ]; + set + { + _diagnostics.Clear(); + _diagnostics.AddRange(value); + } + } + + public bool HasErrors + => _diagnostics.Any(x => x.Severity is DiagnosticSeverity.Error) || + Slots.Any(x => x.Value.HasErrors); + + public CXDoc Document + { + get => _doc ??= ( + this is CXDoc doc + ? doc + : _doc ??= Parent?.Document ?? throw new InvalidOperationException() + ); + set => _doc = value; + } + + public virtual CXParser Parser => Document.Parser; + + public CXToken? FirstTerminal + { + get + { + if (_firstTerminal is not null) return _firstTerminal; + + for (var i = 0; i < _slots.Count; i++) + { + switch (_slots[i].Value) + { + case CXToken token: return _firstTerminal = token; + case CXNode {FirstTerminal: { } firstTerminal}: return _firstTerminal = firstTerminal; + default: continue; + } + } + + return null; + } + } + + public CXToken? LastTerminal + { + get + { + if (_lastTerminal is not null) return _lastTerminal; + + for (var i = _slots.Count - 1; i >= 0; i--) + { + switch (_slots[i].Value) + { + case CXToken token: return _lastTerminal = token; + case CXNode {LastTerminal: { } lastTerminal}: return _lastTerminal = lastTerminal; + default: continue; + } + } + + return null; + } + } + + + public IReadOnlyList Descendants + => _descendants ??= ( + [ + .._slots.SelectMany(x => (ICXNode[]) + [ + x.Value, + ..(x.Value as CXNode)?.Descendants ?? [] + ]) + ]); + + public TextSpan FullSpan => new(Offset, Width); + + public TextSpan Span + => _span ??= ( + FirstTerminal is { } first && LastTerminal is { } last + ? TextSpan.FromBounds(first.Span.Start, last.Span.End) + : FullSpan + ); + + // TODO: + // this could be cached, a caveat though is if we incrementally parse, we need to update the + // offset/width of any nodes right of the change + public int Offset => _offset ??= ComputeOffset(); + + public IReadOnlyList Slots => _slots; + + private readonly List _slots; + private readonly List _diagnostics; + + // cached state + private int? _offset; + private TextSpan? _span; + private CXToken? _firstTerminal; + private CXToken? _lastTerminal; + private CXDoc? _doc; + private int? _graphWidth; + private IReadOnlyList? _descendants; + + public CXNode() + { + _diagnostics = []; + _slots = []; + } + + public bool TryFindToken(int position, out CXToken token) + { + if (!FullSpan.Contains(position)) + { + token = null!; + return false; + } + + var current = this; + + while (true) + { + for (var i = 0; i < current.Slots.Count; i++) + { + var slot = current.Slots[i]; + + if (!slot.FullSpan.Contains(position)) continue; + + switch (slot.Value) + { + case CXToken slotToken: + token = slotToken; + return true; + case CXNode node: + current = node; + break; + default: + token = null!; + return false; + } + + break; + } + + token = null!; + return false; + } + } + + public CXNode FindOwningNode(TextSpan span, out ParseSlot slot) + { + var current = this; + slot = default; + + search: + for (var i = 0; i < current.Slots.Count; i++) + { + slot = current.Slots[i]; + + if ( + // the end is exclusive, since its char-based + !(span.Start >= slot.FullSpan.Start && span.End < slot.FullSpan.End) + ) continue; + + if (slot.Value is not CXNode node) break; + + current = node; + goto search; + } + + // we only want the top most container + // while (current.Parent is not null && current.FullSpan == current.Parent.FullSpan) + // current = current.Parent; + + return current; + } + + protected void ClearSlots() => _slots.Clear(); + + public int GetParentSlotIndex() + { + if (Parent is null) return -1; + + for (var i = 0; i < Parent._slots.Count; i++) + if (Parent._slots[i] == this) + return i; + + return -1; + } + + public int GetIndexOfSlot(ICXNode node) + { + for (var i = 0; i < _slots.Count; i++) + if (_slots[i] == node) + return i; + + return -1; + } + + private int ComputeOffset() + { + if (Parent is null) return Document.Parser.Source.SourceSpan.Start; + + var parentOffset = Parent.Offset; + var parentSlotIndex = GetParentSlotIndex(); + + return parentSlotIndex switch + { + -1 => throw new InvalidOperationException(), + 0 => parentOffset, + _ => Parent._slots[parentSlotIndex - 1].Value switch + { + CXNode sibling => sibling.Offset + sibling.Width, + CXToken token => token.AbsoluteEnd, + _ => throw new InvalidOperationException() + } + }; + } + + protected bool IsGraphChild(CXNode node) => IsGraphChild(node, out _); + + protected bool IsGraphChild(CXNode node, out int index) + { + index = -1; + + if (node.Parent != this) return false; + + index = node.GetParentSlotIndex(); + + return index >= 0 && index < _slots.Count && _slots.ElementAt(index) == node; + } + + + protected void UpdateSlot(CXNode old, CXNode @new) + { + if (!IsGraphChild(old, out var slotIndex)) return; + + _slots[slotIndex] = new(slotIndex, @new); + } + + protected void RemoveSlot(CXNode node) + { + if (!IsGraphChild(node, out var index)) return; + + _slots.RemoveAt(index); + } + + protected void Slot(CXCollection? node) where T : ICXNode => Slot((CXNode?)node); + + protected void Slot(ICXNode? node) + { + if (node is null) return; + + Width += node.Width; + + node.Parent = this; + _slots.Add(new(_slots.Count, node)); + } + + protected void Slot(IEnumerable nodes) + { + foreach (var node in nodes) Slot(node); + } + + public virtual void IncrementalParse(IncrementalParseContext change) => Parent?.IncrementalParse(change); + + public void ResetCachedState() + { + _offset = null; + _span = null; + _firstTerminal = null; + _lastTerminal = null; + _doc = null; + _graphWidth = null; + + // reset any descendants + foreach (var descendant in Descendants.OfType()) + descendant.ResetCachedState(); + + _descendants = null; + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXValue.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXValue.cs similarity index 100% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXValue.cs rename to src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXValue.cs diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlAttribute.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlAttribute.cs deleted file mode 100644 index 9e8fca6b72..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlAttribute.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Collections.Generic; - -namespace Discord.ComponentDesignerGenerator.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/Parsing/Parser/CXmlDiagnostic.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlDiagnostic.cs deleted file mode 100644 index 2802ba747b..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlDiagnostic.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Microsoft.CodeAnalysis; - -namespace Discord.ComponentDesignerGenerator.Parser; - -public readonly record struct CXmlDiagnostic( - DiagnosticSeverity Severity, - string Message, - SourceSpan Span -); diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlDoc.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlDoc.cs deleted file mode 100644 index 9a80722b3d..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlDoc.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Microsoft.CodeAnalysis; -using System.Collections.Generic; -using System.Linq; - -namespace Discord.ComponentDesignerGenerator.Parser; - -public sealed record CXmlDoc( - SourceSpan Span, - IReadOnlyList Elements, - IReadOnlyList InterpolationOffsets, - params IReadOnlyList Diagnostics -) : ICXml -{ - public bool HasErrors => - Diagnostics.Any(x => x.Severity is DiagnosticSeverity.Error) || Elements.Any(x => x.HasErrors); -} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlElement.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlElement.cs deleted file mode 100644 index 704c8c78cc..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlElement.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Collections.Generic; -using System.Linq; - -namespace Discord.ComponentDesignerGenerator.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/Parsing/Parser/CXmlValue.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlValue.cs deleted file mode 100644 index 2badd67aad..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/CXmlValue.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Collections.Generic; - -namespace Discord.ComponentDesignerGenerator.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/Parsing/Parser/ComponentParser.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/ComponentParser.cs deleted file mode 100644 index a3dc685e16..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/ComponentParser.cs +++ /dev/null @@ -1,938 +0,0 @@ -using Microsoft.CodeAnalysis; -using System; -using System.Collections.Generic; - -namespace Discord.ComponentDesignerGenerator.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_CHAR = '\''; - private const char DOUBLE_QUOTE_CHAR = '"'; - - /// - /// 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; - - private ComponentParser(string[] slices, int[] interpolationLengths) - { - _source = string.Join(string.Empty, slices); - _sourceSlices = slices; - _interpolationLengths = interpolationLengths; - - _diagnostics = []; - - _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_CHAR and not DOUBLE_QUOTE_CHAR) - { - // 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); - - /// - /// 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/Parsing/Parser/ICXml.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/ICXml.cs deleted file mode 100644 index 6850f67820..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/ICXml.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Collections.Generic; - -namespace Discord.ComponentDesignerGenerator.Parser; - -public interface ICXml -{ - SourceSpan Span { get; } - - IReadOnlyList Diagnostics { get; } - - bool HasErrors { get; } -} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/SourceLocation.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/SourceLocation.cs deleted file mode 100644 index 72adac3da5..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/SourceLocation.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Discord.ComponentDesignerGenerator.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/Parsing/Parser/SourceSpan.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/SourceSpan.cs deleted file mode 100644 index b4aaef8f94..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser/SourceSpan.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Discord.ComponentDesignerGenerator.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/Parsing/Parser2/CXBlender.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXBlender.cs deleted file mode 100644 index 7eaec53407..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXBlender.cs +++ /dev/null @@ -1,212 +0,0 @@ -using Microsoft.CodeAnalysis.Text; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Diagnostics; - -namespace Discord.ComponentDesignerGenerator.Parser; - -public readonly struct CXBlender -{ - private readonly CXLexer _lexer; - private readonly ImmutableStack _changes; - - private readonly int _newPosition; - private readonly int _changeDelta; - - private readonly CXCursor _cursor; - - - public CXBlender( - CXLexer lexer, - CXDoc document, - IEnumerable changes, - CXCursor? cursor = null - ) - { - _lexer = lexer; - - _newPosition = _lexer.Reader.Source.SourceSpan.Start; - - _changes = [..changes]; - - _cursor = cursor ?? CXCursor.FromRoot(document).MoveToFirstChild(); - } - - private CXBlender( - CXLexer lexer, - CXCursor cursor, - ImmutableStack changes, - int newPosition, - int changeDelta - ) - { - _lexer = lexer; - _cursor = cursor; - _changes = changes; - _newPosition = newPosition; - _changeDelta = changeDelta; - } - - public static TextChangeRange GetAffectedRange(CXDoc doc, TextChangeRange range) - { - return range; - - // // clamp the range to the end of the doc - // var start = Math.Max(Math.Min(range.Span.Start, doc.FullSpan.End - 1), 0); - // - // if (!doc.TryFindToken(start, out var token)) return range; - // - // start = Math.Max(0, token.Span.Start - 1); - // - // var span = TextSpan.FromBounds(start, range.Span.End); - // var length = range.NewLength + (range.Span.Start - start); - // return new(span, length); - } - - public BlendedNode ReadNode() => ReadNodeOrToken(asToken: false); - public BlendedNode ReadToken() => ReadNodeOrToken(asToken: true); - - private BlendedNode ReadNodeOrToken(bool asToken) - => new Reader(this).ReadNodeOrToken(asToken); - - public struct Reader - { - private CXCursor _oldCursor; - private ImmutableStack _changes; - private int _newPosition; - private int _changeDelta; - - private readonly CXLexer _lexer; - - public Reader(CXBlender blender) - { - _lexer = blender._lexer; - _oldCursor = blender._cursor; - _changes = blender._changes; - _newPosition = blender._newPosition; - _changeDelta = blender._changeDelta; - } - - public BlendedNode ReadNodeOrToken(bool asToken) - { - while (true) - { - if (_oldCursor.IsDone) return ReadNewToken(); - - if (_changeDelta < 0) SkipOldToken(); - else if (_changeDelta > 0) return ReadNewToken(); - else - { - if (TryTakeOldNodeOrToken(asToken, out var blendedNode)) return blendedNode; - - if (_oldCursor.Current.Node is not null) - _oldCursor = _oldCursor.MoveToFirstChild(); - else - SkipOldToken(); - } - } - } - - private void SkipOldToken() - { - _oldCursor = _oldCursor.MoveToFirstToken(); - - var current = _oldCursor.Current; - - _changeDelta += current.FullSpan.Length; - - _oldCursor = CXCursor.MoveToNextSibling(_oldCursor); - - SkipPastChanges(); - } - - private void SkipPastChanges() - { - var oldPosition = _oldCursor.Current.FullSpan.Start; - - while (!_changes.IsEmpty && oldPosition >= _changes.Peek().Span.End) - { - var change = _changes.Peek(); - - _changes = _changes.Pop(); - _changeDelta += change.NewLength - change.Span.Length; - } - } - - private BlendedNode ReadNewToken() - { - var token = LexNewToken(); - - _newPosition += token.FullSpan.Length; - _changeDelta -= token.FullSpan.Length; - - SkipPastChanges(); - - return CreateBlendedNode(token: token); - } - - private CXToken LexNewToken() - { - _lexer.Reader.Position = _newPosition; - return _lexer.Next(); - } - - private bool TryTakeOldNodeOrToken( - bool asToken, - out BlendedNode blendedNode - ) - { - if (asToken) _oldCursor = _oldCursor.MoveToFirstToken(); - - var current = _oldCursor.Current; - - if (!CanReuse(current)) - { - blendedNode = default; - return false; - } - - _newPosition += current.FullSpan.Length; - _oldCursor = CXCursor.MoveToNextSibling(_oldCursor); - - blendedNode = CreateBlendedNode( - node: current.Node, - token: current.Token - ); - return true; - } - - private bool CanReuse(NodeOrToken value) - { - if (!value.HasValue) return false; - - if (value.FullSpan.IsEmpty) return false; - - if (IntersectsNextChange(value.FullSpan)) return false; - - // TODO: more riggerous checking; no error nodes/tokens, etc - - return true; - } - - private bool IntersectsNextChange(TextSpan span) - => !_changes.IsEmpty && span.IntersectsWith(_changes.Peek().Span); - - private BlendedNode CreateBlendedNode(CXNode? node = null, CXToken? token = null) - { - Debug.Assert(node is not null || token.HasValue); - - return new( - node, - token, - new CXBlender( - _lexer, - _oldCursor, - _changes, - _newPosition, - _changeDelta - ) - ); - } - } -} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXCursor.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXCursor.cs deleted file mode 100644 index 8cde3db98b..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXCursor.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System.Collections; - -namespace Discord.ComponentDesignerGenerator.Parser; - -public readonly struct CXCursor -{ - public readonly NodeOrToken Current; - private readonly int _index; - - public CXCursor(NodeOrToken current, int index) - { - Current = current; - _index = index; - } - - public static CXCursor FromRoot(CXDoc doc) => new(new(doc), 0); - - public bool IsDone => !Current.HasValue || Current.Token?.Kind is CXTokenKind.EOF or CXTokenKind.Invalid; - - public static bool IsNonZeroWidthOrIsEOF(CXNode.ParseSlot value) - => value.Token?.Kind is CXTokenKind.EOF || !value.FullSpan.IsEmpty; - - - private CXCursor TryFindNextNonZeroWidthOrIsEOFSibling() - { - if (Current.Parent is not null) - { - for (var i = _index + 1; i < Current.Parent.Slots.Count; i++) - { - var sibling = Current.Parent.Slots[i]; - - if (IsNonZeroWidthOrIsEOF(sibling)) - return new CXCursor(NodeOrToken.FromSlot(sibling, Current.Parent), i); - } - } - - return default; - } - - private CXCursor MoveToParent() - { - if (Current.Parent is null) return this; - - var parent = Current.Parent; - return new(new(parent), parent.GetParentSlotIndex()); - } - - public static CXCursor MoveToNextSibling(CXCursor cursor) - { - while (cursor.Current.Parent is not null) - { - var next = cursor.TryFindNextNonZeroWidthOrIsEOFSibling(); - - if (next.Current.HasValue) - return next; - - cursor = cursor.MoveToParent(); - } - - return default; - } - - public CXCursor MoveToFirstChild() - { - if (Current.Node is not {Slots.Count: > 0} node) return default; - - for (var i = 0; i < node.Slots.Count; i++) - { - var child = node.Slots[i]; - if (IsNonZeroWidthOrIsEOF(child)) return new CXCursor(NodeOrToken.FromSlot(child, node), i); - } - - return default; - } - - public CXCursor MoveToFirstToken() - { - var cursor = this; - - if (!cursor.IsDone) - { - for ( - var current = cursor.Current; - current is {HasValue: true, Token: null}; - current = cursor.Current - ) cursor = cursor.MoveToFirstChild(); - } - - return cursor; - } -} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXDoc.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXDoc.cs deleted file mode 100644 index 4004d23cf8..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXDoc.cs +++ /dev/null @@ -1,126 +0,0 @@ -using Microsoft.CodeAnalysis.Text; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; - -namespace Discord.ComponentDesignerGenerator.Parser; - -public sealed class CXDoc : CXNode -{ - public override CXParser Parser { get; } - - public IReadOnlyList Tokens { get; } - - public IReadOnlyList RootElements { get; private set; } - - public CXDoc( - CXParser parser, - IReadOnlyList rootElements, - IReadOnlyList tokens - ) - { - Tokens = tokens; - Parser = parser; - Slot(RootElements = rootElements); - } - - public void ApplyChanges( - CXSource source, - IReadOnlyList changes - ) - { - var affectedRange = CXBlender.GetAffectedRange( - this, - TextChangeRange.Collapse(changes.Select(x => (TextChangeRange)x)) - ); - - var blender = new CXBlender(Parser.Lexer, this, [affectedRange]); - - Parser.Source = source; - Parser.Reset(); - Parser.RootBlender = blender; - - var context = new IncrementalParseContext(changes, affectedRange); - - var owner = FindOwningNode(affectedRange.Span, out _); - - owner.IncrementalParse(context); - } - - public override void IncrementalParse(IncrementalParseContext context) - { - var children = new List(); - - while (Parser.CurrentToken.Kind is not CXTokenKind.EOF and not CXTokenKind.Invalid) - { - children.Add(Parser.ParseElement()); - } - - ClearSlots(); - Slot(RootElements = children); - } - - public bool TryFindToken(int position, out CXToken token) - { - if (!FullSpan.Contains(position)) - { - token = default; - return false; - } - - CXNode? current = this; - - while (current is not null) - { - for (var i = 0; i < current.Slots.Count; i++) - { - var slot = current.Slots[i]; - - if (!slot.FullSpan.Contains(position)) continue; - - if (slot.Token.HasValue) - { - token = slot.Token.Value; - return true; - } - - current = slot.Node; - break; - } - } - - token = default; - return false; - } - - public CXNode FindOwningNode(TextSpan span, out ParseSlot slot) - { - CXNode current = this; - slot = default; - - search: - for (var i = 0; i < current.Slots.Count; i++) - { - slot = current.Slots[i]; - - if ( - // the end is exclusive, since its char-based - !(span.Start >= slot.FullSpan.Start && span.End < slot.FullSpan.End) - ) continue; - - if (slot.Node is null) break; - - current = slot.Node; - goto search; - } - - // // we only want the top most container - // while (current.Parent is not null && current.FullSpan == current.Parent.FullSpan) - // current = current.Parent; - - return current; - } - - public string GetTokenValue(CXToken token) => Parser.Source.GetValue(token.Span); - public string GetTokenValueWithTrivia(CXToken token) => Parser.Source.GetValue(token.FullSpan); -} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXNode.cs deleted file mode 100644 index 5c23887810..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/CXNode.cs +++ /dev/null @@ -1,230 +0,0 @@ -using Microsoft.CodeAnalysis.Text; -using System; -using System.Collections.Generic; -using System.Linq; - -namespace Discord.ComponentDesignerGenerator.Parser; - -public abstract class CXNode -{ - public readonly struct ParseSlot : IEquatable - { - public TextSpan FullSpan => Node?.FullSpan ?? Token?.FullSpan ?? default; - - public readonly int Id; - - public readonly CXNode? Node; - public readonly CXToken? Token; - - public ParseSlot(int id, CXNode node) - { - Id = id; - Node = node; - } - - public ParseSlot(int id, CXToken token) - { - Id = id; - Token = token; - } - - public static bool operator ==(ParseSlot slot, CXNode node) - => slot.Node == node; - - public static bool operator !=(ParseSlot slot, CXNode node) - => slot.Node != node; - - public static bool operator ==(ParseSlot slot, CXToken token) - => slot.Token == token; - - public static bool operator !=(ParseSlot slot, CXToken token) - => slot.Token != token; - - public static bool operator ==(ParseSlot slot, CXToken? token) - => slot.Token == token; - - public static bool operator !=(ParseSlot slot, CXToken? token) - => slot.Token != token; - - public bool Equals(ParseSlot other) - => Equals(Node, other.Node) && Nullable.Equals(Token, other.Token); - - public override bool Equals(object? obj) - => obj is ParseSlot other && Equals(other); - - public override int GetHashCode() - { - unchecked - { - return ((Node != null ? Node.GetHashCode() : 0) * 397) ^ Token.GetHashCode(); - } - } - } - - public CXNode? Parent { get; set; } - public int Width { get; private set; } - - public List Diagnostics { get; } - - public CXDoc Document - { - get => this is CXDoc doc - ? doc - : _doc ??= Parent?.Document ?? throw new InvalidOperationException(); - set => _doc = value; - } - - public virtual CXParser Parser => Document.Parser; - - public CXToken FirstTerminal - { - get - { - if (_slots.Count is 0) return default; - - return _slots[0] switch - { - {Node: { } node} => node.FirstTerminal, - {Token: { } token} => token, - _ => throw new InvalidOperationException() - }; - } - } - - public CXToken LastTerminal - { - get - { - if (_slots.Count is 0) return default; - - return _slots[_slots.Count - 1] switch - { - {Node: { } node} => node.LastTerminal, - {Token: { } token} => token, - _ => throw new InvalidOperationException() - }; - } - } - - public TextSpan FullSpan => new(Offset, Width); - - // TODO: - // this could be cached, a caveat though is if we incrementally parse, we need to update the - // offset/width of any nodes right of the change - public int Offset => ComputeOffset(); - - private int? _offset; - - private CXDoc _doc; - - public IReadOnlyList Slots => _slots; - - private readonly List _slots; - - public CXNode() - { - Diagnostics = []; - _slots = []; - } - - protected void ClearSlots() => _slots.Clear(); - - public int GetParentSlotIndex() - { - if (Parent is null) return -1; - - for (var i = 0; i < Parent._slots.Count; i++) - if (Parent._slots[i] == this) - return i; - - return -1; - } - - private int ComputeOffset() - { - if (Parent is null) return Document.Parser.Source.SourceSpan.Start; - - var parentOffset = Parent.Offset; - var parentSlotIndex = GetParentSlotIndex(); - - return parentSlotIndex switch - { - -1 => throw new InvalidOperationException(), - 0 => parentOffset, - _ => Parent._slots[parentSlotIndex - 1] switch - { - {Node: { } sibling} => sibling.Offset + sibling.Width, - {Token: { } token} => token.AbsoluteEnd, - _ => throw new InvalidOperationException() - } - }; - } - - protected bool IsGraphChild(CXNode node) => IsGraphChild(node, out _); - - protected bool IsGraphChild(CXNode node, out int index) - { - index = -1; - - if (node.Parent != this) return false; - - index = node.GetParentSlotIndex(); - - return index >= 0 && index < _slots.Count && _slots.ElementAt(index) == node; - } - - - protected void UpdateSlot(CXNode old, CXNode @new) - { - if (!IsGraphChild(old, out var slotIndex)) return; - - _slots[slotIndex] = new(slotIndex, @new); - } - - protected void RemoveSlot(CXNode node) - { - if (!IsGraphChild(node, out var index)) return; - - _slots.RemoveAt(index); - } - - protected void Slot(CXCollection? node) where T : CXNode => Slot((CXNode?)node); - protected void Slot(CXNode? node) - { - if (node is null) return; - - Width += node.Width; - - node.Parent = this; - _slots.Add(new(_slots.Count, node)); - } - - protected void Slot(CXToken? token) - { - if (token is null) return; - - _slots.Add(new(_slots.Count, token.Value)); - Width += token.Value.AbsoluteWidth; - } - - protected void Slot(IEnumerable tokens) - { - foreach (var token in tokens) Slot(token); - } - - protected void Slot(IEnumerable nodes) - { - foreach (var node in nodes) Slot(node); - } - - public virtual void IncrementalParse(IncrementalParseContext change) => Parent?.IncrementalParse(change); - - protected void UpdateSelf(CXNode? node) - { - // TODO: update the parents slot - OnDescendantUpdated(this, node); - } - - protected virtual void OnDescendantUpdated(CXNode? old, CXNode? descendant) - => Parent?.OnDescendantUpdated(old, descendant); -} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/NodeOrToken.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/NodeOrToken.cs deleted file mode 100644 index 640e0a56c6..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Parser2/NodeOrToken.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Microsoft.CodeAnalysis.Text; -using System; - -namespace Discord.ComponentDesignerGenerator.Parser; - -public readonly struct NodeOrToken -{ - public bool HasValue => Node is not null || Token is not null; - - public TextSpan FullSpan => Node?.FullSpan ?? Token?.FullSpan ?? default; - - public readonly CXNode? Parent; - - public readonly CXNode? Node; - - public readonly CXToken? Token; - - public NodeOrToken(CXNode node) - { - Node = node; - Parent = node.Parent; - } - - public NodeOrToken(CXNode parent, CXToken token) - { - Token = token; - Parent = parent; - } - - public static NodeOrToken FromSlot(CXNode.ParseSlot slot, CXNode parent) - => slot switch - { - {Token: { } token} => new(parent, token), - {Node: { } node} => new(node), - _ => throw new InvalidOperationException() - }; -} diff --git a/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs b/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs index da01fa9702..2e8ce178cb 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs @@ -4,6 +4,7 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Operations; +using Microsoft.CodeAnalysis.Text; using System; using System.Collections.Generic; using System.Collections.Immutable; @@ -13,156 +14,76 @@ namespace Discord.ComponentDesignerGenerator; -[Generator] -public sealed class SourceGenerator : IIncrementalGenerator +public sealed record Target( + InterceptableLocation InterceptLocation, + InvocationExpressionSyntax InvocationSyntax, + ExpressionSyntax ArgumentExpressionSyntax, + IOperation Operation, + Compilation Compilation, + string? ParentKey, + string CXDesigner, + TextSpan CXDesignerSpan, + DesignerInterpolationInfo[] Interpolations +) { - 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; - } - } - } + public SyntaxTree SyntaxTree => InvocationSyntax.SyntaxTree; +} - private readonly record struct Interceptor( - string? Source, - Diagnostic[] Diagnostics - ); +public sealed record DesignerInterpolationInfo( + TextSpan Span, + ITypeSymbol? Symbol +); +[Generator] +public sealed class SourceGenerator : IIncrementalGenerator +{ + private readonly Dictionary _cache = []; public void Initialize(IncrementalGeneratorInitializationContext context) { - var manager = new SourceManager(context); - return; - var provider = context .SyntaxProvider - .CreateSyntaxProvider((x, _) => - x is InvocationExpressionSyntax - { - Expression: MemberAccessExpressionSyntax - { - Name: {Identifier.Value: "Create" or "cx"} - } or IdentifierNameSyntax - { - Identifier.ValueText: "cx" - } - }, - Transform + .CreateSyntaxProvider( + IsComponentDesignerCall, + MapPossibleComponentDesignerCall ) - .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] + context.RegisterSourceOutput( + provider + .Combine(provider.Select(GetKeysAndUpdateCachedEntries)) + .SelectMany(MapManagers) + .Select((x, _) => x.Render()) + .Collect(), + Generate ); } - private void Generate(SourceProductionContext context, ImmutableArray arg2) + private void Generate(SourceProductionContext context, ImmutableArray interceptors) { + if (interceptors.Length is 0) return; + var sb = new StringBuilder(); - foreach (var interceptor in arg2) + foreach (var interceptor in interceptors) { - if (!interceptor.HasValue) continue; - - foreach (var diagnostic in interceptor.Value.Diagnostics) + foreach (var diagnostic in interceptor.Diagnostics) { context.ReportDiagnostic(diagnostic); } - if (interceptor.Value.Source is not null) sb.AppendLine(interceptor.Value.Source); + sb.AppendLine( + $$""" + [global::System.Runtime.CompilerServices.InterceptsLocation(version: {{interceptor.Location.Version}}, data: "{{interceptor.Location.Data}}")] + public static global::Discord.ComponentBuilderV2 _{{Math.Abs(interceptor.GetHashCode())}}( + global::{{Constants.COMPONENT_DESIGNER_QUALIFIED_NAME}} designer + ) => new( + {{interceptor.Source.WithNewlinePadding(4)}} + ) + """ + ); } - if (sb.Length is 0) return; - context.AddSource( "Interceptors.g.cs", $$""" @@ -171,117 +92,236 @@ private void Generate(SourceProductionContext context, ImmutableArray MapManagers( + (ImmutableArray targets, ImmutableArray keys) tuple, + CancellationToken token + ) { - var operation = context.SemanticModel.GetOperation(context.Node, token); + var (targets, keys) = tuple; - checkOperation: - switch (operation) + for (var i = 0; i < targets.Length; i++) { - case IInvalidOperation invalid: - operation = invalid.ChildOperations.OfType().FirstOrDefault(); - goto checkOperation; - case IInvocationOperation invocation: - if ( - invocation - .TargetMethod - .ContainingType - .ToDisplayString() - is "Discord.ComponentDesigner" - ) break; - goto default; - - default: return null; + var target = targets[i]; + var key = keys[i]; + + if (target is null || key is null) continue; + + // TODO: handle key updates + + if (_cache.TryGetValue(key, out var manager)) + { + manager.OnUpdate(key, target); + } + else + { + manager = _cache[key] = CXGraphManager.Create( + this, + key, + target + ); + } + + yield return manager; } + } - if (context.Node is not InvocationExpressionSyntax invocationSyntax) return null; + private ImmutableArray GetKeysAndUpdateCachedEntries(ImmutableArray target, + CancellationToken token) + { + var result = new string?[target.Length]; - if (context.SemanticModel.GetInterceptableLocation(invocationSyntax) is not { } location) - return null; + var map = new Dictionary(); + var globalCount = 0; - if (invocationSyntax.ArgumentList.Arguments.Count is not 1) return null; + for (var i = 0; i < target.Length; i++) + { + var targetItem = target[i]; - var argument = invocationSyntax.ArgumentList.Arguments[0].Expression; + if (targetItem is null) continue; - var content = new List(); - var interpolations = new List(); - var isMultiLine = false; + string key; + if (targetItem.ParentKey is null) + { + key = $":{globalCount++}"; + } + else + { + map.TryGetValue(targetItem.ParentKey, out var index); - switch (argument) + key = $"{targetItem.ParentKey}:{index}"; + map[targetItem.ParentKey] = index + 1; + } + + result[i] = key; + } + + foreach (var key in _cache.Keys.Except(result)) { - 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; + if (key is not null) _cache.Remove(key); } + return [..result]; + } + + private static void OnTargetUpdated(Target? target, CancellationToken token) + { + if (target is null) return; + + //target.Compilation.SyntaxTrees + } + + + private static void ProcessTargetsUpdate(ImmutableArray targets, CancellationToken token) + { + foreach (var target in targets) + { + if (target is null) continue; + } + } + + + private static Target? MapPossibleComponentDesignerCall(GeneratorSyntaxContext context, CancellationToken token) + { + if ( + !TryGetValidDesignerCall( + out var operation, + out var invocationSyntax, + out var interceptLocation, + out var argumentSyntax + ) + ) return null; + + if ( + !TryGetCXDesigner( + argumentSyntax, + context.SemanticModel, + out var cxDesigner, + out var span, + out var interpolationInfos + ) + ) return null; + return new Target( - location, - argument.GetLocation(), - content.ToArray(), - interpolations.ToArray(), - isMultiLine, - context.SemanticModel.Compilation.GetKnownTypes(), - LookupNode + interceptLocation, + invocationSyntax, + argumentSyntax, + operation, + context.SemanticModel.Compilation, + context.SemanticModel + .GetEnclosingSymbol(invocationSyntax.SpanStart, token) + ?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + cxDesigner, + span, + interpolationInfos ); - ImmutableArray LookupNode(string? name) - => context.SemanticModel.LookupNamespacesAndTypes(context.Node.SpanStart, name: name); + static bool TryGetCXDesigner( + ExpressionSyntax expression, + SemanticModel semanticModel, + out string content, + out TextSpan span, + out DesignerInterpolationInfo[] interpolations + ) + { + switch (expression) + { + case LiteralExpressionSyntax {Token.Value: string literalContent} literal: + content = literalContent; + interpolations = []; + span = literal.Token.Span; + return true; + + case InterpolatedStringExpressionSyntax interpolated: + content = interpolated.Contents.ToString(); + interpolations = interpolated.Contents + .OfType() + .Select(x => new DesignerInterpolationInfo( + x.FullSpan, + semanticModel.GetTypeInfo(x.Expression).Type + )) + .ToArray(); + span = interpolated.Contents.Span; + return true; + default: + content = string.Empty; + span = default; + interpolations = []; + return false; + } + } + + bool TryGetValidDesignerCall( + out IOperation operation, + out InvocationExpressionSyntax invocationSyntax, + out InterceptableLocation interceptLocation, + out ExpressionSyntax argumentExpressionSyntax + ) + { + operation = context.SemanticModel.GetOperation(context.Node, token)!; + interceptLocation = null!; + argumentExpressionSyntax = null!; + invocationSyntax = null!; + + checkOperation: + switch (operation) + { + case IInvalidOperation invalid: + operation = invalid.ChildOperations.OfType().FirstOrDefault()!; + goto checkOperation; + case IInvocationOperation invocation: + if ( + invocation + .TargetMethod + .ContainingType + .ToDisplayString() + is "Discord.ComponentDesigner" + ) break; + goto default; + + default: return false; + } + + if (context.Node is not InvocationExpressionSyntax syntax) return false; + + invocationSyntax = syntax; + + if (context.SemanticModel.GetInterceptableLocation(invocationSyntax) is not { } location) + return false; + + interceptLocation = location; + + if (invocationSyntax.ArgumentList.Arguments.Count is not 1) return false; + + argumentExpressionSyntax = invocationSyntax.ArgumentList.Arguments[0].Expression; + + return true; + } } + + private static bool IsComponentDesignerCall(SyntaxNode node, CancellationToken token) + => node is InvocationExpressionSyntax + { + Expression: MemberAccessExpressionSyntax + { + Name: {Identifier.Value: "Create" or "cx"} + } or IdentifierNameSyntax + { + Identifier.ValueText: "cx" + } + }; } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Utils/IsExternalInit.cs b/src/Discord.Net.ComponentDesigner.Generator/Utils/IsExternalInit.cs index 103d14aa61..533cb3384a 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Utils/IsExternalInit.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Utils/IsExternalInit.cs @@ -1,3 +1,6 @@ namespace System.Runtime.CompilerServices; internal sealed class IsExternalInit : Attribute; +internal sealed class CompilerFeatureRequiredAttribute(string s) : Attribute; + +internal sealed class RequiredMemberAttribute : Attribute; diff --git a/src/Discord.Net.Core/Discord.Net.Core.csproj b/src/Discord.Net.Core/Discord.Net.Core.csproj index c51e74ecf3..74bf16cd47 100644 --- a/src/Discord.Net.Core/Discord.Net.Core.csproj +++ b/src/Discord.Net.Core/Discord.Net.Core.csproj @@ -11,6 +11,7 @@ false false true + NU1510 snupkg From 6a67e90fe07af2a2b650c1e605d7d413ead5886b Mon Sep 17 00:00:00 2001 From: Quin Lynch <49576606+quinchs@users.noreply.github.com> Date: Fri, 19 Sep 2025 12:15:16 -0300 Subject: [PATCH 15/17] scaffold incremental components --- .../Diagnostics.cs | 173 +++++- .../Generator/CXGraph.cs | 142 ----- .../Graph/CXGraph.cs | 320 ++++++++++ .../{Generator => Graph}/CXGraphManager.cs | 128 ++-- .../RenderedInterceptor.cs | 5 +- .../Nodes/ComponentContext.cs | 18 +- .../Nodes/ComponentNode.cs | 76 ++- .../Nodes/ComponentProperty.cs | 10 + .../Nodes/ComponentPropertyValue.cs | 31 +- .../Nodes/ComponentState.cs | 71 ++- .../Components/ActionRowComponentNode.cs | 63 ++ .../Nodes/Components/ButtonComponentNode.cs | 141 ++++- .../Components/ContainerComponentNode.cs | 74 +++ .../Nodes/Components/FileComponentNode.cs | 43 ++ .../Nodes/Components/LabelComponentNode.cs | 35 ++ .../Components/MediaGalleryComponentNode.cs | 78 +++ .../Nodes/Components/RowComponentNode.cs | 26 - .../Nodes/Components/SectionComponentnode.cs | 160 +++++ .../SelectMenus/SelectMenuComponentNode.cs | 224 +++++++ .../SelectMenus/SelectMenuDefaultValueKind.cs | 8 + .../SelectMenus/SelectMenuDefautValue.cs | 8 + .../StringSelectOptionComponentNode.cs | 74 +++ .../Components/SeparatorComponentNode.cs | 45 ++ .../Components/TextDisplayComponentNode.cs | 45 ++ .../Components/TextInputComponentNode.cs | 76 +++ .../Components/ThumbnailComponentNode.cs | 49 ++ .../Nodes/Renderers/Renderers.cs | 555 +++++++++++++++--- .../Nodes/Validators/Validators.cs | 45 +- .../Parsing/CXParser.cs | 28 +- .../Parsing/ICXNode.cs | 2 + .../Parsing/Incremental/CXBlender.cs | 20 +- .../Parsing/Lexer/CXLexer.cs | 15 +- .../Parsing/Lexer/CXToken.cs | 40 +- .../Parsing/Nodes/CXCollection.cs | 16 +- .../Parsing/Nodes/CXDoc.cs | 15 + .../Parsing/Nodes/CXNode.cs | 131 ++++- .../Parsing/Nodes/CXValue.cs | 6 +- .../SourceGenerator.cs | 12 +- .../Utils/StringUtils.cs | 20 +- 39 files changed, 2624 insertions(+), 404 deletions(-) delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Generator/CXGraph.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraph.cs rename src/Discord.Net.ComponentDesigner.Generator/{Generator => Graph}/CXGraphManager.cs (56%) rename src/Discord.Net.ComponentDesigner.Generator/{Generator => Graph}/RenderedInterceptor.cs (78%) create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.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 delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/RowComponentNode.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentnode.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuComponentNode.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefaultValueKind.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefautValue.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/StringSelectOptionComponentNode.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.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 diff --git a/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs b/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs index f81e63a347..467659051e 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs @@ -7,7 +7,7 @@ public static partial class Diagnostics public static readonly DiagnosticDescriptor ParseError = new( "DCP001", "CX Parsing error", - "{}", + "{0}", "Component Parser (CX)", DiagnosticSeverity.Error, true @@ -66,4 +66,175 @@ public static partial class Diagnostics DiagnosticSeverity.Error, true ); + + public static readonly DiagnosticDescriptor LinkButtonUrlMissing = new( + "DC0007", + "Invalid button", + "A 'link' button must specify 'url'", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor PremiumButtonSkuMissing = new( + "DC0008", + "Invalid button", + "A 'premium' button must specify 'skuId'", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor PremiumButtonPropertyNotAllowed = new( + "DC0009", + "Invalid button", + "A 'premium' button cannot specify '{0}'", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor ButtonLabelDuplicate = new( + "DC0010", + "Duplicate label definition", + "A button cannot specify both a body and a 'label'", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor EmptyActionRow = new( + "DC0011", + "Empty Action Row", + "An action row must contain at least one child", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor MissingRequiredProperty = new( + "DC0012", + "Missing Property", + "'{0}' requires the property '{1}' to be specified", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor UnknownProperty = new( + "DC0013", + "Unknown Property", + "'{0}' is not a known property of '{1}'", + "Components", + DiagnosticSeverity.Warning, + true + ); + + public static readonly DiagnosticDescriptor EmptyAccessory = new( + "DC0014", + "Empty Accessory", + "An accessory must have 1 child", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor TooManyAccessoryChildren = new( + "DC0015", + "Too many accessory children", + "An accessory must have 1 child", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor EmptySection = new( + "DC0016", + "Section cannot be empty", + "A section must have an accessory and a child", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor InvalidAccessoryChild = new( + "DC0017", + "Invalid accessory child", + "'{0}' is not a valid accessory, only buttons and thumbnails are allowed", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor MissingAccessory = new( + "DC0018", + "Missing accessory", + "A section must contain an accessory", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor TooManyAccessories = new( + "DC0019", + "Too many accessories", + "A section can only contain one accessory", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor MissingSectionChild = new( + "DC0020", + "Missing section child", + "A section must contain at least 1 non-accessory component", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor TooManySectionChildren = new( + "DC0021", + "Too many section children", + "A section must contain at most 3 non-accessory components", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor InvalidSectionChildComponentType = new( + "DC0022", + "Invalid section child component type", + "'{0}' is not a valid child component of a section; only text displays are allowed", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor MissingSelectMenuType = new( + "DC0023", + "Missing select menu type", + "You must specify the type of the select menu, being one of 'string', 'user', 'role', 'channel', or 'mentionable'", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor InvalidSelectMenuType = new( + "DC0024", + "Invalid select menu type", + "Select menu type must be either 'string', 'user', 'role', 'channel', or 'mentionable'", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor SpecifiedInvalidSelectMenuType = new( + "DC0024", + "Invalid select menu type", + "'{0}' is not a valid elect menu type; must be either 'string', 'user', 'role', 'channel', or 'mentionable'", + "Components", + DiagnosticSeverity.Error, + true + ); } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Generator/CXGraph.cs b/src/Discord.Net.ComponentDesigner.Generator/Generator/CXGraph.cs deleted file mode 100644 index f30fe57014..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Generator/CXGraph.cs +++ /dev/null @@ -1,142 +0,0 @@ -using Discord.ComponentDesignerGenerator.Nodes; -using Discord.ComponentDesignerGenerator.Parser; -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; - -namespace Discord.ComponentDesignerGenerator; - -public sealed class CXGraph -{ - public CXDoc Document { get; } - public CXGraphManager Manager { get; } - - public List Roots { get; } - - public Dictionary NodeCacheMap { get; private set; } - public Dictionary PropertyCacheMap { get; } - - public CXGraph(CXDoc document, CXGraphManager manager) - { - Document = document; - Manager = manager; - Roots = []; - NodeCacheMap = []; - PropertyCacheMap = []; - } - - public void Update( - CXDoc doc, - IReadOnlyList reusedNodes - ) - { - var context = new ComponentContext(this); - - var map = new Dictionary(); - - // update the properties map - foreach (var cxNode in PropertyCacheMap.Keys.Except(reusedNodes.OfType())) - { - PropertyCacheMap.Remove(cxNode); - } - - Roots.Clear(); - Roots.AddRange( - doc.RootElements.Select(x => CreateNode(null, x)).Where(x => x is not null)! - ); - - NodeCacheMap.Clear(); - NodeCacheMap = map; - - return; - - Node? CreateNode(Node? parent, CXNode cxNode) - { - if (reusedNodes.Contains(cxNode) && NodeCacheMap.TryGetValue(cxNode, out var existing)) - return map[cxNode] = existing with {Parent = parent}; - - switch (cxNode) - { - case CXElement element: - if (!ComponentNode.TryGetNode(element.Identifier, out var componentNode)) - { - context.AddDiagnostic( - Diagnostics.UnknownComponent, - element, - element.Identifier - ); - - return null; - } - - var children = new List(); - - var state = componentNode.Create(element, children); - - if (state is null) return null; - - var node = state.OwningNode = new Node( - componentNode, - state, - parent, - [], - this - ); - - map[element] = node; - - node.Children.AddRange( - children.Select(x => CreateNode(node, x)).Where(x => x is not null)! - ); - - return node; - default: return null; - } - } - } - - public static CXGraph Create(CXDoc doc, CXGraphManager manager) - { - var graph = new CXGraph(doc, manager); - - graph.Update(doc, []); - - return graph; - } - - public void Validate(ComponentContext? context = null) - { - context ??= new ComponentContext(this); - - foreach (var node in Roots) node.Validate(context); - } - - public string Render(ComponentContext? context = null) - { - context ??= new ComponentContext(this); - - return string.Join(",\n", Roots.Select(x => x.Inner.Render(x.State, context))); - } - - public sealed record Node( - ComponentNode Inner, - ComponentState State, - Node? Parent, - List Children, - CXGraph Graph - ) - { - private string? _render; - - public string Render(ComponentContext context) - => _render ??= Inner.Render(State, context); - - public void Validate(ComponentContext context) - { - Inner.Validate(State, context); - - foreach (var child in Children) child.Validate(context); - } - } -} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraph.cs b/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraph.cs new file mode 100644 index 0000000000..839e4965cb --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraph.cs @@ -0,0 +1,320 @@ +using Discord.ComponentDesignerGenerator.Nodes; +using Discord.ComponentDesignerGenerator.Parser; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.ComponentDesignerGenerator; + +public readonly struct CXGraph +{ + public readonly CXGraphManager Manager; + public readonly ImmutableArray RootNodes; + public readonly ImmutableArray Diagnostics; + public readonly IReadOnlyDictionary NodeMap; + + public CXGraph( + CXGraphManager manager, + ImmutableArray rootNodes, + ImmutableArray diagnostics, + IReadOnlyDictionary nodeMap + ) + { + Manager = manager; + RootNodes = rootNodes; + Diagnostics = diagnostics; + NodeMap = nodeMap; + } + + public Location GetLocation(ICXNode node) => GetLocation(Manager, node); + public Location GetLocation(TextSpan span) => GetLocation(Manager, span); + + public static Location GetLocation(CXGraphManager manager, ICXNode node) + => GetLocation(manager, node.Span); + public static Location GetLocation(CXGraphManager manager, TextSpan span) + => manager.SyntaxTree.GetLocation(span); + + public CXGraph Update(CXGraphManager manager, IncrementalParseResult parseResult) + { + if (manager == Manager) return this; + + var map = new Dictionary(); + var diagnostics = ImmutableArray.CreateBuilder(); + + var rootNodes = ImmutableArray.CreateBuilder(); + + foreach (var cxNode in manager.Document.RootElements) + { + var node = CreateNode( + manager, + cxNode, + null, + parseResult.ReusedNodes, + this, + map, diagnostics + ); + + if (node is not null) rootNodes.Add(node); + } + + return new(manager, rootNodes.ToImmutable(), diagnostics.ToImmutable(), map); + } + + public static CXGraph Create( + CXGraphManager manager + ) + { + var map = new Dictionary(); + var diagnostics = ImmutableArray.CreateBuilder(); + + var rootNodes = manager.Document + .RootElements + .Select(x => + CreateNode( + manager, + x, + null, + [], + null, + map, + diagnostics + ) + ) + .Where(x => x is not null) + .ToImmutableArray(); + + return new(manager, rootNodes!, diagnostics.ToImmutable(), map); + } + + private static Node? CreateNode( + CXGraphManager manager, + CXNode cxNode, + Node? parent, + IReadOnlyList reusedNodes, + CXGraph? oldGraph, + Dictionary map, + ImmutableArray.Builder diagnostics + ) + { + if ( + oldGraph.HasValue && + reusedNodes.Contains(cxNode) && + oldGraph.Value.NodeMap.TryGetValue(cxNode, out var existing) + ) return map[cxNode] = existing with {Parent = parent}; + + switch (cxNode) + { + case CXElement element: + if (!ComponentNode.TryGetNode(element.Identifier, out var componentNode)) + { + diagnostics.Add( + Diagnostic.Create( + ComponentDesignerGenerator.Diagnostics.UnknownComponent, + GetLocation(manager, element), + element.Identifier + ) + ); + + return null; + } + + var children = new List(); + + var state = componentNode.Create(element, children); + + if (state is null) return null; + + var nodeChildren = new List(); + var node = map[element] = state.OwningNode = new( + componentNode, + state, + parent, + nodeChildren + ); + + nodeChildren.AddRange( + children + .Select(x => CreateNode( + manager, + x, + node, + reusedNodes, + oldGraph, + map, + diagnostics + ) + ) + .Where(x => x is not null)! + ); + + return node; + default: return null; + } + } + + public void Validate(ComponentContext context) + { + foreach (var node in RootNodes) node.Validate(context); + } + + public string Render(ComponentContext context) + => string.Join(",\n", RootNodes.Select(x => x.Render(context))); + + public sealed record Node( + ComponentNode Inner, + ComponentState State, + Node? Parent, + IReadOnlyList Children + ) + { + private string? _render; + + public string Render(ComponentContext context) + => _render ??= Inner.Render(State, context); + + public void Validate(ComponentContext context) + { + Inner.Validate(State, context); + foreach(var child in Children) child.Validate(context); + } + } + + // public CXDoc Document { get; } + // public CXGraphManager Manager { get; } + // + // public List Roots { get; } + // + // public Dictionary NodeCacheMap { get; private set; } + // public Dictionary PropertyCacheMap { get; } + // + // public CXGraph(CXDoc document, CXGraphManager manager) + // { + // Document = document; + // Manager = manager; + // Roots = []; + // NodeCacheMap = []; + // PropertyCacheMap = []; + // } + // + // public CXGraph Update(CXGraphManager manager) + // { + // + // } + // + // // public CXGraph Update( + // // CXDoc doc, + // // IReadOnlyList reusedNodes + // // ) + // // { + // // var context = new ComponentContext(this); + // // + // // var map = new Dictionary(); + // // + // // // update the properties map + // // foreach (var cxNode in PropertyCacheMap.Keys.Except(reusedNodes.OfType())) + // // { + // // PropertyCacheMap.Remove(cxNode); + // // } + // // + // // Roots.Clear(); + // // Roots.AddRange( + // // doc.RootElements.Select(x => CreateNode(null, x)).Where(x => x is not null)! + // // ); + // // + // // NodeCacheMap.Clear(); + // // NodeCacheMap = map; + // // + // // return; + // // + // // Node? CreateNode(Node? parent, CXNode cxNode) + // // { + // // if (reusedNodes.Contains(cxNode) && NodeCacheMap.TryGetValue(cxNode, out var existing)) + // // return map[cxNode] = existing with {Parent = parent}; + // // + // // switch (cxNode) + // // { + // // case CXElement element: + // // if (!ComponentNode.TryGetNode(element.Identifier, out var componentNode)) + // // { + // // context.AddDiagnostic( + // // Diagnostics.UnknownComponent, + // // element, + // // element.Identifier + // // ); + // // + // // return null; + // // } + // // + // // var children = new List(); + // // + // // var state = componentNode.Create(element, children); + // // + // // if (state is null) return null; + // // + // // var node = state.OwningNode = new Node( + // // componentNode, + // // state, + // // parent, + // // [], + // // this + // // ); + // // + // // map[element] = node; + // // + // // node.Children.AddRange( + // // children.Select(x => CreateNode(node, x)).Where(x => x is not null)! + // // ); + // // + // // return node; + // // default: return null; + // // } + // // } + // // } + // + // public static CXGraph Create(CXDoc doc, CXGraphManager manager) + // { + // var graph = new CXGraph(doc, manager); + // + // graph.Update(doc, []); + // + // return graph; + // } + // + // public void Validate(ComponentContext? context = null) + // { + // context ??= new ComponentContext(this); + // + // foreach (var node in Roots) node.Validate(context); + // } + // + // public string Render(ComponentContext? context = null) + // { + // context ??= new ComponentContext(this); + // + // return string.Join(",\n", Roots.Select(x => x.Inner.Render(x.State, context))); + // } + // + // public sealed record Node( + // ComponentNode Inner, + // ComponentState State, + // Node? Parent, + // List Children, + // CXGraph Graph + // ) + // { + // private string? _render; + // + // public string Render(ComponentContext context) + // => _render ??= Inner.Render(State, context); + // + // public void Validate(ComponentContext context) + // { + // Inner.Validate(State, context); + // + // foreach (var child in Children) child.Validate(context); + // } + // } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Generator/CXGraphManager.cs b/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraphManager.cs similarity index 56% rename from src/Discord.Net.ComponentDesigner.Generator/Generator/CXGraphManager.cs rename to src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraphManager.cs index 2cb5766cd8..5da5bf9d25 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Generator/CXGraphManager.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraphManager.cs @@ -4,6 +4,7 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -11,51 +12,67 @@ namespace Discord.ComponentDesignerGenerator; -public sealed class CXGraphManager +// public sealed class GraphManagerComparer : IEqualityComparer +// { +// public static readonly GraphManagerComparer Instance = new(); +// +// public bool Equals(CXGraphManager? x, CXGraphManager? y) +// => (x, y) switch +// { +// (not null, not null) => x.SyntaxTree.Equals(y.SyntaxTree) && x.Version == y.Version, +// _ => false +// }; +// +// public int GetHashCode(CXGraphManager manager) +// => (manager.SyntaxTree.GetHashCode() * 397) ^ manager.Version; +// } + +public sealed record CXGraphManager( + SourceGenerator Generator, + string Key, + Target Target, + CXDoc Document +) { public SyntaxTree SyntaxTree => InvocationSyntax.SyntaxTree; - public InterceptableLocation InterceptLocation => _target.InterceptLocation; - public InvocationExpressionSyntax InvocationSyntax => _target.InvocationSyntax; - public ExpressionSyntax ArgumentExpressionSyntax => _target.ArgumentExpressionSyntax; - public IOperation Operation => _target.Operation; - public Compilation Compilation => _target.Compilation; + public InterceptableLocation InterceptLocation => Target.InterceptLocation; + public InvocationExpressionSyntax InvocationSyntax => Target.InvocationSyntax; + public ExpressionSyntax ArgumentExpressionSyntax => Target.ArgumentExpressionSyntax; + public IOperation Operation => Target.Operation; + public Compilation Compilation => Target.Compilation; - public string CXDesigner => _target.CXDesigner; - public DesignerInterpolationInfo[] InterpolationInfos => _target.Interpolations; + public string CXDesigner => Target.CXDesigner; + public DesignerInterpolationInfo[] InterpolationInfos => Target.Interpolations; - public TextSpan CXDesignerSpan => _target.CXDesignerSpan; + public TextSpan CXDesignerSpan => Target.CXDesignerSpan; - public CXParser Parser => _document.Parser; + public CXParser Parser => Document.Parser; - private readonly SourceGenerator _generator; - - private CXDoc _document; - private Target _target; - private string _key; - - private string _basicCXSource; - - private CXGraph _graph; - - public CXGraphManager( - SourceGenerator generator, - string key, - Target target, - CXDoc document - ) + public CXGraph Graph { - _generator = generator; - _target = target; - _document = document; - _key = key; + get => _graph ??= CXGraph.Create(this); + init => _graph = value; + } - _basicCXSource = GetCXWithoutInterpolations( + public string SimpleSource => _simpleSource ??= ( + GetCXWithoutInterpolations( CXDesignerSpan.Start, CXDesigner, InterpolationInfos - ); + ) + ); + + private string? _simpleSource; - _graph = CXGraph.Create(_document, this); + private CXGraph? _graph; + + public CXGraphManager(CXGraphManager other) + { + _graph = other.Graph; + Generator = other.Generator; + Key = other.Key; + Target = other.Target; + Document = other.Document; } public static CXGraphManager Create(SourceGenerator generator, string key, Target target) @@ -66,10 +83,17 @@ public static CXGraphManager Create(SourceGenerator generator, string key, Targe target.Interpolations.Select(x => x.Span).ToArray() ); - return new CXGraphManager(generator, key, target, CXParser.Parse(source)); + var doc = CXParser.Parse(source); + + return new CXGraphManager( + generator, + key, + target, + doc + ); } - public void OnUpdate(string key, Target target) + public CXGraphManager OnUpdate(string key, Target target) { /* * TODO: @@ -90,26 +114,25 @@ public void OnUpdate(string key, Target target) * our emitted source. */ + var result = this with {Key = key, Target = target}; + var newCXWithoutInterpolations = GetCXWithoutInterpolations( target.ArgumentExpressionSyntax.SpanStart, target.CXDesigner, target.Interpolations ); - if (newCXWithoutInterpolations != _basicCXSource) + if (newCXWithoutInterpolations != SimpleSource) { // we're going to need to reparse, the underlying CX structure changed - DoReparse(target); + result.DoReparse(target, this, ref result); } - _target = target; - _key = key; + return result; } - private void DoReparse(Target target) + private void DoReparse(Target target, CXGraphManager old, ref CXGraphManager result) { - Debug.Assert(_document is not null); - var source = new CXSource( target.CXDesignerSpan, target.CXDesigner, @@ -118,22 +141,22 @@ private void DoReparse(Target target) var changes = target .SyntaxTree - .GetChanges(_target.SyntaxTree) - .Where(x => CXDesignerSpan.Contains(x.Span)) + .GetChanges(old.SyntaxTree) + .Where(x => CXDesignerSpan.IntersectsWith(x.Span)) .ToArray(); - var result = _document!.ApplyChanges( + var parseResult = Document.ApplyChanges( source, changes ); - _graph.Update(_document, result.ReusedNodes); + result = result with {Graph = result.Graph.Update(result, parseResult)}; } public RenderedInterceptor Render() { var diagnostics = new List( - _document + Document .Diagnostics .Select(x => Diagnostic.Create( Diagnostics.ParseError, @@ -141,6 +164,9 @@ public RenderedInterceptor Render() x.Message ) ) + .Concat( + Graph.Diagnostics + ) ); if (diagnostics.Count > 0) @@ -148,17 +174,17 @@ public RenderedInterceptor Render() return new(InterceptLocation, string.Empty, [..diagnostics]); } - var context = new ComponentContext(_graph) {Diagnostics = diagnostics}; + var context = new ComponentContext(Graph) {Diagnostics = diagnostics}; - _graph.Validate(context); + Graph.Validate(context); var source = context.HasErrors ? string.Empty - : _graph.Render(context); + : Graph.Render(context); return new( this.InterceptLocation, - _graph.Render(), + source, [..diagnostics] ); } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Generator/RenderedInterceptor.cs b/src/Discord.Net.ComponentDesigner.Generator/Graph/RenderedInterceptor.cs similarity index 78% rename from src/Discord.Net.ComponentDesigner.Generator/Generator/RenderedInterceptor.cs rename to src/Discord.Net.ComponentDesigner.Generator/Graph/RenderedInterceptor.cs index b34dc57b9d..de0ab5fc62 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Generator/RenderedInterceptor.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Graph/RenderedInterceptor.cs @@ -12,7 +12,10 @@ ImmutableArray Diagnostics ) { public bool Equals(RenderedInterceptor other) - => Location.Equals(other.Location) && Source == other.Source && Diagnostics.SequenceEqual(other.Diagnostics); + => Location.Data == other.Location.Data && + Location.Version == other.Location.Version && + Source == other.Source && + Diagnostics.SequenceEqual(other.Diagnostics); public override int GetHashCode() { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentContext.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentContext.cs index 0aa156af8c..57a88dd21c 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentContext.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentContext.cs @@ -1,5 +1,6 @@ using Discord.ComponentDesignerGenerator.Parser; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; using System.Collections.Generic; using System.Linq; @@ -21,12 +22,27 @@ public ComponentContext(CXGraph graph) _graph = graph; } + public string GetDesignerValue(CXValue.Interpolation interpolation, string? type = null) + => GetDesignerValue(interpolation.InterpolationIndex, type); + + public string GetDesignerValue(DesignerInterpolationInfo interpolation, string? type = null) + => GetDesignerValue(interpolation.Id, type); + + public string GetDesignerValue(int index, string? type = null) + => type is not null ? $"designer.GetValue<{type}>({index})" : $"designer.GetValueAsString({index})"; + + public Location GetLocation(ICXNode node) - => _graph.Manager.SyntaxTree.GetLocation(node.Span); + => _graph.GetLocation(node); + public Location GetLocation(TextSpan span) + => _graph.GetLocation(span); public void AddDiagnostic(DiagnosticDescriptor descriptor, ICXNode node, params object?[]? args) => AddDiagnostic(Diagnostic.Create(descriptor, GetLocation(node), args)); + public void AddDiagnostic(DiagnosticDescriptor descriptor, TextSpan span, params object?[]? args) + => AddDiagnostic(Diagnostic.Create(descriptor, GetLocation(span), args)); + public DesignerInterpolationInfo GetInterpolationInfo(CXValue.Interpolation interpolation) => GetInterpolationInfo(interpolation.InterpolationIndex); diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs index 020b472663..7a17aa76c4 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs @@ -6,20 +6,23 @@ namespace Discord.ComponentDesignerGenerator.Nodes; -public abstract class ComponentNode : ComponentNode where TState : ComponentState, IEquatable, new() +public abstract class ComponentNode : ComponentNode + where TState : ComponentState { - public abstract string Render(TState state); + public abstract string Render(TState state, ComponentContext context); public virtual void UpdateState(ref TState state) { } public sealed override void UpdateState(ref ComponentState state) => UpdateState(ref Unsafe.As(ref state)); - public override ComponentState? Create(ICXNode source, List children) - => new TState() {Source = source}; + public abstract TState? CreateState(ICXNode source, List children); + + public sealed override ComponentState? Create(ICXNode source, List children) + => CreateState(source, children); public sealed override string Render(ComponentState state, ComponentContext context) - => Render((TState)state); + => Render((TState)state, context); public virtual void Validate(TState state, ComponentContext context) { } @@ -36,14 +39,73 @@ public abstract class ComponentNode public virtual IReadOnlyList Properties { get; } = []; - public virtual void Validate(ComponentState state, ComponentContext context) { } + public virtual void Validate(ComponentState state, ComponentContext context) + { + // validate properties + foreach (var property in Properties) + foreach (var validator in property.Validators) + { + var propertyValue = state.GetProperty(property); + + validator(context, propertyValue); + + if (!property.IsOptional && !propertyValue.HasValue) + { + context.AddDiagnostic( + Diagnostics.MissingRequiredProperty, + state.Source, + Name, + property.Name + ); + } + } + + // report any unknown properties + if (state.Source is CXElement element) + { + foreach (var attribute in element.Attributes) + { + if (!TryGetPropertyFromName(attribute.Identifier.Value, out _)) + { + context.AddDiagnostic( + Diagnostics.UnknownProperty, + attribute, + attribute.Identifier.Value, + Name + ); + } + } + } + } + + private bool TryGetPropertyFromName(string name, out ComponentProperty result) + { + foreach (var property in Properties) + { + if (property.Name == name || property.Aliases.Contains(name)) + { + result = property; + return true; + } + } + + result = null!; + return false; + } public abstract string Render(ComponentState state, ComponentContext context); public virtual void UpdateState(ref ComponentState state) { } public virtual ComponentState? Create(ICXNode source, List children) - => new() {Source = source}; + { + if (HasChildren && source is CXElement element) + { + children.AddRange(element.Children); + } + + return new ComponentState() {Source = source}; + } private static readonly Dictionary _nodes; diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs index eca70838b7..a6e20e69b8 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs @@ -8,11 +8,19 @@ namespace Discord.ComponentDesignerGenerator.Nodes; public sealed class ComponentProperty { + public static ComponentProperty Id => new( + "id", + isOptional: true, + renderer: Renderers.Integer, + dotnetPropertyName: "Id" + ); + public string Name { get; } public IReadOnlyList Aliases { get; } public bool IsOptional { get; } public string DotnetPropertyName { get; } + public string DotnetParameterName { get; } public PropertyRenderer Renderer { get; } public IReadOnlyList Validators { get; } @@ -23,6 +31,7 @@ public ComponentProperty( IEnumerable? aliases = null, IEnumerable? validators = null, PropertyRenderer? renderer = null, + string? dotnetParameterName = null, string? dotnetPropertyName = null ) { @@ -30,6 +39,7 @@ public ComponentProperty( Aliases = [..aliases ?? []]; IsOptional = isOptional; DotnetPropertyName = dotnetPropertyName ?? name; + DotnetParameterName = dotnetParameterName ?? name; Renderer = renderer ?? Renderers.CreateDefault(this); Validators = [..validators ?? []]; } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs index ed0f58124f..93542db869 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs @@ -9,11 +9,40 @@ public sealed record ComponentPropertyValue( CXAttribute? Attribute ) { - public CXValue? Value => Attribute?.Value; + private CXValue? _value; + + public CXValue? Value + { + get => _value ??= Attribute?.Value; + init => _value = value; + } public bool IsSpecified => Attribute is not null; + public bool HasValue => Value is not null; + private readonly List _diagnostics = []; public void AddDiagnostic(Diagnostic diagnostic) => _diagnostics.Add(diagnostic); + + public bool TryGetLiteralValue(ComponentContext context, out string value) + { + switch (Value) + { + case CXValue.Scalar scalar: + value = scalar.Value; + return true; + case CXValue.StringLiteral {HasInterpolations: false} literal: + value = literal.Tokens.ToString(); + return true; + // case CXValue.Interpolation interpolation: + // var info = context.GetInterpolationInfo(interpolation); + // + // break; + + default: + value = string.Empty; + return false; + } + } } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentState.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentState.cs index ab73b0fbe9..c3ca8227eb 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentState.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentState.cs @@ -1,5 +1,6 @@ using Discord.ComponentDesignerGenerator.Parser; using Microsoft.CodeAnalysis; +using System; using System.Collections.Generic; using System.Linq; @@ -12,39 +13,41 @@ public class ComponentState public bool HasChildren => OwningNode?.Children.Count > 0; + public IReadOnlyList Children + => OwningNode?.Children ?? []; + public bool IsElement => Source is CXElement; private readonly Dictionary _properties = []; - public ComponentPropertyValue? GetProperty(ComponentProperty property) + public ComponentPropertyValue GetProperty(ComponentProperty property) { - if (!IsElement) return null; + //if (!IsElement) return null; if (_properties.TryGetValue(property, out var value)) return value; - var attribute = ((CXElement)Source) + var attribute = (Source as CXElement)? .Attributes .FirstOrDefault(x => property.Name == x.Identifier.Value || property.Aliases.Contains(x.Identifier.Value) ); - ComponentPropertyValue? propertyValue; - - if (attribute is null) - { - propertyValue = new(property, attribute); - } - else if (OwningNode is null || !OwningNode.Graph.PropertyCacheMap.TryGetValue(attribute, out propertyValue)) - { - propertyValue = new(property, attribute); - - if (OwningNode is not null) OwningNode.Graph.PropertyCacheMap[attribute] = propertyValue; - } + return _properties[property] = new(property, attribute); + } - return _properties[property] = propertyValue; + public void SubstitutePropertyValue(ComponentProperty property, CXValue value) + { + if (!_properties.TryGetValue(property, out var existing)) + _properties[property] = new(property, null) {Value = value}; + else + _properties[property] = _properties[property] with {Value = value}; } - public string RenderProperties(ComponentNode node, ComponentContext context) + public string RenderProperties( + ComponentNode node, + ComponentContext context, + bool asInitializers = false + ) { // TODO: correct handling? if (Source is not CXElement element) return string.Empty; @@ -55,20 +58,44 @@ public string RenderProperties(ComponentNode node, ComponentContext context) { var propertyValue = GetProperty(property); - if (propertyValue?.Value is not null) - values.Add($"{property.DotnetPropertyName}: {property.Renderer(context, propertyValue)}"); + if (propertyValue?.Value is null) continue; + + var prefix = asInitializers + ? $"{property.DotnetPropertyName} = " + : $"{property.DotnetPropertyName}: "; + + values.Add($"{prefix}{property.Renderer(context, propertyValue)}"); } - return string.Join(",\n", values); + var joiner = asInitializers ? "," : string.Empty; + return string.Join($"{joiner}\n", values); } - public string RenderChildren(ComponentContext context) + public string RenderInitializer(ComponentNode node, ComponentContext context) + { + var props = RenderProperties(node, context, asInitializers: true); + + if (string.IsNullOrWhiteSpace(props)) return string.Empty; + + return + $$""" + { + {{props.WithNewlinePadding(4)}} + } + """; + } + + public string RenderChildren(ComponentContext context, Func? predicate = null) { if (OwningNode is null || !HasChildren) return string.Empty; + IEnumerable children = OwningNode.Children; + + if (predicate is not null) children = children.Where(predicate); + return string.Join( ",\n", - OwningNode.Children.Select(x => x.Render(context)) + children.Select(x => x.Render(context)) ); } } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs new file mode 100644 index 0000000000..4c3a250752 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs @@ -0,0 +1,63 @@ +using Discord.ComponentDesignerGenerator.Parser; +using Microsoft.CodeAnalysis; +using System.Collections.Generic; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.ComponentDesignerGenerator.Nodes.Components; + +public sealed class ActionRowComponentNode : ComponentNode +{ + public override string Name => "row"; + + public override bool HasChildren => true; + + public override IReadOnlyList Properties { get; } = [ComponentProperty.Id]; + + public override void Validate(ComponentState state, ComponentContext context) + { + if (!state.HasChildren) + { + context.AddDiagnostic( + Diagnostics.EmptyActionRow, + state.Source + ); + + base.Validate(state, context); + return; + } + + for (var i = 0; i < state.Children.Count; i++) + { + var child = state.Children[i]; + // TODO: validate children types + + } + + base.Validate(state, context); + } + + public override string Render(ComponentState state, ComponentContext context) + => $$""" + new {{context.KnownTypes.ActionRowBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}}{{ + $"{ + state + .RenderProperties(this, context, asInitializers: true) + .PostfixIfSome("\n") + }{ + state.RenderChildren(context) + .Map(x => + $""" + Components = + [ + {x.WithNewlinePadding(4)} + ] + """ + ) + }" + .TrimEnd() + .WithNewlinePadding(4) + .PrefixIfSome("\n{\n".Postfix(4)) + .PostfixIfSome("\n}") + }} + """; +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs index 9835f24455..af0bbe0d85 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs @@ -7,7 +7,9 @@ namespace Discord.ComponentDesignerGenerator.Nodes.Components; public sealed class ButtonComponentNode : ComponentNode { + public const string BUTTON_STYLE_ENUM = "Discord.ButtonStyle"; + public override string Name => "button"; public override IReadOnlyList Properties { get; } @@ -23,6 +25,7 @@ public ButtonComponentNode() { Properties = [ + ComponentProperty.Id, Style = new ComponentProperty( "style", isOptional: true, @@ -39,54 +42,168 @@ public ButtonComponentNode() "emoji", isOptional: true, aliases: ["emote"], - validators: [Validators.Emote] + validators: [Validators.Emote], + renderer: Renderers.Emoji ), CustomId = new( "customId", isOptional: true, - validators: [Validators.Range(upper: Constants.CUSTOM_ID_MAX_LENGTH)] + validators: [Validators.Range(upper: Constants.CUSTOM_ID_MAX_LENGTH)], + renderer: Renderers.String ), SkuId = new( "skuId", aliases: ["sku"], isOptional: true, - validators: [Validators.Snowflake] + validators: [Validators.Snowflake], + renderer: Renderers.Snowflake ), Url = new( "url", isOptional: true, - validators: [Validators.Range(upper: Constants.BUTTON_URL_MAX_LENGTH)] + validators: [Validators.Range(upper: Constants.BUTTON_URL_MAX_LENGTH)], + renderer: Renderers.String ) ]; } + public override ComponentState? Create(ICXNode source, List children) + { + var state = base.Create(source, children); + + if (source is CXElement {Children.Count: 1} element && element.Children[0] is CXValue value) + state?.SubstitutePropertyValue(Label, value); + + return state; + } + public override void Validate(ComponentState state, ComponentContext context) { - if (state.GetProperty(Url)!.IsSpecified && state.GetProperty(CustomId)!.IsSpecified) + var label = state.GetProperty(Label); + + if ( + label.Attribute?.Value is not null && + label.Value is not null && + label.Value != label.Attribute.Value + ) { context.AddDiagnostic( Diagnostic.Create( - Diagnostics.ButtonCustomIdUrlConflict, - context.GetLocation(state.Source) + Diagnostics.ButtonLabelDuplicate, + context.GetLocation(label.Value!) ) ); } - if (!state.GetProperty(Url)!.IsSpecified && !state.GetProperty(CustomId)!.IsSpecified) + if (state.GetProperty(Url)!.IsSpecified && state.GetProperty(CustomId)!.IsSpecified) { context.AddDiagnostic( Diagnostic.Create( - Diagnostics.ButtonCustomIdOrUrlMissing, + Diagnostics.ButtonCustomIdUrlConflict, context.GetLocation(state.Source) ) ); } + + // TODO: interpolations with constants can be checked + if ( + state.GetProperty(Style).TryGetLiteralValue(context, out var style) + ) + { + switch (style.ToLowerInvariant()) + { + case "link" when !state.GetProperty(Url).IsSpecified: + context.AddDiagnostic( + Diagnostic.Create( + Diagnostics.LinkButtonUrlMissing, + context.GetLocation(state.Source) + ) + ); + break; + case "premium" when !state.GetProperty(SkuId).IsSpecified: + context.AddDiagnostic( + Diagnostic.Create( + Diagnostics.PremiumButtonSkuMissing, + context.GetLocation(state.Source) + ) + ); + + if (state.GetProperty(CustomId).IsSpecified) + { + context.AddDiagnostic( + Diagnostic.Create( + Diagnostics.PremiumButtonPropertyNotAllowed, + context.GetLocation(state.Source), + "customId" + ) + ); + } + + if (state.GetProperty(Label).IsSpecified) + { + context.AddDiagnostic( + Diagnostic.Create( + Diagnostics.PremiumButtonPropertyNotAllowed, + context.GetLocation(state.Source), + "label" + ) + ); + } + + if (state.GetProperty(Url).IsSpecified) + { + context.AddDiagnostic( + Diagnostic.Create( + Diagnostics.PremiumButtonPropertyNotAllowed, + context.GetLocation(state.Source), + "url" + ) + ); + } + + if (state.GetProperty(Emoji).IsSpecified) + { + context.AddDiagnostic( + Diagnostic.Create( + Diagnostics.PremiumButtonPropertyNotAllowed, + context.GetLocation(state.Source), + "emoji" + ) + ); + } + + break; + default: CheckForCustomIdAndUrl(); break; + } + } + else + { + CheckForCustomIdAndUrl(); + } + + base.Validate(state, context); + + void CheckForCustomIdAndUrl() + { + if (!state.GetProperty(Url)!.IsSpecified && !state.GetProperty(CustomId)!.IsSpecified) + { + context.AddDiagnostic( + Diagnostic.Create( + Diagnostics.ButtonCustomIdOrUrlMissing, + context.GetLocation(state.Source) + ) + ); + } + } } public override string Render(ComponentState state, ComponentContext context) => $""" - new {context.KnownTypes.ButtonBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}( - {state.RenderProperties(this, context).WithNewlinePadding(4)} - ) + new {context.KnownTypes.ButtonBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({ + state.RenderProperties(this, context) + .WithNewlinePadding(4) + .PrefixIfSome(4) + .WrapIfSome("\n") + }) """; } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs new file mode 100644 index 0000000000..a9fb318fce --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs @@ -0,0 +1,74 @@ +using Discord.ComponentDesignerGenerator.Parser; +using System.Collections.Generic; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.ComponentDesignerGenerator.Nodes.Components; + +public sealed class ContainerComponentNode : ComponentNode +{ + public override string Name => "container"; + + public override bool HasChildren => true; + + public ComponentProperty Id { get; } + public ComponentProperty AccentColor { get; } + public ComponentProperty Spoiler { get; } + + public override IReadOnlyList Properties { get; } + + public ContainerComponentNode() + { + Properties = + [ + Id = ComponentProperty.Id, + AccentColor = new( + "accentColor", + isOptional: true, + aliases: ["color", "accent"], + renderer: Renderers.Color, + dotnetPropertyName: "AccentColor" + ), + Spoiler = new( + "spoiler", + isOptional: true, + renderer: Renderers.Boolean, + dotnetPropertyName: "IsSpoiler" + ) + ]; + } + + public override void Validate(ComponentState state, ComponentContext context) + { + foreach (var child in state.Children) + { + // TODO: check for allowed children + } + + base.Validate(state, context); + } + + public override string Render(ComponentState state, ComponentContext context) + => $$""" + new {{context.KnownTypes.ContainerBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}}{{ + $"{ + state + .RenderProperties(this, context, asInitializers: true) + .PostfixIfSome("\n") + }{ + state.RenderChildren(context) + .Map(x => + $""" + Components = + [ + {x.WithNewlinePadding(4)} + ] + """ + ) + }" + .TrimEnd() + .WithNewlinePadding(4) + .PrefixIfSome("\n{\n".Postfix(4)) + .PostfixIfSome("\n}") + }} + """; +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs new file mode 100644 index 0000000000..1659902acd --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.ComponentDesignerGenerator.Nodes.Components; + +public sealed class FileComponentNode : ComponentNode +{ + public override string Name => "file"; + + public ComponentProperty File { get; } + public ComponentProperty Spoiler { get; } + + public override IReadOnlyList Properties { get; } + + public FileComponentNode() + { + Properties = + [ + ComponentProperty.Id, + File = new( + "file", + renderer: Renderers.UnfurledMediaItem, + dotnetParameterName: "media" + ), + Spoiler = new( + "spoiler", + isOptional: true, + renderer: Renderers.Boolean, + dotnetParameterName: "isSpoiler" + ) + ]; + } + + public override string Render(ComponentState state, ComponentContext context) + => $""" + new {context.KnownTypes.FileComponentBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({ + state.RenderProperties(this, context) + .WithNewlinePadding(4) + .PrefixIfSome(4) + .WrapIfSome("\n") + }) + """; +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs new file mode 100644 index 0000000000..34433e24c2 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; + +namespace Discord.ComponentDesignerGenerator.Nodes.Components; + +public sealed class LabelComponentNode : ComponentNode +{ + public override string Name => "label"; + + public ComponentProperty Value { get; } + public ComponentProperty Description { get; } + + public override bool HasChildren => true; + + public override IReadOnlyList Properties { get; } + + public LabelComponentNode() + { + Properties = + [ + ComponentProperty.Id, + Value = new( + "value", + renderer: Renderers.String + ), + Description = new( + "description", + isOptional: true, + renderer: Renderers.String + ) + ]; + } + + public override string Render(ComponentState state, ComponentContext context) + => string.Empty; +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs new file mode 100644 index 0000000000..e1d76c4c96 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.ComponentDesignerGenerator.Nodes.Components; + +public sealed class MediaGalleryComponentNode : ComponentNode +{ + public override string Name => "gallery"; + + public override IReadOnlyList Properties { get; } = [ComponentProperty.Id]; + + public override bool HasChildren => true; + + public override string Render(ComponentState state, ComponentContext context) + => $$""" + new {{context.KnownTypes.MediaGalleryBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}}{{ + $"{ + state + .RenderProperties(this, context, asInitializers: true) + .PostfixIfSome("\n") + }{ + state.RenderChildren(context) + .Map(x => + $""" + Items = + [ + {x.WithNewlinePadding(4)} + ] + """ + ) + }" + .TrimEnd() + .WithNewlinePadding(4) + .PrefixIfSome("\n{\n".Postfix(4)) + .PostfixIfSome("\n}") + }} + """; +} + +public sealed class MediaGalleryItemComponentNode : ComponentNode +{ + public override string Name => "media"; + + public ComponentProperty Url { get; } + public ComponentProperty Description { get; } + public ComponentProperty Spoiler { get; } + + public override IReadOnlyList Properties { get; } + + public MediaGalleryItemComponentNode() + { + Properties = + [ + Url = new( + "url", + renderer: Renderers.UnfurledMediaItem + ), + Description = new( + "description", + isOptional: true, + renderer: Renderers.String + ), + Spoiler = new( + "spoiler", + isOptional: true, + renderer: Renderers.Boolean, + dotnetParameterName: "isSpoiler" + ) + ]; + } + + public override string Render(ComponentState state, ComponentContext context) + => $""" + new {context.KnownTypes.MediaGalleryItemPropertiesType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}( + {state.RenderProperties(this, context).WithNewlinePadding(4)} + ) + """; +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/RowComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/RowComponentNode.cs deleted file mode 100644 index 7e7e5211ff..0000000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/RowComponentNode.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Discord.ComponentDesignerGenerator.Parser; -using System.Collections.Generic; -using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; - -namespace Discord.ComponentDesignerGenerator.Nodes.Components; - -public sealed class RowComponentNode : ComponentNode -{ - public override string Name => "row"; - - public override ComponentState? Create(ICXNode source, List children) - { - if (source is not CXElement element) return null; - - children.AddRange(element.Children); - - return base.Create(source, children); - } - - public override string Render(ComponentState state, ComponentContext context) - => $""" - new {context.KnownTypes.ActionRowBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}( - {state.RenderChildren(context).WithNewlinePadding(4)} - ) - """; -} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentnode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentnode.cs new file mode 100644 index 0000000000..4e727d188d --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentnode.cs @@ -0,0 +1,160 @@ +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; +using System.Linq; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.ComponentDesignerGenerator.Nodes.Components; + +public sealed class SectionComponentnode : ComponentNode +{ + public override string Name => "section"; + + public override bool HasChildren => true; + + public override IReadOnlyList Properties { get; } = [ComponentProperty.Id]; + + public override void Validate(ComponentState state, ComponentContext context) + { + if (!state.HasChildren) + { + context.AddDiagnostic( + Diagnostics.EmptySection, + state.Source + ); + + base.Validate(state, context); + return; + } + + var accessoryCount = state.Children.Count(x => x.Inner is AccessoryComponentNode); + var nonAccessoryCount = state.Children.Count - accessoryCount; + + switch (accessoryCount) + { + case 0: + context.AddDiagnostic( + Diagnostics.MissingAccessory, + state.Source + ); + break; + case > 1: + foreach (var accessory in state.Children.Where(x => x.Inner is AccessoryComponentNode).Skip(1)) + { + context.AddDiagnostic( + Diagnostics.TooManyAccessories, + accessory.State.Source + ); + } + + break; + } + + switch (nonAccessoryCount) + { + case 0: + context.AddDiagnostic( + Diagnostics.MissingSectionChild, + state.Source + ); + break; + case > 3: + foreach (var child in state.Children.Where(x => x.Inner is not AccessoryComponentNode).Skip(3)) + { + context.AddDiagnostic( + Diagnostics.TooManySectionChildren, + child.State.Source + ); + } + break; + } + + foreach (var child in state.Children.Where(x => x.Inner is not AccessoryComponentNode)) + { + if (!IsValidChildType(child.Inner)) + { + context.AddDiagnostic( + Diagnostics.InvalidSectionChildComponentType, + child.State.Source, + child.Inner.Name + ); + } + } + + static bool IsValidChildType(ComponentNode node) + => node is TextDisplayComponentNode; + } + + public override string Render(ComponentState state, ComponentContext context) + { + var accessory = state.Children + .FirstOrDefault(x => x.Inner is AccessoryComponentNode); + + return + $""" + new {context.KnownTypes.SectionBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}( + accessory: {accessory?.Render(context).WithNewlinePadding(4) ?? "null"}, + components: + [ + { + state + .RenderChildren(context, x => x.Inner is not AccessoryComponentNode) + .WithNewlinePadding(4) + } + ] + ){state.RenderInitializer(this, context).PrefixIfSome("\n")} + """; + } +} + +public sealed class AccessoryComponentNode : ComponentNode +{ + public override string Name => "accessory"; + + public override bool HasChildren => true; + + public override void Validate(ComponentState state, ComponentContext context) + { + if (!state.HasChildren) + { + context.AddDiagnostic( + Diagnostics.EmptyAccessory, + state.Source + ); + + base.Validate(state, context); + return; + } + + + if (state.Children.Count is not 1) + { + var start = state.OwningNode!.Children[0].State.Source.Span.Start; + var end = state.OwningNode!.Children[state.Children.Count - 1].State.Source.Span.End; + + context.AddDiagnostic( + Diagnostics.TooManyAccessoryChildren, + TextSpan.FromBounds(start, end) + ); + + base.Validate(state, context); + return; + } + + if (!IsAllowedChild(state.Children[0].Inner)) + { + context.AddDiagnostic( + Diagnostics.InvalidAccessoryChild, + state.Children[0].State.Source, + state.Children[0].Inner.Name + ); + } + + base.Validate(state, context); + } + + private static bool IsAllowedChild(ComponentNode node) + => node is ButtonComponentNode or ThumbnailComponentNode; + + public override string Render(ComponentState state, ComponentContext context) + => state.RenderChildren(context); +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuComponentNode.cs new file mode 100644 index 0000000000..46b6cc734f --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuComponentNode.cs @@ -0,0 +1,224 @@ +using Discord.ComponentDesignerGenerator.Parser; +using System; +using System.Collections.Generic; +using System.Linq; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.ComponentDesignerGenerator.Nodes.Components.SelectMenus; + +public sealed class SelectMenuComponentNode : ComponentNode +{ + public sealed class MissingTypeState : ComponentState; + + public sealed class InvalidTypeState : ComponentState + { + public string? Kind { get; init; } + } + + public sealed class StringSelectState : ComponentState; + + public abstract class SelectStateWithDefaults : ComponentState + { + public required IReadOnlyList Defaults { get; init; } + } + + public sealed class UserSelectState : SelectStateWithDefaults; + + public sealed class RoleSelectState : SelectStateWithDefaults; + + // TODO: channel types? + public sealed class ChannelSelectState : SelectStateWithDefaults; + + public sealed class MentionableSelectState : SelectStateWithDefaults; + + public override string Name => "select"; + + public ComponentProperty CustomId { get; } + public ComponentProperty Placeholder { get; } + public ComponentProperty MinValues { get; } + public ComponentProperty MaxValues { get; } + public ComponentProperty Required { get; } + public ComponentProperty Disabled { get; } + + public override IReadOnlyList Properties { get; } + + public SelectMenuComponentNode() + { + Properties = + [ + ComponentProperty.Id, + CustomId = new( + "customId", + isOptional: false, + renderer: Renderers.String + ), + Placeholder = new( + "placeholder", + isOptional: true, + renderer: Renderers.String + ), + MinValues = new( + "minValues", + isOptional: true, + aliases: ["min"], + renderer: Renderers.Integer + ), + MaxValues = new( + "maxValues", + isOptional: true, + aliases: ["max"], + renderer: Renderers.Integer + ), + Required = new( + "required", + isOptional: true, + renderer: Renderers.Boolean + ), + Disabled = new( + "disabled", + isOptional: true, + renderer: Renderers.Boolean + ) + ]; + } + + public override ComponentState? Create(ICXNode source, List children) + { + if (source is not CXElement element) return null; + + var typeAttribute = element.Attributes + .FirstOrDefault(x => x.Identifier.Value.ToLowerInvariant() is "type"); + + if (typeAttribute is null) return new MissingTypeState() {Source = source}; + + if (typeAttribute.Value is not CXValue.StringLiteral {HasInterpolations: false} typeValue) + return new InvalidTypeState() {Source = source,}; + + var kind = typeValue.Tokens.ToString().ToLowerInvariant(); + switch (kind) + { + case "string" or "text": + children.AddRange(element.Children); + return new StringSelectState() {Source = source}; + case "user": + return new UserSelectState() {Source = source, Defaults = ExtractDefaultValues()}; + case "role": + return new RoleSelectState() {Source = source, Defaults = ExtractDefaultValues()}; + case "channel": + return new ChannelSelectState() {Source = source, Defaults = ExtractDefaultValues()}; + case "mention" or "mentionable": + return new MentionableSelectState() {Source = source, Defaults = ExtractDefaultValues()}; + default: return new InvalidTypeState() {Source = source, Kind = kind}; + } + + IReadOnlyList ExtractDefaultValues() + { + var result = new List(); + + foreach (var child in element.Children) + { + if (child is not CXElement element) + { + // TODO: diagnostics + continue; + } + + if (!Enum.TryParse(element.Identifier, true, out var kind)) + { + // TODO: diagnostics + continue; + } + + if (element.Children.Count is not 1 || element.Children[0] is not CXValue value) + { + // TODO: diagnostics + continue; + } + + result.Add(new(kind, value)); + } + + return result; + } + } + + public override void Validate(ComponentState state, ComponentContext context) + { + switch (state) + { + case MissingTypeState: + context.AddDiagnostic( + Diagnostics.MissingSelectMenuType, + state.Source + ); + return; + case InvalidTypeState {Kind: var kind}: + context.AddDiagnostic( + kind is not null ? Diagnostics.SpecifiedInvalidSelectMenuType : Diagnostics.InvalidSelectMenuType, + state.Source, + kind is not null ? [kind] : null + ); + return; + } + } + + private static string RenderDefaultValue(ComponentContext context, SelectMenuDefautValue defaultValue) + => $""" + new {context.KnownTypes.SelectMenuDefaultValueType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}( + id: {Renderers.Snowflake(context, defaultValue.Value)}, + type: {context.KnownTypes.SelectDefaultValueTypeEnumType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.{defaultValue.Kind} + ) + """; + + public override string Render(ComponentState state, ComponentContext context) + => $""" + new {context.KnownTypes.SelectMenuBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({ + string + .Join( + ",\n", + ((IEnumerable) + [ + ( + state switch + { + UserSelectState => "UserSelect", + RoleSelectState => "RoleSelect", + MentionableSelectState => "MentionableSelect", + ChannelSelectState => "ChannelSelect", + _ => string.Empty + } + ).Map(x => $"type: {context.KnownTypes.ComponentTypeEnumType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.{x}"), + state.RenderProperties(this, + context), + state.RenderChildren(context, x => x.Inner is StringSelectOptionComponentNode) + .Map(x => + $""" + options: + [ + {x.WithNewlinePadding(4)} + ] + """ + ), + state is SelectStateWithDefaults {Defaults: var defaults} + ? string + .Join( + ",\n", + defaults.Select(x => RenderDefaultValue(context, x)) + ) + .Map(x => + $""" + defaultValues: + [ + {x.WithNewlinePadding(4)} + ] + """ + ) + : string.Empty + ]).Where(x => !string.IsNullOrEmpty(x)) + ) + .PrefixIfSome(4) + .WithNewlinePadding(4) + .WrapIfSome("\n") + }) + """; +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefaultValueKind.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefaultValueKind.cs new file mode 100644 index 0000000000..e68caa8d2b --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefaultValueKind.cs @@ -0,0 +1,8 @@ +namespace Discord.ComponentDesignerGenerator.Nodes.Components.SelectMenus; + +public enum SelectMenuDefaultValueKind +{ + User, + Role, + Channel +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefautValue.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefautValue.cs new file mode 100644 index 0000000000..c30f8dca23 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefautValue.cs @@ -0,0 +1,8 @@ +using Discord.ComponentDesignerGenerator.Parser; + +namespace Discord.ComponentDesignerGenerator.Nodes.Components.SelectMenus; + +public readonly record struct SelectMenuDefautValue( + SelectMenuDefaultValueKind Kind, + CXValue Value +); diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/StringSelectOptionComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/StringSelectOptionComponentNode.cs new file mode 100644 index 0000000000..122deb13eb --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/StringSelectOptionComponentNode.cs @@ -0,0 +1,74 @@ +using Discord.ComponentDesignerGenerator.Parser; +using Microsoft.CodeAnalysis; +using System.Collections.Generic; + +namespace Discord.ComponentDesignerGenerator.Nodes.Components.SelectMenus; + +public sealed class StringSelectOptionComponentNode : ComponentNode +{ + public override string Name => "option"; + + public ComponentProperty Label { get; } + public ComponentProperty Value { get; } + public ComponentProperty Description { get; } + public ComponentProperty Emoji { get; } + public ComponentProperty Default { get; } + + public override IReadOnlyList Properties { get; } + + public StringSelectOptionComponentNode() + { + Properties = + [ + Label = new( + "label", + renderer: Renderers.String + ), + Value = new( + "value", + renderer: Renderers.String + ), + Description = new( + "description", + isOptional: true, + renderer: Renderers.String + ), + Emoji = new( + "emoji", + isOptional: false, + renderer: Renderers.Emoji + ), + Default = new( + "default", + isOptional: true, + renderer: Renderers.Boolean, + dotnetParameterName: "isDefault" + ) + ]; + } + + public override ComponentState? Create(ICXNode source, List children) + { + var state = base.Create(source, children); + + if ( + source is CXElement {Children.Count: 1} element && + element.Children[0] is CXValue value + ) + { + state!.SubstitutePropertyValue(Value, value); + } + + return state; + } + + public override string Render(ComponentState state, ComponentContext context) + => $""" + new {context.KnownTypes.SelectMenuOptionBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({ + state.RenderProperties(this, context) + .WithNewlinePadding(4) + .PrefixIfSome(4) + .WrapIfSome("\n") + }) + """; +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs new file mode 100644 index 0000000000..b51db8e63c --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.ComponentDesignerGenerator.Nodes.Components; + +public sealed class SeparatorComponentNode : ComponentNode +{ + public const string SEPARATOR_SPACING_QUALIFIED_NAME = "Discord.SeparatorSpacingSize"; + + public override string Name => "separator"; + + public ComponentProperty Id { get; } + public ComponentProperty Divider { get; } + public ComponentProperty Spacing { get; } + + public override IReadOnlyList Properties { get; } + + public SeparatorComponentNode() + { + Properties = + [ + Id = ComponentProperty.Id, + Divider = new( + "divider", + isOptional: true, + renderer: Renderers.Boolean + ), + Spacing = new( + "spacing", + isOptional: true, + renderer: Renderers.RenderEnum(SEPARATOR_SPACING_QUALIFIED_NAME) + ) + ]; + } + + public override string Render(ComponentState state, ComponentContext context) + => $""" + new {context.KnownTypes.SeparatorBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({ + state.RenderProperties(this, context) + .WithNewlinePadding(4) + .PrefixIfSome(4) + .WrapIfSome("\n") + }) + """; +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs new file mode 100644 index 0000000000..33982cdafe --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs @@ -0,0 +1,45 @@ +using Discord.ComponentDesignerGenerator.Parser; +using System.Collections.Generic; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.ComponentDesignerGenerator.Nodes.Components; + +public sealed class TextDisplayComponentNode : ComponentNode +{ + public override string Name => "text"; + + public ComponentProperty Content { get; } + public override IReadOnlyList Properties { get; } + + public TextDisplayComponentNode() + { + Properties = + [ + ComponentProperty.Id, + Content = new( + "content", + renderer: Renderers.String + ) + ]; + } + + public override ComponentState? Create(ICXNode source, List children) + { + var state = base.Create(source, children)!; + + if (source is CXElement {Children.Count: 1} element && element.Children[0] is CXValue value) + state.SubstitutePropertyValue(Content, value); + + return state; + } + + public override string Render(ComponentState state, ComponentContext context) + => $""" + new {context.KnownTypes.TextDisplayBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({ + state.RenderProperties(this, context) + .WithNewlinePadding(4) + .PrefixIfSome(4) + .WrapIfSome("\n") + }) + """; +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs new file mode 100644 index 0000000000..2797b3facc --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.ComponentDesignerGenerator.Nodes.Components; + +public sealed class TextInputComponentNode : ComponentNode +{ + public const string LIBRARY_TEXT_INPUT_STYLE_ENUM = "Discord.TextInputStyle"; + + public override string Name => "input"; + + public ComponentProperty CustomId { get; } + public ComponentProperty Style { get; } + public ComponentProperty MinLength { get; } + public ComponentProperty MaxLength { get; } + public ComponentProperty Required { get; } + public ComponentProperty Value { get; } + public ComponentProperty Placeholder { get; } + + public override IReadOnlyList Properties { get; } + + public TextInputComponentNode() + { + Properties = + [ + ComponentProperty.Id, + CustomId = new( + "customId", + isOptional: false, + renderer: Renderers.String + ), + Style = new( + "style", + isOptional: false, + renderer: Renderers.RenderEnum(LIBRARY_TEXT_INPUT_STYLE_ENUM) + ), + MinLength = new( + "minLength", + aliases: ["min"], + isOptional: true, + renderer: Renderers.Integer + ), + MaxLength = new( + "maxLength", + aliases: ["max"], + isOptional: true, + renderer: Renderers.Integer + ), + Required = new( + "required", + isOptional: true, + renderer: Renderers.Boolean + ), + Value = new( + "value", + isOptional: true, + renderer: Renderers.String + ), + Placeholder = new( + "placeholder", + isOptional: true, + renderer: Renderers.String + ) + ]; + } + + public override string Render(ComponentState state, ComponentContext context) + => $""" + new {context.KnownTypes.TextInputBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({ + state.RenderProperties(this, context) + .PrefixIfSome(4) + .WithNewlinePadding(4) + .WrapIfSome("\n") + }) + """; +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs new file mode 100644 index 0000000000..35db6fc908 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.ComponentDesignerGenerator.Nodes.Components; + +public sealed class ThumbnailComponentNode : ComponentNode +{ + public override string Name => "thumbnail"; + + public ComponentProperty Id { get; } + public ComponentProperty Media { get; } + public ComponentProperty Description { get; } + public ComponentProperty Spoiler { get; } + + public override IReadOnlyList Properties { get; } + + public ThumbnailComponentNode() + { + Properties = + [ + Id = ComponentProperty.Id, + Media = new( + "media", + aliases: ["href", "url"], + renderer: Renderers.UnfurledMediaItem + ), + Description = new( + "description", + isOptional: true, + renderer: Renderers.String + ), + Spoiler = new( + "spoiler", + isOptional: true, + renderer: Renderers.Boolean, + dotnetParameterName: "isSpoiler" + ) + ]; + } + + public override string Render(ComponentState state, ComponentContext context) + => $""" + new {context.KnownTypes.ThumbnailBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({ + state.RenderProperties(this, context) + .PrefixIfSome(4) + .WrapIfSome("\n") + }) + """; +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs index b327200ca6..0e9e5f607b 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs @@ -1,6 +1,8 @@ using Discord.ComponentDesignerGenerator.Parser; using Microsoft.CodeAnalysis; using System; +using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text; @@ -16,113 +18,369 @@ public static PropertyRenderer CreateDefault(ComponentProperty property) }; } - public static string String(ComponentContext context, ComponentPropertyValue propertyValue) + private static bool IsLoneInterpolatedLiteral( + ComponentContext context, + CXValue.StringLiteral literal, + out DesignerInterpolationInfo info) + { + if ( + literal is {HasInterpolations: true, Tokens.Count: 1} && + literal.Document.TryGetInterpolationIndex(literal.Tokens[0], out var index) + ) + { + info = context.GetInterpolationInfo(index); + return true; + } + + info = null!; + return false; + } + + public static string UnfurledMediaItem(ComponentContext context, ComponentPropertyValue propertyValue) + => $"new {context.KnownTypes.UnfurledMediaItemPropertiesType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({String(context, propertyValue)})"; + + public static string Integer(ComponentContext context, ComponentPropertyValue propertyValue) { switch (propertyValue.Value) { - default: - case null or CXValue.Invalid: return "string.Empty"; + case CXValue.Scalar scalar: + return FromText(scalar.Value); + + case CXValue.Interpolation interpolation: + return FromInterpolation(interpolation, context.GetInterpolationInfo(interpolation)); case CXValue.StringLiteral literal: + if (!literal.HasInterpolations) + return FromText(literal.Tokens.ToString().Trim()); + + if (IsLoneInterpolatedLiteral(context, literal, out var info)) + return FromInterpolation(literal, info); + + return $"int.Parse({RenderStringLiteral(literal)})"; + default: return "default"; + } + + string FromInterpolation(ICXNode owner, DesignerInterpolationInfo info) + { + if (info.Constant.Value is int || int.TryParse(info.Constant.Value?.ToString(), out _)) + return info.Constant.Value!.ToString(); + + if ( + context.Compilation.HasImplicitConversion( + info.Symbol, + context.Compilation.GetSpecialType(SpecialType.System_Int32) + ) + ) { - var sb = new StringBuilder(); - //var value = scalar.Value; - - var parts = literal.Tokens - .Where(x => x.Kind is CXTokenKind.Text) - .Select(x => x.Value) - .ToArray(); - - var quoteCount = parts.Select(x => x.Count(x => x is '"')).Max() + 1; - - var dollars = new string( - '$', - parts.Select(GetInterpolationDollarRequirement).Max() + - ( - literal.Tokens.Any(x => x.Kind is CXTokenKind.Interpolation) - ? 1 - : 0 - ) + return context.GetDesignerValue(info, "int"); + } + + return $"int.Parse({context.GetDesignerValue(info)})"; + } + + string FromText(string text) + { + if (int.TryParse(text, out _)) return text; + + return $"int.Parse({ToCSharpString(text)})"; + } + } + + public static string Boolean(ComponentContext context, ComponentPropertyValue propertyValue) + { + switch (propertyValue.Value) + { + case CXValue.Interpolation interpolation: + return FromInterpolation(interpolation, context.GetInterpolationInfo(interpolation)); + + case CXValue.Scalar scalar: + return FromText(scalar, scalar.Value.Trim().ToLowerInvariant()); + + case CXValue.StringLiteral stringLiteral: + if (!stringLiteral.HasInterpolations) + return FromText(stringLiteral, stringLiteral.Tokens.ToString().Trim().ToLowerInvariant()); + + if (IsLoneInterpolatedLiteral(context, stringLiteral, out var info)) + return FromInterpolation(stringLiteral, info); + + + return $"bool.Parse({context.GetDesignerValue(info)})"; + default: return "default"; + } + + string FromInterpolation(ICXNode node, DesignerInterpolationInfo info) + { + if ( + context.Compilation.HasImplicitConversion( + info.Symbol, + context.Compilation.GetSpecialType(SpecialType.System_Boolean) + ) + ) + { + return context.GetDesignerValue(info, "bool"); + } + + if (info.Constant.Value is bool b) return b ? "true" : "false"; + + if (info.Constant.Value?.ToString().Trim().ToLowerInvariant() is { } str and ("true" or "false")) + return str; + + return $"bool.Parse({context.GetDesignerValue(info)})"; + } + + string FromText(ICXNode owner, string value) + { + if (value is not "true" or "false") + { + context.AddDiagnostic( + Diagnostics.TypeMismatch, + owner, + "string", + "bool" ); + } - var startInterpolation = dollars.Length > 0 - ? new string('{', dollars.Length) - : string.Empty; + return value; + } + } - var endInterpolation = dollars.Length > 0 - ? new string('}', dollars.Length) - : string.Empty; + private static readonly Dictionary _colorPresets = []; - var isMultiline = parts.Any(x => x.Contains('\n')); + private static bool TryGetColorPreset( + ComponentContext context, + string value, + out string fieldName) + { + var colorSymbol = context.KnownTypes.ColorType; - if (isMultiline) - { - sb.AppendLine(); - quoteCount = Math.Max(quoteCount, 3); - } + if (colorSymbol is null) + { + fieldName = null!; + return false; + } - var quotes = new string('"', quoteCount); + if (_colorPresets.Count is 0) + { + foreach ( + var field + in colorSymbol.GetMembers() + .OfType() + .Where(x => + x.Type.Equals(colorSymbol, SymbolEqualityComparer.Default) && + x.IsStatic + ) + ) + { + _colorPresets[field.Name.ToLowerInvariant()] = field.Name; + } + } - sb.Append(dollars).Append(quotes); + return _colorPresets.TryGetValue(value.ToLowerInvariant(), out fieldName); + } - if (isMultiline) sb.AppendLine(); + public static string Color(ComponentContext context, ComponentPropertyValue propertyValue) + { + var colorSymbol = context.KnownTypes.ColorType; + var qualifiedColor = colorSymbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - foreach (var token in literal.Tokens) + switch (propertyValue.Value) + { + case CXValue.Interpolation interpolation: + var info = context.GetInterpolationInfo(interpolation); + + if ( + info.Symbol is not null && + context.Compilation.HasImplicitConversion( + info.Symbol, + colorSymbol + ) + ) { - switch (token.Kind) - { - case CXTokenKind.Text: - sb.Append(token.Value); - break; - case CXTokenKind.Interpolation: - var index = Array.IndexOf(literal.Document.InterpolationTokens, token); + return context.GetDesignerValue( + interpolation, + qualifiedColor + ); + } - // TODO: handle better - if (index is -1) throw new InvalidOperationException(); + if ( + context.Compilation.HasImplicitConversion( + info.Symbol, + context.Compilation.GetSpecialType(SpecialType.System_UInt32) + ) + ) + { + return $"new {qualifiedColor}({context.GetDesignerValue(interpolation, "uint")})"; + } - sb.Append(startInterpolation).Append($"designer.GetValueAsString({index})") - .Append(endInterpolation); - break; - default: continue; - } - } + if (info.Constant.Value is string str) + { + if (TryGetColorPreset(context, str, out var preset)) + return $"{qualifiedColor}.{preset}"; - if (isMultiline) sb.AppendLine(); - sb.Append(quotes); + if (TryParseHexColor(str, out var hexColor)) + return $"new {qualifiedColor}({hexColor})"; + } + else if (info.Constant.HasValue && uint.TryParse(info.Constant.Value?.ToString(), out var hexColor)) + { + return $"new {qualifiedColor}({hexColor})"; + } - return sb.ToString(); - } + return $"{qualifiedColor}.Parse({context.GetDesignerValue(interpolation)})"; case CXValue.Scalar scalar: + return UseLibraryParser(scalar.Value); + + case CXValue.StringLiteral stringLiteral: + return UseLibraryParser(RenderStringLiteral(stringLiteral)); + default: return "default"; + } + + string UseLibraryParser(string source) + => $"{qualifiedColor}.Parse({source})"; + + static bool TryParseHexColor(string hexColor, out uint color) + { + if (string.IsNullOrWhiteSpace(hexColor)) { - var sb = new StringBuilder(); - var value = scalar.Value; + color = 0; + return false; + } - var quoteCount = value.Count(x => x is '"') + 1; + if (hexColor[0] is '#') + hexColor = hexColor.Substring(1); + else if (hexColor.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + hexColor = hexColor.Substring(2); - var isMultiline = value.Contains('\n'); + return uint.TryParse(hexColor, NumberStyles.HexNumber, null, out color); + } + } + + public static string Snowflake(ComponentContext context, ComponentPropertyValue propertyValue) + => Snowflake(context, propertyValue.Value); + public static string Snowflake(ComponentContext context, CXValue? value) + { + switch (value) + { + case CXValue.Interpolation interpolation: + var targetType = context.Compilation.GetSpecialType(SpecialType.System_UInt64); + + var interpolationInfo = context.GetInterpolationInfo(interpolation); - if (isMultiline) + if ( + interpolationInfo.Symbol is not null && + context.Compilation.HasImplicitConversion(interpolationInfo.Symbol, targetType) + ) { - sb.AppendLine(); - quoteCount = Math.Max(quoteCount, 3); + return $"designer.GetValue({interpolation.InterpolationIndex})"; } - var quotes = new string('"', quoteCount); + return UseParseMethod($"designer.GetValueAsString({interpolation.InterpolationIndex})"); - sb.Append(quotes); + case CXValue.Scalar scalar: + return UseParseMethod(ToCSharpString(scalar.Value)); - if (isMultiline) sb.AppendLine(); + case CXValue.StringLiteral stringLiteral: + return UseParseMethod(RenderStringLiteral(stringLiteral)); - sb.Append(value); + default: return "default"; + } - if (isMultiline) sb.AppendLine(); - sb.Append(quotes); + static string UseParseMethod(string input) + => $"ulong.Parse({input})"; + } - return sb.ToString(); + public static string String(ComponentContext context, ComponentPropertyValue propertyValue) + { + switch (propertyValue.Value) + { + default: return "string.Empty"; + + case CXValue.Interpolation interpolation: + if (context.GetInterpolationInfo(interpolation).Constant.Value is string constant) + return ToCSharpString(constant); + + return context.GetDesignerValue(interpolation); + case CXValue.StringLiteral literal: return RenderStringLiteral(literal); + case CXValue.Scalar scalar: + return ToCSharpString(scalar.Value.Trim()); + } + } + + private static string RenderStringLiteral(CXValue.StringLiteral literal) + { + var sb = new StringBuilder(); + + var parts = literal.Tokens + .Where(x => x.Kind is CXTokenKind.Text) + .Select(x => x.Value) + .ToArray(); + + if (parts.Length is 0) return string.Empty; + + parts[0] = parts[0].TrimStart(); + + parts[parts.Length - 1] = parts[parts.Length - 1].TrimEnd(); + + var quoteCount = parts.Select(x => x.Count(x => x is '"')).Max() + 1; + + var dollars = new string( + '$', + parts.Select(GetInterpolationDollarRequirement).Max() + + ( + literal.Tokens.Any(x => x.Kind is CXTokenKind.Interpolation) + ? 1 + : 0 + ) + ); + + var startInterpolation = dollars.Length > 0 + ? new string('{', dollars.Length) + : string.Empty; + + var endInterpolation = dollars.Length > 0 + ? new string('}', dollars.Length) + : string.Empty; + + var isMultiline = parts.Any(x => x.Contains('\n')); + + if (isMultiline) + { + sb.AppendLine(); + quoteCount = Math.Max(quoteCount, 3); + } + + var quotes = new string('"', quoteCount); + + sb.Append(dollars).Append(quotes); + + if (isMultiline) sb.AppendLine(); + + foreach (var token in literal.Tokens) + { + switch (token.Kind) + { + case CXTokenKind.Text: + sb.Append(EscapeBackslashes(token.Value)); + break; + case CXTokenKind.Interpolation: + var index = Array.IndexOf(literal.Document.InterpolationTokens, token); + + // TODO: handle better + if (index is -1) throw new InvalidOperationException(); + + sb.Append(startInterpolation).Append($"designer.GetValueAsString({index})") + .Append(endInterpolation); + break; + + default: continue; } } + if (isMultiline) sb.AppendLine(); + sb.Append(quotes); + + return sb.ToString(); + static int GetInterpolationDollarRequirement(string part) { var result = 0; @@ -160,15 +418,71 @@ static int GetInterpolationDollarRequirement(string part) } } + private static string ToCSharpString(string text) + { + var quoteCount = (GetSequentialQuoteCount(text) + 1) switch + { + 2 => 3, + var r => r + }; + + var isMultiline = text.Contains('\n'); + + if (isMultiline) + quoteCount = Math.Max(3, quoteCount); + + var quotes = new string('"', quoteCount); + + var sb = new StringBuilder(); + + sb.Append(quotes); + + if (isMultiline) sb.AppendLine(); + + sb.Append(text); + + if (isMultiline) + sb.AppendLine(); + + sb.Append(quotes); + + return sb.ToString(); + } + + private static string EscapeBackslashes(string text) + => text.Replace("\\", @"\\"); + + private static int GetSequentialQuoteCount(string text) + { + var result = 0; + var count = 0; + + foreach (var ch in text) + { + if (ch is '"') + { + count++; + continue; + } + + if (count > 0) + { + result = Math.Max(result, count); + count = 0; + } + } + + return result; + } public static PropertyRenderer RenderEnum(string fullyQualifiedName) { ITypeSymbol? symbol = null; - IFieldSymbol[]? variants = null; + Dictionary variants = []; - return (context, value) => + return (context, propertyValue) => { - if (symbol is null || variants is null) + if (symbol is null || variants.Count is 0) { symbol = context.Compilation.GetTypeByMetadataName(fullyQualifiedName); @@ -180,10 +494,101 @@ public static PropertyRenderer RenderEnum(string fullyQualifiedName) variants = symbol .GetMembers() .OfType() - .ToArray(); + .Where(x => x.Type == symbol) + .ToDictionary(x => x.Name.ToLowerInvariant(), x => x.Name); } - return string.Empty; + switch (propertyValue.Value) + { + case CXValue.Scalar scalar: + return FromText(scalar.Value.Trim()); + case CXValue.Interpolation interpolation: + return FromInterpolation(interpolation, context.GetInterpolationInfo(interpolation)); + case CXValue.StringLiteral literal: + if (!literal.HasInterpolations) + return FromText(literal.Tokens.ToString().Trim().ToLowerInvariant()); + + if (IsLoneInterpolatedLiteral(context, literal, out var info)) + return FromInterpolation(literal, info); + + return + $"Enum.Parse<{symbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}>({RenderStringLiteral(literal)})"; + default: return "default"; + } + + string FromInterpolation(ICXNode owner, DesignerInterpolationInfo info) + { + if ( + context.Compilation.HasImplicitConversion( + info.Symbol, + symbol + ) + ) + { + return context.GetDesignerValue( + info, + symbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + ); + } + + if (info.Constant.Value?.ToString() is {} str) + { + return FromText(str.Trim().ToLowerInvariant()); + } + + return $"Enum.Parse<{symbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}>({context.GetDesignerValue(info)})"; + } + + string FromText(string text) + { + if (variants.TryGetValue(text, out var name)) + return $"{symbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.{name}"; + + return + $"Enum.Parse<{symbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}>({ToCSharpString(text)})"; + } }; } + + public static string Emoji(ComponentContext context, ComponentPropertyValue propertyValue) + { + switch (propertyValue.Value) + { + case CXValue.Interpolation interpolation: + var interpolationInfo = context.GetInterpolationInfo(interpolation); + + if ( + interpolationInfo.Symbol is not null && + context.Compilation.HasImplicitConversion( + interpolationInfo.Symbol, + context.KnownTypes.IEmoteType + ) + ) + { + return context.GetDesignerValue( + interpolation, + $"{context.KnownTypes.IEmoteType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}" + ); + } + + return UseLibraryParser( + context.GetDesignerValue(interpolation) + ); + + case CXValue.Scalar scalar: + return UseLibraryParser(ToCSharpString(scalar.Value)); + + case CXValue.StringLiteral stringLiteral: + return UseLibraryParser(RenderStringLiteral(stringLiteral)); + + default: return "null"; + } + + static string UseLibraryParser(string source) + => $""" + global::Discord.Emoji.TryPase({source}, out var emoji) + ? (global::Discord.IEmote)emoji + : global::Discord.Emote.Parse({source}) + """; + } } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.cs index 6684c8f2a6..5e5f48227f 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.cs @@ -1,13 +1,16 @@ using Discord.ComponentDesignerGenerator.Parser; using Microsoft.CodeAnalysis; using System; +using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.Linq; namespace Discord.ComponentDesignerGenerator.Nodes; public static class Validators { + public static void Snowflake(ComponentContext context, ComponentPropertyValue propertyValue) { switch (propertyValue.Value) @@ -74,17 +77,49 @@ public static PropertyValidator Range(int? lower = null, int? upper = null) switch (propertyValue.Value) { case null or CXValue.Invalid: return; + case CXValue.Interpolation interpolation: + if (context.GetInterpolationInfo(interpolation).Constant.Value is string constantValue) + Check(constantValue.Length); + break; - case CXValue.Scalar {Value.Length: { } length}: - if ( - length > upper || length < lower - ) + case CXValue.StringLiteral literal: + int? length = null; + + foreach (var token in literal.Tokens) { - context.AddDiagnostic(Diagnostics.OutOfRange, propertyValue.Value, propertyValue.Property.Name, bounds); + switch (token.Kind) + { + case CXTokenKind.Text: + length += token.Span.Length; + break; + case CXTokenKind.Interpolation + when literal.Document.TryGetInterpolationIndex(token, out var index): + var info = context.GetInterpolationInfo(index); + if (info.Constant.Value is string str) + length += str.Length; + break; + } } + if (length.HasValue) Check(length.Value); + + break; + case CXValue.Scalar scalar: + Check(scalar.Value.Length); + return; } + + void Check(int length) + { + if ( + length > upper || length < lower + ) + { + context.AddDiagnostic(Diagnostics.OutOfRange, propertyValue.Value, propertyValue.Property.Name, + bounds); + } + } }; } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXParser.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXParser.cs index 7682c974ff..889814cc70 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXParser.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXParser.cs @@ -59,10 +59,13 @@ public CXParser(CXSource source) public void Reset() { - _tokens.Clear(); Reader.Position = Source.SourceSpan.Start; - _tokenIndex = 0; Lexer.Reset(); + + _tokens.Clear(); + _blendedNodes.Clear(); + _tokenIndex = 0; + _currentBlendedNode = null; } @@ -88,6 +91,8 @@ internal CXElement ParseElement() return element; } + using var _ = Lexer.SetMode(CXLexer.LexMode.Default); + var diagnostics = new List(); var start = Expect(CXTokenKind.LessThan); @@ -119,16 +124,15 @@ out var endClose endIdent, endClose ) {Diagnostics = diagnostics}; + default: case CXTokenKind.ForwardSlashGreaterThan: return new CXElement( start, identifier, attributes, - Eat(), + Expect(CXTokenKind.ForwardSlashGreaterThan), new() ); - default: - throw new InvalidOperationException("Unexpected token"); } void ParseClosingElement( @@ -280,7 +284,7 @@ internal CXValue ParseAttributeValue() { case CXTokenKind.Interpolation: return new CXValue.Interpolation( - CurrentToken, + Eat(), Lexer.InterpolationIndex!.Value ); case CXTokenKind.StringLiteralStart: @@ -347,7 +351,7 @@ internal CXValue ParseStringLiteral() return new CXValue.StringLiteral( start, - tokens, + new CXCollection(tokens), end ) {Diagnostics = diagnostics}; } @@ -397,10 +401,10 @@ internal CXToken Expect(params ReadOnlySpan kinds) return new CXToken( kinds[0], new TextSpan(current.Span.Start, 0), - current.LeadingTriviaLength, + 0, 0, Flags: CXTokenFlags.Missing, - Value: string.Empty, + FullValue: string.Empty, CreateError( $"Unexpected token, expected one of '{string.Join(", ", kinds.ToArray())}', got '{current.Kind}'", current.Span @@ -421,10 +425,10 @@ internal CXToken Expect(CXTokenKind kind) return new CXToken( kind, new TextSpan(token.Span.Start, 0), - token.LeadingTriviaLength, + 0, 0, Flags: CXTokenFlags.Missing, - Value: string.Empty, + FullValue: string.Empty, CreateError($"Unexpected token, expected '{kind}', got '{token.Kind}'", token.Span) ); } @@ -444,7 +448,7 @@ internal CXToken Expect(CXTokenKind kind) _blendedNodes.Add(_currentBlendedNode!.Value); - _tokenIndex++; + _tokenIndex += 2; // add two since we want to cause a re-lex of the blender _currentBlendedNode = null; diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/ICXNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/ICXNode.cs index 68706780d3..a6fedaee91 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/ICXNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/ICXNode.cs @@ -21,4 +21,6 @@ public interface ICXNode IReadOnlyList Slots { get; } void ResetCachedState(); + + string ToString(bool includeLeadingTrivia, bool includeTrailingTrivia); } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/CXBlender.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/CXBlender.cs index d28655edf0..9ddb7432a3 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/CXBlender.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/CXBlender.cs @@ -251,6 +251,9 @@ private bool TryTakeOldNodeOrToken( MoveToNextSibling(ref cursor); + if (current is CXToken token) + current = token.WithNewPosition(cursor.NewPosition); + blendedNode = new( current, cursor with {NewPosition = cursor.NewPosition + current.FullSpan.Length,} @@ -264,15 +267,26 @@ private bool CanReuse(ICXNode? node, Cursor cursor) if (node.FullSpan.IsEmpty) return false; - if (IntersectsChange(node.FullSpan, cursor)) return false; + if (IntersectsChange(node, cursor)) return false; if (node.HasErrors) return false; return true; } - private static bool IntersectsChange(TextSpan span, Cursor cursor) - => !cursor.Changes.IsEmpty && span.IntersectsWith(cursor.Changes.Peek().Span); + private static bool IntersectsChange(ICXNode node, Cursor cursor) + { + if (cursor.Changes.IsEmpty) return false; + + // for collections, we assume anything after *could* be another element to + // the collection. A simple way to force that is to up the nodes span by 1 + // before checking the changes + var span = node is ICXCollection + ? new TextSpan(node.FullSpan.Start, node.FullSpan.Length + 1) + : node.FullSpan; + + return span.IntersectsWith(cursor.Changes.Peek().Span); + } private BlendedNode ReadNewToken(Cursor cursor) { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXLexer.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXLexer.cs index 3df6d51cdc..da5229bdb6 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXLexer.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXLexer.cs @@ -141,25 +141,25 @@ public CXToken Next() var info = default(TokenInfo); - GetTrivia(isTrailing: false, ref info.LeadingTriviaLength); - info.Start = Reader.Position; - Scan(ref info); + GetTrivia(isTrailing: false, ref info.LeadingTriviaLength); - info.End = Reader.Position; + Scan(ref info); GetTrivia(isTrailing: true, ref info.TrailingTriviaLength); - var span = new TextSpan(info.Start, info.End - info.Start); + info.End = Reader.Position; + + var fullSpan = TextSpan.FromBounds(info.Start, info.End); var token = new CXToken( info.Kind, - span, + fullSpan, info.LeadingTriviaLength, info.TrailingTriviaLength, info.Flags, - Reader.Source.GetValue(span) + fullSpan.IsEmpty ? string.Empty : Reader.Source.GetValue(fullSpan) ); if (info.Kind is CXTokenKind.Interpolation) @@ -318,7 +318,6 @@ private bool TryScanAttributeValue(ref TokenInfo info) QuoteChar = Reader.Current; Reader.Advance(); info.Kind = CXTokenKind.StringLiteralStart; - Mode = LexMode.StringLiteral; return true; } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXToken.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXToken.cs index b3f55aa2db..3ea3990c9c 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXToken.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXToken.cs @@ -8,14 +8,24 @@ namespace Discord.ComponentDesignerGenerator.Parser; public sealed record CXToken( CXTokenKind Kind, - TextSpan Span, + TextSpan FullSpan, int LeadingTriviaLength, int TrailingTriviaLength, CXTokenFlags Flags, - string Value, + string FullValue, params IReadOnlyList Diagnostics ) : ICXNode { + public string Value => FullValue.Substring( + LeadingTriviaLength, + FullValue.Length - LeadingTriviaLength - TrailingTriviaLength + ); + + public TextSpan Span => new( + FullSpan.Start + LeadingTriviaLength, + FullValue.Length - LeadingTriviaLength - TrailingTriviaLength + ); + public CXNode? Parent { get; set; } public bool HasErrors @@ -31,13 +41,6 @@ Kind is CXTokenKind.Invalid || public bool IsInvalid => Kind is CXTokenKind.Invalid; - public int AbsoluteStart => Span.Start - LeadingTriviaLength; - public int AbsoluteEnd => Span.End + TrailingTriviaLength; - - public int AbsoluteWidth => AbsoluteEnd - AbsoluteStart; - - public TextSpan FullSpan => new(AbsoluteStart, AbsoluteWidth); - public int Width => FullSpan.Length; int ICXNode.GraphWidth => 0; @@ -50,6 +53,25 @@ public void ResetCachedState() _hasErrors = null; } + public CXToken WithNewPosition(int position) + { + if (FullSpan.Start == position) return this; + + return this with {FullSpan = new(position, FullSpan.Length)}; + } + + public override string ToString() => ToString(false, false); + public string ToFullString() => ToString(true, true); + + public string ToString(bool includeLeadingTrivia, bool includeTrailingTrivia) + => (includeLeadingTrivia, includeTrailingTrivia) switch + { + (false, false) => Value, + (true, true) => FullValue, + (false, true) => FullValue.Substring(LeadingTriviaLength), + (true, false) => FullValue.Substring(0, FullValue.Length - TrailingTriviaLength) + }; + public bool Equals(CXToken? other) { if (other is null) return false; diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXCollection.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXCollection.cs index d265c11cb5..b7ad3ead5e 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXCollection.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXCollection.cs @@ -4,8 +4,17 @@ namespace Discord.ComponentDesignerGenerator.Parser; -public sealed class CXCollection : CXNode, IReadOnlyList - where T : ICXNode +public interface ICXCollection : ICXNode +{ + int Count { get; } + ICXNode this[int index] { get; } +} + +public sealed class CXCollection : + CXNode, + ICXCollection, + IReadOnlyList + where T : class, ICXNode { public T this[int index] => _items[index]; @@ -18,7 +27,10 @@ public CXCollection(params IEnumerable items) Slot((IEnumerable)(_items = [..items])); } + public IEnumerator GetEnumerator() => _items.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable) _items).GetEnumerator(); + ICXNode ICXCollection.this[int index] => this[index]; + int ICXCollection.Count => Count; } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXDoc.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXDoc.cs index 895416c8f3..b60513a061 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXDoc.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXDoc.cs @@ -1,4 +1,5 @@ using Microsoft.CodeAnalysis.Text; +using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; @@ -9,6 +10,8 @@ public sealed class CXDoc : CXNode { public override CXParser Parser { get; } + public CXSource Source => Parser.Source; + public IReadOnlyList Tokens { get; } public IReadOnlyList RootElements { get; private set; } @@ -27,6 +30,18 @@ IReadOnlyList tokens InterpolationTokens = parser.Lexer.InterpolationMap; } + public bool TryGetInterpolationIndex(CXToken token, out int index) + { + if (token.Kind is not CXTokenKind.Interpolation) + { + index = -1; + return false; + } + + index = Array.IndexOf(InterpolationTokens, token); + return index != -1; + } + public IncrementalParseResult ApplyChanges( CXSource source, IReadOnlyList changes diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXNode.cs index dc8ea69acf..cb9bddf6a4 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXNode.cs @@ -1,8 +1,11 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; using System; +using System.Collections; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using System.Text; namespace Discord.ComponentDesignerGenerator.Parser; @@ -39,11 +42,7 @@ public bool HasErrors public CXDoc Document { - get => _doc ??= ( - this is CXDoc doc - ? doc - : _doc ??= Parent?.Document ?? throw new InvalidOperationException() - ); + get => TryGetDocument(out var doc) ? doc : throw new InvalidOperationException(); set => _doc = value; } @@ -53,8 +52,6 @@ public CXToken? FirstTerminal { get { - if (_firstTerminal is not null) return _firstTerminal; - for (var i = 0; i < _slots.Count; i++) { switch (_slots[i].Value) @@ -73,8 +70,6 @@ public CXToken? LastTerminal { get { - if (_lastTerminal is not null) return _lastTerminal; - for (var i = _slots.Count - 1; i >= 0; i--) { switch (_slots[i].Value) @@ -103,11 +98,9 @@ public IReadOnlyList Descendants public TextSpan FullSpan => new(Offset, Width); public TextSpan Span - => _span ??= ( - FirstTerminal is { } first && LastTerminal is { } last - ? TextSpan.FromBounds(first.Span.Start, last.Span.End) - : FullSpan - ); + => FirstTerminal is { } first && LastTerminal is { } last + ? TextSpan.FromBounds(first.Span.Start, last.Span.End) + : FullSpan; // TODO: // this could be cached, a caveat though is if we incrementally parse, we need to update the @@ -121,7 +114,6 @@ FirstTerminal is { } first && LastTerminal is { } last // cached state private int? _offset; - private TextSpan? _span; private CXToken? _firstTerminal; private CXToken? _lastTerminal; private CXDoc? _doc; @@ -134,6 +126,31 @@ public CXNode() _slots = []; } + private bool TryGetDocument(out CXDoc result) + { + if (_doc is not null) + { + result = _doc; + return true; + } + + var current = this; + + while (current is not null) + { + if (current is CXDoc document) + { + result = _doc = document; + return true; + } + + current = current.Parent; + } + + result = null!; + return false; + } + public bool TryFindToken(int position, out CXToken token) { if (!FullSpan.Contains(position)) @@ -225,7 +242,8 @@ public int GetIndexOfSlot(ICXNode node) private int ComputeOffset() { - if (Parent is null) return Document.Parser.Source.SourceSpan.Start; + if (Parent is null) + return TryGetDocument(out var doc) ? doc.Parser.Source.SourceSpan.Start : 0; var parentOffset = Parent.Offset; var parentSlotIndex = GetParentSlotIndex(); @@ -237,12 +255,24 @@ private int ComputeOffset() _ => Parent._slots[parentSlotIndex - 1].Value switch { CXNode sibling => sibling.Offset + sibling.Width, - CXToken token => token.AbsoluteEnd, + CXToken token => token.FullSpan.End, _ => throw new InvalidOperationException() } }; } + private int ComputeWidth() + { + if (Slots.Count is 0) return 0; + + return Slots.Sum(x => x.Value switch + { + CXToken token => token.FullSpan.Length, + CXNode node => node.Width, + _ => 0 + }); + } + protected bool IsGraphChild(CXNode node) => IsGraphChild(node, out _); protected bool IsGraphChild(CXNode node, out int index) @@ -271,7 +301,7 @@ protected void RemoveSlot(CXNode node) _slots.RemoveAt(index); } - protected void Slot(CXCollection? node) where T : ICXNode => Slot((CXNode?)node); + protected void Slot(CXCollection? node) where T : class, ICXNode => Slot((CXNode?)node); protected void Slot(ICXNode? node) { @@ -293,7 +323,6 @@ protected void Slot(IEnumerable nodes) public void ResetCachedState() { _offset = null; - _span = null; _firstTerminal = null; _lastTerminal = null; _doc = null; @@ -305,4 +334,68 @@ public void ResetCachedState() _descendants = null; } + + public override string ToString() => ToString(false, false); + public string ToFullString() => ToString(true, true); + + public string ToString(bool includeLeadingTrivia, bool includeTrailingTrivia) + { + if (TryGetDocument(out var document)) + { + return document.Source.GetValue( + (includeLeadingTrivia, includeTrailingTrivia) switch + { + (true, true) => FullSpan, + (false, false) => Span, + (true, false) => TextSpan.FromBounds(FullSpan.Start, Span.End), + (false, true) => TextSpan.FromBounds(Span.Start, FullSpan.Start), + } + ); + } + + var tokens = new List(); + + var stack = new Stack<(CXNode Node, int Index)>([(this, 0)]); + + while (stack.Count > 0) + { + var (node, index) = stack.Pop(); + + if (node.Slots.Count <= index) continue; + + var child = node.Slots[index]; + + if (node.Slots.Count - 1 > index) + stack.Push((node, index + 1)); + + switch (child.Value) + { + case CXToken token: + tokens.Add(token); + continue; + case CXNode childNode: + stack.Push((childNode, 0)); + continue; + } + } + + var sb = new StringBuilder(); + + for (var i = 0; i < tokens.Count; i++) + { + var token = tokens[i]; + + var isFirst = i == 0; + var isLast = i == tokens.Count - 1; + + sb.Append( + token.ToString( + !isFirst || includeLeadingTrivia, + !isLast || includeTrailingTrivia + ) + ); + } + + return sb.ToString(); + } } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXValue.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXValue.cs index 125ec0a006..1d36f31c4b 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXValue.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXValue.cs @@ -1,5 +1,6 @@ using Microsoft.CodeAnalysis.Text; using System.Collections.Generic; +using System.Linq; namespace Discord.ComponentDesignerGenerator.Parser; @@ -9,13 +10,14 @@ public sealed class Invalid : CXValue; public sealed class StringLiteral : CXValue { + public bool HasInterpolations => Tokens.Any(x => x.Kind is CXTokenKind.Interpolation); public CXToken StartToken { get; } - public IReadOnlyList Tokens { get; } + public CXCollection Tokens { get; } public CXToken EndToken { get; } public StringLiteral( CXToken start, - List tokens, + CXCollection tokens, CXToken end ) { diff --git a/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs b/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs index 2e8ce178cb..bb741c6313 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs @@ -30,8 +30,10 @@ DesignerInterpolationInfo[] Interpolations } public sealed record DesignerInterpolationInfo( + int Id, TextSpan Span, - ITypeSymbol? Symbol + ITypeSymbol? Symbol, + Optional Constant ); [Generator] @@ -124,7 +126,7 @@ CancellationToken token if (_cache.TryGetValue(key, out var manager)) { - manager.OnUpdate(key, target); + manager = _cache[key] = manager.OnUpdate(key, target); } else { @@ -250,9 +252,11 @@ out DesignerInterpolationInfo[] interpolations content = interpolated.Contents.ToString(); interpolations = interpolated.Contents .OfType() - .Select(x => new DesignerInterpolationInfo( + .Select((x, i) => new DesignerInterpolationInfo( + i, x.FullSpan, - semanticModel.GetTypeInfo(x.Expression).Type + semanticModel.GetTypeInfo(x.Expression).Type, + semanticModel.GetConstantValue(x.Expression) )) .ToArray(); span = interpolated.Contents.Span; diff --git a/src/Discord.Net.ComponentDesigner.Generator/Utils/StringUtils.cs b/src/Discord.Net.ComponentDesigner.Generator/Utils/StringUtils.cs index ed6140bde1..124b0edb73 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Utils/StringUtils.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Utils/StringUtils.cs @@ -1,4 +1,6 @@ -namespace Discord.ComponentDesignerGenerator; +using System; + +namespace Discord.ComponentDesignerGenerator; public static class StringUtils { @@ -10,4 +12,20 @@ public static string Postfix(this string str, int count, char prefixChar = ' ') public static string WithNewlinePadding(this string str, int pad) => str.Replace("\n", "\n".Postfix(pad)); + + public static string WrapIfSome(this string str, string wrapping) + => string.IsNullOrWhiteSpace(str) ? str : $"{wrapping}{str}{wrapping}"; + + public static string PrefixIfSome(this string str, int count, char prefixChar = ' ') + => string.IsNullOrWhiteSpace(str) ? str : $"{new string(prefixChar, count)}{str}"; + public static string PrefixIfSome(this string str, string prefix) + => string.IsNullOrWhiteSpace(str) ? str : $"{prefix}{str}"; + + public static string PostfixIfSome(this string str, int count, char prefixChar = ' ') + => string.IsNullOrWhiteSpace(str) ? str : $"{str}{new string(prefixChar, count)}"; + public static string PostfixIfSome(this string str, string postfix) + => string.IsNullOrWhiteSpace(str) ? str : $"{str}{postfix}"; + + public static string Map(this string str, Func mapper) + => string.IsNullOrWhiteSpace(str) ? str : mapper(str); } From d792ee3f84df399cea4a067ca32ebefb6a135f59 Mon Sep 17 00:00:00 2001 From: Quin Lynch <49576606+quinchs@users.noreply.github.com> Date: Sat, 20 Sep 2025 13:20:45 -0300 Subject: [PATCH 16/17] More fixes and progress --- .../Constants.cs | 2 +- .../Diagnostics.cs | 11 +- .../Graph/CXGraph.cs | 178 ++++-------------- .../Graph/CXGraphManager.cs | 50 +++-- .../Nodes/ComponentNode.cs | 3 + .../Components/ActionRowComponentNode.cs | 55 +++++- .../Components/InterleavedComponentNode.cs | 23 +++ .../SelectMenus/SelectMenuComponentNode.cs | 3 +- .../Nodes/Renderers/Renderers.cs | 13 +- .../Parsing/CXParser.cs | 67 ++++--- .../Parsing/CXSource.cs | 9 +- .../Parsing/CXSourceReader.cs | 12 +- .../Parsing/Incremental/CXBlender.cs | 19 +- .../Parsing/Lexer/CXLexer.cs | 118 +++++++++--- .../Parsing/Lexer/CXTokenKind.cs | 1 + .../Parsing/Nodes/CXDoc.cs | 44 ++--- .../Parsing/Nodes/CXNode.cs | 8 +- .../SourceGenerator.cs | 109 +++++++---- .../ComponentDesigner.cs | 11 ++ 19 files changed, 441 insertions(+), 295 deletions(-) create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterleavedComponentNode.cs diff --git a/src/Discord.Net.ComponentDesigner.Generator/Constants.cs b/src/Discord.Net.ComponentDesigner.Generator/Constants.cs index f894360f6e..e52625ddfd 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Constants.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Constants.cs @@ -2,7 +2,7 @@ public static class Constants { - public const string COMPONENT_DESIGNER_QUALIFIED_NAME = "Discord.ComponentDesigner"; + public const string COMPONENT_DESIGNER_QUALIFIED_NAME = "Discord.DesignerInterpolationHandler"; public const string INTERPOLATION_DESIGNER_QUALIFIED_NAME = "Discord.DesignerInterpolationHandler"; public const int PLACEHOLDER_MAX_LENGTH = 150; diff --git a/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs b/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs index 467659051e..f0abd6c126 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs @@ -230,11 +230,20 @@ public static partial class Diagnostics ); public static readonly DiagnosticDescriptor SpecifiedInvalidSelectMenuType = new( - "DC0024", + "DC0025", "Invalid select menu type", "'{0}' is not a valid elect menu type; must be either 'string', 'user', 'role', 'channel', or 'mentionable'", "Components", DiagnosticSeverity.Error, true ); + + public static readonly DiagnosticDescriptor ActionRowInvalidChild = new( + "DC0026", + "Invalid action row child component", + "An action row can only contain 1 select menu OR at most 5 buttons", + "Components", + DiagnosticSeverity.Error, + true + ); } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraph.cs b/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraph.cs index 839e4965cb..f7e10e8fcf 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraph.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraph.cs @@ -1,4 +1,5 @@ using Discord.ComponentDesignerGenerator.Nodes; +using Discord.ComponentDesignerGenerator.Nodes.Components; using Discord.ComponentDesignerGenerator.Parser; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; @@ -34,10 +35,15 @@ IReadOnlyDictionary nodeMap public static Location GetLocation(CXGraphManager manager, ICXNode node) => GetLocation(manager, node.Span); + public static Location GetLocation(CXGraphManager manager, TextSpan span) => manager.SyntaxTree.GetLocation(span); - public CXGraph Update(CXGraphManager manager, IncrementalParseResult parseResult) + public CXGraph Update( + CXGraphManager manager, + IncrementalParseResult parseResult, + CXDoc document + ) { if (manager == Manager) return this; @@ -46,7 +52,7 @@ public CXGraph Update(CXGraphManager manager, IncrementalParseResult parseResult var rootNodes = ImmutableArray.CreateBuilder(); - foreach (var cxNode in manager.Document.RootElements) + foreach (var cxNode in document.RootElements) { var node = CreateNode( manager, @@ -107,7 +113,36 @@ ImmutableArray.Builder diagnostics switch (cxNode) { + case CXValue.Interpolation interpolation: + { + var info = manager.InterpolationInfos[interpolation.InterpolationIndex]; + + if ( + manager.Compilation.HasImplicitConversion( + info.Symbol, + manager.Compilation.GetKnownTypes() + .IMessageComponentBuilderType + ) + ) + { + var inner = ComponentNode.GetComponentNode(); + + var state = inner.Create(interpolation, []); + + if (state is null) return null; + + return map[interpolation] = new( + inner, + state, + parent, + [] + ); + } + + return null; + } case CXElement element: + { if (!ComponentNode.TryGetNode(element.Identifier, out var componentNode)) { diagnostics.Add( @@ -151,6 +186,7 @@ ImmutableArray.Builder diagnostics ); return node; + } default: return null; } } @@ -178,143 +214,7 @@ public string Render(ComponentContext context) public void Validate(ComponentContext context) { Inner.Validate(State, context); - foreach(var child in Children) child.Validate(context); + foreach (var child in Children) child.Validate(context); } } - - // public CXDoc Document { get; } - // public CXGraphManager Manager { get; } - // - // public List Roots { get; } - // - // public Dictionary NodeCacheMap { get; private set; } - // public Dictionary PropertyCacheMap { get; } - // - // public CXGraph(CXDoc document, CXGraphManager manager) - // { - // Document = document; - // Manager = manager; - // Roots = []; - // NodeCacheMap = []; - // PropertyCacheMap = []; - // } - // - // public CXGraph Update(CXGraphManager manager) - // { - // - // } - // - // // public CXGraph Update( - // // CXDoc doc, - // // IReadOnlyList reusedNodes - // // ) - // // { - // // var context = new ComponentContext(this); - // // - // // var map = new Dictionary(); - // // - // // // update the properties map - // // foreach (var cxNode in PropertyCacheMap.Keys.Except(reusedNodes.OfType())) - // // { - // // PropertyCacheMap.Remove(cxNode); - // // } - // // - // // Roots.Clear(); - // // Roots.AddRange( - // // doc.RootElements.Select(x => CreateNode(null, x)).Where(x => x is not null)! - // // ); - // // - // // NodeCacheMap.Clear(); - // // NodeCacheMap = map; - // // - // // return; - // // - // // Node? CreateNode(Node? parent, CXNode cxNode) - // // { - // // if (reusedNodes.Contains(cxNode) && NodeCacheMap.TryGetValue(cxNode, out var existing)) - // // return map[cxNode] = existing with {Parent = parent}; - // // - // // switch (cxNode) - // // { - // // case CXElement element: - // // if (!ComponentNode.TryGetNode(element.Identifier, out var componentNode)) - // // { - // // context.AddDiagnostic( - // // Diagnostics.UnknownComponent, - // // element, - // // element.Identifier - // // ); - // // - // // return null; - // // } - // // - // // var children = new List(); - // // - // // var state = componentNode.Create(element, children); - // // - // // if (state is null) return null; - // // - // // var node = state.OwningNode = new Node( - // // componentNode, - // // state, - // // parent, - // // [], - // // this - // // ); - // // - // // map[element] = node; - // // - // // node.Children.AddRange( - // // children.Select(x => CreateNode(node, x)).Where(x => x is not null)! - // // ); - // // - // // return node; - // // default: return null; - // // } - // // } - // // } - // - // public static CXGraph Create(CXDoc doc, CXGraphManager manager) - // { - // var graph = new CXGraph(doc, manager); - // - // graph.Update(doc, []); - // - // return graph; - // } - // - // public void Validate(ComponentContext? context = null) - // { - // context ??= new ComponentContext(this); - // - // foreach (var node in Roots) node.Validate(context); - // } - // - // public string Render(ComponentContext? context = null) - // { - // context ??= new ComponentContext(this); - // - // return string.Join(",\n", Roots.Select(x => x.Inner.Render(x.State, context))); - // } - // - // public sealed record Node( - // ComponentNode Inner, - // ComponentState State, - // Node? Parent, - // List Children, - // CXGraph Graph - // ) - // { - // private string? _render; - // - // public string Render(ComponentContext context) - // => _render ??= Inner.Render(State, context); - // - // public void Validate(ComponentContext context) - // { - // Inner.Validate(State, context); - // - // foreach (var child in Children) child.Validate(context); - // } - // } } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraphManager.cs b/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraphManager.cs index 5da5bf9d25..3c49ae5a90 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraphManager.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraphManager.cs @@ -9,24 +9,10 @@ using System.Diagnostics; using System.Linq; using System.Text; +using System.Threading; namespace Discord.ComponentDesignerGenerator; -// public sealed class GraphManagerComparer : IEqualityComparer -// { -// public static readonly GraphManagerComparer Instance = new(); -// -// public bool Equals(CXGraphManager? x, CXGraphManager? y) -// => (x, y) switch -// { -// (not null, not null) => x.SyntaxTree.Equals(y.SyntaxTree) && x.Version == y.Version, -// _ => false -// }; -// -// public int GetHashCode(CXGraphManager manager) -// => (manager.SyntaxTree.GetHashCode() * 397) ^ manager.Version; -// } - public sealed record CXGraphManager( SourceGenerator Generator, string Key, @@ -75,15 +61,16 @@ public CXGraphManager(CXGraphManager other) Document = other.Document; } - public static CXGraphManager Create(SourceGenerator generator, string key, Target target) + public static CXGraphManager Create(SourceGenerator generator, string key, Target target, CancellationToken token) { var source = new CXSource( target.CXDesignerSpan, target.CXDesigner, - target.Interpolations.Select(x => x.Span).ToArray() + target.Interpolations.Select(x => x.Span).ToArray(), + target.CXQuoteCount ); - var doc = CXParser.Parse(source); + var doc = CXParser.Parse(source, token); return new CXGraphManager( generator, @@ -93,7 +80,7 @@ public static CXGraphManager Create(SourceGenerator generator, string key, Targe ); } - public CXGraphManager OnUpdate(string key, Target target) + public CXGraphManager OnUpdate(string key, Target target, CancellationToken token) { /* * TODO: @@ -125,18 +112,19 @@ public CXGraphManager OnUpdate(string key, Target target) if (newCXWithoutInterpolations != SimpleSource) { // we're going to need to reparse, the underlying CX structure changed - result.DoReparse(target, this, ref result); + result.DoReparse(target, this, ref result, token); } return result; } - private void DoReparse(Target target, CXGraphManager old, ref CXGraphManager result) + private void DoReparse(Target target, CXGraphManager old, ref CXGraphManager result, CancellationToken token) { var source = new CXSource( target.CXDesignerSpan, target.CXDesigner, - target.Interpolations.Select(x => x.Span).ToArray() + target.Interpolations.Select(x => x.Span).ToArray(), + target.CXQuoteCount ); var changes = target @@ -145,15 +133,21 @@ private void DoReparse(Target target, CXGraphManager old, ref CXGraphManager res .Where(x => CXDesignerSpan.IntersectsWith(x.Span)) .ToArray(); - var parseResult = Document.ApplyChanges( + var document = Document.IncrementalParse( source, - changes + changes, + out var parseResult, + token ); - result = result with {Graph = result.Graph.Update(result, parseResult)}; + result = result with + { + Graph = result.Graph.Update(result, parseResult, document), + Document = document + }; } - public RenderedInterceptor Render() + public RenderedInterceptor Render(CancellationToken token = default) { var diagnostics = new List( Document @@ -199,10 +193,12 @@ DesignerInterpolationInfo[] interpolations var builder = new StringBuilder(cx); + var rmDelta = 0; for (var i = 0; i < interpolations.Length; i++) { var interpolation = interpolations[i]; - builder.Remove(interpolation.Span.Start - offset, interpolation.Span.Length); + builder.Remove(interpolation.Span.Start - offset - rmDelta, interpolation.Span.Length); + rmDelta += interpolation.Span.Length; } return builder.ToString(); diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs index 7a17aa76c4..f8cb181e4a 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs @@ -124,6 +124,9 @@ static ComponentNode() .ToDictionary(x => x.Key, x => x.Value); } + public static T GetComponentNode() where T : ComponentNode + => _nodes.Values.OfType().First(); + public static bool TryGetNode(string name, out ComponentNode node) => _nodes.TryGetValue(name, out node); } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs index 4c3a250752..ec4692434b 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs @@ -1,6 +1,8 @@ -using Discord.ComponentDesignerGenerator.Parser; +using Discord.ComponentDesignerGenerator.Nodes.Components.SelectMenus; +using Discord.ComponentDesignerGenerator.Parser; using Microsoft.CodeAnalysis; using System.Collections.Generic; +using System.Linq; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; namespace Discord.ComponentDesignerGenerator.Nodes.Components; @@ -26,19 +28,60 @@ public override void Validate(ComponentState state, ComponentContext context) return; } - for (var i = 0; i < state.Children.Count; i++) + switch (state.Children[0].Inner) { - var child = state.Children[i]; - // TODO: validate children types + case ButtonComponentNode: + foreach (var rest in state.Children.Skip(1)) + { + if (rest.Inner is not ButtonComponentNode) + { + context.AddDiagnostic( + Diagnostics.ActionRowInvalidChild, + rest.State.Source + ); + } + } + break; + case SelectMenuComponentNode: + foreach (var rest in state.Children.Skip(1)) + { + context.AddDiagnostic( + Diagnostics.ActionRowInvalidChild, + rest.State.Source + ); + } + + break; + + case InterleavedComponentNode: break; + + default: + foreach ( + var rest + in state.Children.Where(x => !IsValidChild(x.Inner)) + ) + { + context.AddDiagnostic( + Diagnostics.ActionRowInvalidChild, + rest.State.Source + ); + } + + break; } base.Validate(state, context); } + private static bool IsValidChild(ComponentNode node) + => node is ButtonComponentNode + or SelectMenuComponentNode + or InterleavedComponentNode; + public override string Render(ComponentState state, ComponentContext context) => $$""" - new {{context.KnownTypes.ActionRowBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}}{{ + new {{context.KnownTypes.ActionRowBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}}{{ $"{ state .RenderProperties(this, context, asInitializers: true) @@ -59,5 +102,5 @@ public override string Render(ComponentState state, ComponentContext context) .PrefixIfSome("\n{\n".Postfix(4)) .PostfixIfSome("\n}") }} - """; + """; } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterleavedComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterleavedComponentNode.cs new file mode 100644 index 0000000000..23d1bfe730 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterleavedComponentNode.cs @@ -0,0 +1,23 @@ +using Discord.ComponentDesignerGenerator.Parser; +using System.Collections.Generic; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.ComponentDesignerGenerator.Nodes.Components; + +public sealed class InterleavedComponentNode : ComponentNode +{ + public override string Name => ""; + + public override ComponentState? Create(ICXNode source, List children) + { + if (source is not CXValue.Interpolation interpolation) return null; + + return base.Create(source, children); + } + + public override string Render(ComponentState state, ComponentContext context) + => context.GetDesignerValue( + (CXValue.Interpolation)state.Source, + context.KnownTypes.IMessageComponentBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + ); +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuComponentNode.cs index 46b6cc734f..c8f4677051 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuComponentNode.cs @@ -188,8 +188,7 @@ public override string Render(ComponentState state, ComponentContext context) _ => string.Empty } ).Map(x => $"type: {context.KnownTypes.ComponentTypeEnumType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.{x}"), - state.RenderProperties(this, - context), + state.RenderProperties(this, context), state.RenderChildren(context, x => x.Inner is StringSelectOptionComponentNode) .Map(x => $""" diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs index 0e9e5f607b..2df6833d15 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs @@ -257,6 +257,7 @@ static bool TryParseHexColor(string hexColor, out uint color) public static string Snowflake(ComponentContext context, ComponentPropertyValue propertyValue) => Snowflake(context, propertyValue.Value); + public static string Snowflake(ComponentContext context, CXValue? value) { switch (value) @@ -277,14 +278,24 @@ interpolationInfo.Symbol is not null && return UseParseMethod($"designer.GetValueAsString({interpolation.InterpolationIndex})"); case CXValue.Scalar scalar: - return UseParseMethod(ToCSharpString(scalar.Value)); + return FromText(scalar.Value.Trim()); case CXValue.StringLiteral stringLiteral: + if (!stringLiteral.HasInterpolations) + return FromText(stringLiteral.Tokens.ToString().Trim()); + return UseParseMethod(RenderStringLiteral(stringLiteral)); default: return "default"; } + string FromText(string text) + { + if (ulong.TryParse(text, out _)) return text; + + return UseParseMethod(ToCSharpString(text)); + } + static string UseParseMethod(string input) => $"ulong.Parse({input})"; } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXParser.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXParser.cs index 889814cc70..8d780a0af4 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXParser.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXParser.cs @@ -4,23 +4,13 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Threading; namespace Discord.ComponentDesignerGenerator.Parser; public sealed class CXParser { - public CXSource Source - { - get => _source; - set - { - _source = value; - Reader.Source = value; - } - } - public CXToken CurrentToken => Lex(_tokenIndex); - public CXToken NextToken => Lex(_tokenIndex + 1); public ICXNode? CurrentNode => (_currentBlendedNode ??= GetCurrentBlendedNode())?.Value; @@ -37,26 +27,40 @@ public ICXNode? CurrentNode .Where(x => x is not null)! ]; + public IReadOnlyList Tokens + => IsIncremental ? [..BlendedNodes.OfType()] : [.._tokens]; + private readonly List _blendedNodes; public CXSourceReader Reader { get; } public bool IsIncremental => Blender is not null; - public CXBlender? Blender { get; set; } - private BlendedNode? _currentBlendedNode; + public CXBlender? Blender { get; } + + public CXSource Source { get; } + + public CancellationToken CancellationToken { get; } - private CXSource _source; - public CXParser(CXSource source) + private BlendedNode? _currentBlendedNode; + + public CXParser(CXSource source, CancellationToken token = default) { - _source = source; + CancellationToken = token; + Source = source; Reader = new CXSourceReader(source); - Lexer = new CXLexer(Reader); + Lexer = new CXLexer(Reader, token); _tokens = []; _blendedNodes = []; } + public CXParser(CXSource source, CXDoc document, TextChangeRange change, CancellationToken token = default) + : this(source, token) + { + Blender = new CXBlender(Lexer, document, change); + } + public void Reset() { Reader.Position = Source.SourceSpan.Start; @@ -69,18 +73,22 @@ public void Reset() _currentBlendedNode = null; } - public static CXDoc Parse(CXSource source) + public static CXDoc Parse(CXSource source, CancellationToken token = default) { var elements = new List(); - var parser = new CXParser(source); + var parser = new CXParser(source, token: token); while (parser.CurrentToken.Kind is not CXTokenKind.EOF and not CXTokenKind.Invalid) { - elements.Add(parser.ParseElement()); + var element = parser.ParseElement(); + elements.Add(element); + token.ThrowIfCancellationRequested(); + + if (element.Width is 0) break; } - return new CXDoc(parser, elements, [..parser._tokens]); + return new CXDoc(parser, elements); } internal CXElement ParseElement() @@ -173,6 +181,8 @@ CXCollection ParseElementChildren() { while (TryParseElementChild(diagnostics, out var child)) children.Add(child); + + CancellationToken.ThrowIfCancellationRequested(); } return new CXCollection(children) {Diagnostics = diagnostics}; @@ -235,6 +245,8 @@ internal CXCollection ParseAttributes() { while (CurrentToken.Kind is CXTokenKind.Identifier) attributes.Add(ParseAttribute()); + + CancellationToken.ThrowIfCancellationRequested(); } return new CXCollection(attributes); @@ -321,10 +333,14 @@ internal CXValue ParseStringLiteral() var start = Expect(CXTokenKind.StringLiteralStart); using var _ = Lexer.SetMode(CXLexer.LexMode.StringLiteral); - Lexer.QuoteChar = Reader[start.Span.Start]; + + // we grab the last char to ensure it's a quote incase its actually escaped + Lexer.QuoteChar = start.Value[start.Value.Length - 1]; while (CurrentToken.Kind is not CXTokenKind.StringLiteralEnd) { + CancellationToken.ThrowIfCancellationRequested(); + switch (CurrentToken.Kind) { case CXTokenKind.Text: @@ -410,10 +426,7 @@ internal CXToken Expect(params ReadOnlySpan kinds) current.Span ) ); - break; } - - return current; } internal CXToken Expect(CXTokenKind kind) @@ -462,6 +475,8 @@ internal CXToken Lex(int index) while (_tokens.Count <= index) { + CancellationToken.ThrowIfCancellationRequested(); + var token = Lexer.Next(); _tokens.Add(token); @@ -475,6 +490,8 @@ CXToken FetchBlended() { while (_blendedNodes.Count <= index) { + CancellationToken.ThrowIfCancellationRequested(); + var cursor = _blendedNodes.Count is 0 ? Blender.StartingCursor : _blendedNodes[_blendedNodes.Count - 1].Cursor; diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXSource.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXSource.cs index 48246edb0c..2bb0ad21bd 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXSource.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXSource.cs @@ -6,6 +6,7 @@ namespace Discord.ComponentDesignerGenerator.Parser; public sealed class CXSource { public string Value { get; } + public int WrappingQuoteCount { get; } public char this[int index] => Value[index - SourceSpan.Start]; @@ -14,10 +15,16 @@ public sealed class CXSource public readonly TextSpan SourceSpan; - public CXSource(TextSpan sourceSpan, string content, TextSpan[] interpolations) + public CXSource( + TextSpan sourceSpan, + string content, + TextSpan[] interpolations, + int wrappingQuoteCount + ) { SourceSpan = sourceSpan; Value = content; + WrappingQuoteCount = wrappingQuoteCount; Interpolations = interpolations; } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXSourceReader.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXSourceReader.cs index d0c1d01965..c5ea206023 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXSourceReader.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXSourceReader.cs @@ -1,4 +1,7 @@ -namespace Discord.ComponentDesignerGenerator.Parser; +using Microsoft.CodeAnalysis.Text; +using System; + +namespace Discord.ComponentDesignerGenerator.Parser; public sealed class CXSourceReader { @@ -34,4 +37,11 @@ public void Advance(int count = 1) Position++; } } + + public string Peek(int count = 1) + { + var upper = Math.Min(Source.SourceSpan.End, Position + count); + + return Source.GetValue(TextSpan.FromBounds(Position, upper)); + } } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/CXBlender.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/CXBlender.cs index 9ddb7432a3..dc4ce86806 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/CXBlender.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/CXBlender.cs @@ -1,6 +1,7 @@ using Microsoft.CodeAnalysis.Text; using System.Collections.Generic; using System.Collections.Immutable; +using System.Threading; namespace Discord.ComponentDesignerGenerator.Parser; @@ -44,6 +45,8 @@ public BlendedNode BlendChangedNode(ICXNode node) private ICXNode? this[in Cursor cursor] => cursor.Index >= 0 && cursor.Index < _graph.Count ? _graph[cursor.Index] : null; + public CancellationToken CancellationToken => _lexer.CancellationToken; + private readonly CXLexer _lexer; private readonly IReadOnlyList _graph; @@ -76,7 +79,10 @@ private void MoveToFirstToken(ref Cursor cursor) var index = cursor.Index; while (index < _graph.Count && _graph[index] is not CXToken) + { index++; + CancellationToken.ThrowIfCancellationRequested(); + } cursor = cursor with {Index = index}; } @@ -85,7 +91,10 @@ private void MoveToNextSibling(ref Cursor cursor) { while (this[cursor]?.Parent is not null) { + CancellationToken.ThrowIfCancellationRequested(); + var tempCursor = cursor; + FindNextNonZeroWidthOrIsEOFSibling(ref cursor); if (cursor.IsInvalid) @@ -112,7 +121,9 @@ private void MoveToParent(ref Cursor cursor) var delta = 1; for (var i = index - 1; i >= 0; i--) + { delta += current.Parent.Slots[i].Value.GraphWidth + 1; + } cursor = cursor with {Index = cursor.Index - delta}; } @@ -132,6 +143,8 @@ private void FindNextNonZeroWidthOrIsEOFSibling(ref Cursor cursor) cursorIndex += parent.Slots[slotIndex++].Value.GraphWidth + 1 ) { + CancellationToken.ThrowIfCancellationRequested(); + var sibling = parent.Slots[slotIndex]; if (IsNonZeroWidthOrIsEOF(sibling.Value)) @@ -161,6 +174,8 @@ private void MoveToFirstChild(ref Cursor cursor) childGraphIndex += current.Slots[childIndex++].Value.GraphWidth + 1 ) { + CancellationToken.ThrowIfCancellationRequested(); + var child = current.Slots[childIndex]; if (IsNonZeroWidthOrIsEOF(child.Value)) { @@ -185,6 +200,8 @@ public BlendedNode Next(bool asToken, Cursor cursor) { while (true) { + CancellationToken.ThrowIfCancellationRequested(); + if (IsCompletedCursor(cursor)) return ReadNewToken(cursor); if (cursor.ChangeDelta < 0) SkipOldToken(ref cursor); @@ -308,7 +325,7 @@ private BlendedNode ReadNewToken(Cursor cursor) private CXToken LexNewToken(Cursor cursor) { - _lexer.Reader.Position = cursor.NewPosition; + _lexer.Seek(cursor.NewPosition); return _lexer.Next(); } } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXLexer.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXLexer.cs index da5229bdb6..bef7cd12c6 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXLexer.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXLexer.cs @@ -1,5 +1,6 @@ using Microsoft.CodeAnalysis.Text; using System; +using System.Threading; namespace Discord.ComponentDesignerGenerator.Parser; @@ -57,8 +58,11 @@ public TextSpan? CurrentInterpolationSpan // there's no next interpolation if (Reader.Source.Interpolations.Length <= _interpolationIndex) return null; + for (; _interpolationIndex < Reader.Source.Interpolations.Length; _interpolationIndex++) { + CancellationToken.ThrowIfCancellationRequested(); + var interpolationSpan = Reader.Source.Interpolations[_interpolationIndex]; if (interpolationSpan.End < Reader.Position) continue; @@ -82,18 +86,29 @@ public TextSpan? NextInterpolationSpan if (Reader.Source.Interpolations.Length <= _nextInterpolationIndex) return null; // check if it's ahead of us - TextSpan? interpolationSpan = null; + var interpolationSpan = Reader.Source.Interpolations[_nextInterpolationIndex]; + + if (interpolationSpan.End > Reader.Position) return interpolationSpan; for (; _nextInterpolationIndex < Reader.Source.Interpolations.Length; _nextInterpolationIndex++) { + CancellationToken.ThrowIfCancellationRequested(); + interpolationSpan = Reader.Source.Interpolations[_nextInterpolationIndex]; - if (interpolationSpan.Value.Start > Reader.Position) break; + if (interpolationSpan.Start > Reader.Position) break; } return interpolationSpan; } } + private int InterpolationBoundary + => CurrentInterpolationSpan?.Start ?? + NextInterpolationSpan?.Start ?? + Reader.Source.SourceSpan.End; + + public bool ForcedEscapedQuotes => Reader.Source.WrappingQuoteCount == 1; + public LexMode Mode { get; set; } public CXToken[] InterpolationMap; @@ -103,13 +118,27 @@ public TextSpan? NextInterpolationSpan private int _nextInterpolationIndex; private int _interpolationIndex; - public CXLexer(CXSourceReader reader) + public CancellationToken CancellationToken { get; set; } + + + public CXLexer( + CXSourceReader reader, + CancellationToken cancellationToken = default + ) { + CancellationToken = cancellationToken; Reader = reader; Mode = LexMode.Default; InterpolationMap = new CXToken[Reader.Source.Interpolations.Length]; } + public void Seek(int position) + { + Reader.Position = position; + _interpolationIndex = 0; + _nextInterpolationIndex = 0; + } + public void Reset() { InterpolationMap = new CXToken[Reader.Source.Interpolations.Length]; @@ -118,6 +147,7 @@ public void Reset() public readonly struct ModeSentinel(CXLexer? lexer) : IDisposable { private readonly LexMode _mode = lexer?.Mode ?? LexMode.Default; + public void Dispose() { if (lexer is null) return; @@ -162,8 +192,8 @@ public CXToken Next() fullSpan.IsEmpty ? string.Empty : Reader.Source.GetValue(fullSpan) ); - if (info.Kind is CXTokenKind.Interpolation) - InterpolationMap[_interpolationIndex] = token; + if (info.Kind is CXTokenKind.Interpolation && InterpolationIndex.HasValue) + InterpolationMap[InterpolationIndex.Value] = token; return token; } @@ -193,6 +223,7 @@ private void Scan(ref TokenInfo info) Reader.Advance(); return; } + info.Kind = CXTokenKind.LessThan; return; case FORWARD_SLASH_CHAR when Reader.Next is GREATER_THAN_CHAR: @@ -226,12 +257,14 @@ private void Scan(ref TokenInfo info) private bool TryScanElementValue(ref TokenInfo info) { - var interpolationUpperBounds = NextInterpolationSpan?.Start ?? Reader.Source.SourceSpan.End; + var interpolationUpperBounds = InterpolationBoundary; var start = Reader.Position; for (; Reader.Position < interpolationUpperBounds; Reader.Advance()) { + CancellationToken.ThrowIfCancellationRequested(); + switch (Reader.Current) { case NULL_CHAR @@ -265,7 +298,7 @@ private void LexStringLiteral(ref TokenInfo info) return; } - var interpolationUpperBounds = NextInterpolationSpan?.Start ?? Reader.Source.SourceSpan.End; + var interpolationUpperBounds = InterpolationBoundary; if (Reader.Position >= interpolationUpperBounds) { @@ -277,9 +310,13 @@ private void LexStringLiteral(ref TokenInfo info) return; } - if (Reader.Current == QuoteChar) + if ( + ForcedEscapedQuotes + ? Reader.Current is BACK_SLASH_CHAR && Reader.Next == QuoteChar + : Reader.Current == QuoteChar + ) { - Reader.Advance(); + Reader.Advance(ForcedEscapedQuotes ? 2 : 1); info.Kind = CXTokenKind.StringLiteralEnd; QuoteChar = null; @@ -289,41 +326,61 @@ private void LexStringLiteral(ref TokenInfo info) for (; Reader.Position < interpolationUpperBounds; Reader.Advance()) { - if (QuoteChar == Reader.Current) + CancellationToken.ThrowIfCancellationRequested(); + + if (Reader.Current is BACK_SLASH_CHAR) { - // is it escaped? - if (Reader.Previous is FORWARD_SLASH_CHAR) + // escaped backslash, advance thru the current and next character + if (Reader.Next is BACK_SLASH_CHAR && ForcedEscapedQuotes) { - // allow + Reader.Advance(); continue; } - // we've reached the end - info.Kind = CXTokenKind.Text; - return; + // is the escaped quote forced? meaning we treat it as the ending quote to the string literal + if (QuoteChar == Reader.Next && ForcedEscapedQuotes) + { + break; + } + + // TODO: open back slash error? } + else if (QuoteChar == Reader.Current) break; } + + // we've reached the end + info.Kind = CXTokenKind.Text; + return; } private bool TryScanAttributeValue(ref TokenInfo info) { if (Mode is LexMode.StringLiteral) return false; - if (Reader.Current is not QUOTE_CHAR and not DOUBLE_QUOTE_CHAR) + var isEscaped = ForcedEscapedQuotes && Reader.Current is BACK_SLASH_CHAR; + + // this is the gate for handling single vs double quotes: + // single quotes *can not* be escaped as a valid starting + // quote + var quoteTestChar = isEscaped && Reader.Next is DOUBLE_QUOTE_CHAR + ? Reader.Next + : Reader.Current; + + if (quoteTestChar is not QUOTE_CHAR and not DOUBLE_QUOTE_CHAR) { // interpolations only return TryScanInterpolation(ref info); } - QuoteChar = Reader.Current; - Reader.Advance(); + QuoteChar = quoteTestChar; + Reader.Advance(isEscaped ? 2 : 1); info.Kind = CXTokenKind.StringLiteralStart; return true; } private bool TryScanIdentifier(ref TokenInfo info) { - var upperBounds = NextInterpolationSpan?.Start ?? Reader.Source.SourceSpan.End; + var upperBounds = InterpolationBoundary; if (!IsValidIdentifierStartChar(Reader.Current) || Reader.Position >= upperBounds) return false; @@ -331,8 +388,10 @@ private bool TryScanIdentifier(ref TokenInfo info) do { Reader.Advance(); - } while (IsValidIdentifierChar(Reader.Current) && Reader.Position < upperBounds); + } while (IsValidIdentifierChar(Reader.Current) && Reader.Position < upperBounds && + !CancellationToken.IsCancellationRequested); + CancellationToken.ThrowIfCancellationRequested(); info.Kind = CXTokenKind.Identifier; return true; @@ -368,6 +427,8 @@ private void GetTrivia(bool isTrailing, ref int trivia) { start: + CancellationToken.ThrowIfCancellationRequested(); + var current = Reader.Current; if (CurrentInterpolationSpan is not null) return; @@ -395,10 +456,25 @@ private void GetTrivia(bool isTrailing, ref int trivia) continue; } + if (current is LESS_THAN_CHAR && IsCurrentlyAtCommentStart()) + { + while (!Reader.IsEOF && !IsCurrentlAtCommentEnd() && !CancellationToken.IsCancellationRequested) + { + trivia++; + Reader.Advance(); + } + } + return; } } + private bool IsCurrentlyAtCommentStart() + => Reader.Peek(COMMENT_START.Length) == COMMENT_START; + + private bool IsCurrentlAtCommentEnd() + => Reader.Peek(COMMENT_END.Length) == COMMENT_END; + private static bool IsWhitespace(char ch) => char.IsWhiteSpace(ch); } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXTokenKind.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXTokenKind.cs index c546b1925e..44ad74104f 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXTokenKind.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXTokenKind.cs @@ -13,6 +13,7 @@ public enum CXTokenKind : byte Text, Interpolation, + StringLiteralStart, StringLiteralEnd, diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXDoc.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXDoc.cs index b60513a061..e340b40a68 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXDoc.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXDoc.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using System.Threading; namespace Discord.ComponentDesignerGenerator.Parser; @@ -20,12 +21,11 @@ public sealed class CXDoc : CXNode public CXDoc( CXParser parser, - IReadOnlyList rootElements, - IReadOnlyList tokens + IReadOnlyList rootElements ) { - Tokens = tokens; Parser = parser; + Tokens = parser.Tokens; Slot(RootElements = rootElements); InterpolationTokens = parser.Lexer.InterpolationMap; } @@ -42,24 +42,29 @@ public bool TryGetInterpolationIndex(CXToken token, out int index) return index != -1; } - public IncrementalParseResult ApplyChanges( + public CXDoc IncrementalParse( CXSource source, - IReadOnlyList changes + IReadOnlyList changes, + out IncrementalParseResult result, + CancellationToken token = default ) { var affectedRange = TextChangeRange.Collapse(changes.Select(x => (TextChangeRange)x)); - var blender = new CXBlender(Parser.Lexer, this, affectedRange); - - Parser.Source = source; - Parser.Reset(); - Parser.Blender = blender; + var parser = new CXParser(source, this, affectedRange, token); var context = new IncrementalParseContext(changes, affectedRange); - var owner = FindOwningNode(affectedRange.Span, out _); + var children = new List(); + + while (parser.CurrentToken.Kind is not CXTokenKind.EOF and not CXTokenKind.Invalid) + { + var element = parser.ParseElement(); - owner.IncrementalParse(context); + children.Add(element); + + if (element.Width is 0) break; + } var reusedNodes = new List(); var flatGraph = GetFlatGraph(); @@ -74,25 +79,14 @@ IReadOnlyList changes reusedNodes.AddRange(concreteNode.Descendants); } - return new( + result = new( reusedNodes, [..GetFlatGraph().Except(Parser.BlendedNodes)], changes, affectedRange ); - } - - public override void IncrementalParse(IncrementalParseContext context) - { - var children = new List(); - - while (Parser.CurrentToken.Kind is not CXTokenKind.EOF and not CXTokenKind.Invalid) - { - children.Add(Parser.ParseElement()); - } - ClearSlots(); - Slot(RootElements = children); + return new CXDoc(parser, children); } public string GetTokenValue(CXToken token) => Parser.Source.GetValue(token.Span); diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXNode.cs index cb9bddf6a4..d1a62d5b70 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXNode.cs @@ -29,7 +29,7 @@ public IReadOnlyList Diagnostics .._diagnostics .Concat(Slots.SelectMany(x => x.Value.Diagnostics)) ]; - set + init { _diagnostics.Clear(); _diagnostics.AddRange(value); @@ -43,7 +43,6 @@ public bool HasErrors public CXDoc Document { get => TryGetDocument(out var doc) ? doc : throw new InvalidOperationException(); - set => _doc = value; } public virtual CXParser Parser => Document.Parser; @@ -218,8 +217,6 @@ public CXNode FindOwningNode(TextSpan span, out ParseSlot slot) return current; } - protected void ClearSlots() => _slots.Clear(); - public int GetParentSlotIndex() { if (Parent is null) return -1; @@ -286,7 +283,6 @@ protected bool IsGraphChild(CXNode node, out int index) return index >= 0 && index < _slots.Count && _slots.ElementAt(index) == node; } - protected void UpdateSlot(CXNode old, CXNode @new) { if (!IsGraphChild(old, out var slotIndex)) return; @@ -318,8 +314,6 @@ protected void Slot(IEnumerable nodes) foreach (var node in nodes) Slot(node); } - public virtual void IncrementalParse(IncrementalParseContext change) => Parent?.IncrementalParse(change); - public void ResetCachedState() { _offset = null; diff --git a/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs b/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs index bb741c6313..f2a7db84f3 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs @@ -23,7 +23,8 @@ public sealed record Target( string? ParentKey, string CXDesigner, TextSpan CXDesignerSpan, - DesignerInterpolationInfo[] Interpolations + DesignerInterpolationInfo[] Interpolations, + int CXQuoteCount ) { public SyntaxTree SyntaxTree => InvocationSyntax.SyntaxTree; @@ -81,7 +82,7 @@ private void Generate(SourceProductionContext context, ImmutableArray new( {{interceptor.Source.WithNewlinePadding(4)}} - ) + ); """ ); } @@ -126,14 +127,15 @@ CancellationToken token if (_cache.TryGetValue(key, out var manager)) { - manager = _cache[key] = manager.OnUpdate(key, target); + manager = _cache[key] = manager.OnUpdate(key, target, token); } else { manager = _cache[key] = CXGraphManager.Create( this, key, - target + target, + token ); } @@ -141,8 +143,10 @@ CancellationToken token } } - private ImmutableArray GetKeysAndUpdateCachedEntries(ImmutableArray target, - CancellationToken token) + private ImmutableArray GetKeysAndUpdateCachedEntries( + ImmutableArray target, + CancellationToken token + ) { var result = new string?[target.Length]; @@ -179,24 +183,14 @@ CancellationToken token return [..result]; } - private static void OnTargetUpdated(Target? target, CancellationToken token) - { - if (target is null) return; - - //target.Compilation.SyntaxTrees - } - - - private static void ProcessTargetsUpdate(ImmutableArray targets, CancellationToken token) - { - foreach (var target in targets) - { - if (target is null) continue; - } - } - - private static Target? MapPossibleComponentDesignerCall(GeneratorSyntaxContext context, CancellationToken token) + => MapPossibleComponentDesignerCall(context.SemanticModel, context.Node, token); + + public static Target? MapPossibleComponentDesignerCall( + SemanticModel semanticModel, + SyntaxNode node, + CancellationToken token + ) { if ( !TryGetValidDesignerCall( @@ -210,10 +204,12 @@ out var argumentSyntax if ( !TryGetCXDesigner( argumentSyntax, - context.SemanticModel, + semanticModel, out var cxDesigner, out var span, - out var interpolationInfos + out var interpolationInfos, + out var quoteCount, + token ) ) return null; @@ -223,13 +219,14 @@ out var interpolationInfos invocationSyntax, argumentSyntax, operation, - context.SemanticModel.Compilation, - context.SemanticModel + semanticModel.Compilation, + semanticModel .GetEnclosingSymbol(invocationSyntax.SpanStart, token) ?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), cxDesigner, span, - interpolationInfos + interpolationInfos, + quoteCount ); static bool TryGetCXDesigner( @@ -237,15 +234,26 @@ static bool TryGetCXDesigner( SemanticModel semanticModel, out string content, out TextSpan span, - out DesignerInterpolationInfo[] interpolations + out DesignerInterpolationInfo[] interpolations, + out int quoteCount, + CancellationToken token ) { switch (expression) { - case LiteralExpressionSyntax {Token.Value: string literalContent} literal: - content = literalContent; + case LiteralExpressionSyntax {Token.Text: { } literalContent} literal: + content = PrepareRawLiteral( + literalContent, + out var startQuoteCount, + out var endQuoteCount + ); + + quoteCount = startQuoteCount; interpolations = []; - span = literal.Token.Span; + span = TextSpan.FromBounds( + literal.Token.Span.Start + startQuoteCount, + literal.Token.Span.End - endQuoteCount + ); return true; case InterpolatedStringExpressionSyntax interpolated: @@ -255,20 +263,47 @@ out DesignerInterpolationInfo[] interpolations .Select((x, i) => new DesignerInterpolationInfo( i, x.FullSpan, - semanticModel.GetTypeInfo(x.Expression).Type, - semanticModel.GetConstantValue(x.Expression) + semanticModel.GetTypeInfo(x.Expression, token).Type, + semanticModel.GetConstantValue(x.Expression, token) )) .ToArray(); span = interpolated.Contents.Span; + quoteCount = interpolated.StringEndToken.Span.Length; return true; default: content = string.Empty; span = default; interpolations = []; + quoteCount = 0; return false; } } + static string PrepareRawLiteral( + string literal, + out int startQuoteCount, + out int endQuoteCount + ) + { + for (startQuoteCount = 0; startQuoteCount < literal.Length; startQuoteCount++) + { + if (literal[startQuoteCount] is not '"') break; + } + + endQuoteCount = 0; + if (literal.Length == startQuoteCount) + { + return string.Empty; + } + + for (var i = literal.Length - 1; i >= startQuoteCount; i--, endQuoteCount++) + if (literal[i] is not '"') break; + + return literal.Substring( + startQuoteCount, literal.Length - startQuoteCount - endQuoteCount + ); + } + bool TryGetValidDesignerCall( out IOperation operation, out InvocationExpressionSyntax invocationSyntax, @@ -276,7 +311,7 @@ bool TryGetValidDesignerCall( out ExpressionSyntax argumentExpressionSyntax ) { - operation = context.SemanticModel.GetOperation(context.Node, token)!; + operation = semanticModel.GetOperation(node, token)!; interceptLocation = null!; argumentExpressionSyntax = null!; invocationSyntax = null!; @@ -300,11 +335,11 @@ out ExpressionSyntax argumentExpressionSyntax default: return false; } - if (context.Node is not InvocationExpressionSyntax syntax) return false; + if (node is not InvocationExpressionSyntax syntax) return false; invocationSyntax = syntax; - if (context.SemanticModel.GetInterceptableLocation(invocationSyntax) is not { } location) + if (semanticModel.GetInterceptableLocation(invocationSyntax, token) is not { } location) return false; interceptLocation = location; diff --git a/src/Discord.Net.ComponentDesigner/ComponentDesigner.cs b/src/Discord.Net.ComponentDesigner/ComponentDesigner.cs index ca83d57799..c634a5a566 100644 --- a/src/Discord.Net.ComponentDesigner/ComponentDesigner.cs +++ b/src/Discord.Net.ComponentDesigner/ComponentDesigner.cs @@ -15,4 +15,15 @@ public static T cx( [StringSyntax("html")] DesignerInterpolationHandler designer ) where T : IMessageComponentBuilder => throw new InvalidOperationException(); + + // ReSharper disable once InconsistentNaming + public static IMessageComponentBuilder cx( + [StringSyntax("html")] string cx + ) => cx(cx); + + // ReSharper disable once InconsistentNaming + public static T cx( + [StringSyntax("html")] string cx + ) where T : IMessageComponentBuilder + => throw new InvalidOperationException(); } From 600d813b342907dc08f61a2b926d7b145e7beb8d Mon Sep 17 00:00:00 2001 From: Quin Lynch <49576606+quinchs@users.noreply.github.com> Date: Sun, 21 Sep 2025 17:48:27 -0300 Subject: [PATCH 17/17] LSP basics --- Discord.Net.sln | 30 ++ .../Constants.cs | 2 +- .../Diagnostics.cs | 2 +- ...ord.Net.ComponentDesigner.Generator.csproj | 4 + .../Graph/CXGraph.cs | 10 +- .../Graph/CXGraphManager.cs | 6 +- .../Graph/RenderedInterceptor.cs | 2 +- .../InterpolationInfo.cs | 2 +- .../Nodes/ComponentContext.cs | 4 +- .../Nodes/ComponentNode.cs | 4 +- .../Nodes/ComponentProperty.cs | 4 +- .../Nodes/ComponentPropertyValue.cs | 4 +- .../Nodes/ComponentState.cs | 4 +- .../Components/ActionRowComponentNode.cs | 6 +- .../Nodes/Components/ButtonComponentNode.cs | 4 +- .../Components/ContainerComponentNode.cs | 4 +- .../Nodes/Components/FileComponentNode.cs | 2 +- .../Components/InterleavedComponentNode.cs | 4 +- .../Nodes/Components/LabelComponentNode.cs | 2 +- .../Components/MediaGalleryComponentNode.cs | 2 +- .../Nodes/Components/SectionComponentnode.cs | 2 +- .../SelectMenus/SelectMenuComponentNode.cs | 4 +- .../SelectMenus/SelectMenuDefaultValueKind.cs | 2 +- .../SelectMenus/SelectMenuDefautValue.cs | 4 +- .../StringSelectOptionComponentNode.cs | 4 +- .../Components/SeparatorComponentNode.cs | 2 +- .../Components/TextDisplayComponentNode.cs | 4 +- .../Components/TextInputComponentNode.cs | 2 +- .../Components/ThumbnailComponentNode.cs | 2 +- .../Nodes/Renderers/Renderers.cs | 4 +- .../Nodes/Validators/Validators.cs | 4 +- .../SourceGenerator.cs | 6 +- .../Utils/KnownTypes.cs | 2 +- .../Utils/StringUtils.cs | 2 +- .../ComponentDocument.cs | 76 ++++ ...et.ComponentDesigner.LanguageServer.csproj | 26 ++ .../DocumentHandler.cs | 75 ++++ .../Program.cs | 26 ++ .../SemanticTokensHandler.cs | 28 ++ .../CXDiagnostic.cs | 2 +- .../CXParser.cs | 2 +- .../CXSource.cs | 2 +- .../CXSourceReader.cs | 2 +- .../CXTreeWalker.cs | 2 +- ...iscord.Net.ComponentDesigner.Parser.csproj | 15 + .../ICXNode.cs | 2 +- .../Incremental/BlendedNode.cs | 2 +- .../Incremental/CXBlender.cs | 2 +- .../Incremental/IncrementalParseContext.cs | 2 +- .../Incremental/IncrementalParseResult.cs | 2 +- .../Lexer/CXLexer.cs | 2 +- .../Lexer/CXToken.cs | 2 +- .../Lexer/CXTokenFlags.cs | 2 +- .../Lexer/CXTokenKind.cs | 2 +- .../Nodes/CXAttribute.cs | 2 +- .../Nodes/CXCollection.cs | 2 +- .../Nodes/CXDoc.cs | 2 +- .../Nodes/CXElement.cs | 2 +- .../Nodes/CXNode.ParseSlot.cs | 2 +- .../Nodes/CXNode.cs | 2 +- .../Nodes/CXValue.cs | 2 +- .../Source/CXSourceText.cs | 217 +++++++++++ .../Source/ChangedText.cs | 367 ++++++++++++++++++ .../Source/CompositeText.cs | 205 ++++++++++ .../Source/SourceLocation.cs | 7 + .../Source/StringSource.cs | 18 + .../Source/SubText.cs | 121 ++++++ .../Source/TextLineCollection.cs | 55 +++ .../Util/IsExternalInit.cs | 5 + .../Util/TextUtils.cs | 9 + src/Discord.Net.Core/packages.lock.json | 14 + 71 files changed, 1375 insertions(+), 77 deletions(-) create mode 100644 src/Discord.Net.ComponentDesigner.LanguageServer/ComponentDocument.cs create mode 100644 src/Discord.Net.ComponentDesigner.LanguageServer/Discord.Net.ComponentDesigner.LanguageServer.csproj create mode 100644 src/Discord.Net.ComponentDesigner.LanguageServer/DocumentHandler.cs create mode 100644 src/Discord.Net.ComponentDesigner.LanguageServer/Program.cs create mode 100644 src/Discord.Net.ComponentDesigner.LanguageServer/SemanticTokensHandler.cs rename src/{Discord.Net.ComponentDesigner.Generator/Parsing => Discord.Net.ComponentDesigner.Parser}/CXDiagnostic.cs (78%) rename src/{Discord.Net.ComponentDesigner.Generator/Parsing => Discord.Net.ComponentDesigner.Parser}/CXParser.cs (99%) rename src/{Discord.Net.ComponentDesigner.Generator/Parsing => Discord.Net.ComponentDesigner.Parser}/CXSource.cs (95%) rename src/{Discord.Net.ComponentDesigner.Generator/Parsing => Discord.Net.ComponentDesigner.Parser}/CXSourceReader.cs (95%) rename src/{Discord.Net.ComponentDesigner.Generator/Parsing => Discord.Net.ComponentDesigner.Parser}/CXTreeWalker.cs (92%) create mode 100644 src/Discord.Net.ComponentDesigner.Parser/Discord.Net.ComponentDesigner.Parser.csproj rename src/{Discord.Net.ComponentDesigner.Generator/Parsing => Discord.Net.ComponentDesigner.Parser}/ICXNode.cs (90%) rename src/{Discord.Net.ComponentDesigner.Generator/Parsing => Discord.Net.ComponentDesigner.Parser}/Incremental/BlendedNode.cs (62%) rename src/{Discord.Net.ComponentDesigner.Generator/Parsing => Discord.Net.ComponentDesigner.Parser}/Incremental/CXBlender.cs (99%) rename src/{Discord.Net.ComponentDesigner.Generator/Parsing => Discord.Net.ComponentDesigner.Parser}/Incremental/IncrementalParseContext.cs (79%) rename src/{Discord.Net.ComponentDesigner.Generator/Parsing => Discord.Net.ComponentDesigner.Parser}/Incremental/IncrementalParseResult.cs (84%) rename src/{Discord.Net.ComponentDesigner.Generator/Parsing => Discord.Net.ComponentDesigner.Parser}/Lexer/CXLexer.cs (99%) rename src/{Discord.Net.ComponentDesigner.Generator/Parsing => Discord.Net.ComponentDesigner.Parser}/Lexer/CXToken.cs (98%) rename src/{Discord.Net.ComponentDesigner.Generator/Parsing => Discord.Net.ComponentDesigner.Parser}/Lexer/CXTokenFlags.cs (64%) rename src/{Discord.Net.ComponentDesigner.Generator/Parsing => Discord.Net.ComponentDesigner.Parser}/Lexer/CXTokenKind.cs (82%) rename src/{Discord.Net.ComponentDesigner.Generator/Parsing => Discord.Net.ComponentDesigner.Parser}/Nodes/CXAttribute.cs (89%) rename src/{Discord.Net.ComponentDesigner.Generator/Parsing => Discord.Net.ComponentDesigner.Parser}/Nodes/CXCollection.cs (94%) rename src/{Discord.Net.ComponentDesigner.Generator/Parsing => Discord.Net.ComponentDesigner.Parser}/Nodes/CXDoc.cs (98%) rename src/{Discord.Net.ComponentDesigner.Generator/Parsing => Discord.Net.ComponentDesigner.Parser}/Nodes/CXElement.cs (96%) rename src/{Discord.Net.ComponentDesigner.Generator/Parsing => Discord.Net.ComponentDesigner.Parser}/Nodes/CXNode.ParseSlot.cs (94%) rename src/{Discord.Net.ComponentDesigner.Generator/Parsing => Discord.Net.ComponentDesigner.Parser}/Nodes/CXNode.cs (99%) rename src/{Discord.Net.ComponentDesigner.Generator/Parsing => Discord.Net.ComponentDesigner.Parser}/Nodes/CXValue.cs (96%) create mode 100644 src/Discord.Net.ComponentDesigner.Parser/Source/CXSourceText.cs create mode 100644 src/Discord.Net.ComponentDesigner.Parser/Source/ChangedText.cs create mode 100644 src/Discord.Net.ComponentDesigner.Parser/Source/CompositeText.cs create mode 100644 src/Discord.Net.ComponentDesigner.Parser/Source/SourceLocation.cs create mode 100644 src/Discord.Net.ComponentDesigner.Parser/Source/StringSource.cs create mode 100644 src/Discord.Net.ComponentDesigner.Parser/Source/SubText.cs create mode 100644 src/Discord.Net.ComponentDesigner.Parser/Source/TextLineCollection.cs create mode 100644 src/Discord.Net.ComponentDesigner.Parser/Util/IsExternalInit.cs create mode 100644 src/Discord.Net.ComponentDesigner.Parser/Util/TextUtils.cs diff --git a/Discord.Net.sln b/Discord.Net.sln index c7dd1fd89d..5969f786f5 100644 --- a/Discord.Net.sln +++ b/Discord.Net.sln @@ -48,6 +48,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discord.Net.ComponentDesign EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discord.Net.ComponentDesigner.Generator", "src\Discord.Net.ComponentDesigner.Generator\Discord.Net.ComponentDesigner.Generator.csproj", "{ACBDEE4C-FD57-4A47-B58B-6E10872D0464}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discord.Net.ComponentDesigner.Parser", "src\Discord.Net.ComponentDesigner.Parser\Discord.Net.ComponentDesigner.Parser.csproj", "{F08906A4-7F99-47D9-B43A-905F631F81F8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discord.Net.ComponentDesigner.LanguageServer", "src\Discord.Net.ComponentDesigner.LanguageServer\Discord.Net.ComponentDesigner.LanguageServer.csproj", "{3FD59032-5BA1-418F-88D3-EC385A63E6F2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -274,6 +278,30 @@ Global {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Release|x64.Build.0 = Release|Any CPU {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Release|x86.ActiveCfg = Release|Any CPU {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Release|x86.Build.0 = Release|Any CPU + {F08906A4-7F99-47D9-B43A-905F631F81F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F08906A4-7F99-47D9-B43A-905F631F81F8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F08906A4-7F99-47D9-B43A-905F631F81F8}.Debug|x64.ActiveCfg = Debug|Any CPU + {F08906A4-7F99-47D9-B43A-905F631F81F8}.Debug|x64.Build.0 = Debug|Any CPU + {F08906A4-7F99-47D9-B43A-905F631F81F8}.Debug|x86.ActiveCfg = Debug|Any CPU + {F08906A4-7F99-47D9-B43A-905F631F81F8}.Debug|x86.Build.0 = Debug|Any CPU + {F08906A4-7F99-47D9-B43A-905F631F81F8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F08906A4-7F99-47D9-B43A-905F631F81F8}.Release|Any CPU.Build.0 = Release|Any CPU + {F08906A4-7F99-47D9-B43A-905F631F81F8}.Release|x64.ActiveCfg = Release|Any CPU + {F08906A4-7F99-47D9-B43A-905F631F81F8}.Release|x64.Build.0 = Release|Any CPU + {F08906A4-7F99-47D9-B43A-905F631F81F8}.Release|x86.ActiveCfg = Release|Any CPU + {F08906A4-7F99-47D9-B43A-905F631F81F8}.Release|x86.Build.0 = Release|Any CPU + {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Debug|x64.ActiveCfg = Debug|Any CPU + {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Debug|x64.Build.0 = Debug|Any CPU + {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Debug|x86.ActiveCfg = Debug|Any CPU + {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Debug|x86.Build.0 = Debug|Any CPU + {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Release|Any CPU.Build.0 = Release|Any CPU + {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Release|x64.ActiveCfg = Release|Any CPU + {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Release|x64.Build.0 = Release|Any CPU + {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Release|x86.ActiveCfg = Release|Any CPU + {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -296,6 +324,8 @@ Global {115F4921-B44D-4F69-996B-69796959C99D} = {CC3D4B1C-9DE0-448B-8AE7-F3F1F3EC5C3A} {11317A05-C2AF-4F5D-829F-129046C8E326} = {3752F226-625C-4564-8A19-B6E9F2329D1E} {ACBDEE4C-FD57-4A47-B58B-6E10872D0464} = {3752F226-625C-4564-8A19-B6E9F2329D1E} + {F08906A4-7F99-47D9-B43A-905F631F81F8} = {3752F226-625C-4564-8A19-B6E9F2329D1E} + {3FD59032-5BA1-418F-88D3-EC385A63E6F2} = {3752F226-625C-4564-8A19-B6E9F2329D1E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D2404771-EEC8-45F2-9D71-F3373F6C1495} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Constants.cs b/src/Discord.Net.ComponentDesigner.Generator/Constants.cs index e52625ddfd..6b881cc537 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Constants.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Constants.cs @@ -1,4 +1,4 @@ -namespace Discord.ComponentDesignerGenerator; +namespace Discord.CX; public static class Constants { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs b/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs index f0abd6c126..3fef610442 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs @@ -1,6 +1,6 @@ using Microsoft.CodeAnalysis; -namespace Discord.ComponentDesignerGenerator; +namespace Discord.CX; public static partial class Diagnostics { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Discord.Net.ComponentDesigner.Generator.csproj b/src/Discord.Net.ComponentDesigner.Generator/Discord.Net.ComponentDesigner.Generator.csproj index 2214de95d0..b67d1b5dcd 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Discord.Net.ComponentDesigner.Generator.csproj +++ b/src/Discord.Net.ComponentDesigner.Generator/Discord.Net.ComponentDesigner.Generator.csproj @@ -20,4 +20,8 @@ + + + + diff --git a/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraph.cs b/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraph.cs index f7e10e8fcf..3b49145129 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraph.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraph.cs @@ -1,6 +1,6 @@ -using Discord.ComponentDesignerGenerator.Nodes; -using Discord.ComponentDesignerGenerator.Nodes.Components; -using Discord.ComponentDesignerGenerator.Parser; +using Discord.CX.Nodes; +using Discord.CX.Nodes.Components; +using Discord.CX.Parser; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; using System; @@ -8,7 +8,7 @@ using System.Collections.Immutable; using System.Linq; -namespace Discord.ComponentDesignerGenerator; +namespace Discord.CX; public readonly struct CXGraph { @@ -147,7 +147,7 @@ ImmutableArray.Builder diagnostics { diagnostics.Add( Diagnostic.Create( - ComponentDesignerGenerator.Diagnostics.UnknownComponent, + CX.Diagnostics.UnknownComponent, GetLocation(manager, element), element.Identifier ) diff --git a/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraphManager.cs b/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraphManager.cs index 3c49ae5a90..09223c4ebe 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraphManager.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraphManager.cs @@ -1,5 +1,5 @@ -using Discord.ComponentDesignerGenerator.Nodes; -using Discord.ComponentDesignerGenerator.Parser; +using Discord.CX.Nodes; +using Discord.CX.Parser; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -11,7 +11,7 @@ using System.Text; using System.Threading; -namespace Discord.ComponentDesignerGenerator; +namespace Discord.CX; public sealed record CXGraphManager( SourceGenerator Generator, diff --git a/src/Discord.Net.ComponentDesigner.Generator/Graph/RenderedInterceptor.cs b/src/Discord.Net.ComponentDesigner.Generator/Graph/RenderedInterceptor.cs index de0ab5fc62..c7c08632b4 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Graph/RenderedInterceptor.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Graph/RenderedInterceptor.cs @@ -3,7 +3,7 @@ using System.Collections.Immutable; using System.Linq; -namespace Discord.ComponentDesignerGenerator; +namespace Discord.CX; public readonly record struct RenderedInterceptor( InterceptableLocation Location, diff --git a/src/Discord.Net.ComponentDesigner.Generator/InterpolationInfo.cs b/src/Discord.Net.ComponentDesigner.Generator/InterpolationInfo.cs index 7535ad2e8b..0f96afc3ff 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/InterpolationInfo.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/InterpolationInfo.cs @@ -1,6 +1,6 @@ using Microsoft.CodeAnalysis; -namespace Discord.ComponentDesignerGenerator; +namespace Discord.CX; public readonly record struct InterpolationInfo( int Id, diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentContext.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentContext.cs index 57a88dd21c..2946dfc516 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentContext.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentContext.cs @@ -1,10 +1,10 @@ -using Discord.ComponentDesignerGenerator.Parser; +using Discord.CX.Parser; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; using System.Collections.Generic; using System.Linq; -namespace Discord.ComponentDesignerGenerator.Nodes; +namespace Discord.CX.Nodes; public sealed class ComponentContext { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs index f8cb181e4a..4e9ca9480f 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs @@ -1,10 +1,10 @@ -using Discord.ComponentDesignerGenerator.Parser; +using Discord.CX.Parser; using System; using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; -namespace Discord.ComponentDesignerGenerator.Nodes; +namespace Discord.CX.Nodes; public abstract class ComponentNode : ComponentNode where TState : ComponentState diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs index a6e20e69b8..66cd41afab 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs @@ -1,7 +1,7 @@ -using Discord.ComponentDesignerGenerator.Parser; +using Discord.CX.Parser; using System.Collections.Generic; -namespace Discord.ComponentDesignerGenerator.Nodes; +namespace Discord.CX.Nodes; public delegate void PropertyValidator(ComponentContext context, ComponentPropertyValue value); public delegate string PropertyRenderer(ComponentContext context, ComponentPropertyValue value); diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs index 93542db869..1cb783ca13 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs @@ -1,8 +1,8 @@ -using Discord.ComponentDesignerGenerator.Parser; +using Discord.CX.Parser; using Microsoft.CodeAnalysis; using System.Collections.Generic; -namespace Discord.ComponentDesignerGenerator.Nodes; +namespace Discord.CX.Nodes; public sealed record ComponentPropertyValue( ComponentProperty Property, diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentState.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentState.cs index c3ca8227eb..6de549a598 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentState.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentState.cs @@ -1,10 +1,10 @@ -using Discord.ComponentDesignerGenerator.Parser; +using Discord.CX.Parser; using Microsoft.CodeAnalysis; using System; using System.Collections.Generic; using System.Linq; -namespace Discord.ComponentDesignerGenerator.Nodes; +namespace Discord.CX.Nodes; public class ComponentState { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs index ec4692434b..6a0804e9cf 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs @@ -1,11 +1,11 @@ -using Discord.ComponentDesignerGenerator.Nodes.Components.SelectMenus; -using Discord.ComponentDesignerGenerator.Parser; +using Discord.CX.Parser; +using Discord.CX.Nodes.Components.SelectMenus; using Microsoft.CodeAnalysis; using System.Collections.Generic; using System.Linq; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesignerGenerator.Nodes.Components; +namespace Discord.CX.Nodes.Components; public sealed class ActionRowComponentNode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs index af0bbe0d85..857919be4d 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs @@ -1,9 +1,9 @@ -using Discord.ComponentDesignerGenerator.Parser; +using Discord.CX.Parser; using Microsoft.CodeAnalysis; using System.Collections.Generic; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesignerGenerator.Nodes.Components; +namespace Discord.CX.Nodes.Components; public sealed class ButtonComponentNode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs index a9fb318fce..ffc064d1d8 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs @@ -1,8 +1,8 @@ -using Discord.ComponentDesignerGenerator.Parser; +using Discord.CX.Parser; using System.Collections.Generic; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesignerGenerator.Nodes.Components; +namespace Discord.CX.Nodes.Components; public sealed class ContainerComponentNode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs index 1659902acd..634ae445f7 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesignerGenerator.Nodes.Components; +namespace Discord.CX.Nodes.Components; public sealed class FileComponentNode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterleavedComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterleavedComponentNode.cs index 23d1bfe730..b136ce091c 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterleavedComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterleavedComponentNode.cs @@ -1,8 +1,8 @@ -using Discord.ComponentDesignerGenerator.Parser; +using Discord.CX.Parser; using System.Collections.Generic; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesignerGenerator.Nodes.Components; +namespace Discord.CX.Nodes.Components; public sealed class InterleavedComponentNode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs index 34433e24c2..41753ec1a1 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace Discord.ComponentDesignerGenerator.Nodes.Components; +namespace Discord.CX.Nodes.Components; public sealed class LabelComponentNode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs index e1d76c4c96..f4f713e984 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesignerGenerator.Nodes.Components; +namespace Discord.CX.Nodes.Components; public sealed class MediaGalleryComponentNode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentnode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentnode.cs index 4e727d188d..f66266e20e 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentnode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentnode.cs @@ -3,7 +3,7 @@ using System.Linq; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesignerGenerator.Nodes.Components; +namespace Discord.CX.Nodes.Components; public sealed class SectionComponentnode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuComponentNode.cs index c8f4677051..8049974f40 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuComponentNode.cs @@ -1,10 +1,10 @@ -using Discord.ComponentDesignerGenerator.Parser; +using Discord.CX.Parser; using System; using System.Collections.Generic; using System.Linq; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesignerGenerator.Nodes.Components.SelectMenus; +namespace Discord.CX.Nodes.Components.SelectMenus; public sealed class SelectMenuComponentNode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefaultValueKind.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefaultValueKind.cs index e68caa8d2b..e87d675532 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefaultValueKind.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefaultValueKind.cs @@ -1,4 +1,4 @@ -namespace Discord.ComponentDesignerGenerator.Nodes.Components.SelectMenus; +namespace Discord.CX.Nodes.Components.SelectMenus; public enum SelectMenuDefaultValueKind { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefautValue.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefautValue.cs index c30f8dca23..493658e4e8 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefautValue.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefautValue.cs @@ -1,6 +1,6 @@ -using Discord.ComponentDesignerGenerator.Parser; +using Discord.CX.Parser; -namespace Discord.ComponentDesignerGenerator.Nodes.Components.SelectMenus; +namespace Discord.CX.Nodes.Components.SelectMenus; public readonly record struct SelectMenuDefautValue( SelectMenuDefaultValueKind Kind, diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/StringSelectOptionComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/StringSelectOptionComponentNode.cs index 122deb13eb..1bbebc9c04 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/StringSelectOptionComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/StringSelectOptionComponentNode.cs @@ -1,8 +1,8 @@ -using Discord.ComponentDesignerGenerator.Parser; +using Discord.CX.Parser; using Microsoft.CodeAnalysis; using System.Collections.Generic; -namespace Discord.ComponentDesignerGenerator.Nodes.Components.SelectMenus; +namespace Discord.CX.Nodes.Components.SelectMenus; public sealed class StringSelectOptionComponentNode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs index b51db8e63c..97603112a1 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesignerGenerator.Nodes.Components; +namespace Discord.CX.Nodes.Components; public sealed class SeparatorComponentNode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs index 33982cdafe..08c6228387 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs @@ -1,8 +1,8 @@ -using Discord.ComponentDesignerGenerator.Parser; +using Discord.CX.Parser; using System.Collections.Generic; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesignerGenerator.Nodes.Components; +namespace Discord.CX.Nodes.Components; public sealed class TextDisplayComponentNode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs index 2797b3facc..ebc7935804 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesignerGenerator.Nodes.Components; +namespace Discord.CX.Nodes.Components; public sealed class TextInputComponentNode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs index 35db6fc908..9ebd2c838d 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; -namespace Discord.ComponentDesignerGenerator.Nodes.Components; +namespace Discord.CX.Nodes.Components; public sealed class ThumbnailComponentNode : ComponentNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs index 2df6833d15..ec28fbff18 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs @@ -1,4 +1,4 @@ -using Discord.ComponentDesignerGenerator.Parser; +using Discord.CX.Parser; using Microsoft.CodeAnalysis; using System; using System.Collections.Generic; @@ -6,7 +6,7 @@ using System.Linq; using System.Text; -namespace Discord.ComponentDesignerGenerator.Nodes; +namespace Discord.CX.Nodes; public static class Renderers { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.cs index 5e5f48227f..0c6a5dcb50 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.cs @@ -1,4 +1,4 @@ -using Discord.ComponentDesignerGenerator.Parser; +using Discord.CX.Parser; using Microsoft.CodeAnalysis; using System; using System.Collections.Generic; @@ -6,7 +6,7 @@ using System.Globalization; using System.Linq; -namespace Discord.ComponentDesignerGenerator.Nodes; +namespace Discord.CX.Nodes; public static class Validators { diff --git a/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs b/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs index f2a7db84f3..9feadc8568 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs @@ -1,5 +1,5 @@ -using Discord.ComponentDesignerGenerator.Nodes; -using Discord.ComponentDesignerGenerator.Parser; +using Discord.CX.Nodes; +using Discord.CX.Parser; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -12,7 +12,7 @@ using System.Text; using System.Threading; -namespace Discord.ComponentDesignerGenerator; +namespace Discord.CX; public sealed record Target( InterceptableLocation InterceptLocation, diff --git a/src/Discord.Net.ComponentDesigner.Generator/Utils/KnownTypes.cs b/src/Discord.Net.ComponentDesigner.Generator/Utils/KnownTypes.cs index 92bd902100..4932938937 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Utils/KnownTypes.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Utils/KnownTypes.cs @@ -8,7 +8,7 @@ using System.Reflection; using System.Runtime.CompilerServices; -namespace Discord.ComponentDesignerGenerator; +namespace Discord.CX; public class KnownTypes { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Utils/StringUtils.cs b/src/Discord.Net.ComponentDesigner.Generator/Utils/StringUtils.cs index 124b0edb73..a3d56ccc08 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Utils/StringUtils.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Utils/StringUtils.cs @@ -1,6 +1,6 @@ using System; -namespace Discord.ComponentDesignerGenerator; +namespace Discord.CX; public static class StringUtils { diff --git a/src/Discord.Net.ComponentDesigner.LanguageServer/ComponentDocument.cs b/src/Discord.Net.ComponentDesigner.LanguageServer/ComponentDocument.cs new file mode 100644 index 0000000000..1c1547d0ef --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.LanguageServer/ComponentDocument.cs @@ -0,0 +1,76 @@ +using Discord.CX.Parser; +using Microsoft.CodeAnalysis.Text; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace Discord.ComponentDesigner.LanguageServer.CX; + +public sealed class ComponentDocument +{ + private static readonly Dictionary _documents = []; + + public DocumentUri Uri { get; } + + public int? Version { get; } + + public CXDoc CX => _cxDoc ??= Parse(); + + private string _source; + + private TextSpan? _incrementalChangeRange; + + private CXDoc? _cxDoc; + + public ComponentDocument( + DocumentUri uri, + string source, + int? version + ) + { + Uri = uri; + Version = version; + _source = source; + } + + private CXDoc Parse() + { + + } + + public static ComponentDocument Create( + DocumentUri uri, + string content, + int? version, + CancellationToken token + ) => _documents[uri] = new(uri, content, version); + + public void Update( + int? version, + Container changes, + CancellationToken token + ) + { + if (Version.HasValue && Version == version) return; + + // build up the new source + var sb = new StringBuilder(_source); + var changeSpans = new List(); + + foreach (var change in changes) + { + if(change.Range is null) continue; + + if(change.Range.IsEmpty()) + } + } + + public void Close() + { + _documents.Remove(Uri); + } + + public static bool TryGet(DocumentUri uri, [MaybeNullWhen(false)] out ComponentDocument document) + => _documents.TryGetValue(uri, out document); +} diff --git a/src/Discord.Net.ComponentDesigner.LanguageServer/Discord.Net.ComponentDesigner.LanguageServer.csproj b/src/Discord.Net.ComponentDesigner.LanguageServer/Discord.Net.ComponentDesigner.LanguageServer.csproj new file mode 100644 index 0000000000..799bbb5ecf --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.LanguageServer/Discord.Net.ComponentDesigner.LanguageServer.csproj @@ -0,0 +1,26 @@ + + + + Exe + net9.0 + preview + enable + enable + Discord.ComponentDesigner.LanguageServer + + + + + + + + + + + + + + + + + diff --git a/src/Discord.Net.ComponentDesigner.LanguageServer/DocumentHandler.cs b/src/Discord.Net.ComponentDesigner.LanguageServer/DocumentHandler.cs new file mode 100644 index 0000000000..5d78d8209a --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.LanguageServer/DocumentHandler.cs @@ -0,0 +1,75 @@ +using Discord.ComponentDesigner.LanguageServer.CX; +using MediatR; +using Microsoft.Extensions.Logging; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Document; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Server.Capabilities; + +namespace Discord.ComponentDesigner.LanguageServer; + +public class DocumentHandler : TextDocumentSyncHandlerBase +{ + private readonly ILogger _logger; + + private readonly TextDocumentSelector _documentSelector = new( + new TextDocumentFilter {Pattern = "**/*.cx"} + ); + + public DocumentHandler(ILogger logger) + { + _logger = logger; + } + + public override TextDocumentAttributes GetTextDocumentAttributes(DocumentUri uri) => + throw new NotImplementedException(); + + public override Task Handle(DidOpenTextDocumentParams request, CancellationToken cancellationToken) + { + _logger.LogInformation("Opening {}", request.TextDocument.Uri); + + ComponentDocument.Create( + request.TextDocument.Uri, + request.TextDocument.Text, + request.TextDocument.Version, + cancellationToken + ); + + return Unit.Task; + } + + public override Task Handle(DidChangeTextDocumentParams request, CancellationToken cancellationToken) + { + if (!ComponentDocument.TryGet(request.TextDocument.Uri, out var document)) + { + _logger.LogWarning("Unknown document update {}", request.TextDocument.Uri); + return Unit.Task; + } + + _logger.LogInformation("Updating {}", request.TextDocument.Uri); + + document.Update(request.TextDocument.Version, request.ContentChanges, cancellationToken); + + return Unit.Task; + } + + public override Task Handle(DidSaveTextDocumentParams request, CancellationToken cancellationToken) + => Unit.Task; + + public override Task Handle(DidCloseTextDocumentParams request, CancellationToken cancellationToken) + { + if (!ComponentDocument.TryGet(request.TextDocument.Uri, out var document)) return Unit.Task; + + document.Close(); + return Unit.Task; + } + + protected override TextDocumentSyncRegistrationOptions CreateRegistrationOptions( + TextSynchronizationCapability capability, + ClientCapabilities clientCapabilities + ) => new TextDocumentSyncRegistrationOptions() + { + Change = TextDocumentSyncKind.Incremental, Save = false, DocumentSelector = _documentSelector + }; +} diff --git a/src/Discord.Net.ComponentDesigner.LanguageServer/Program.cs b/src/Discord.Net.ComponentDesigner.LanguageServer/Program.cs new file mode 100644 index 0000000000..fa4ee4b13a --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.LanguageServer/Program.cs @@ -0,0 +1,26 @@ +using Discord.ComponentDesigner.LanguageServer; +using Microsoft.Extensions.Logging; +using OmniSharp.Extensions.LanguageServer.Server; +using Serilog; + +Log.Logger = new LoggerConfiguration() + .Enrich.FromLogContext() + .WriteTo.Console( + outputTemplate: "{Timestamp:HH:mm:ss} | {Level} - [{SourceContext}]: {Message:lj}{NewLine}{Exception}" + ) + .CreateLogger(); + +var server = await LanguageServer.From(options => options + .WithInput(Console.OpenStandardInput()) + .WithOutput(Console.OpenStandardOutput()) + .ConfigureLogging(x => x + .AddSerilog(Log.Logger) + .AddLanguageProtocolLogging() + .SetMinimumLevel(LogLevel.Debug) + ) + .AddHandler() + .OnInitialize(((languageServer, request, token) => + { + request. + })) +); diff --git a/src/Discord.Net.ComponentDesigner.LanguageServer/SemanticTokensHandler.cs b/src/Discord.Net.ComponentDesigner.LanguageServer/SemanticTokensHandler.cs new file mode 100644 index 0000000000..134cd66405 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.LanguageServer/SemanticTokensHandler.cs @@ -0,0 +1,28 @@ +using Discord.ComponentDesigner.LanguageServer.CX; +using MediatR; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Document; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Discord.ComponentDesigner.LanguageServer; + +public sealed class SemanticTokensHandler : SemanticTokensHandlerBase +{ + protected override SemanticTokensRegistrationOptions CreateRegistrationOptions( + SemanticTokensCapability capability, + ClientCapabilities clientCapabilities + ) => new() { }; + + protected override Task Tokenize( + SemanticTokensBuilder builder, + ITextDocumentIdentifierParams identifier, + CancellationToken cancellationToken + ) + { + if (!ComponentDocument.TryGet(identifier.TextDocument.Uri, out var document)) + return Task.CompletedTask; + } + + protected override Task GetSemanticTokensDocument(ITextDocumentIdentifierParams @params, + CancellationToken cancellationToken) => throw new NotImplementedException(); +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXDiagnostic.cs b/src/Discord.Net.ComponentDesigner.Parser/CXDiagnostic.cs similarity index 78% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/CXDiagnostic.cs rename to src/Discord.Net.ComponentDesigner.Parser/CXDiagnostic.cs index ca2a9471a7..cd74c52229 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXDiagnostic.cs +++ b/src/Discord.Net.ComponentDesigner.Parser/CXDiagnostic.cs @@ -1,7 +1,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; -namespace Discord.ComponentDesignerGenerator.Parser; +namespace Discord.CX.Parser; public readonly record struct CXDiagnostic( DiagnosticSeverity Severity, diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXParser.cs b/src/Discord.Net.ComponentDesigner.Parser/CXParser.cs similarity index 99% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/CXParser.cs rename to src/Discord.Net.ComponentDesigner.Parser/CXParser.cs index 8d780a0af4..ff739da3f3 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXParser.cs +++ b/src/Discord.Net.ComponentDesigner.Parser/CXParser.cs @@ -6,7 +6,7 @@ using System.Linq; using System.Threading; -namespace Discord.ComponentDesignerGenerator.Parser; +namespace Discord.CX.Parser; public sealed class CXParser { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXSource.cs b/src/Discord.Net.ComponentDesigner.Parser/CXSource.cs similarity index 95% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/CXSource.cs rename to src/Discord.Net.ComponentDesigner.Parser/CXSource.cs index 2bb0ad21bd..1607f81647 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXSource.cs +++ b/src/Discord.Net.ComponentDesigner.Parser/CXSource.cs @@ -1,7 +1,7 @@ using Microsoft.CodeAnalysis.Text; using System.Collections.Generic; -namespace Discord.ComponentDesignerGenerator.Parser; +namespace Discord.CX.Parser; public sealed class CXSource { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXSourceReader.cs b/src/Discord.Net.ComponentDesigner.Parser/CXSourceReader.cs similarity index 95% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/CXSourceReader.cs rename to src/Discord.Net.ComponentDesigner.Parser/CXSourceReader.cs index c5ea206023..5458416a01 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXSourceReader.cs +++ b/src/Discord.Net.ComponentDesigner.Parser/CXSourceReader.cs @@ -1,7 +1,7 @@ using Microsoft.CodeAnalysis.Text; using System; -namespace Discord.ComponentDesignerGenerator.Parser; +namespace Discord.CX.Parser; public sealed class CXSourceReader { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXTreeWalker.cs b/src/Discord.Net.ComponentDesigner.Parser/CXTreeWalker.cs similarity index 92% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/CXTreeWalker.cs rename to src/Discord.Net.ComponentDesigner.Parser/CXTreeWalker.cs index deb5626d58..3add89fa37 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/CXTreeWalker.cs +++ b/src/Discord.Net.ComponentDesigner.Parser/CXTreeWalker.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace Discord.ComponentDesignerGenerator.Parser; +namespace Discord.CX.Parser; public class CXTreeWalker(CXDoc doc) { diff --git a/src/Discord.Net.ComponentDesigner.Parser/Discord.Net.ComponentDesigner.Parser.csproj b/src/Discord.Net.ComponentDesigner.Parser/Discord.Net.ComponentDesigner.Parser.csproj new file mode 100644 index 0000000000..d1a4b73d33 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/Discord.Net.ComponentDesigner.Parser.csproj @@ -0,0 +1,15 @@ + + + + netstandard2.0;net9.0 + enable + latest + Discord.CX + + + + + + + + diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/ICXNode.cs b/src/Discord.Net.ComponentDesigner.Parser/ICXNode.cs similarity index 90% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/ICXNode.cs rename to src/Discord.Net.ComponentDesigner.Parser/ICXNode.cs index a6fedaee91..5a98c51b19 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/ICXNode.cs +++ b/src/Discord.Net.ComponentDesigner.Parser/ICXNode.cs @@ -1,7 +1,7 @@ using Microsoft.CodeAnalysis.Text; using System.Collections.Generic; -namespace Discord.ComponentDesignerGenerator.Parser; +namespace Discord.CX.Parser; public interface ICXNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/BlendedNode.cs b/src/Discord.Net.ComponentDesigner.Parser/Incremental/BlendedNode.cs similarity index 62% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/BlendedNode.cs rename to src/Discord.Net.ComponentDesigner.Parser/Incremental/BlendedNode.cs index 39145b781e..3688399b26 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/BlendedNode.cs +++ b/src/Discord.Net.ComponentDesigner.Parser/Incremental/BlendedNode.cs @@ -1,4 +1,4 @@ -namespace Discord.ComponentDesignerGenerator.Parser; +namespace Discord.CX.Parser; public readonly record struct BlendedNode( ICXNode Value, diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/CXBlender.cs b/src/Discord.Net.ComponentDesigner.Parser/Incremental/CXBlender.cs similarity index 99% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/CXBlender.cs rename to src/Discord.Net.ComponentDesigner.Parser/Incremental/CXBlender.cs index dc4ce86806..f99b727cb7 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/CXBlender.cs +++ b/src/Discord.Net.ComponentDesigner.Parser/Incremental/CXBlender.cs @@ -3,7 +3,7 @@ using System.Collections.Immutable; using System.Threading; -namespace Discord.ComponentDesignerGenerator.Parser; +namespace Discord.CX.Parser; public sealed class CXBlender { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/IncrementalParseContext.cs b/src/Discord.Net.ComponentDesigner.Parser/Incremental/IncrementalParseContext.cs similarity index 79% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/IncrementalParseContext.cs rename to src/Discord.Net.ComponentDesigner.Parser/Incremental/IncrementalParseContext.cs index d5c98d5096..34464c10d0 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/IncrementalParseContext.cs +++ b/src/Discord.Net.ComponentDesigner.Parser/Incremental/IncrementalParseContext.cs @@ -1,7 +1,7 @@ using Microsoft.CodeAnalysis.Text; using System.Collections.Generic; -namespace Discord.ComponentDesignerGenerator.Parser; +namespace Discord.CX.Parser; public readonly record struct IncrementalParseContext( IReadOnlyList Changes, diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/IncrementalParseResult.cs b/src/Discord.Net.ComponentDesigner.Parser/Incremental/IncrementalParseResult.cs similarity index 84% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/IncrementalParseResult.cs rename to src/Discord.Net.ComponentDesigner.Parser/Incremental/IncrementalParseResult.cs index 2942acc45f..e226655a31 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Incremental/IncrementalParseResult.cs +++ b/src/Discord.Net.ComponentDesigner.Parser/Incremental/IncrementalParseResult.cs @@ -1,7 +1,7 @@ using Microsoft.CodeAnalysis.Text; using System.Collections.Generic; -namespace Discord.ComponentDesignerGenerator.Parser; +namespace Discord.CX.Parser; public readonly record struct IncrementalParseResult( IReadOnlyList ReusedNodes, diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXLexer.cs b/src/Discord.Net.ComponentDesigner.Parser/Lexer/CXLexer.cs similarity index 99% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXLexer.cs rename to src/Discord.Net.ComponentDesigner.Parser/Lexer/CXLexer.cs index bef7cd12c6..d44b5b7a37 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXLexer.cs +++ b/src/Discord.Net.ComponentDesigner.Parser/Lexer/CXLexer.cs @@ -2,7 +2,7 @@ using System; using System.Threading; -namespace Discord.ComponentDesignerGenerator.Parser; +namespace Discord.CX.Parser; public sealed class CXLexer { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXToken.cs b/src/Discord.Net.ComponentDesigner.Parser/Lexer/CXToken.cs similarity index 98% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXToken.cs rename to src/Discord.Net.ComponentDesigner.Parser/Lexer/CXToken.cs index 3ea3990c9c..c22b1d279e 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXToken.cs +++ b/src/Discord.Net.ComponentDesigner.Parser/Lexer/CXToken.cs @@ -4,7 +4,7 @@ using System.Collections.Immutable; using System.Linq; -namespace Discord.ComponentDesignerGenerator.Parser; +namespace Discord.CX.Parser; public sealed record CXToken( CXTokenKind Kind, diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXTokenFlags.cs b/src/Discord.Net.ComponentDesigner.Parser/Lexer/CXTokenFlags.cs similarity index 64% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXTokenFlags.cs rename to src/Discord.Net.ComponentDesigner.Parser/Lexer/CXTokenFlags.cs index 6007c9a9d5..cec05a7701 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXTokenFlags.cs +++ b/src/Discord.Net.ComponentDesigner.Parser/Lexer/CXTokenFlags.cs @@ -1,6 +1,6 @@ using System; -namespace Discord.ComponentDesignerGenerator.Parser; +namespace Discord.CX.Parser; [Flags] public enum CXTokenFlags : byte diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXTokenKind.cs b/src/Discord.Net.ComponentDesigner.Parser/Lexer/CXTokenKind.cs similarity index 82% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXTokenKind.cs rename to src/Discord.Net.ComponentDesigner.Parser/Lexer/CXTokenKind.cs index 44ad74104f..d42bb8cb52 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Lexer/CXTokenKind.cs +++ b/src/Discord.Net.ComponentDesigner.Parser/Lexer/CXTokenKind.cs @@ -1,4 +1,4 @@ -namespace Discord.ComponentDesignerGenerator.Parser; +namespace Discord.CX.Parser; public enum CXTokenKind : byte { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXAttribute.cs b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXAttribute.cs similarity index 89% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXAttribute.cs rename to src/Discord.Net.ComponentDesigner.Parser/Nodes/CXAttribute.cs index b6cf7aeb89..6c99648aac 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXAttribute.cs +++ b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXAttribute.cs @@ -1,6 +1,6 @@ using Microsoft.CodeAnalysis.Text; -namespace Discord.ComponentDesignerGenerator.Parser; +namespace Discord.CX.Parser; public sealed class CXAttribute : CXNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXCollection.cs b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXCollection.cs similarity index 94% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXCollection.cs rename to src/Discord.Net.ComponentDesigner.Parser/Nodes/CXCollection.cs index b7ad3ead5e..9c4c6660b0 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXCollection.cs +++ b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXCollection.cs @@ -2,7 +2,7 @@ using System.Collections; using System.Collections.Generic; -namespace Discord.ComponentDesignerGenerator.Parser; +namespace Discord.CX.Parser; public interface ICXCollection : ICXNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXDoc.cs b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXDoc.cs similarity index 98% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXDoc.cs rename to src/Discord.Net.ComponentDesigner.Parser/Nodes/CXDoc.cs index e340b40a68..1f1d10fe86 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXDoc.cs +++ b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXDoc.cs @@ -5,7 +5,7 @@ using System.Linq; using System.Threading; -namespace Discord.ComponentDesignerGenerator.Parser; +namespace Discord.CX.Parser; public sealed class CXDoc : CXNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXElement.cs b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXElement.cs similarity index 96% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXElement.cs rename to src/Discord.Net.ComponentDesigner.Parser/Nodes/CXElement.cs index 92081f8f40..98b9ecac60 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXElement.cs +++ b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXElement.cs @@ -2,7 +2,7 @@ using System; using System.Collections.Generic; -namespace Discord.ComponentDesignerGenerator.Parser; +namespace Discord.CX.Parser; public sealed class CXElement : CXNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXNode.ParseSlot.cs b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXNode.ParseSlot.cs similarity index 94% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXNode.ParseSlot.cs rename to src/Discord.Net.ComponentDesigner.Parser/Nodes/CXNode.ParseSlot.cs index f52cff1444..763120f808 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXNode.ParseSlot.cs +++ b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXNode.ParseSlot.cs @@ -1,7 +1,7 @@ using Microsoft.CodeAnalysis.Text; using System; -namespace Discord.ComponentDesignerGenerator.Parser; +namespace Discord.CX.Parser; partial class CXNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXNode.cs b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXNode.cs similarity index 99% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXNode.cs rename to src/Discord.Net.ComponentDesigner.Parser/Nodes/CXNode.cs index d1a62d5b70..dda90ae2d4 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXNode.cs +++ b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXNode.cs @@ -7,7 +7,7 @@ using System.Linq; using System.Text; -namespace Discord.ComponentDesignerGenerator.Parser; +namespace Discord.CX.Parser; public abstract partial class CXNode : ICXNode { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXValue.cs b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXValue.cs similarity index 96% rename from src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXValue.cs rename to src/Discord.Net.ComponentDesigner.Parser/Nodes/CXValue.cs index 1d36f31c4b..c099d91f32 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Parsing/Nodes/CXValue.cs +++ b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXValue.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Linq; -namespace Discord.ComponentDesignerGenerator.Parser; +namespace Discord.CX.Parser; public abstract class CXValue : CXNode { diff --git a/src/Discord.Net.ComponentDesigner.Parser/Source/CXSourceText.cs b/src/Discord.Net.ComponentDesigner.Parser/Source/CXSourceText.cs new file mode 100644 index 0000000000..eb2ad6f488 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/Source/CXSourceText.cs @@ -0,0 +1,217 @@ +using Microsoft.CodeAnalysis.Text; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Runtime.InteropServices; + +namespace Discord.CX.Parser; + +public abstract partial class CXSourceText +{ + public abstract int Length { get; } + + public abstract char this[int position] { get; } + + public virtual string this[TextSpan span] => this[span.Start, span.Length]; + + public virtual string this[int position, int length] + { + get + { + var slice = new char[length]; + + for(var i = 0; i < length; i++) + slice[i] = this[position + i]; + + return new string(slice); + } + } + + public TextLineCollection Lines => _lines ??= ComputeLines(); + private TextLineCollection? _lines; + + public virtual CXSourceText GetSubText(TextSpan span) + { + if (span.Length == 0) return new StringSource(string.Empty); + + if (span.Length == Length && span.Start == 0) return this; + + return new SubText(this, span); + } + + public virtual CXSourceText WithChanges(params IReadOnlyCollection changes) + { + if (changes.Count == 0) return this; + + var segments = new List(); + var changeRanges = new List(); + + var pos = 0; + foreach (var change in changes) + { + if (change.Span.Start < pos) + { + if (change.Span.End <= changeRanges.Last().Span.Start) + { + return WithChanges( + changes + .Where(x => !x.Span.IsEmpty || x.NewText?.Length > 0) + .OrderBy(x => x.Span) + .ToList() + ); + } + + throw new InvalidOperationException("Changes cannot overlap."); + } + + var newTextLength = change.NewText?.Length ?? 0; + + if (change.Span.Length == 0 && newTextLength == 0) + continue; + + if (change.Span.Start > pos) + { + var sub = GetSubText(new(pos, change.Span.Start)); + CompositeText.AddSegments(segments, sub); + } + + if (newTextLength > 0) + { + var segment = new StringSource(change.NewText!); + CompositeText.AddSegments(segments, segment); + } + + pos = change.Span.End; + changeRanges.Add(new(change.Span, newTextLength)); + } + + if (pos == 0 && segments.Count == 0) return this; + + if (pos < Length) + { + var subText = GetSubText(new(pos, Length)); + CompositeText.AddSegments(segments, subText); + } + + var newText = CompositeText.Create([..segments], this); + + return new ChangedText(this, newText, [..changeRanges]); + } + + public virtual IReadOnlyList GetChangeRanges(CXSourceText oldText) + { + if (oldText == this) return []; + + return [new TextChangeRange(new(0, oldText.Length), Length)]; + } + + public CXSourceText Replace(TextSpan span, string? newText) + => WithChanges(new TextChange(span, newText ?? string.Empty)); + + public virtual IReadOnlyList GetTextChanges(CXSourceText oldText) + { + var newPosDelta = 0; + + var ranges = GetChangeRanges(oldText); + var results = new List(); + + foreach (var range in ranges) + { + var newPos = range.Span.Start + newPosDelta; + + var text = range.NewLength > 0 + ? this[new TextSpan(newPos, range.NewLength)].ToString() + : string.Empty; + + results.Add(new(range.Span, text)); + newPosDelta += range.NewLength - range.Span.Length; + } + + return results; + } + + protected virtual TextLineCollection ComputeLines() + => new LineInfo(this, ParseLineOffsets()); + + private ImmutableArray ParseLineOffsets() + { + if (Length == 0) return [0]; + + var lineStarts = new List(Length / 64) {0}; + + for (var i = 0; i < Length; i++) + { + var ch = this[i]; + + const uint bias = '\r' + 1; + if (unchecked(ch - bias) <= 127 - bias) + continue; + + if (ch is '\r') + { + if (Length == i + 1) + break; + + if (this[i + 1] is '\n') + { + i += 2; + lineStarts.Add(i); + continue; + } + + lineStarts.Add(i + 1); + continue; + } + + if (!ch.IsNewline()) continue; + + lineStarts.Add(i + 1); + } + + return [..lineStarts]; + } + + private sealed class LineInfo : TextLineCollection + { + public override int Count => _lineOffsets.Length; + + public override TextLine this[int index] + { + get + { + if (index < 0 || index >= _lineOffsets.Length) + throw new ArgumentOutOfRangeException(nameof(index)); + + var start = _lineOffsets[index]; + + var end = index == _lineOffsets.Length - 1 + ? _source.Length + : _lineOffsets[index + 1]; + + return new(_source, start, end); + } + } + + private readonly CXSourceText _source; + private readonly ImmutableArray _lineOffsets; + + public LineInfo(CXSourceText source, ImmutableArray lineOffsets) + { + _source = source; + _lineOffsets = lineOffsets; + } + + public override int IndexOf(int position) + { + if (position < 0 || position > _source.Length) + throw new ArgumentOutOfRangeException(nameof(position)); + + var lineNumber = _lineOffsets.BinarySearch(position); + + if (lineNumber < 0) lineNumber = ~lineNumber - 1; + + return lineNumber; + } + } +} diff --git a/src/Discord.Net.ComponentDesigner.Parser/Source/ChangedText.cs b/src/Discord.Net.ComponentDesigner.Parser/Source/ChangedText.cs new file mode 100644 index 0000000000..e1fced5acb --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/Source/ChangedText.cs @@ -0,0 +1,367 @@ +using Microsoft.CodeAnalysis.Text; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord.CX.Parser; + +partial class CXSourceText +{ + public sealed class ChangedText : CXSourceText + { + private sealed record ChangeInfo( + ImmutableArray Changes, + WeakReference WeakOldText, + ChangeInfo? Previous = null + ) + { + public ChangeInfo? Previous { get; private set; } = Previous; + + public void Clean() + { + var lastInfo = this; + for (var info = this; info is not null; info = info.Previous) + { + if (info.WeakOldText.TryGetTarget(out _)) + lastInfo = info; + } + + ChangeInfo? prev; + while (lastInfo is not null) + { + prev = lastInfo.Previous; + lastInfo.Previous = null; + lastInfo = prev; + } + } + } + + private readonly CXSourceText _newText; + private readonly ChangeInfo _info; + + public override char this[int position] => _newText[position]; + + public override int Length => _newText.Length; + + public ChangedText( + CXSourceText oldText, + CXSourceText newText, + ImmutableArray changes) + { + _newText = newText; + _info = new(changes, new(oldText), (oldText as ChangedText)?._info); + } + + protected override TextLineCollection ComputeLines() => _newText.Lines; + + public override CXSourceText WithChanges(params IReadOnlyCollection changes) + { + var changed = _newText.WithChanges(changes); + + if (changed is ChangedText changedText) + return new ChangedText(this, changedText._newText, changedText._info.Changes); + + return changed; + } + + public override IReadOnlyList GetChangeRanges(CXSourceText oldText) + { + if (_info.WeakOldText.TryGetTarget(out var actualOldText) && actualOldText == oldText) + return _info.Changes; + + if (IsChangedFrom(oldText)) + { + var changes = GetChangesBetween(oldText, this); + + if (changes.Count > 1) return Merge(changes); + } + + if (actualOldText is not null && actualOldText.GetChangeRanges(oldText).Count == 0) + return _info.Changes; + + return [new TextChangeRange(new(0, oldText.Length), _newText.Length)]; + } + + private bool IsChangedFrom(CXSourceText oldText) + { + for (var info = _info; info is not null; info = info.Previous) + { + if (info.WeakOldText.TryGetTarget(out var text) && text == oldText) + return true; + } + + return false; + } + + private static IReadOnlyList> GetChangesBetween( + CXSourceText oldText, + ChangedText newText + ) + { + var results = new List>(); + + var info = newText._info; + results.Add(info.Changes); + + while (info is not null) + { + info.WeakOldText.TryGetTarget(out var actualOldText); + + if (actualOldText == oldText) return results; + + if ((info = info.Previous) is not null) + results.Insert(0, info.Changes); + } + + results.Clear(); + return results; + } + + private static ImmutableArray Merge(IReadOnlyList> changes) + { + var merged = changes[0]; + for (var i = 1; i < changes.Count; i++) + { + merged = Merge(merged, changes[i]); + } + + return merged; + } + + private static ImmutableArray Merge( + ImmutableArray oldChanges, + ImmutableArray newChanges + ) + { + var results = new List(); + + var oldChange = oldChanges[0]; + var newChange = new UnadjustedNewChange(newChanges[0]); + + var oldIndex = 0; + var newIndex = 0; + + var oldDelta = 0; + + while (true) + { + if (oldChange is {Span.Length: 0, NewLength: 0}) + { + // old change doesn't insert or delete anything, so it can be discarded. + if (TryGetNextOldChange()) continue; + + break; + } + + if (newChange is {SpanLength: 0, NewLength: 0}) + { + // new change doesn't insert or delete anything, so it can be discarded. + if (TryGetNextNewChange()) continue; + break; + } + + if (newChange.SpanEnd <= oldChange.Span.Start + oldDelta) + { + // new change is before old change, so just take the new change + AdjustAndAddNewChange(results, oldDelta, newChange); + + if (TryGetNextNewChange()) continue; + + break; + } + + if (newChange.SpanStart >= oldChange.Span.Start + oldChange.NewLength + oldDelta) + { + // new change is after old change, so just take the old change + AddAndAdjustOldDelta(results, ref oldDelta, oldChange); + + if (TryGetNextOldChange()) continue; + break; + } + + if (newChange.SpanStart < oldChange.Span.Start + oldDelta) + { + // new change overlaps + var newChangeLeadingDeletion = oldChange.Span.Start + oldDelta - newChange.SpanStart; + AdjustAndAddNewChange( + results, + oldDelta, + new( + newChange.SpanStart, + newChangeLeadingDeletion, + NewLength: 0 + ) + ); + newChange = newChange with + { + SpanStart = oldChange.Span.Start + oldDelta, + SpanLength = newChange.SpanLength - newChangeLeadingDeletion, + }; + continue; + } + + if (newChange.SpanStart > oldChange.Span.Start + oldDelta) + { + // new change starts after old change, but it overlaps + + var oldChangeLeadingInsertion = newChange.SpanStart - (oldChange.Span.Start + oldDelta); + var oldChangeLeadingDeletion = Math.Min(oldChange.Span.Length, oldChangeLeadingInsertion); + AddAndAdjustOldDelta( + results, + ref oldDelta, + new TextChangeRange( + TextSpan.FromBounds(oldChange.Span.Start, oldChangeLeadingDeletion), + oldChangeLeadingInsertion + ) + ); + + oldChange = new TextChangeRange( + new TextSpan(newChange.SpanStart - oldDelta, oldChange.Span.Length - oldChangeLeadingDeletion), + oldChange.NewLength - oldChangeLeadingInsertion + ); + continue; + } + + // old and new change start at the same position + if (newChange.SpanLength <= oldChange.NewLength) + { + // new change deletes less + oldChange = new(oldChange.Span, oldChange.NewLength - newChange.SpanLength); + + oldDelta += newChange.SpanLength; + newChange = newChange with {SpanLength = 0}; + AdjustAndAddNewChange(results, oldDelta, newChange); + + if (TryGetNextNewChange()) continue; + break; + } + + // new change deletes more + oldDelta -= oldChange.Span.Length + oldChange.NewLength; + + var newDeletion = newChange.SpanLength + oldChange.Span.Length - oldChange.NewLength; + newChange = newChange with {SpanStart = oldChange.Span.Start + oldDelta, SpanLength = newDeletion,}; + + if (TryGetNextOldChange()) continue; + break; + } + + // there may be remaining old changes, but they're mutually exclusive + switch (oldIndex == oldChanges.Length, newIndex == newChanges.Length) + { + case (true, true) or (false, false): + throw new InvalidOperationException(); + } + + while (oldIndex < oldChanges.Length) + { + AddAndAdjustOldDelta(results, ref oldDelta, oldChange); + TryGetNextOldChange(); + } + + while (newIndex < newChanges.Length) + { + AdjustAndAddNewChange(results, oldDelta, newChange); + TryGetNextNewChange(); + } + + return [..results]; + + static void AddAndAdjustOldDelta( + List results, + ref int oldDelta, + TextChangeRange oldChange + ) + { + oldDelta -= (oldChange.Span.Length + oldChange.NewLength); + Add(results, oldChange); + } + + static void AdjustAndAddNewChange( + List results, + int oldDelta, + UnadjustedNewChange newChange + ) + { + Add( + results, + new( + new(newChange.SpanStart - oldDelta, newChange.SpanLength), + newChange.NewLength + ) + ); + } + + static void Add( + List results, + TextChangeRange change + ) + { + if (results.Count == 0) + { + results.Add(change); + return; + } + + var last = results[^1]; + if (last.Span.End == change.Span.Start) + { + // merge + results[^1] = new( + new TextSpan(last.Span.Start, last.Span.Length + change.Span.Length), + last.NewLength + change.NewLength + ); + return; + } + + if (last.Span.End > change.Span.Start) + { + throw new ArgumentOutOfRangeException(nameof(change)); + } + + results.Add(change); + } + + + bool TryGetNextNewChange() + { + newIndex++; + if (newIndex < newChanges.Length) + { + newChange = new UnadjustedNewChange(newChanges[newIndex]); + return true; + } + + newChange = default; + return false; + } + + bool TryGetNextOldChange() + { + oldIndex++; + if (oldIndex < oldChanges.Length) + { + oldChange = oldChanges[oldIndex]; + return true; + } + + oldChange = default; + return false; + } + } + + private readonly record struct UnadjustedNewChange( + int SpanStart, + int SpanLength, + int NewLength + ) + { + public int SpanEnd => SpanStart + SpanLength; + + public UnadjustedNewChange(TextChangeRange range) : this(range.Span.Start, range.Span.Length, + range.NewLength) + { + } + } + } +} diff --git a/src/Discord.Net.ComponentDesigner.Parser/Source/CompositeText.cs b/src/Discord.Net.ComponentDesigner.Parser/Source/CompositeText.cs new file mode 100644 index 0000000000..f909e909df --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/Source/CompositeText.cs @@ -0,0 +1,205 @@ +using Microsoft.CodeAnalysis.Text; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord.CX.Parser; + +partial class CXSourceText +{ + public sealed class CompositeText : CXSourceText + { + public override char this[int position] + { + get + { + GetIndexAndOffset(position, out var index, out var offset); + return _segments[index][offset]; + } + } + + public override int Length { get; } + + private readonly ImmutableArray _segments; + private readonly CXSourceText _original; + private readonly int[] _offsets; + + public CompositeText(ImmutableArray segments, CXSourceText original) + { + _segments = segments; + _original = original; + + _offsets = new int[segments.Length]; + + for (var i = 0; i < segments.Length; i++) + { + _offsets[i] = Length; + Length += segments[i].Length; + } + } + + private void GetIndexAndOffset(int pos, out int index, out int offset) + { + var idx = BinSearchOffsets(pos); + index = idx >= 0 ? idx : (~idx - 1); + offset = pos - _offsets[index]; + } + + private int BinSearchOffsets(int pos) + { + var low = 0; + var high = _offsets.Length - 1; + + while (low <= high) + { + var mid = low + ((high - low) >> 1); + var midVal = _offsets[mid]; + + if (midVal == pos) return mid; + + if (midVal > pos) + { + high = mid - 1; + continue; + } + + low = mid + 1; + } + + return ~low; + } + + + public static CompositeText Create( + ImmutableArray segments, + CXSourceText original + ) => new(segments, original); + + public static void AddSegments(List segments, CXSourceText text) + { + if (text is CompositeText composite) + segments.AddRange(composite._segments); + else segments.Add(text); + } + + private sealed class CompositeTextLineInfo : TextLineCollection + { + public override int Count => _lineCount; + + public override TextLine this[int index] + { + get + { + if (index < 0 || index >= _lineCount) + throw new ArgumentOutOfRangeException(nameof(index)); + + GetSegmentIndexRangeContainingLine( + index, + out var firstSegmentIndexInclusive, + out var lastSegmentIndexInclusive + ); + + var firstSegmentFirstLineNumber = _segmentLineNumbers[firstSegmentIndexInclusive]; + var firstSegment = _text._segments[firstSegmentIndexInclusive]; + var firstSegmentOffset = _text._offsets[firstSegmentIndexInclusive]; + var firstSegmentTextLine = firstSegment.Lines[index - firstSegmentFirstLineNumber]; + + var lineLength = firstSegmentTextLine.SpanIncludingBreaks.Length; + + for ( + var nextSegmentIndex = firstSegmentIndexInclusive + 1; + nextSegmentIndex < lastSegmentIndexInclusive; + nextSegmentIndex++ + ) + { + var nextSegment = _text._segments[nextSegmentIndex]; + + lineLength += nextSegment.Lines[0].SpanIncludingBreaks.Length; + } + + if (firstSegmentIndexInclusive != lastSegmentIndexInclusive) + { + var lastSegment = _text._segments[lastSegmentIndexInclusive]; + lineLength += lastSegment.Lines[0].SpanIncludingBreaks.Length; + } + + return new TextLine( + _text, + firstSegmentOffset + firstSegmentTextLine.Start, + firstSegmentOffset + firstSegmentTextLine.Start + lineLength + ); + } + } + + private readonly CompositeText _text; + private readonly ImmutableArray _segmentLineNumbers; + private readonly int _lineCount; + + public CompositeTextLineInfo(CompositeText text) + { + var segmentLineNumbers = new int[text._segments.Length]; + var accumulatedLineCount = 0; + + for (var i = 0; i < text._segments.Length; i++) + { + segmentLineNumbers[i] = accumulatedLineCount; + + var segment = text._segments[i]; + accumulatedLineCount += segment.Lines.Count; + } + + _segmentLineNumbers = [..segmentLineNumbers]; + _text = text; + _lineCount = accumulatedLineCount + 1; + } + + public override int IndexOf(int position) + { + if (position < 0 || position >= _text.Length) + throw new ArgumentOutOfRangeException(nameof(position)); + + _text.GetIndexAndOffset(position, out var index, out var offset); + + var segment = _text._segments[index]; + var lineNumberWithinSegment = segment.Lines.IndexOf(offset); + + return _segmentLineNumbers[index] + lineNumberWithinSegment; + } + + private void GetSegmentIndexRangeContainingLine( + int lineNumber, + out int firstSegmentIndexInclusive, + out int lastSegmentIndexInclusive + ) + { + var idx = _segmentLineNumbers.BinarySearch(lineNumber); + var binarySearchSegmentIndex = idx >= 0 ? idx : (~idx - 1); + + for ( + firstSegmentIndexInclusive = binarySearchSegmentIndex; + firstSegmentIndexInclusive > 0; + firstSegmentIndexInclusive-- + ) + { + if (_segmentLineNumbers[firstSegmentIndexInclusive] != lineNumber) + break; + + var previousSegment = _text._segments[firstSegmentIndexInclusive - 1]; + var previousSegmentLastChar = previousSegment[^1]; + + if (previousSegmentLastChar.IsNewline()) break; + } + + for ( + lastSegmentIndexInclusive = binarySearchSegmentIndex; + lastSegmentIndexInclusive < _text._segments.Length - 1; + lastSegmentIndexInclusive++ + ) + { + if (_segmentLineNumbers[lastSegmentIndexInclusive + 1] != lineNumber) + break; + } + } + } + } +} diff --git a/src/Discord.Net.ComponentDesigner.Parser/Source/SourceLocation.cs b/src/Discord.Net.ComponentDesigner.Parser/Source/SourceLocation.cs new file mode 100644 index 0000000000..99f51d8ed4 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/Source/SourceLocation.cs @@ -0,0 +1,7 @@ +namespace Discord.CX.Parser; + +public readonly record struct SourceLocation( + int Line, + int Column, + int Position +); diff --git a/src/Discord.Net.ComponentDesigner.Parser/Source/StringSource.cs b/src/Discord.Net.ComponentDesigner.Parser/Source/StringSource.cs new file mode 100644 index 0000000000..546ffdf044 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/Source/StringSource.cs @@ -0,0 +1,18 @@ +using Discord.CX.Parser; +using Microsoft.CodeAnalysis.Text; + +namespace Discord.CX.Parser; + +partial class CXSourceText +{ + public sealed class StringSource(string text) : CXSourceText + { + public string Text { get; } = text; + + public override char this[int i] => Text[i]; + public override int Length => Text.Length; + + public override string this[int start, int length] + => Text.Substring(start, length); + } +} diff --git a/src/Discord.Net.ComponentDesigner.Parser/Source/SubText.cs b/src/Discord.Net.ComponentDesigner.Parser/Source/SubText.cs new file mode 100644 index 0000000000..4e619d7f7a --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/Source/SubText.cs @@ -0,0 +1,121 @@ +using Discord.CX.Parser; +using Microsoft.CodeAnalysis.Text; +using System; + +namespace Discord.CX.Parser; + +partial class CXSourceText +{ + public sealed class SubText : CXSourceText + { + public override char this[int position] + => _underlyingText[position + _span.Start]; + + public override int Length => _span.Length; + + private readonly CXSourceText _underlyingText; + private readonly TextSpan _span; + + public SubText(CXSourceText underlyingText, TextSpan span) + { + _underlyingText = underlyingText; + _span = span; + } + + private TextSpan GetCompositeSpan(TextSpan span) + { + var compositeStart = Math.Min(_underlyingText.Length, _span.Start + span.Start); + var compositeEnd = Math.Min(_underlyingText.Length, compositeStart + span.Length); + + return TextSpan.FromBounds(compositeStart, compositeEnd); + } + + protected override TextLineCollection ComputeLines() => new SubTextLineInfo(this); + + public override CXSourceText GetSubText(TextSpan span) + => new SubText(_underlyingText, GetCompositeSpan(span)); + + private sealed class SubTextLineInfo : TextLineCollection + { + public override int Count { get; } + + public override TextLine this[int index] + { + get + { + if (index < 0 || index >= Count) + throw new ArgumentOutOfRangeException(nameof(index)); + + if (_endsWithinSplitCRLF && index == Count - 1) + return new(_text, _text._span.End, _text._span.End); + + var underlyingTextLine = _text._underlyingText.Lines[index + _startLineNumberInUnderlyingText]; + + var startInUnderlyingText = Math.Max(underlyingTextLine.Start, _text._span.Start); + var endInUnderlyingText = Math.Min(underlyingTextLine.EndIncludingBreaks, _text._span.End); + + var startInSubText = startInUnderlyingText - _text._span.Start; + var resultLine = new TextLine(_text, startInUnderlyingText, endInUnderlyingText); + + var shouldContainLineBreak = index != Count - 1; + var resultContainsLineBreak = resultLine.EndIncludingBreaks > resultLine.End; + + if (shouldContainLineBreak != resultContainsLineBreak) + throw new InvalidOperationException(); + + return resultLine; + } + } + + private readonly SubText _text; + + private readonly int _startLineNumberInUnderlyingText; + private readonly bool _startsWithinSplitCRLF; + private readonly bool _endsWithinSplitCRLF; + + public SubTextLineInfo(SubText text) + { + _text = text; + + var startLineInUnderlyingText = text._underlyingText.Lines.GetLineFromPosition(text._span.Start); + var endLineInUnderlyingText = text._underlyingText.Lines.GetLineFromPosition(text._span.End); + + _startLineNumberInUnderlyingText = startLineInUnderlyingText.LineNumber; + Count = endLineInUnderlyingText.LineNumber - _startLineNumberInUnderlyingText + 1; + + var underlyingSpanStart = text._span.Start; + if ( + underlyingSpanStart == startLineInUnderlyingText.End + 1 && + underlyingSpanStart == startLineInUnderlyingText.EndIncludingBreaks - 1 + ) + { + _startsWithinSplitCRLF = true; + } + + var underlyingSpanEnd = text._span.End; + if ( + underlyingSpanEnd == endLineInUnderlyingText.End + 1 && + underlyingSpanEnd == endLineInUnderlyingText.EndIncludingBreaks - 1 + ) + { + _endsWithinSplitCRLF = true; + Count++; + } + } + + public override int IndexOf(int position) + { + if (position < 0 && position > _text._span.Length) + throw new ArgumentOutOfRangeException(nameof(position)); + + var underlyingPosition = position + _text._span.Start; + var underlyingLineNumber = _text._underlyingText.Lines.IndexOf(underlyingPosition); + + if (_startsWithinSplitCRLF && position != 0) + underlyingLineNumber++; + + return underlyingLineNumber - _startLineNumberInUnderlyingText; + } + } + } +} diff --git a/src/Discord.Net.ComponentDesigner.Parser/Source/TextLineCollection.cs b/src/Discord.Net.ComponentDesigner.Parser/Source/TextLineCollection.cs new file mode 100644 index 0000000000..91d88892b9 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/Source/TextLineCollection.cs @@ -0,0 +1,55 @@ +using Microsoft.CodeAnalysis.Text; +using System.Net.Mime; + +namespace Discord.CX.Parser; + +public abstract class TextLineCollection +{ + public abstract int Count { get; } + public abstract TextLine this[int index] { get; } + + public abstract int IndexOf(int position); + + public virtual TextLine GetLineFromPosition(int position) => this[IndexOf(position)]; + + public virtual SourceLocation GetSourceLocation(int position) + { + var line = GetLineFromPosition(position); + return new(line.LineNumber, position - line.Start, position); + } +} + +public readonly record struct TextLine( + CXSourceText Source, + int Start, + int EndIncludingBreaks +) +{ + public int LineNumber => Source.Lines.IndexOf(Start); + + public int End => EndIncludingBreaks - LineBreakLength; + + public TextSpan Span => TextSpan.FromBounds(Start, End); + public TextSpan SpanIncludingBreaks => TextSpan.FromBounds(Start, End); + + + private int LineBreakLength + { + get + { + var ch = Source[EndIncludingBreaks - 1]; + + if (ch is '\n') + { + if (EndIncludingBreaks > 1 && Source[EndIncludingBreaks - 2] is '\r') + return 2; + + return 1; + } + + if (ch.IsNewline()) return 1; + + return 0; + } + } +} diff --git a/src/Discord.Net.ComponentDesigner.Parser/Util/IsExternalInit.cs b/src/Discord.Net.ComponentDesigner.Parser/Util/IsExternalInit.cs new file mode 100644 index 0000000000..f7c9ba59b0 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/Util/IsExternalInit.cs @@ -0,0 +1,5 @@ +namespace System.Runtime.CompilerServices; + +internal sealed class IsExternalInit : Attribute; +internal sealed class CompilerFeatureRequiredAttribute(string s) : Attribute; +internal sealed class RequiredMemberAttribute : Attribute; diff --git a/src/Discord.Net.ComponentDesigner.Parser/Util/TextUtils.cs b/src/Discord.Net.ComponentDesigner.Parser/Util/TextUtils.cs new file mode 100644 index 0000000000..4ca73984f3 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/Util/TextUtils.cs @@ -0,0 +1,9 @@ +namespace Discord.CX.Parser; + +internal static class TextUtils +{ + public static bool IsNewline(this char ch) + { + return ch is '\r' or '\n' or '\u0085' or '\u2028' or '\u2029'; + } +} diff --git a/src/Discord.Net.Core/packages.lock.json b/src/Discord.Net.Core/packages.lock.json index a924fa1912..2a777794c2 100644 --- a/src/Discord.Net.Core/packages.lock.json +++ b/src/Discord.Net.Core/packages.lock.json @@ -8,6 +8,15 @@ "resolved": "4.0.8", "contentHash": "vNi4NMG0CcJyjXxiNDcQ21FwV/whM9o9OEZKD+oP7tuxAqFEzX/x5OhC3OZJqW/w+8GOtCmJPBquYgMWgz0rfQ==" }, + "Microsoft.NETFramework.ReferenceAssemblies": { + "type": "Direct", + "requested": "[1.0.3, )", + "resolved": "1.0.3", + "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", + "dependencies": { + "Microsoft.NETFramework.ReferenceAssemblies.net461": "1.0.3" + } + }, "Newtonsoft.Json": { "type": "Direct", "requested": "[13.0.3, )", @@ -47,6 +56,11 @@ "System.Threading.Tasks.Extensions": "4.5.4" } }, + "Microsoft.NETFramework.ReferenceAssemblies.net461": { + "type": "Transitive", + "resolved": "1.0.3", + "contentHash": "AmOJZwCqnOCNp6PPcf9joyogScWLtwy0M1WkqfEQ0M9nYwyDD7EX9ZjscKS5iYnyvteX7kzSKFCKt9I9dXA6mA==" + }, "System.Buffers": { "type": "Transitive", "resolved": "4.5.1",