diff --git a/Discord.Net.sln b/Discord.Net.sln index 48c80d54fe..5969f786f5 100644 --- a/Discord.Net.sln +++ b/Discord.Net.sln @@ -42,6 +42,16 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{BB59 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discord.Net.BuildOverrides", "experiment\Discord.Net.BuildOverrides\Discord.Net.BuildOverrides.csproj", "{115F4921-B44D-4F69-996B-69796959C99D}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ComponentDesigner", "ComponentDesigner", "{3752F226-625C-4564-8A19-B6E9F2329D1E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discord.Net.ComponentDesigner", "src\Discord.Net.ComponentDesigner\Discord.Net.ComponentDesigner.csproj", "{11317A05-C2AF-4F5D-829F-129046C8E326}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discord.Net.ComponentDesigner.Generator", "src\Discord.Net.ComponentDesigner.Generator\Discord.Net.ComponentDesigner.Generator.csproj", "{ACBDEE4C-FD57-4A47-B58B-6E10872D0464}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discord.Net.ComponentDesigner.Parser", "src\Discord.Net.ComponentDesigner.Parser\Discord.Net.ComponentDesigner.Parser.csproj", "{F08906A4-7F99-47D9-B43A-905F631F81F8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discord.Net.ComponentDesigner.LanguageServer", "src\Discord.Net.ComponentDesigner.LanguageServer\Discord.Net.ComponentDesigner.LanguageServer.csproj", "{3FD59032-5BA1-418F-88D3-EC385A63E6F2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -244,6 +254,54 @@ Global {115F4921-B44D-4F69-996B-69796959C99D}.Release|x64.Build.0 = Release|Any CPU {115F4921-B44D-4F69-996B-69796959C99D}.Release|x86.ActiveCfg = Release|Any CPU {115F4921-B44D-4F69-996B-69796959C99D}.Release|x86.Build.0 = Release|Any CPU + {11317A05-C2AF-4F5D-829F-129046C8E326}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {11317A05-C2AF-4F5D-829F-129046C8E326}.Debug|Any CPU.Build.0 = Debug|Any CPU + {11317A05-C2AF-4F5D-829F-129046C8E326}.Debug|x64.ActiveCfg = Debug|Any CPU + {11317A05-C2AF-4F5D-829F-129046C8E326}.Debug|x64.Build.0 = Debug|Any CPU + {11317A05-C2AF-4F5D-829F-129046C8E326}.Debug|x86.ActiveCfg = Debug|Any CPU + {11317A05-C2AF-4F5D-829F-129046C8E326}.Debug|x86.Build.0 = Debug|Any CPU + {11317A05-C2AF-4F5D-829F-129046C8E326}.Release|Any CPU.ActiveCfg = Release|Any CPU + {11317A05-C2AF-4F5D-829F-129046C8E326}.Release|Any CPU.Build.0 = Release|Any CPU + {11317A05-C2AF-4F5D-829F-129046C8E326}.Release|x64.ActiveCfg = Release|Any CPU + {11317A05-C2AF-4F5D-829F-129046C8E326}.Release|x64.Build.0 = Release|Any CPU + {11317A05-C2AF-4F5D-829F-129046C8E326}.Release|x86.ActiveCfg = Release|Any CPU + {11317A05-C2AF-4F5D-829F-129046C8E326}.Release|x86.Build.0 = Release|Any CPU + {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Debug|x64.ActiveCfg = Debug|Any CPU + {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Debug|x64.Build.0 = Debug|Any CPU + {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Debug|x86.ActiveCfg = Debug|Any CPU + {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Debug|x86.Build.0 = Debug|Any CPU + {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Release|Any CPU.Build.0 = Release|Any CPU + {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Release|x64.ActiveCfg = Release|Any CPU + {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Release|x64.Build.0 = Release|Any CPU + {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Release|x86.ActiveCfg = Release|Any CPU + {ACBDEE4C-FD57-4A47-B58B-6E10872D0464}.Release|x86.Build.0 = Release|Any CPU + {F08906A4-7F99-47D9-B43A-905F631F81F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F08906A4-7F99-47D9-B43A-905F631F81F8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F08906A4-7F99-47D9-B43A-905F631F81F8}.Debug|x64.ActiveCfg = Debug|Any CPU + {F08906A4-7F99-47D9-B43A-905F631F81F8}.Debug|x64.Build.0 = Debug|Any CPU + {F08906A4-7F99-47D9-B43A-905F631F81F8}.Debug|x86.ActiveCfg = Debug|Any CPU + {F08906A4-7F99-47D9-B43A-905F631F81F8}.Debug|x86.Build.0 = Debug|Any CPU + {F08906A4-7F99-47D9-B43A-905F631F81F8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F08906A4-7F99-47D9-B43A-905F631F81F8}.Release|Any CPU.Build.0 = Release|Any CPU + {F08906A4-7F99-47D9-B43A-905F631F81F8}.Release|x64.ActiveCfg = Release|Any CPU + {F08906A4-7F99-47D9-B43A-905F631F81F8}.Release|x64.Build.0 = Release|Any CPU + {F08906A4-7F99-47D9-B43A-905F631F81F8}.Release|x86.ActiveCfg = Release|Any CPU + {F08906A4-7F99-47D9-B43A-905F631F81F8}.Release|x86.Build.0 = Release|Any CPU + {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Debug|x64.ActiveCfg = Debug|Any CPU + {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Debug|x64.Build.0 = Debug|Any CPU + {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Debug|x86.ActiveCfg = Debug|Any CPU + {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Debug|x86.Build.0 = Debug|Any CPU + {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Release|Any CPU.Build.0 = Release|Any CPU + {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Release|x64.ActiveCfg = Release|Any CPU + {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Release|x64.Build.0 = Release|Any CPU + {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Release|x86.ActiveCfg = Release|Any CPU + {3FD59032-5BA1-418F-88D3-EC385A63E6F2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -264,6 +322,10 @@ Global {B61AAE66-15CC-40E4-873A-C23E697C3411} = {BB59D5B5-E7B0-4BF4-8F82-D14431B2799B} {4A03840B-9EBE-47E3-89AB-E0914DF21AFB} = {BB59D5B5-E7B0-4BF4-8F82-D14431B2799B} {115F4921-B44D-4F69-996B-69796959C99D} = {CC3D4B1C-9DE0-448B-8AE7-F3F1F3EC5C3A} + {11317A05-C2AF-4F5D-829F-129046C8E326} = {3752F226-625C-4564-8A19-B6E9F2329D1E} + {ACBDEE4C-FD57-4A47-B58B-6E10872D0464} = {3752F226-625C-4564-8A19-B6E9F2329D1E} + {F08906A4-7F99-47D9-B43A-905F631F81F8} = {3752F226-625C-4564-8A19-B6E9F2329D1E} + {3FD59032-5BA1-418F-88D3-EC385A63E6F2} = {3752F226-625C-4564-8A19-B6E9F2329D1E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D2404771-EEC8-45F2-9D71-F3373F6C1495} diff --git a/Discord.Net.targets b/Discord.Net.targets index 7f9836d47f..2c4bbc351f 100644 --- a/Discord.Net.targets +++ b/Discord.Net.targets @@ -28,10 +28,10 @@ $(NoWarn);CS1573;CS1591 true true - true + true - - + + - + \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/Constants.cs b/src/Discord.Net.ComponentDesigner.Generator/Constants.cs new file mode 100644 index 0000000000..6b881cc537 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Constants.cs @@ -0,0 +1,43 @@ +namespace Discord.CX; + +public static class Constants +{ + public const string COMPONENT_DESIGNER_QUALIFIED_NAME = "Discord.DesignerInterpolationHandler"; + public const string INTERPOLATION_DESIGNER_QUALIFIED_NAME = "Discord.DesignerInterpolationHandler"; + + public const int PLACEHOLDER_MAX_LENGTH = 150; + + public const int BUTTON_MAX_LABEL_LENGTH = 80; + public const int CUSTOM_ID_MAX_LENGTH = 100; + public const int BUTTON_URL_MAX_LENGTH = 512; + + public const int MAX_ACTION_ROW_COMPONENTS = 5; + + public const int SELECT_MIN_VALUES = 0; + public const int SELECT_MAX_VALUES = 25; + + public const int MAX_MEDIA_ITEMS = 25; + public const int MAX_MEDIA_ITEM_DESCRIPTION_LENGTH = 1024; + + public const int MAX_SECTION_CHILDREN = 3; + + public const int STRING_SELECT_OPTION_LABEL_MAX_LENGTH = 100; + public const int STRING_SELECT_OPTION_VALUE_MAX_LENGTH = 100; + public const int STRING_SELECT_OPTION_DESCRIPTION_MAX_LENGTH = 100; + + public const int TEXT_INPUT_LABEL_MAX_LENGTH = 45; + + public const int TEXT_INPUT_MIN_LENGTH_MIN_VALUE = 0; + public const int TEXT_INPUT_MIN_LENGTH_MAX_VALUE = 4000; + + public const int TEXT_INPUT_MAX_LENGTH_MIN_VALUE = 1; + public const int TEXT_INPUT_MAX_LENGTH_MAX_VALUE = 4000; + + public const int TEXT_INPUT_VALUE_MAX_LENGTH = 4000; + public const int TEXT_INPUT_PLACEHOLDER_MAX_LENGTH = 100; + + public const int THUMBNAIL_DESCRIPTION_MAX_LENGTH = 1024; + + + +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs b/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs new file mode 100644 index 0000000000..3fef610442 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs @@ -0,0 +1,249 @@ +using Microsoft.CodeAnalysis; + +namespace Discord.CX; + +public static partial class Diagnostics +{ + public static readonly DiagnosticDescriptor ParseError = new( + "DCP001", + "CX Parsing error", + "{0}", + "Component Parser (CX)", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor InvalidEnumVariant = new( + "DC0001", + "Invalid enum variant", + "'{0}' is not a valid variant of '{1}'; valid values are '{2}'", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor TypeMismatch = new( + "DC0002", + "Type mismatch", + "'{0}' is not of expected type '{1}'", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor OutOfRange = new( + "DC0003", + "Type mismatch", + "'{0}' must be {1} in length", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor UnknownComponent = new( + "DC0004", + "Unknown component", + "'{0}' is not a known component", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor ButtonCustomIdUrlConflict = new( + "DC0005", + "Invalid button", + "Buttons cannot contain both a 'url' and a 'customid'", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor ButtonCustomIdOrUrlMissing = new( + "DC0006", + "Invalid button", + "A button must specify either a 'customId' or a 'url'", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor LinkButtonUrlMissing = new( + "DC0007", + "Invalid button", + "A 'link' button must specify 'url'", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor PremiumButtonSkuMissing = new( + "DC0008", + "Invalid button", + "A 'premium' button must specify 'skuId'", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor PremiumButtonPropertyNotAllowed = new( + "DC0009", + "Invalid button", + "A 'premium' button cannot specify '{0}'", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor ButtonLabelDuplicate = new( + "DC0010", + "Duplicate label definition", + "A button cannot specify both a body and a 'label'", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor EmptyActionRow = new( + "DC0011", + "Empty Action Row", + "An action row must contain at least one child", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor MissingRequiredProperty = new( + "DC0012", + "Missing Property", + "'{0}' requires the property '{1}' to be specified", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor UnknownProperty = new( + "DC0013", + "Unknown Property", + "'{0}' is not a known property of '{1}'", + "Components", + DiagnosticSeverity.Warning, + true + ); + + public static readonly DiagnosticDescriptor EmptyAccessory = new( + "DC0014", + "Empty Accessory", + "An accessory must have 1 child", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor TooManyAccessoryChildren = new( + "DC0015", + "Too many accessory children", + "An accessory must have 1 child", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor EmptySection = new( + "DC0016", + "Section cannot be empty", + "A section must have an accessory and a child", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor InvalidAccessoryChild = new( + "DC0017", + "Invalid accessory child", + "'{0}' is not a valid accessory, only buttons and thumbnails are allowed", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor MissingAccessory = new( + "DC0018", + "Missing accessory", + "A section must contain an accessory", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor TooManyAccessories = new( + "DC0019", + "Too many accessories", + "A section can only contain one accessory", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor MissingSectionChild = new( + "DC0020", + "Missing section child", + "A section must contain at least 1 non-accessory component", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor TooManySectionChildren = new( + "DC0021", + "Too many section children", + "A section must contain at most 3 non-accessory components", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor InvalidSectionChildComponentType = new( + "DC0022", + "Invalid section child component type", + "'{0}' is not a valid child component of a section; only text displays are allowed", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor MissingSelectMenuType = new( + "DC0023", + "Missing select menu type", + "You must specify the type of the select menu, being one of 'string', 'user', 'role', 'channel', or 'mentionable'", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor InvalidSelectMenuType = new( + "DC0024", + "Invalid select menu type", + "Select menu type must be either 'string', 'user', 'role', 'channel', or 'mentionable'", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor SpecifiedInvalidSelectMenuType = new( + "DC0025", + "Invalid select menu type", + "'{0}' is not a valid elect menu type; must be either 'string', 'user', 'role', 'channel', or 'mentionable'", + "Components", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor ActionRowInvalidChild = new( + "DC0026", + "Invalid action row child component", + "An action row can only contain 1 select menu OR at most 5 buttons", + "Components", + DiagnosticSeverity.Error, + true + ); +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Discord.Net.ComponentDesigner.Generator.csproj b/src/Discord.Net.ComponentDesigner.Generator/Discord.Net.ComponentDesigner.Generator.csproj new file mode 100644 index 0000000000..b67d1b5dcd --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Discord.Net.ComponentDesigner.Generator.csproj @@ -0,0 +1,27 @@ + + + + netstandard2.0 + false + enable + latest + + true + true + Discord.Net.ComponentDesignerGenerator + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraph.cs b/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraph.cs new file mode 100644 index 0000000000..3b49145129 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraph.cs @@ -0,0 +1,220 @@ +using Discord.CX.Nodes; +using Discord.CX.Nodes.Components; +using Discord.CX.Parser; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.CX; + +public readonly struct CXGraph +{ + public readonly CXGraphManager Manager; + public readonly ImmutableArray RootNodes; + public readonly ImmutableArray Diagnostics; + public readonly IReadOnlyDictionary NodeMap; + + public CXGraph( + CXGraphManager manager, + ImmutableArray rootNodes, + ImmutableArray diagnostics, + IReadOnlyDictionary nodeMap + ) + { + Manager = manager; + RootNodes = rootNodes; + Diagnostics = diagnostics; + NodeMap = nodeMap; + } + + public Location GetLocation(ICXNode node) => GetLocation(Manager, node); + public Location GetLocation(TextSpan span) => GetLocation(Manager, span); + + public static Location GetLocation(CXGraphManager manager, ICXNode node) + => GetLocation(manager, node.Span); + + public static Location GetLocation(CXGraphManager manager, TextSpan span) + => manager.SyntaxTree.GetLocation(span); + + public CXGraph Update( + CXGraphManager manager, + IncrementalParseResult parseResult, + CXDoc document + ) + { + if (manager == Manager) return this; + + var map = new Dictionary(); + var diagnostics = ImmutableArray.CreateBuilder(); + + var rootNodes = ImmutableArray.CreateBuilder(); + + foreach (var cxNode in document.RootElements) + { + var node = CreateNode( + manager, + cxNode, + null, + parseResult.ReusedNodes, + this, + map, diagnostics + ); + + if (node is not null) rootNodes.Add(node); + } + + return new(manager, rootNodes.ToImmutable(), diagnostics.ToImmutable(), map); + } + + public static CXGraph Create( + CXGraphManager manager + ) + { + var map = new Dictionary(); + var diagnostics = ImmutableArray.CreateBuilder(); + + var rootNodes = manager.Document + .RootElements + .Select(x => + CreateNode( + manager, + x, + null, + [], + null, + map, + diagnostics + ) + ) + .Where(x => x is not null) + .ToImmutableArray(); + + return new(manager, rootNodes!, diagnostics.ToImmutable(), map); + } + + private static Node? CreateNode( + CXGraphManager manager, + CXNode cxNode, + Node? parent, + IReadOnlyList reusedNodes, + CXGraph? oldGraph, + Dictionary map, + ImmutableArray.Builder diagnostics + ) + { + if ( + oldGraph.HasValue && + reusedNodes.Contains(cxNode) && + oldGraph.Value.NodeMap.TryGetValue(cxNode, out var existing) + ) return map[cxNode] = existing with {Parent = parent}; + + switch (cxNode) + { + case CXValue.Interpolation interpolation: + { + var info = manager.InterpolationInfos[interpolation.InterpolationIndex]; + + if ( + manager.Compilation.HasImplicitConversion( + info.Symbol, + manager.Compilation.GetKnownTypes() + .IMessageComponentBuilderType + ) + ) + { + var inner = ComponentNode.GetComponentNode(); + + var state = inner.Create(interpolation, []); + + if (state is null) return null; + + return map[interpolation] = new( + inner, + state, + parent, + [] + ); + } + + return null; + } + case CXElement element: + { + if (!ComponentNode.TryGetNode(element.Identifier, out var componentNode)) + { + diagnostics.Add( + Diagnostic.Create( + CX.Diagnostics.UnknownComponent, + GetLocation(manager, element), + element.Identifier + ) + ); + + return null; + } + + var children = new List(); + + var state = componentNode.Create(element, children); + + if (state is null) return null; + + var nodeChildren = new List(); + var node = map[element] = state.OwningNode = new( + componentNode, + state, + parent, + nodeChildren + ); + + nodeChildren.AddRange( + children + .Select(x => CreateNode( + manager, + x, + node, + reusedNodes, + oldGraph, + map, + diagnostics + ) + ) + .Where(x => x is not null)! + ); + + return node; + } + default: return null; + } + } + + public void Validate(ComponentContext context) + { + foreach (var node in RootNodes) node.Validate(context); + } + + public string Render(ComponentContext context) + => string.Join(",\n", RootNodes.Select(x => x.Render(context))); + + public sealed record Node( + ComponentNode Inner, + ComponentState State, + Node? Parent, + IReadOnlyList Children + ) + { + private string? _render; + + public string Render(ComponentContext context) + => _render ??= Inner.Render(State, context); + + public void Validate(ComponentContext context) + { + Inner.Validate(State, context); + foreach (var child in Children) child.Validate(context); + } + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraphManager.cs b/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraphManager.cs new file mode 100644 index 0000000000..09223c4ebe --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraphManager.cs @@ -0,0 +1,206 @@ +using Discord.CX.Nodes; +using Discord.CX.Parser; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading; + +namespace Discord.CX; + +public sealed record CXGraphManager( + SourceGenerator Generator, + string Key, + Target Target, + CXDoc Document +) +{ + public SyntaxTree SyntaxTree => InvocationSyntax.SyntaxTree; + public InterceptableLocation InterceptLocation => Target.InterceptLocation; + public InvocationExpressionSyntax InvocationSyntax => Target.InvocationSyntax; + public ExpressionSyntax ArgumentExpressionSyntax => Target.ArgumentExpressionSyntax; + public IOperation Operation => Target.Operation; + public Compilation Compilation => Target.Compilation; + + public string CXDesigner => Target.CXDesigner; + public DesignerInterpolationInfo[] InterpolationInfos => Target.Interpolations; + + public TextSpan CXDesignerSpan => Target.CXDesignerSpan; + + public CXParser Parser => Document.Parser; + + public CXGraph Graph + { + get => _graph ??= CXGraph.Create(this); + init => _graph = value; + } + + public string SimpleSource => _simpleSource ??= ( + GetCXWithoutInterpolations( + CXDesignerSpan.Start, + CXDesigner, + InterpolationInfos + ) + ); + + private string? _simpleSource; + + private CXGraph? _graph; + + public CXGraphManager(CXGraphManager other) + { + _graph = other.Graph; + Generator = other.Generator; + Key = other.Key; + Target = other.Target; + Document = other.Document; + } + + public static CXGraphManager Create(SourceGenerator generator, string key, Target target, CancellationToken token) + { + var source = new CXSource( + target.CXDesignerSpan, + target.CXDesigner, + target.Interpolations.Select(x => x.Span).ToArray(), + target.CXQuoteCount + ); + + var doc = CXParser.Parse(source, token); + + return new CXGraphManager( + generator, + key, + target, + doc + ); + } + + public CXGraphManager OnUpdate(string key, Target target, CancellationToken token) + { + /* + * TODO: + * There are 2 modes of incremental updating: re-parse and re-gen, + * + * Reparsing: + * This requires incremental parsing and then re-generating the updated nodes that were parsed, we can + * re-use old gen information + * + * Regenerating + * Caused mostly by interpolation types changing, the actual values don't matter since it doesn't change + * out emitted code + * + * Some key things to note: + * A fast-path is possible for regenerating, if an interpolations content (source code) has changed, we + * can skip reparse and regeneration, and simply update any diagnostics' text spans. + * If an interpolations type has changed, we re-run the validator wrapping the interpolation, and regenerate + * our emitted source. + */ + + var result = this with {Key = key, Target = target}; + + var newCXWithoutInterpolations = GetCXWithoutInterpolations( + target.ArgumentExpressionSyntax.SpanStart, + target.CXDesigner, + target.Interpolations + ); + + if (newCXWithoutInterpolations != SimpleSource) + { + // we're going to need to reparse, the underlying CX structure changed + result.DoReparse(target, this, ref result, token); + } + + return result; + } + + private void DoReparse(Target target, CXGraphManager old, ref CXGraphManager result, CancellationToken token) + { + var source = new CXSource( + target.CXDesignerSpan, + target.CXDesigner, + target.Interpolations.Select(x => x.Span).ToArray(), + target.CXQuoteCount + ); + + var changes = target + .SyntaxTree + .GetChanges(old.SyntaxTree) + .Where(x => CXDesignerSpan.IntersectsWith(x.Span)) + .ToArray(); + + var document = Document.IncrementalParse( + source, + changes, + out var parseResult, + token + ); + + result = result with + { + Graph = result.Graph.Update(result, parseResult, document), + Document = document + }; + } + + public RenderedInterceptor Render(CancellationToken token = default) + { + var diagnostics = new List( + Document + .Diagnostics + .Select(x => Diagnostic.Create( + Diagnostics.ParseError, + SyntaxTree.GetLocation(x.Span), + x.Message + ) + ) + .Concat( + Graph.Diagnostics + ) + ); + + if (diagnostics.Count > 0) + { + return new(InterceptLocation, string.Empty, [..diagnostics]); + } + + var context = new ComponentContext(Graph) {Diagnostics = diagnostics}; + + Graph.Validate(context); + + var source = context.HasErrors + ? string.Empty + : Graph.Render(context); + + return new( + this.InterceptLocation, + source, + [..diagnostics] + ); + } + + private static string GetCXWithoutInterpolations( + int offset, + string cx, + DesignerInterpolationInfo[] interpolations + ) + { + if (interpolations.Length is 0) return cx; + + var builder = new StringBuilder(cx); + + var rmDelta = 0; + for (var i = 0; i < interpolations.Length; i++) + { + var interpolation = interpolations[i]; + builder.Remove(interpolation.Span.Start - offset - rmDelta, interpolation.Span.Length); + rmDelta += interpolation.Span.Length; + } + + return builder.ToString(); + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Graph/RenderedInterceptor.cs b/src/Discord.Net.ComponentDesigner.Generator/Graph/RenderedInterceptor.cs new file mode 100644 index 0000000000..c7c08632b4 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Graph/RenderedInterceptor.cs @@ -0,0 +1,30 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.CX; + +public readonly record struct RenderedInterceptor( + InterceptableLocation Location, + string Source, + ImmutableArray Diagnostics +) +{ + public bool Equals(RenderedInterceptor other) + => Location.Data == other.Location.Data && + Location.Version == other.Location.Version && + Source == other.Source && + Diagnostics.SequenceEqual(other.Diagnostics); + + public override int GetHashCode() + { + unchecked + { + var hashCode = Location.GetHashCode(); + hashCode = (hashCode * 397) ^ Source.GetHashCode(); + hashCode = (hashCode * 397) ^ Diagnostics.Aggregate(0, (a, b) => (a * 397) ^ b.GetHashCode()); + return hashCode; + } + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/InterpolationInfo.cs b/src/Discord.Net.ComponentDesigner.Generator/InterpolationInfo.cs new file mode 100644 index 0000000000..0f96afc3ff --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/InterpolationInfo.cs @@ -0,0 +1,27 @@ +using Microsoft.CodeAnalysis; + +namespace Discord.CX; + +public readonly record struct InterpolationInfo( + int Id, + int Length, + ITypeSymbol Type +) +{ + public bool Equals(InterpolationInfo? other) + => other is { } info && + Id == info.Id && + Length == info.Length && + Type.ToDisplayString() == info.Type.ToDisplayString(); + + public override int GetHashCode() + { + unchecked + { + var hashCode = Id; + hashCode = (hashCode * 397) ^ Length; + hashCode = (hashCode * 397) ^ Type.ToDisplayString().GetHashCode(); + return hashCode; + } + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentContext.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentContext.cs new file mode 100644 index 0000000000..2946dfc516 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentContext.cs @@ -0,0 +1,56 @@ +using Discord.CX.Parser; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; +using System.Linq; + +namespace Discord.CX.Nodes; + +public sealed class ComponentContext +{ + public KnownTypes KnownTypes => Compilation.GetKnownTypes(); + public Compilation Compilation => _graph.Manager.Compilation; + + public bool HasErrors => Diagnostics.Any(x => x.Severity is DiagnosticSeverity.Error); + + public List Diagnostics { get; init; } = []; + + private readonly CXGraph _graph; + + public ComponentContext(CXGraph graph) + { + _graph = graph; + } + + public string GetDesignerValue(CXValue.Interpolation interpolation, string? type = null) + => GetDesignerValue(interpolation.InterpolationIndex, type); + + public string GetDesignerValue(DesignerInterpolationInfo interpolation, string? type = null) + => GetDesignerValue(interpolation.Id, type); + + public string GetDesignerValue(int index, string? type = null) + => type is not null ? $"designer.GetValue<{type}>({index})" : $"designer.GetValueAsString({index})"; + + + public Location GetLocation(ICXNode node) + => _graph.GetLocation(node); + public Location GetLocation(TextSpan span) + => _graph.GetLocation(span); + + public void AddDiagnostic(DiagnosticDescriptor descriptor, ICXNode node, params object?[]? args) + => AddDiagnostic(Diagnostic.Create(descriptor, GetLocation(node), args)); + + public void AddDiagnostic(DiagnosticDescriptor descriptor, TextSpan span, params object?[]? args) + => AddDiagnostic(Diagnostic.Create(descriptor, GetLocation(span), args)); + + + public DesignerInterpolationInfo GetInterpolationInfo(CXValue.Interpolation interpolation) + => GetInterpolationInfo(interpolation.InterpolationIndex); + + public DesignerInterpolationInfo GetInterpolationInfo(int index) => _graph.Manager.InterpolationInfos[index]; + + public void AddDiagnostic(Diagnostic diagnostics) + { + Diagnostics.Add(diagnostics); + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs new file mode 100644 index 0000000000..4e9ca9480f --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs @@ -0,0 +1,132 @@ +using Discord.CX.Parser; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; + +namespace Discord.CX.Nodes; + +public abstract class ComponentNode : ComponentNode + where TState : ComponentState +{ + public abstract string Render(TState state, ComponentContext context); + + public virtual void UpdateState(ref TState state) { } + + public sealed override void UpdateState(ref ComponentState state) + => UpdateState(ref Unsafe.As(ref state)); + + public abstract TState? CreateState(ICXNode source, List children); + + public sealed override ComponentState? Create(ICXNode source, List children) + => CreateState(source, children); + + public sealed override string Render(ComponentState state, ComponentContext context) + => Render((TState)state, context); + + public virtual void Validate(TState state, ComponentContext context) { } + + public sealed override void Validate(ComponentState state, ComponentContext context) + => Validate((TState)state, context); +} + +public abstract class ComponentNode +{ + public abstract string Name { get; } + public virtual IReadOnlyList Aliases { get; } = []; + + public virtual bool HasChildren => false; + + public virtual IReadOnlyList Properties { get; } = []; + + public virtual void Validate(ComponentState state, ComponentContext context) + { + // validate properties + foreach (var property in Properties) + foreach (var validator in property.Validators) + { + var propertyValue = state.GetProperty(property); + + validator(context, propertyValue); + + if (!property.IsOptional && !propertyValue.HasValue) + { + context.AddDiagnostic( + Diagnostics.MissingRequiredProperty, + state.Source, + Name, + property.Name + ); + } + } + + // report any unknown properties + if (state.Source is CXElement element) + { + foreach (var attribute in element.Attributes) + { + if (!TryGetPropertyFromName(attribute.Identifier.Value, out _)) + { + context.AddDiagnostic( + Diagnostics.UnknownProperty, + attribute, + attribute.Identifier.Value, + Name + ); + } + } + } + } + + private bool TryGetPropertyFromName(string name, out ComponentProperty result) + { + foreach (var property in Properties) + { + if (property.Name == name || property.Aliases.Contains(name)) + { + result = property; + return true; + } + } + + result = null!; + return false; + } + + public abstract string Render(ComponentState state, ComponentContext context); + + public virtual void UpdateState(ref ComponentState state) { } + + public virtual ComponentState? Create(ICXNode source, List children) + { + if (HasChildren && source is CXElement element) + { + children.AddRange(element.Children); + } + + return new ComponentState() {Source = source}; + } + + + private static readonly Dictionary _nodes; + + static ComponentNode() + { + _nodes = typeof(ComponentNode) + .Assembly + .GetTypes() + .Where(x => !x.IsAbstract && typeof(ComponentNode).IsAssignableFrom(x)) + .Select(x => (ComponentNode)Activator.CreateInstance(x)!) + .SelectMany(x => x + .Aliases + .Prepend(x.Name) + .Select(y => new KeyValuePair(y, x))) + .ToDictionary(x => x.Key, x => x.Value); + } + + public static T GetComponentNode() where T : ComponentNode + => _nodes.Values.OfType().First(); + + public static bool TryGetNode(string name, out ComponentNode node) + => _nodes.TryGetValue(name, out node); +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs new file mode 100644 index 0000000000..66cd41afab --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs @@ -0,0 +1,46 @@ +using Discord.CX.Parser; +using System.Collections.Generic; + +namespace Discord.CX.Nodes; + +public delegate void PropertyValidator(ComponentContext context, ComponentPropertyValue value); +public delegate string PropertyRenderer(ComponentContext context, ComponentPropertyValue value); + +public sealed class ComponentProperty +{ + public static ComponentProperty Id => new( + "id", + isOptional: true, + renderer: Renderers.Integer, + dotnetPropertyName: "Id" + ); + + public string Name { get; } + public IReadOnlyList Aliases { get; } + + public bool IsOptional { get; } + public string DotnetPropertyName { get; } + public string DotnetParameterName { get; } + public PropertyRenderer Renderer { get; } + + public IReadOnlyList Validators { get; } + + public ComponentProperty( + string name, + bool isOptional = false, + IEnumerable? aliases = null, + IEnumerable? validators = null, + PropertyRenderer? renderer = null, + string? dotnetParameterName = null, + string? dotnetPropertyName = null + ) + { + Name = name; + Aliases = [..aliases ?? []]; + IsOptional = isOptional; + DotnetPropertyName = dotnetPropertyName ?? name; + DotnetParameterName = dotnetParameterName ?? name; + Renderer = renderer ?? Renderers.CreateDefault(this); + Validators = [..validators ?? []]; + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs new file mode 100644 index 0000000000..1cb783ca13 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs @@ -0,0 +1,48 @@ +using Discord.CX.Parser; +using Microsoft.CodeAnalysis; +using System.Collections.Generic; + +namespace Discord.CX.Nodes; + +public sealed record ComponentPropertyValue( + ComponentProperty Property, + CXAttribute? Attribute +) +{ + private CXValue? _value; + + public CXValue? Value + { + get => _value ??= Attribute?.Value; + init => _value = value; + } + + public bool IsSpecified => Attribute is not null; + + public bool HasValue => Value is not null; + + private readonly List _diagnostics = []; + + public void AddDiagnostic(Diagnostic diagnostic) => _diagnostics.Add(diagnostic); + + public bool TryGetLiteralValue(ComponentContext context, out string value) + { + switch (Value) + { + case CXValue.Scalar scalar: + value = scalar.Value; + return true; + case CXValue.StringLiteral {HasInterpolations: false} literal: + value = literal.Tokens.ToString(); + return true; + // case CXValue.Interpolation interpolation: + // var info = context.GetInterpolationInfo(interpolation); + // + // break; + + default: + value = string.Empty; + return false; + } + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentState.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentState.cs new file mode 100644 index 0000000000..6de549a598 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentState.cs @@ -0,0 +1,101 @@ +using Discord.CX.Parser; +using Microsoft.CodeAnalysis; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Discord.CX.Nodes; + +public class ComponentState +{ + public CXGraph.Node? OwningNode { get; set; } + public required ICXNode Source { get; init; } + + public bool HasChildren => OwningNode?.Children.Count > 0; + + public IReadOnlyList Children + => OwningNode?.Children ?? []; + + public bool IsElement => Source is CXElement; + + private readonly Dictionary _properties = []; + + public ComponentPropertyValue GetProperty(ComponentProperty property) + { + //if (!IsElement) return null; + + if (_properties.TryGetValue(property, out var value)) return value; + + var attribute = (Source as CXElement)? + .Attributes + .FirstOrDefault(x => + property.Name == x.Identifier.Value || property.Aliases.Contains(x.Identifier.Value) + ); + + return _properties[property] = new(property, attribute); + } + + public void SubstitutePropertyValue(ComponentProperty property, CXValue value) + { + if (!_properties.TryGetValue(property, out var existing)) + _properties[property] = new(property, null) {Value = value}; + else + _properties[property] = _properties[property] with {Value = value}; + } + + public string RenderProperties( + ComponentNode node, + ComponentContext context, + bool asInitializers = false + ) + { + // TODO: correct handling? + if (Source is not CXElement element) return string.Empty; + + var values = new List(); + + foreach (var property in node.Properties) + { + var propertyValue = GetProperty(property); + + if (propertyValue?.Value is null) continue; + + var prefix = asInitializers + ? $"{property.DotnetPropertyName} = " + : $"{property.DotnetPropertyName}: "; + + values.Add($"{prefix}{property.Renderer(context, propertyValue)}"); + } + + var joiner = asInitializers ? "," : string.Empty; + return string.Join($"{joiner}\n", values); + } + + public string RenderInitializer(ComponentNode node, ComponentContext context) + { + var props = RenderProperties(node, context, asInitializers: true); + + if (string.IsNullOrWhiteSpace(props)) return string.Empty; + + return + $$""" + { + {{props.WithNewlinePadding(4)}} + } + """; + } + + public string RenderChildren(ComponentContext context, Func? predicate = null) + { + if (OwningNode is null || !HasChildren) return string.Empty; + + IEnumerable children = OwningNode.Children; + + if (predicate is not null) children = children.Where(predicate); + + return string.Join( + ",\n", + children.Select(x => x.Render(context)) + ); + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs new file mode 100644 index 0000000000..6a0804e9cf --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs @@ -0,0 +1,106 @@ +using Discord.CX.Parser; +using Discord.CX.Nodes.Components.SelectMenus; +using Microsoft.CodeAnalysis; +using System.Collections.Generic; +using System.Linq; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.CX.Nodes.Components; + +public sealed class ActionRowComponentNode : ComponentNode +{ + public override string Name => "row"; + + public override bool HasChildren => true; + + public override IReadOnlyList Properties { get; } = [ComponentProperty.Id]; + + public override void Validate(ComponentState state, ComponentContext context) + { + if (!state.HasChildren) + { + context.AddDiagnostic( + Diagnostics.EmptyActionRow, + state.Source + ); + + base.Validate(state, context); + return; + } + + switch (state.Children[0].Inner) + { + case ButtonComponentNode: + foreach (var rest in state.Children.Skip(1)) + { + if (rest.Inner is not ButtonComponentNode) + { + context.AddDiagnostic( + Diagnostics.ActionRowInvalidChild, + rest.State.Source + ); + } + } + + break; + case SelectMenuComponentNode: + foreach (var rest in state.Children.Skip(1)) + { + context.AddDiagnostic( + Diagnostics.ActionRowInvalidChild, + rest.State.Source + ); + } + + break; + + case InterleavedComponentNode: break; + + default: + foreach ( + var rest + in state.Children.Where(x => !IsValidChild(x.Inner)) + ) + { + context.AddDiagnostic( + Diagnostics.ActionRowInvalidChild, + rest.State.Source + ); + } + + break; + } + + base.Validate(state, context); + } + + private static bool IsValidChild(ComponentNode node) + => node is ButtonComponentNode + or SelectMenuComponentNode + or InterleavedComponentNode; + + public override string Render(ComponentState state, ComponentContext context) + => $$""" + new {{context.KnownTypes.ActionRowBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}}{{ + $"{ + state + .RenderProperties(this, context, asInitializers: true) + .PostfixIfSome("\n") + }{ + state.RenderChildren(context) + .Map(x => + $""" + Components = + [ + {x.WithNewlinePadding(4)} + ] + """ + ) + }" + .TrimEnd() + .WithNewlinePadding(4) + .PrefixIfSome("\n{\n".Postfix(4)) + .PostfixIfSome("\n}") + }} + """; +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs new file mode 100644 index 0000000000..857919be4d --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs @@ -0,0 +1,209 @@ +using Discord.CX.Parser; +using Microsoft.CodeAnalysis; +using System.Collections.Generic; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.CX.Nodes.Components; + +public sealed class ButtonComponentNode : ComponentNode +{ + + public const string BUTTON_STYLE_ENUM = "Discord.ButtonStyle"; + + public override string Name => "button"; + + public override IReadOnlyList Properties { get; } + + public ComponentProperty Style { get; } + public ComponentProperty Label { get; } + public ComponentProperty Emoji { get; } + public ComponentProperty CustomId { get; } + public ComponentProperty SkuId { get; } + public ComponentProperty Url { get; } + + public ButtonComponentNode() + { + Properties = + [ + ComponentProperty.Id, + Style = new ComponentProperty( + "style", + isOptional: true, + validators: [Validators.EnumVariant(BUTTON_STYLE_ENUM)], + renderer: Renderers.RenderEnum(BUTTON_STYLE_ENUM) + ), + Label = new ComponentProperty( + "label", + isOptional: true, + validators: [Validators.Range(upper: Constants.BUTTON_MAX_LABEL_LENGTH)], + renderer: Renderers.String + ), + Emoji = new ComponentProperty( + "emoji", + isOptional: true, + aliases: ["emote"], + validators: [Validators.Emote], + renderer: Renderers.Emoji + ), + CustomId = new( + "customId", + isOptional: true, + validators: [Validators.Range(upper: Constants.CUSTOM_ID_MAX_LENGTH)], + renderer: Renderers.String + ), + SkuId = new( + "skuId", + aliases: ["sku"], + isOptional: true, + validators: [Validators.Snowflake], + renderer: Renderers.Snowflake + ), + Url = new( + "url", + isOptional: true, + validators: [Validators.Range(upper: Constants.BUTTON_URL_MAX_LENGTH)], + renderer: Renderers.String + ) + ]; + } + + public override ComponentState? Create(ICXNode source, List children) + { + var state = base.Create(source, children); + + if (source is CXElement {Children.Count: 1} element && element.Children[0] is CXValue value) + state?.SubstitutePropertyValue(Label, value); + + return state; + } + + public override void Validate(ComponentState state, ComponentContext context) + { + var label = state.GetProperty(Label); + + if ( + label.Attribute?.Value is not null && + label.Value is not null && + label.Value != label.Attribute.Value + ) + { + context.AddDiagnostic( + Diagnostic.Create( + Diagnostics.ButtonLabelDuplicate, + context.GetLocation(label.Value!) + ) + ); + } + + if (state.GetProperty(Url)!.IsSpecified && state.GetProperty(CustomId)!.IsSpecified) + { + context.AddDiagnostic( + Diagnostic.Create( + Diagnostics.ButtonCustomIdUrlConflict, + context.GetLocation(state.Source) + ) + ); + } + + // TODO: interpolations with constants can be checked + if ( + state.GetProperty(Style).TryGetLiteralValue(context, out var style) + ) + { + switch (style.ToLowerInvariant()) + { + case "link" when !state.GetProperty(Url).IsSpecified: + context.AddDiagnostic( + Diagnostic.Create( + Diagnostics.LinkButtonUrlMissing, + context.GetLocation(state.Source) + ) + ); + break; + case "premium" when !state.GetProperty(SkuId).IsSpecified: + context.AddDiagnostic( + Diagnostic.Create( + Diagnostics.PremiumButtonSkuMissing, + context.GetLocation(state.Source) + ) + ); + + if (state.GetProperty(CustomId).IsSpecified) + { + context.AddDiagnostic( + Diagnostic.Create( + Diagnostics.PremiumButtonPropertyNotAllowed, + context.GetLocation(state.Source), + "customId" + ) + ); + } + + if (state.GetProperty(Label).IsSpecified) + { + context.AddDiagnostic( + Diagnostic.Create( + Diagnostics.PremiumButtonPropertyNotAllowed, + context.GetLocation(state.Source), + "label" + ) + ); + } + + if (state.GetProperty(Url).IsSpecified) + { + context.AddDiagnostic( + Diagnostic.Create( + Diagnostics.PremiumButtonPropertyNotAllowed, + context.GetLocation(state.Source), + "url" + ) + ); + } + + if (state.GetProperty(Emoji).IsSpecified) + { + context.AddDiagnostic( + Diagnostic.Create( + Diagnostics.PremiumButtonPropertyNotAllowed, + context.GetLocation(state.Source), + "emoji" + ) + ); + } + + break; + default: CheckForCustomIdAndUrl(); break; + } + } + else + { + CheckForCustomIdAndUrl(); + } + + base.Validate(state, context); + + void CheckForCustomIdAndUrl() + { + if (!state.GetProperty(Url)!.IsSpecified && !state.GetProperty(CustomId)!.IsSpecified) + { + context.AddDiagnostic( + Diagnostic.Create( + Diagnostics.ButtonCustomIdOrUrlMissing, + context.GetLocation(state.Source) + ) + ); + } + } + } + + public override string Render(ComponentState state, ComponentContext context) + => $""" + new {context.KnownTypes.ButtonBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({ + state.RenderProperties(this, context) + .WithNewlinePadding(4) + .PrefixIfSome(4) + .WrapIfSome("\n") + }) + """; +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs new file mode 100644 index 0000000000..ffc064d1d8 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs @@ -0,0 +1,74 @@ +using Discord.CX.Parser; +using System.Collections.Generic; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.CX.Nodes.Components; + +public sealed class ContainerComponentNode : ComponentNode +{ + public override string Name => "container"; + + public override bool HasChildren => true; + + public ComponentProperty Id { get; } + public ComponentProperty AccentColor { get; } + public ComponentProperty Spoiler { get; } + + public override IReadOnlyList Properties { get; } + + public ContainerComponentNode() + { + Properties = + [ + Id = ComponentProperty.Id, + AccentColor = new( + "accentColor", + isOptional: true, + aliases: ["color", "accent"], + renderer: Renderers.Color, + dotnetPropertyName: "AccentColor" + ), + Spoiler = new( + "spoiler", + isOptional: true, + renderer: Renderers.Boolean, + dotnetPropertyName: "IsSpoiler" + ) + ]; + } + + public override void Validate(ComponentState state, ComponentContext context) + { + foreach (var child in state.Children) + { + // TODO: check for allowed children + } + + base.Validate(state, context); + } + + public override string Render(ComponentState state, ComponentContext context) + => $$""" + new {{context.KnownTypes.ContainerBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}}{{ + $"{ + state + .RenderProperties(this, context, asInitializers: true) + .PostfixIfSome("\n") + }{ + state.RenderChildren(context) + .Map(x => + $""" + Components = + [ + {x.WithNewlinePadding(4)} + ] + """ + ) + }" + .TrimEnd() + .WithNewlinePadding(4) + .PrefixIfSome("\n{\n".Postfix(4)) + .PostfixIfSome("\n}") + }} + """; +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs new file mode 100644 index 0000000000..634ae445f7 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.CX.Nodes.Components; + +public sealed class FileComponentNode : ComponentNode +{ + public override string Name => "file"; + + public ComponentProperty File { get; } + public ComponentProperty Spoiler { get; } + + public override IReadOnlyList Properties { get; } + + public FileComponentNode() + { + Properties = + [ + ComponentProperty.Id, + File = new( + "file", + renderer: Renderers.UnfurledMediaItem, + dotnetParameterName: "media" + ), + Spoiler = new( + "spoiler", + isOptional: true, + renderer: Renderers.Boolean, + dotnetParameterName: "isSpoiler" + ) + ]; + } + + public override string Render(ComponentState state, ComponentContext context) + => $""" + new {context.KnownTypes.FileComponentBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({ + state.RenderProperties(this, context) + .WithNewlinePadding(4) + .PrefixIfSome(4) + .WrapIfSome("\n") + }) + """; +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterleavedComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterleavedComponentNode.cs new file mode 100644 index 0000000000..b136ce091c --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterleavedComponentNode.cs @@ -0,0 +1,23 @@ +using Discord.CX.Parser; +using System.Collections.Generic; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.CX.Nodes.Components; + +public sealed class InterleavedComponentNode : ComponentNode +{ + public override string Name => ""; + + public override ComponentState? Create(ICXNode source, List children) + { + if (source is not CXValue.Interpolation interpolation) return null; + + return base.Create(source, children); + } + + public override string Render(ComponentState state, ComponentContext context) + => context.GetDesignerValue( + (CXValue.Interpolation)state.Source, + context.KnownTypes.IMessageComponentBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + ); +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs new file mode 100644 index 0000000000..41753ec1a1 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; + +namespace Discord.CX.Nodes.Components; + +public sealed class LabelComponentNode : ComponentNode +{ + public override string Name => "label"; + + public ComponentProperty Value { get; } + public ComponentProperty Description { get; } + + public override bool HasChildren => true; + + public override IReadOnlyList Properties { get; } + + public LabelComponentNode() + { + Properties = + [ + ComponentProperty.Id, + Value = new( + "value", + renderer: Renderers.String + ), + Description = new( + "description", + isOptional: true, + renderer: Renderers.String + ) + ]; + } + + public override string Render(ComponentState state, ComponentContext context) + => string.Empty; +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs new file mode 100644 index 0000000000..f4f713e984 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.CX.Nodes.Components; + +public sealed class MediaGalleryComponentNode : ComponentNode +{ + public override string Name => "gallery"; + + public override IReadOnlyList Properties { get; } = [ComponentProperty.Id]; + + public override bool HasChildren => true; + + public override string Render(ComponentState state, ComponentContext context) + => $$""" + new {{context.KnownTypes.MediaGalleryBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}}{{ + $"{ + state + .RenderProperties(this, context, asInitializers: true) + .PostfixIfSome("\n") + }{ + state.RenderChildren(context) + .Map(x => + $""" + Items = + [ + {x.WithNewlinePadding(4)} + ] + """ + ) + }" + .TrimEnd() + .WithNewlinePadding(4) + .PrefixIfSome("\n{\n".Postfix(4)) + .PostfixIfSome("\n}") + }} + """; +} + +public sealed class MediaGalleryItemComponentNode : ComponentNode +{ + public override string Name => "media"; + + public ComponentProperty Url { get; } + public ComponentProperty Description { get; } + public ComponentProperty Spoiler { get; } + + public override IReadOnlyList Properties { get; } + + public MediaGalleryItemComponentNode() + { + Properties = + [ + Url = new( + "url", + renderer: Renderers.UnfurledMediaItem + ), + Description = new( + "description", + isOptional: true, + renderer: Renderers.String + ), + Spoiler = new( + "spoiler", + isOptional: true, + renderer: Renderers.Boolean, + dotnetParameterName: "isSpoiler" + ) + ]; + } + + public override string Render(ComponentState state, ComponentContext context) + => $""" + new {context.KnownTypes.MediaGalleryItemPropertiesType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}( + {state.RenderProperties(this, context).WithNewlinePadding(4)} + ) + """; +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentnode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentnode.cs new file mode 100644 index 0000000000..f66266e20e --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentnode.cs @@ -0,0 +1,160 @@ +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; +using System.Linq; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.CX.Nodes.Components; + +public sealed class SectionComponentnode : ComponentNode +{ + public override string Name => "section"; + + public override bool HasChildren => true; + + public override IReadOnlyList Properties { get; } = [ComponentProperty.Id]; + + public override void Validate(ComponentState state, ComponentContext context) + { + if (!state.HasChildren) + { + context.AddDiagnostic( + Diagnostics.EmptySection, + state.Source + ); + + base.Validate(state, context); + return; + } + + var accessoryCount = state.Children.Count(x => x.Inner is AccessoryComponentNode); + var nonAccessoryCount = state.Children.Count - accessoryCount; + + switch (accessoryCount) + { + case 0: + context.AddDiagnostic( + Diagnostics.MissingAccessory, + state.Source + ); + break; + case > 1: + foreach (var accessory in state.Children.Where(x => x.Inner is AccessoryComponentNode).Skip(1)) + { + context.AddDiagnostic( + Diagnostics.TooManyAccessories, + accessory.State.Source + ); + } + + break; + } + + switch (nonAccessoryCount) + { + case 0: + context.AddDiagnostic( + Diagnostics.MissingSectionChild, + state.Source + ); + break; + case > 3: + foreach (var child in state.Children.Where(x => x.Inner is not AccessoryComponentNode).Skip(3)) + { + context.AddDiagnostic( + Diagnostics.TooManySectionChildren, + child.State.Source + ); + } + break; + } + + foreach (var child in state.Children.Where(x => x.Inner is not AccessoryComponentNode)) + { + if (!IsValidChildType(child.Inner)) + { + context.AddDiagnostic( + Diagnostics.InvalidSectionChildComponentType, + child.State.Source, + child.Inner.Name + ); + } + } + + static bool IsValidChildType(ComponentNode node) + => node is TextDisplayComponentNode; + } + + public override string Render(ComponentState state, ComponentContext context) + { + var accessory = state.Children + .FirstOrDefault(x => x.Inner is AccessoryComponentNode); + + return + $""" + new {context.KnownTypes.SectionBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}( + accessory: {accessory?.Render(context).WithNewlinePadding(4) ?? "null"}, + components: + [ + { + state + .RenderChildren(context, x => x.Inner is not AccessoryComponentNode) + .WithNewlinePadding(4) + } + ] + ){state.RenderInitializer(this, context).PrefixIfSome("\n")} + """; + } +} + +public sealed class AccessoryComponentNode : ComponentNode +{ + public override string Name => "accessory"; + + public override bool HasChildren => true; + + public override void Validate(ComponentState state, ComponentContext context) + { + if (!state.HasChildren) + { + context.AddDiagnostic( + Diagnostics.EmptyAccessory, + state.Source + ); + + base.Validate(state, context); + return; + } + + + if (state.Children.Count is not 1) + { + var start = state.OwningNode!.Children[0].State.Source.Span.Start; + var end = state.OwningNode!.Children[state.Children.Count - 1].State.Source.Span.End; + + context.AddDiagnostic( + Diagnostics.TooManyAccessoryChildren, + TextSpan.FromBounds(start, end) + ); + + base.Validate(state, context); + return; + } + + if (!IsAllowedChild(state.Children[0].Inner)) + { + context.AddDiagnostic( + Diagnostics.InvalidAccessoryChild, + state.Children[0].State.Source, + state.Children[0].Inner.Name + ); + } + + base.Validate(state, context); + } + + private static bool IsAllowedChild(ComponentNode node) + => node is ButtonComponentNode or ThumbnailComponentNode; + + public override string Render(ComponentState state, ComponentContext context) + => state.RenderChildren(context); +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuComponentNode.cs new file mode 100644 index 0000000000..8049974f40 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuComponentNode.cs @@ -0,0 +1,223 @@ +using Discord.CX.Parser; +using System; +using System.Collections.Generic; +using System.Linq; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.CX.Nodes.Components.SelectMenus; + +public sealed class SelectMenuComponentNode : ComponentNode +{ + public sealed class MissingTypeState : ComponentState; + + public sealed class InvalidTypeState : ComponentState + { + public string? Kind { get; init; } + } + + public sealed class StringSelectState : ComponentState; + + public abstract class SelectStateWithDefaults : ComponentState + { + public required IReadOnlyList Defaults { get; init; } + } + + public sealed class UserSelectState : SelectStateWithDefaults; + + public sealed class RoleSelectState : SelectStateWithDefaults; + + // TODO: channel types? + public sealed class ChannelSelectState : SelectStateWithDefaults; + + public sealed class MentionableSelectState : SelectStateWithDefaults; + + public override string Name => "select"; + + public ComponentProperty CustomId { get; } + public ComponentProperty Placeholder { get; } + public ComponentProperty MinValues { get; } + public ComponentProperty MaxValues { get; } + public ComponentProperty Required { get; } + public ComponentProperty Disabled { get; } + + public override IReadOnlyList Properties { get; } + + public SelectMenuComponentNode() + { + Properties = + [ + ComponentProperty.Id, + CustomId = new( + "customId", + isOptional: false, + renderer: Renderers.String + ), + Placeholder = new( + "placeholder", + isOptional: true, + renderer: Renderers.String + ), + MinValues = new( + "minValues", + isOptional: true, + aliases: ["min"], + renderer: Renderers.Integer + ), + MaxValues = new( + "maxValues", + isOptional: true, + aliases: ["max"], + renderer: Renderers.Integer + ), + Required = new( + "required", + isOptional: true, + renderer: Renderers.Boolean + ), + Disabled = new( + "disabled", + isOptional: true, + renderer: Renderers.Boolean + ) + ]; + } + + public override ComponentState? Create(ICXNode source, List children) + { + if (source is not CXElement element) return null; + + var typeAttribute = element.Attributes + .FirstOrDefault(x => x.Identifier.Value.ToLowerInvariant() is "type"); + + if (typeAttribute is null) return new MissingTypeState() {Source = source}; + + if (typeAttribute.Value is not CXValue.StringLiteral {HasInterpolations: false} typeValue) + return new InvalidTypeState() {Source = source,}; + + var kind = typeValue.Tokens.ToString().ToLowerInvariant(); + switch (kind) + { + case "string" or "text": + children.AddRange(element.Children); + return new StringSelectState() {Source = source}; + case "user": + return new UserSelectState() {Source = source, Defaults = ExtractDefaultValues()}; + case "role": + return new RoleSelectState() {Source = source, Defaults = ExtractDefaultValues()}; + case "channel": + return new ChannelSelectState() {Source = source, Defaults = ExtractDefaultValues()}; + case "mention" or "mentionable": + return new MentionableSelectState() {Source = source, Defaults = ExtractDefaultValues()}; + default: return new InvalidTypeState() {Source = source, Kind = kind}; + } + + IReadOnlyList ExtractDefaultValues() + { + var result = new List(); + + foreach (var child in element.Children) + { + if (child is not CXElement element) + { + // TODO: diagnostics + continue; + } + + if (!Enum.TryParse(element.Identifier, true, out var kind)) + { + // TODO: diagnostics + continue; + } + + if (element.Children.Count is not 1 || element.Children[0] is not CXValue value) + { + // TODO: diagnostics + continue; + } + + result.Add(new(kind, value)); + } + + return result; + } + } + + public override void Validate(ComponentState state, ComponentContext context) + { + switch (state) + { + case MissingTypeState: + context.AddDiagnostic( + Diagnostics.MissingSelectMenuType, + state.Source + ); + return; + case InvalidTypeState {Kind: var kind}: + context.AddDiagnostic( + kind is not null ? Diagnostics.SpecifiedInvalidSelectMenuType : Diagnostics.InvalidSelectMenuType, + state.Source, + kind is not null ? [kind] : null + ); + return; + } + } + + private static string RenderDefaultValue(ComponentContext context, SelectMenuDefautValue defaultValue) + => $""" + new {context.KnownTypes.SelectMenuDefaultValueType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}( + id: {Renderers.Snowflake(context, defaultValue.Value)}, + type: {context.KnownTypes.SelectDefaultValueTypeEnumType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.{defaultValue.Kind} + ) + """; + + public override string Render(ComponentState state, ComponentContext context) + => $""" + new {context.KnownTypes.SelectMenuBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({ + string + .Join( + ",\n", + ((IEnumerable) + [ + ( + state switch + { + UserSelectState => "UserSelect", + RoleSelectState => "RoleSelect", + MentionableSelectState => "MentionableSelect", + ChannelSelectState => "ChannelSelect", + _ => string.Empty + } + ).Map(x => $"type: {context.KnownTypes.ComponentTypeEnumType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.{x}"), + state.RenderProperties(this, context), + state.RenderChildren(context, x => x.Inner is StringSelectOptionComponentNode) + .Map(x => + $""" + options: + [ + {x.WithNewlinePadding(4)} + ] + """ + ), + state is SelectStateWithDefaults {Defaults: var defaults} + ? string + .Join( + ",\n", + defaults.Select(x => RenderDefaultValue(context, x)) + ) + .Map(x => + $""" + defaultValues: + [ + {x.WithNewlinePadding(4)} + ] + """ + ) + : string.Empty + ]).Where(x => !string.IsNullOrEmpty(x)) + ) + .PrefixIfSome(4) + .WithNewlinePadding(4) + .WrapIfSome("\n") + }) + """; +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefaultValueKind.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefaultValueKind.cs new file mode 100644 index 0000000000..e87d675532 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefaultValueKind.cs @@ -0,0 +1,8 @@ +namespace Discord.CX.Nodes.Components.SelectMenus; + +public enum SelectMenuDefaultValueKind +{ + User, + Role, + Channel +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefautValue.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefautValue.cs new file mode 100644 index 0000000000..493658e4e8 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuDefautValue.cs @@ -0,0 +1,8 @@ +using Discord.CX.Parser; + +namespace Discord.CX.Nodes.Components.SelectMenus; + +public readonly record struct SelectMenuDefautValue( + SelectMenuDefaultValueKind Kind, + CXValue Value +); diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/StringSelectOptionComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/StringSelectOptionComponentNode.cs new file mode 100644 index 0000000000..1bbebc9c04 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/StringSelectOptionComponentNode.cs @@ -0,0 +1,74 @@ +using Discord.CX.Parser; +using Microsoft.CodeAnalysis; +using System.Collections.Generic; + +namespace Discord.CX.Nodes.Components.SelectMenus; + +public sealed class StringSelectOptionComponentNode : ComponentNode +{ + public override string Name => "option"; + + public ComponentProperty Label { get; } + public ComponentProperty Value { get; } + public ComponentProperty Description { get; } + public ComponentProperty Emoji { get; } + public ComponentProperty Default { get; } + + public override IReadOnlyList Properties { get; } + + public StringSelectOptionComponentNode() + { + Properties = + [ + Label = new( + "label", + renderer: Renderers.String + ), + Value = new( + "value", + renderer: Renderers.String + ), + Description = new( + "description", + isOptional: true, + renderer: Renderers.String + ), + Emoji = new( + "emoji", + isOptional: false, + renderer: Renderers.Emoji + ), + Default = new( + "default", + isOptional: true, + renderer: Renderers.Boolean, + dotnetParameterName: "isDefault" + ) + ]; + } + + public override ComponentState? Create(ICXNode source, List children) + { + var state = base.Create(source, children); + + if ( + source is CXElement {Children.Count: 1} element && + element.Children[0] is CXValue value + ) + { + state!.SubstitutePropertyValue(Value, value); + } + + return state; + } + + public override string Render(ComponentState state, ComponentContext context) + => $""" + new {context.KnownTypes.SelectMenuOptionBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({ + state.RenderProperties(this, context) + .WithNewlinePadding(4) + .PrefixIfSome(4) + .WrapIfSome("\n") + }) + """; +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs new file mode 100644 index 0000000000..97603112a1 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.CX.Nodes.Components; + +public sealed class SeparatorComponentNode : ComponentNode +{ + public const string SEPARATOR_SPACING_QUALIFIED_NAME = "Discord.SeparatorSpacingSize"; + + public override string Name => "separator"; + + public ComponentProperty Id { get; } + public ComponentProperty Divider { get; } + public ComponentProperty Spacing { get; } + + public override IReadOnlyList Properties { get; } + + public SeparatorComponentNode() + { + Properties = + [ + Id = ComponentProperty.Id, + Divider = new( + "divider", + isOptional: true, + renderer: Renderers.Boolean + ), + Spacing = new( + "spacing", + isOptional: true, + renderer: Renderers.RenderEnum(SEPARATOR_SPACING_QUALIFIED_NAME) + ) + ]; + } + + public override string Render(ComponentState state, ComponentContext context) + => $""" + new {context.KnownTypes.SeparatorBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({ + state.RenderProperties(this, context) + .WithNewlinePadding(4) + .PrefixIfSome(4) + .WrapIfSome("\n") + }) + """; +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs new file mode 100644 index 0000000000..08c6228387 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs @@ -0,0 +1,45 @@ +using Discord.CX.Parser; +using System.Collections.Generic; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.CX.Nodes.Components; + +public sealed class TextDisplayComponentNode : ComponentNode +{ + public override string Name => "text"; + + public ComponentProperty Content { get; } + public override IReadOnlyList Properties { get; } + + public TextDisplayComponentNode() + { + Properties = + [ + ComponentProperty.Id, + Content = new( + "content", + renderer: Renderers.String + ) + ]; + } + + public override ComponentState? Create(ICXNode source, List children) + { + var state = base.Create(source, children)!; + + if (source is CXElement {Children.Count: 1} element && element.Children[0] is CXValue value) + state.SubstitutePropertyValue(Content, value); + + return state; + } + + public override string Render(ComponentState state, ComponentContext context) + => $""" + new {context.KnownTypes.TextDisplayBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({ + state.RenderProperties(this, context) + .WithNewlinePadding(4) + .PrefixIfSome(4) + .WrapIfSome("\n") + }) + """; +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs new file mode 100644 index 0000000000..ebc7935804 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.CX.Nodes.Components; + +public sealed class TextInputComponentNode : ComponentNode +{ + public const string LIBRARY_TEXT_INPUT_STYLE_ENUM = "Discord.TextInputStyle"; + + public override string Name => "input"; + + public ComponentProperty CustomId { get; } + public ComponentProperty Style { get; } + public ComponentProperty MinLength { get; } + public ComponentProperty MaxLength { get; } + public ComponentProperty Required { get; } + public ComponentProperty Value { get; } + public ComponentProperty Placeholder { get; } + + public override IReadOnlyList Properties { get; } + + public TextInputComponentNode() + { + Properties = + [ + ComponentProperty.Id, + CustomId = new( + "customId", + isOptional: false, + renderer: Renderers.String + ), + Style = new( + "style", + isOptional: false, + renderer: Renderers.RenderEnum(LIBRARY_TEXT_INPUT_STYLE_ENUM) + ), + MinLength = new( + "minLength", + aliases: ["min"], + isOptional: true, + renderer: Renderers.Integer + ), + MaxLength = new( + "maxLength", + aliases: ["max"], + isOptional: true, + renderer: Renderers.Integer + ), + Required = new( + "required", + isOptional: true, + renderer: Renderers.Boolean + ), + Value = new( + "value", + isOptional: true, + renderer: Renderers.String + ), + Placeholder = new( + "placeholder", + isOptional: true, + renderer: Renderers.String + ) + ]; + } + + public override string Render(ComponentState state, ComponentContext context) + => $""" + new {context.KnownTypes.TextInputBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({ + state.RenderProperties(this, context) + .PrefixIfSome(4) + .WithNewlinePadding(4) + .WrapIfSome("\n") + }) + """; +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs new file mode 100644 index 0000000000..9ebd2c838d --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.CX.Nodes.Components; + +public sealed class ThumbnailComponentNode : ComponentNode +{ + public override string Name => "thumbnail"; + + public ComponentProperty Id { get; } + public ComponentProperty Media { get; } + public ComponentProperty Description { get; } + public ComponentProperty Spoiler { get; } + + public override IReadOnlyList Properties { get; } + + public ThumbnailComponentNode() + { + Properties = + [ + Id = ComponentProperty.Id, + Media = new( + "media", + aliases: ["href", "url"], + renderer: Renderers.UnfurledMediaItem + ), + Description = new( + "description", + isOptional: true, + renderer: Renderers.String + ), + Spoiler = new( + "spoiler", + isOptional: true, + renderer: Renderers.Boolean, + dotnetParameterName: "isSpoiler" + ) + ]; + } + + public override string Render(ComponentState state, ComponentContext context) + => $""" + new {context.KnownTypes.ThumbnailBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({ + state.RenderProperties(this, context) + .PrefixIfSome(4) + .WrapIfSome("\n") + }) + """; +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs new file mode 100644 index 0000000000..ec28fbff18 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs @@ -0,0 +1,605 @@ +using Discord.CX.Parser; +using Microsoft.CodeAnalysis; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; + +namespace Discord.CX.Nodes; + +public static class Renderers +{ + public static PropertyRenderer CreateDefault(ComponentProperty property) + { + return (context, value) => + { + return string.Empty; + }; + } + + private static bool IsLoneInterpolatedLiteral( + ComponentContext context, + CXValue.StringLiteral literal, + out DesignerInterpolationInfo info) + { + if ( + literal is {HasInterpolations: true, Tokens.Count: 1} && + literal.Document.TryGetInterpolationIndex(literal.Tokens[0], out var index) + ) + { + info = context.GetInterpolationInfo(index); + return true; + } + + info = null!; + return false; + } + + public static string UnfurledMediaItem(ComponentContext context, ComponentPropertyValue propertyValue) + => $"new {context.KnownTypes.UnfurledMediaItemPropertiesType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({String(context, propertyValue)})"; + + public static string Integer(ComponentContext context, ComponentPropertyValue propertyValue) + { + switch (propertyValue.Value) + { + case CXValue.Scalar scalar: + return FromText(scalar.Value); + + case CXValue.Interpolation interpolation: + return FromInterpolation(interpolation, context.GetInterpolationInfo(interpolation)); + + case CXValue.StringLiteral literal: + if (!literal.HasInterpolations) + return FromText(literal.Tokens.ToString().Trim()); + + if (IsLoneInterpolatedLiteral(context, literal, out var info)) + return FromInterpolation(literal, info); + + return $"int.Parse({RenderStringLiteral(literal)})"; + default: return "default"; + } + + string FromInterpolation(ICXNode owner, DesignerInterpolationInfo info) + { + if (info.Constant.Value is int || int.TryParse(info.Constant.Value?.ToString(), out _)) + return info.Constant.Value!.ToString(); + + if ( + context.Compilation.HasImplicitConversion( + info.Symbol, + context.Compilation.GetSpecialType(SpecialType.System_Int32) + ) + ) + { + return context.GetDesignerValue(info, "int"); + } + + return $"int.Parse({context.GetDesignerValue(info)})"; + } + + string FromText(string text) + { + if (int.TryParse(text, out _)) return text; + + return $"int.Parse({ToCSharpString(text)})"; + } + } + + public static string Boolean(ComponentContext context, ComponentPropertyValue propertyValue) + { + switch (propertyValue.Value) + { + case CXValue.Interpolation interpolation: + return FromInterpolation(interpolation, context.GetInterpolationInfo(interpolation)); + + case CXValue.Scalar scalar: + return FromText(scalar, scalar.Value.Trim().ToLowerInvariant()); + + case CXValue.StringLiteral stringLiteral: + if (!stringLiteral.HasInterpolations) + return FromText(stringLiteral, stringLiteral.Tokens.ToString().Trim().ToLowerInvariant()); + + if (IsLoneInterpolatedLiteral(context, stringLiteral, out var info)) + return FromInterpolation(stringLiteral, info); + + + return $"bool.Parse({context.GetDesignerValue(info)})"; + default: return "default"; + } + + string FromInterpolation(ICXNode node, DesignerInterpolationInfo info) + { + if ( + context.Compilation.HasImplicitConversion( + info.Symbol, + context.Compilation.GetSpecialType(SpecialType.System_Boolean) + ) + ) + { + return context.GetDesignerValue(info, "bool"); + } + + if (info.Constant.Value is bool b) return b ? "true" : "false"; + + if (info.Constant.Value?.ToString().Trim().ToLowerInvariant() is { } str and ("true" or "false")) + return str; + + return $"bool.Parse({context.GetDesignerValue(info)})"; + } + + string FromText(ICXNode owner, string value) + { + if (value is not "true" or "false") + { + context.AddDiagnostic( + Diagnostics.TypeMismatch, + owner, + "string", + "bool" + ); + } + + return value; + } + } + + private static readonly Dictionary _colorPresets = []; + + private static bool TryGetColorPreset( + ComponentContext context, + string value, + out string fieldName) + { + var colorSymbol = context.KnownTypes.ColorType; + + if (colorSymbol is null) + { + fieldName = null!; + return false; + } + + if (_colorPresets.Count is 0) + { + foreach ( + var field + in colorSymbol.GetMembers() + .OfType() + .Where(x => + x.Type.Equals(colorSymbol, SymbolEqualityComparer.Default) && + x.IsStatic + ) + ) + { + _colorPresets[field.Name.ToLowerInvariant()] = field.Name; + } + } + + return _colorPresets.TryGetValue(value.ToLowerInvariant(), out fieldName); + } + + public static string Color(ComponentContext context, ComponentPropertyValue propertyValue) + { + var colorSymbol = context.KnownTypes.ColorType; + var qualifiedColor = colorSymbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + switch (propertyValue.Value) + { + case CXValue.Interpolation interpolation: + var info = context.GetInterpolationInfo(interpolation); + + if ( + info.Symbol is not null && + context.Compilation.HasImplicitConversion( + info.Symbol, + colorSymbol + ) + ) + { + return context.GetDesignerValue( + interpolation, + qualifiedColor + ); + } + + if ( + context.Compilation.HasImplicitConversion( + info.Symbol, + context.Compilation.GetSpecialType(SpecialType.System_UInt32) + ) + ) + { + return $"new {qualifiedColor}({context.GetDesignerValue(interpolation, "uint")})"; + } + + + if (info.Constant.Value is string str) + { + if (TryGetColorPreset(context, str, out var preset)) + return $"{qualifiedColor}.{preset}"; + + if (TryParseHexColor(str, out var hexColor)) + return $"new {qualifiedColor}({hexColor})"; + } + else if (info.Constant.HasValue && uint.TryParse(info.Constant.Value?.ToString(), out var hexColor)) + { + return $"new {qualifiedColor}({hexColor})"; + } + + return $"{qualifiedColor}.Parse({context.GetDesignerValue(interpolation)})"; + case CXValue.Scalar scalar: + return UseLibraryParser(scalar.Value); + + case CXValue.StringLiteral stringLiteral: + return UseLibraryParser(RenderStringLiteral(stringLiteral)); + default: return "default"; + } + + string UseLibraryParser(string source) + => $"{qualifiedColor}.Parse({source})"; + + static bool TryParseHexColor(string hexColor, out uint color) + { + if (string.IsNullOrWhiteSpace(hexColor)) + { + color = 0; + return false; + } + + if (hexColor[0] is '#') + hexColor = hexColor.Substring(1); + else if (hexColor.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + hexColor = hexColor.Substring(2); + + return uint.TryParse(hexColor, NumberStyles.HexNumber, null, out color); + } + } + + public static string Snowflake(ComponentContext context, ComponentPropertyValue propertyValue) + => Snowflake(context, propertyValue.Value); + + public static string Snowflake(ComponentContext context, CXValue? value) + { + switch (value) + { + case CXValue.Interpolation interpolation: + var targetType = context.Compilation.GetSpecialType(SpecialType.System_UInt64); + + var interpolationInfo = context.GetInterpolationInfo(interpolation); + + if ( + interpolationInfo.Symbol is not null && + context.Compilation.HasImplicitConversion(interpolationInfo.Symbol, targetType) + ) + { + return $"designer.GetValue({interpolation.InterpolationIndex})"; + } + + return UseParseMethod($"designer.GetValueAsString({interpolation.InterpolationIndex})"); + + case CXValue.Scalar scalar: + return FromText(scalar.Value.Trim()); + + case CXValue.StringLiteral stringLiteral: + if (!stringLiteral.HasInterpolations) + return FromText(stringLiteral.Tokens.ToString().Trim()); + + return UseParseMethod(RenderStringLiteral(stringLiteral)); + + default: return "default"; + } + + string FromText(string text) + { + if (ulong.TryParse(text, out _)) return text; + + return UseParseMethod(ToCSharpString(text)); + } + + static string UseParseMethod(string input) + => $"ulong.Parse({input})"; + } + + public static string String(ComponentContext context, ComponentPropertyValue propertyValue) + { + switch (propertyValue.Value) + { + default: return "string.Empty"; + + case CXValue.Interpolation interpolation: + if (context.GetInterpolationInfo(interpolation).Constant.Value is string constant) + return ToCSharpString(constant); + + return context.GetDesignerValue(interpolation); + case CXValue.StringLiteral literal: return RenderStringLiteral(literal); + case CXValue.Scalar scalar: + return ToCSharpString(scalar.Value.Trim()); + } + } + + private static string RenderStringLiteral(CXValue.StringLiteral literal) + { + var sb = new StringBuilder(); + + var parts = literal.Tokens + .Where(x => x.Kind is CXTokenKind.Text) + .Select(x => x.Value) + .ToArray(); + + if (parts.Length is 0) return string.Empty; + + parts[0] = parts[0].TrimStart(); + + parts[parts.Length - 1] = parts[parts.Length - 1].TrimEnd(); + + var quoteCount = parts.Select(x => x.Count(x => x is '"')).Max() + 1; + + var dollars = new string( + '$', + parts.Select(GetInterpolationDollarRequirement).Max() + + ( + literal.Tokens.Any(x => x.Kind is CXTokenKind.Interpolation) + ? 1 + : 0 + ) + ); + + var startInterpolation = dollars.Length > 0 + ? new string('{', dollars.Length) + : string.Empty; + + var endInterpolation = dollars.Length > 0 + ? new string('}', dollars.Length) + : string.Empty; + + var isMultiline = parts.Any(x => x.Contains('\n')); + + if (isMultiline) + { + sb.AppendLine(); + quoteCount = Math.Max(quoteCount, 3); + } + + var quotes = new string('"', quoteCount); + + sb.Append(dollars).Append(quotes); + + if (isMultiline) sb.AppendLine(); + + foreach (var token in literal.Tokens) + { + switch (token.Kind) + { + case CXTokenKind.Text: + sb.Append(EscapeBackslashes(token.Value)); + break; + case CXTokenKind.Interpolation: + var index = Array.IndexOf(literal.Document.InterpolationTokens, token); + + // TODO: handle better + if (index is -1) throw new InvalidOperationException(); + + sb.Append(startInterpolation).Append($"designer.GetValueAsString({index})") + .Append(endInterpolation); + break; + + default: continue; + } + } + + if (isMultiline) sb.AppendLine(); + sb.Append(quotes); + + return sb.ToString(); + + static int GetInterpolationDollarRequirement(string part) + { + var result = 0; + + var count = 0; + char? last = null; + + foreach (var ch in part) + { + if (ch is '{' or '}') + { + if (last is null) + { + last = ch; + count = 1; + continue; + } + + if (last == ch) + { + count++; + continue; + } + } + + if (count > 0) + { + result = Math.Max(result, count); + last = null; + count = 0; + } + } + + return result; + } + } + + private static string ToCSharpString(string text) + { + var quoteCount = (GetSequentialQuoteCount(text) + 1) switch + { + 2 => 3, + var r => r + }; + + var isMultiline = text.Contains('\n'); + + if (isMultiline) + quoteCount = Math.Max(3, quoteCount); + + var quotes = new string('"', quoteCount); + + var sb = new StringBuilder(); + + sb.Append(quotes); + + if (isMultiline) sb.AppendLine(); + + sb.Append(text); + + if (isMultiline) + sb.AppendLine(); + + sb.Append(quotes); + + return sb.ToString(); + } + + private static string EscapeBackslashes(string text) + => text.Replace("\\", @"\\"); + + private static int GetSequentialQuoteCount(string text) + { + var result = 0; + var count = 0; + + foreach (var ch in text) + { + if (ch is '"') + { + count++; + continue; + } + + if (count > 0) + { + result = Math.Max(result, count); + count = 0; + } + } + + return result; + } + + public static PropertyRenderer RenderEnum(string fullyQualifiedName) + { + ITypeSymbol? symbol = null; + Dictionary variants = []; + + return (context, propertyValue) => + { + if (symbol is null || variants.Count is 0) + { + symbol = context.Compilation.GetTypeByMetadataName(fullyQualifiedName); + + if (symbol is null) throw new InvalidOperationException($"Unknown type '{fullyQualifiedName}'"); + + if (symbol.TypeKind is not TypeKind.Enum) + throw new InvalidOperationException($"'{symbol}' is not an enum type."); + + variants = symbol + .GetMembers() + .OfType() + .Where(x => x.Type == symbol) + .ToDictionary(x => x.Name.ToLowerInvariant(), x => x.Name); + } + + switch (propertyValue.Value) + { + case CXValue.Scalar scalar: + return FromText(scalar.Value.Trim()); + case CXValue.Interpolation interpolation: + return FromInterpolation(interpolation, context.GetInterpolationInfo(interpolation)); + case CXValue.StringLiteral literal: + if (!literal.HasInterpolations) + return FromText(literal.Tokens.ToString().Trim().ToLowerInvariant()); + + if (IsLoneInterpolatedLiteral(context, literal, out var info)) + return FromInterpolation(literal, info); + + return + $"Enum.Parse<{symbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}>({RenderStringLiteral(literal)})"; + default: return "default"; + } + + string FromInterpolation(ICXNode owner, DesignerInterpolationInfo info) + { + if ( + context.Compilation.HasImplicitConversion( + info.Symbol, + symbol + ) + ) + { + return context.GetDesignerValue( + info, + symbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + ); + } + + if (info.Constant.Value?.ToString() is {} str) + { + return FromText(str.Trim().ToLowerInvariant()); + } + + return $"Enum.Parse<{symbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}>({context.GetDesignerValue(info)})"; + } + + string FromText(string text) + { + if (variants.TryGetValue(text, out var name)) + return $"{symbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.{name}"; + + return + $"Enum.Parse<{symbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}>({ToCSharpString(text)})"; + } + }; + } + + public static string Emoji(ComponentContext context, ComponentPropertyValue propertyValue) + { + switch (propertyValue.Value) + { + case CXValue.Interpolation interpolation: + var interpolationInfo = context.GetInterpolationInfo(interpolation); + + if ( + interpolationInfo.Symbol is not null && + context.Compilation.HasImplicitConversion( + interpolationInfo.Symbol, + context.KnownTypes.IEmoteType + ) + ) + { + return context.GetDesignerValue( + interpolation, + $"{context.KnownTypes.IEmoteType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}" + ); + } + + return UseLibraryParser( + context.GetDesignerValue(interpolation) + ); + + case CXValue.Scalar scalar: + return UseLibraryParser(ToCSharpString(scalar.Value)); + + case CXValue.StringLiteral stringLiteral: + return UseLibraryParser(RenderStringLiteral(stringLiteral)); + + default: return "null"; + } + + static string UseLibraryParser(string source) + => $""" + global::Discord.Emoji.TryPase({source}, out var emoji) + ? (global::Discord.IEmote)emoji + : global::Discord.Emote.Parse({source}) + """; + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.cs new file mode 100644 index 0000000000..0c6a5dcb50 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Validators/Validators.cs @@ -0,0 +1,186 @@ +using Discord.CX.Parser; +using Microsoft.CodeAnalysis; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; + +namespace Discord.CX.Nodes; + +public static class Validators +{ + + public static void Snowflake(ComponentContext context, ComponentPropertyValue propertyValue) + { + switch (propertyValue.Value) + { + case null or CXValue.Invalid: return; + + case CXValue.Scalar scalar: + if (!ulong.TryParse(scalar.Value, out _)) + { + context.AddDiagnostic( + Diagnostics.TypeMismatch, + scalar, + scalar.Value, + "Snowflake" + ); + } + + return; + case CXValue.Interpolation interpolation: + var symbol = context.GetInterpolationInfo(interpolation).Symbol; + + if ( + symbol?.SpecialType is not SpecialType.System_UInt64 + ) + { + context.AddDiagnostic( + Diagnostics.TypeMismatch, + interpolation, + symbol, + "Snowflake" + ); + } + + return; + } + } + + public static void Emote(ComponentContext context, ComponentPropertyValue propertyValue) + { + switch (propertyValue.Value) + { + case null or CXValue.Invalid: return; + + case CXValue.Scalar scalar: + + return; + } + } + + public static PropertyValidator Range(int? lower = null, int? upper = null) + { + Debug.Assert(lower.HasValue || upper.HasValue); + + var bounds = (lower, upper) switch + { + (not null, null) => $"at least {lower}", + (null, not null) => $"at most {lower}", + (not null, not null) => $"between {lower} and {upper}", + _ => string.Empty + }; + + return (context, propertyValue) => + { + switch (propertyValue.Value) + { + case null or CXValue.Invalid: return; + case CXValue.Interpolation interpolation: + if (context.GetInterpolationInfo(interpolation).Constant.Value is string constantValue) + Check(constantValue.Length); + break; + + case CXValue.StringLiteral literal: + int? length = null; + + foreach (var token in literal.Tokens) + { + switch (token.Kind) + { + case CXTokenKind.Text: + length += token.Span.Length; + break; + case CXTokenKind.Interpolation + when literal.Document.TryGetInterpolationIndex(token, out var index): + var info = context.GetInterpolationInfo(index); + if (info.Constant.Value is string str) + length += str.Length; + break; + } + } + + if (length.HasValue) Check(length.Value); + + break; + case CXValue.Scalar scalar: + Check(scalar.Value.Length); + + return; + } + + void Check(int length) + { + if ( + length > upper || length < lower + ) + { + context.AddDiagnostic(Diagnostics.OutOfRange, propertyValue.Value, propertyValue.Property.Name, + bounds); + } + } + }; + } + + public static PropertyValidator EnumVariant(string fullyQualifiedName) + { + ITypeSymbol? symbol = null; + IFieldSymbol[]? variants = null; + + return (context, propertyValue) => + { + if (symbol is null || variants is null) + { + symbol = context.Compilation.GetTypeByMetadataName(fullyQualifiedName); + + if (symbol is null) throw new InvalidOperationException($"Unknown type '{fullyQualifiedName}'"); + + if (symbol.TypeKind is not TypeKind.Enum) + throw new InvalidOperationException($"'{symbol}' is not an enum type."); + + variants = symbol + .GetMembers() + .OfType() + .ToArray(); + } + + switch (propertyValue.Value) + { + case null or CXValue.Invalid: return; + + case CXValue.Scalar scalar: + if (variants.All(x => + !string.Equals(x.Name, scalar.Value, StringComparison.InvariantCultureIgnoreCase))) + { + context.AddDiagnostic( + Diagnostics.InvalidEnumVariant, + scalar, + scalar.Value, + string.Join(", ", variants.Select(x => x.Name)) + ); + } + + return; + case CXValue.Interpolation interpolation: + // verify the value is the correct type + var interpolationInfo = context.GetInterpolationInfo(interpolation); + + if ( + interpolationInfo.Symbol is not null && + !symbol.Equals(interpolationInfo.Symbol, SymbolEqualityComparer.Default) + ) + { + context.AddDiagnostic( + Diagnostics.TypeMismatch, + interpolation, + interpolationInfo.Symbol, + symbol + ); + } + + return; + } + }; + } +} diff --git a/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs b/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs new file mode 100644 index 0000000000..9feadc8568 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/SourceGenerator.cs @@ -0,0 +1,366 @@ +using Discord.CX.Nodes; +using Discord.CX.Parser; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.CodeAnalysis.Text; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading; + +namespace Discord.CX; + +public sealed record Target( + InterceptableLocation InterceptLocation, + InvocationExpressionSyntax InvocationSyntax, + ExpressionSyntax ArgumentExpressionSyntax, + IOperation Operation, + Compilation Compilation, + string? ParentKey, + string CXDesigner, + TextSpan CXDesignerSpan, + DesignerInterpolationInfo[] Interpolations, + int CXQuoteCount +) +{ + public SyntaxTree SyntaxTree => InvocationSyntax.SyntaxTree; +} + +public sealed record DesignerInterpolationInfo( + int Id, + TextSpan Span, + ITypeSymbol? Symbol, + Optional Constant +); + +[Generator] +public sealed class SourceGenerator : IIncrementalGenerator +{ + private readonly Dictionary _cache = []; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var provider = context + .SyntaxProvider + .CreateSyntaxProvider( + IsComponentDesignerCall, + MapPossibleComponentDesignerCall + ) + .Collect(); + + context.RegisterSourceOutput( + provider + .Combine(provider.Select(GetKeysAndUpdateCachedEntries)) + .SelectMany(MapManagers) + .Select((x, _) => x.Render()) + .Collect(), + Generate + ); + } + + private void Generate(SourceProductionContext context, ImmutableArray interceptors) + { + if (interceptors.Length is 0) return; + + var sb = new StringBuilder(); + + foreach (var interceptor in interceptors) + { + foreach (var diagnostic in interceptor.Diagnostics) + { + context.ReportDiagnostic(diagnostic); + } + + 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)}} + ); + """ + ); + } + + 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().WithNewlinePadding(8)}} + } + } + """ + ); + } + + 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 = _cache[key] = manager.OnUpdate(key, target, token); + } + else + { + manager = _cache[key] = CXGraphManager.Create( + this, + key, + target, + token + ); + } + + 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 Target? MapPossibleComponentDesignerCall(GeneratorSyntaxContext context, CancellationToken token) + => MapPossibleComponentDesignerCall(context.SemanticModel, context.Node, token); + + public static Target? MapPossibleComponentDesignerCall( + SemanticModel semanticModel, + SyntaxNode node, + CancellationToken token + ) + { + if ( + !TryGetValidDesignerCall( + out var operation, + out var invocationSyntax, + out var interceptLocation, + out var argumentSyntax + ) + ) return null; + + if ( + !TryGetCXDesigner( + argumentSyntax, + semanticModel, + out var cxDesigner, + out var span, + out var interpolationInfos, + out var quoteCount, + token + ) + ) return null; + + + return new Target( + interceptLocation, + invocationSyntax, + argumentSyntax, + operation, + semanticModel.Compilation, + semanticModel + .GetEnclosingSymbol(invocationSyntax.SpanStart, token) + ?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + cxDesigner, + span, + interpolationInfos, + quoteCount + ); + + static bool TryGetCXDesigner( + ExpressionSyntax expression, + SemanticModel semanticModel, + out string content, + out TextSpan span, + out DesignerInterpolationInfo[] interpolations, + out int quoteCount, + CancellationToken token + ) + { + switch (expression) + { + case LiteralExpressionSyntax {Token.Text: { } literalContent} literal: + content = PrepareRawLiteral( + literalContent, + out var startQuoteCount, + out var endQuoteCount + ); + + quoteCount = startQuoteCount; + interpolations = []; + span = TextSpan.FromBounds( + literal.Token.Span.Start + startQuoteCount, + literal.Token.Span.End - endQuoteCount + ); + return true; + + case InterpolatedStringExpressionSyntax interpolated: + content = interpolated.Contents.ToString(); + interpolations = interpolated.Contents + .OfType() + .Select((x, i) => new DesignerInterpolationInfo( + i, + x.FullSpan, + 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, + out InterceptableLocation interceptLocation, + out ExpressionSyntax argumentExpressionSyntax + ) + { + operation = semanticModel.GetOperation(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 (node is not InvocationExpressionSyntax syntax) return false; + + invocationSyntax = syntax; + + if (semanticModel.GetInterceptableLocation(invocationSyntax, token) 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 new file mode 100644 index 0000000000..533cb3384a --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Utils/IsExternalInit.cs @@ -0,0 +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.ComponentDesigner.Generator/Utils/KnownTypes.cs b/src/Discord.Net.ComponentDesigner.Generator/Utils/KnownTypes.cs new file mode 100644 index 0000000000..4932938937 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Utils/KnownTypes.cs @@ -0,0 +1,529 @@ +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.CX; + +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? ColorType + => GetOrResolveType("Discord.Color", ref _ColorType); + + private Optional _ColorType; + + 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); + + 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..a3d56ccc08 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Utils/StringUtils.cs @@ -0,0 +1,31 @@ +using System; + +namespace Discord.CX; + +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)); + + 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); +} 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.Parser/CXDiagnostic.cs b/src/Discord.Net.ComponentDesigner.Parser/CXDiagnostic.cs new file mode 100644 index 0000000000..cd74c52229 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/CXDiagnostic.cs @@ -0,0 +1,10 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Discord.CX.Parser; + +public readonly record struct CXDiagnostic( + DiagnosticSeverity Severity, + string Message, + TextSpan Span +); diff --git a/src/Discord.Net.ComponentDesigner.Parser/CXParser.cs b/src/Discord.Net.ComponentDesigner.Parser/CXParser.cs new file mode 100644 index 0000000000..ff739da3f3 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/CXParser.cs @@ -0,0 +1,523 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; + +namespace Discord.CX.Parser; + +public sealed class CXParser +{ + public CXToken CurrentToken => Lex(_tokenIndex); + + public ICXNode? CurrentNode + => (_currentBlendedNode ??= GetCurrentBlendedNode())?.Value; + + public CXLexer Lexer { get; } + + private readonly List _tokens; + private int _tokenIndex; + + public IReadOnlyList BlendedNodes => + [ + .._blendedNodes + .Select(x => x.Value) + .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; } + + public CXSource Source { get; } + + public CancellationToken CancellationToken { get; } + + + private BlendedNode? _currentBlendedNode; + + public CXParser(CXSource source, CancellationToken token = default) + { + CancellationToken = token; + Source = source; + Reader = new CXSourceReader(source); + 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; + Lexer.Reset(); + + _tokens.Clear(); + _blendedNodes.Clear(); + _tokenIndex = 0; + + _currentBlendedNode = null; + } + + public static CXDoc Parse(CXSource source, CancellationToken token = default) + { + var elements = new List(); + + var parser = new CXParser(source, token: token); + + while (parser.CurrentToken.Kind is not CXTokenKind.EOF and not CXTokenKind.Invalid) + { + var element = parser.ParseElement(); + elements.Add(element); + token.ThrowIfCancellationRequested(); + + if (element.Width is 0) break; + } + + return new CXDoc(parser, elements); + } + + internal CXElement ParseElement() + { + if (IsIncremental && CurrentNode is CXElement element) + { + EatNode(); + return element; + } + + using var _ = Lexer.SetMode(CXLexer.LexMode.Default); + + var diagnostics = new List(); + + var start = Expect(CXTokenKind.LessThan); + + var identifier = ParseIdentifier(); + + var attributes = ParseAttributes(); + + switch (CurrentToken.Kind) + { + case CXTokenKind.GreaterThan: + var end = Eat(); + // parse children + var children = ParseElementChildren(); + + ParseClosingElement( + out var endStart, + out var endIdent, + out var endClose + ); + + return new CXElement( + start, + identifier, + attributes, + end, + children, + endStart, + endIdent, + endClose + ) {Diagnostics = diagnostics}; + default: + case CXTokenKind.ForwardSlashGreaterThan: + return new CXElement( + start, + identifier, + attributes, + Expect(CXTokenKind.ForwardSlashGreaterThan), + new() + ); + } + + void ParseClosingElement( + out CXToken elementEndStart, + out CXToken elementEndIdent, + out CXToken elementEndClose) + { + var sentinel = _tokenIndex; + + elementEndStart = Expect(CXTokenKind.LessThanForwardSlash); + elementEndIdent = ParseIdentifier(); + elementEndClose = Expect(CXTokenKind.GreaterThan); + + if (elementEndIdent.Value != identifier.Value) + { + diagnostics.Add(CreateError("Missing closing tag", identifier.Span)); + // rollback + _tokenIndex = sentinel; + } + } + + CXCollection ParseElementChildren() + { + if (IsIncremental && CurrentNode is CXCollection incrementalChildren) + { + EatNode(); + return incrementalChildren; + } + + // valid children are: + // - other elements + // - interpolations + // - text + var children = new List(); + var diagnostics = new List(); + + using (Lexer.SetMode(CXLexer.LexMode.ElementValue)) + { + while (TryParseElementChild(diagnostics, out var child)) + children.Add(child); + + CancellationToken.ThrowIfCancellationRequested(); + } + + return new CXCollection(children) {Diagnostics = diagnostics}; + } + + bool TryParseElementChild(List diagnostics, out CXNode node) + { + if (IsIncremental && CurrentNode is CXValue or CXElement) + { + node = EatNode()!; + return true; + } + + switch (CurrentToken.Kind) + { + 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 CXCollection ParseAttributes() + { + if (IsIncremental && CurrentNode is CXCollection incrementalNode) + { + EatNode(); + return incrementalNode; + } + + var attributes = new List(); + + using (Lexer.SetMode(CXLexer.LexMode.Identifier)) + { + while (CurrentToken.Kind is CXTokenKind.Identifier) + attributes.Add(ParseAttribute()); + + CancellationToken.ThrowIfCancellationRequested(); + } + + return new CXCollection(attributes); + } + + internal CXAttribute ParseAttribute() + { + if (IsIncremental && CurrentNode is CXAttribute attribute) + { + EatNode(); + return attribute; + } + + using (Lexer.SetMode(CXLexer.LexMode.Attribute)) + { + 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 + ); + } + } + + internal CXValue ParseAttributeValue() + { + if (IsIncremental && CurrentNode is CXValue value) + { + EatNode(); + return value; + } + + switch (CurrentToken.Kind) + { + case CXTokenKind.Interpolation: + return new CXValue.Interpolation( + Eat(), + Lexer.InterpolationIndex!.Value + ); + case CXTokenKind.StringLiteralStart: + return ParseStringLiteral(); + default: + return new CXValue.Invalid() + { + Diagnostics = + [ + new CXDiagnostic( + DiagnosticSeverity.Error, + $"Unexpected attribute valid start, expected interpolation or string literal, got '{CurrentToken.Kind}'", + CurrentToken.Span + ) + ] + }; + } + } + + internal CXValue ParseStringLiteral() + { + if (IsIncremental && CurrentNode is CXValue value) + { + EatNode(); + return value; + } + + var diagnostics = new List(); + + var tokens = new List(); + + var quoteToken = CurrentToken.Kind; + + var start = Expect(CXTokenKind.StringLiteralStart); + + using var _ = Lexer.SetMode(CXLexer.LexMode.StringLiteral); + + // 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: + case CXTokenKind.Interpolation: + tokens.Add(Eat()); + continue; + + case CXTokenKind.Invalid or CXTokenKind.EOF: goto end; + + 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, + new CXCollection(tokens), + end + ) {Diagnostics = diagnostics}; + } + + internal CXToken ParseIdentifier() + { + using (Lexer.SetMode(CXLexer.LexMode.Identifier)) + { + return Expect(CXTokenKind.Identifier); + } + } + + internal CXToken Eat() + { + var token = CurrentToken; + _tokenIndex++; + return token; + } + + internal bool Eat(CXTokenKind kind, out CXToken token) + { + token = CurrentToken; + + if (token.Kind == kind) + { + _tokenIndex++; + return true; + } + + 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(); + } + + return new CXToken( + kinds[0], + new TextSpan(current.Span.Start, 0), + 0, + 0, + Flags: CXTokenFlags.Missing, + FullValue: string.Empty, + CreateError( + $"Unexpected token, expected one of '{string.Join(", ", kinds.ToArray())}', got '{current.Kind}'", + current.Span + ) + ); + } + } + + internal CXToken Expect(CXTokenKind kind) + { + var token = CurrentToken; + + if (token.Kind != kind) + { + return new CXToken( + kind, + new TextSpan(token.Span.Start, 0), + 0, + 0, + Flags: CXTokenFlags.Missing, + FullValue: string.Empty, + CreateError($"Unexpected token, expected '{kind}', got '{token.Kind}'", token.Span) + ); + } + + _tokenIndex++; + return token; + } + + private BlendedNode? GetCurrentBlendedNode() + => Blender?.NextNode( + _tokenIndex is 0 ? Blender.StartingCursor : _blendedNodes[_tokenIndex - 1].Cursor + ); + + private CXNode? EatNode() + { + if (_currentBlendedNode?.Value is not CXNode node) return null; + + _blendedNodes.Add(_currentBlendedNode!.Value); + + _tokenIndex += 2; // add two since we want to cause a re-lex of the blender + + _currentBlendedNode = null; + + node.ResetCachedState(); + return node; + } + + internal CXToken Lex(int index) + { + if (Blender is not null) return FetchBlended(); + + while (_tokens.Count <= index) + { + CancellationToken.ThrowIfCancellationRequested(); + + var token = Lexer.Next(); + + _tokens.Add(token); + + if (token.Kind is CXTokenKind.EOF) return token; + } + + return _tokens[index]; + + CXToken FetchBlended() + { + while (_blendedNodes.Count <= index) + { + CancellationToken.ThrowIfCancellationRequested(); + + var cursor = _blendedNodes.Count is 0 + ? Blender.StartingCursor + : _blendedNodes[_blendedNodes.Count - 1].Cursor; + + var node = Blender.NextToken(cursor); + + _blendedNodes.Add(node); + _currentBlendedNode = null; + + if (node.Value is CXToken {Kind: CXTokenKind.EOF} eof) return eof; + } + + 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.Parser/CXSource.cs b/src/Discord.Net.ComponentDesigner.Parser/CXSource.cs new file mode 100644 index 0000000000..1607f81647 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/CXSource.cs @@ -0,0 +1,46 @@ +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; + +namespace Discord.CX.Parser; + +public sealed class CXSource +{ + public string Value { get; } + public int WrappingQuoteCount { 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, + int wrappingQuoteCount + ) + { + SourceSpan = sourceSpan; + Value = content; + WrappingQuoteCount = wrappingQuoteCount; + 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.Parser/CXSourceReader.cs b/src/Discord.Net.ComponentDesigner.Parser/CXSourceReader.cs new file mode 100644 index 0000000000..5458416a01 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/CXSourceReader.cs @@ -0,0 +1,47 @@ +using Microsoft.CodeAnalysis.Text; +using System; + +namespace Discord.CX.Parser; + +public sealed class CXSourceReader +{ + public char this[int index] + => Source.SourceSpan.Contains(index) + ? Source[index] + : CXLexer.NULL_CHAR; + + public bool IsEOF => Position >= Source.SourceSpan.End; + + 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; set; } + + public CXSourceReader(CXSource source) + { + Source = source; + Position = source.SourceSpan.Start; + } + + public void Advance(int count = 1) + { + for (var i = 0; i < count; i++) + { + 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.Parser/CXTreeWalker.cs b/src/Discord.Net.ComponentDesigner.Parser/CXTreeWalker.cs new file mode 100644 index 0000000000..3add89fa37 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/CXTreeWalker.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; + +namespace Discord.CX.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.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.Parser/ICXNode.cs b/src/Discord.Net.ComponentDesigner.Parser/ICXNode.cs new file mode 100644 index 0000000000..5a98c51b19 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/ICXNode.cs @@ -0,0 +1,26 @@ +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; + +namespace Discord.CX.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(); + + string ToString(bool includeLeadingTrivia, bool includeTrailingTrivia); +} diff --git a/src/Discord.Net.ComponentDesigner.Parser/Incremental/BlendedNode.cs b/src/Discord.Net.ComponentDesigner.Parser/Incremental/BlendedNode.cs new file mode 100644 index 0000000000..3688399b26 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/Incremental/BlendedNode.cs @@ -0,0 +1,6 @@ +namespace Discord.CX.Parser; + +public readonly record struct BlendedNode( + ICXNode Value, + CXBlender.Cursor Cursor +); diff --git a/src/Discord.Net.ComponentDesigner.Parser/Incremental/CXBlender.cs b/src/Discord.Net.ComponentDesigner.Parser/Incremental/CXBlender.cs new file mode 100644 index 0000000000..f99b727cb7 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/Incremental/CXBlender.cs @@ -0,0 +1,331 @@ +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; + +namespace Discord.CX.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; + + public CancellationToken CancellationToken => _lexer.CancellationToken; + + 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++; + CancellationToken.ThrowIfCancellationRequested(); + } + + cursor = cursor with {Index = index}; + } + + private void MoveToNextSibling(ref Cursor cursor) + { + while (this[cursor]?.Parent is not null) + { + CancellationToken.ThrowIfCancellationRequested(); + + 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 + ) + { + CancellationToken.ThrowIfCancellationRequested(); + + 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 + ) + { + CancellationToken.ThrowIfCancellationRequested(); + + 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) + { + CancellationToken.ThrowIfCancellationRequested(); + + 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); + + if (current is CXToken token) + current = token.WithNewPosition(cursor.NewPosition); + + 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, cursor)) return false; + + if (node.HasErrors) return false; + + return true; + } + + 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) + { + 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.Seek(cursor.NewPosition); + return _lexer.Next(); + } +} diff --git a/src/Discord.Net.ComponentDesigner.Parser/Incremental/IncrementalParseContext.cs b/src/Discord.Net.ComponentDesigner.Parser/Incremental/IncrementalParseContext.cs new file mode 100644 index 0000000000..34464c10d0 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/Incremental/IncrementalParseContext.cs @@ -0,0 +1,9 @@ +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; + +namespace Discord.CX.Parser; + +public readonly record struct IncrementalParseContext( + IReadOnlyList Changes, + TextChangeRange AffectedRange +); diff --git a/src/Discord.Net.ComponentDesigner.Parser/Incremental/IncrementalParseResult.cs b/src/Discord.Net.ComponentDesigner.Parser/Incremental/IncrementalParseResult.cs new file mode 100644 index 0000000000..e226655a31 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/Incremental/IncrementalParseResult.cs @@ -0,0 +1,11 @@ +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; + +namespace Discord.CX.Parser; + +public readonly record struct IncrementalParseResult( + IReadOnlyList ReusedNodes, + IReadOnlyList NewNodes, + IReadOnlyList Changes, + TextChangeRange AppliedRange +); diff --git a/src/Discord.Net.ComponentDesigner.Parser/Lexer/CXLexer.cs b/src/Discord.Net.ComponentDesigner.Parser/Lexer/CXLexer.cs new file mode 100644 index 0000000000..d44b5b7a37 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/Lexer/CXLexer.cs @@ -0,0 +1,480 @@ +using Microsoft.CodeAnalysis.Text; +using System; +using System.Threading; + +namespace Discord.CX.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 + } + + 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 + { + // 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; + + // 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 + { + // there's no next interpolation + if (Reader.Source.Interpolations.Length <= _nextInterpolationIndex) return null; + + // check if it's ahead of us + 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.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; + + public char? QuoteChar; + + private int _nextInterpolationIndex; + private int _interpolationIndex; + + 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]; + } + + public readonly struct ModeSentinel(CXLexer? lexer) : IDisposable + { + private readonly LexMode _mode = lexer?.Mode ?? LexMode.Default; + + public void Dispose() + { + if (lexer is null) return; + + 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; + + var info = default(TokenInfo); + + info.Start = Reader.Position; + + GetTrivia(isTrailing: false, ref info.LeadingTriviaLength); + + Scan(ref info); + + GetTrivia(isTrailing: true, ref info.TrailingTriviaLength); + + info.End = Reader.Position; + + var fullSpan = TextSpan.FromBounds(info.Start, info.End); + + var token = new CXToken( + info.Kind, + fullSpan, + info.LeadingTriviaLength, + info.TrailingTriviaLength, + info.Flags, + fullSpan.IsEmpty ? string.Empty : Reader.Source.GetValue(fullSpan) + ); + + if (info.Kind is CXTokenKind.Interpolation && InterpolationIndex.HasValue) + InterpolationMap[InterpolationIndex.Value] = token; + + return token; + } + + 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 = InterpolationBoundary; + + var start = Reader.Position; + + for (; Reader.Position < interpolationUpperBounds; Reader.Advance()) + { + CancellationToken.ThrowIfCancellationRequested(); + + 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 (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 = InterpolationBoundary; + + if (Reader.Position >= interpolationUpperBounds) + { + if (!TryScanInterpolation(ref info)) + { + // TODO: handle + } + + return; + } + + if ( + ForcedEscapedQuotes + ? Reader.Current is BACK_SLASH_CHAR && Reader.Next == QuoteChar + : Reader.Current == QuoteChar + ) + { + Reader.Advance(ForcedEscapedQuotes ? 2 : 1); + + info.Kind = CXTokenKind.StringLiteralEnd; + QuoteChar = null; + + return; + } + + for (; Reader.Position < interpolationUpperBounds; Reader.Advance()) + { + CancellationToken.ThrowIfCancellationRequested(); + + if (Reader.Current is BACK_SLASH_CHAR) + { + // escaped backslash, advance thru the current and next character + if (Reader.Next is BACK_SLASH_CHAR && ForcedEscapedQuotes) + { + Reader.Advance(); + continue; + } + + // 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; + + 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 = quoteTestChar; + Reader.Advance(isEscaped ? 2 : 1); + info.Kind = CXTokenKind.StringLiteralStart; + return true; + } + + private bool TryScanIdentifier(ref TokenInfo info) + { + var upperBounds = InterpolationBoundary; + + if (!IsValidIdentifierStartChar(Reader.Current) || Reader.Position >= upperBounds) + return false; + + do + { + Reader.Advance(); + } while (IsValidIdentifierChar(Reader.Current) && Reader.Position < upperBounds && + !CancellationToken.IsCancellationRequested); + + CancellationToken.ThrowIfCancellationRequested(); + 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 = _interpolationIndex; + + return true; + } + + return false; + } + + private void GetTrivia(bool isTrailing, ref int trivia) + { + if (Mode is LexMode.StringLiteral) return; + + for (;; trivia++, Reader.Advance()) + { + start: + + CancellationToken.ThrowIfCancellationRequested(); + + 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; + } + + 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.Parser/Lexer/CXToken.cs b/src/Discord.Net.ComponentDesigner.Parser/Lexer/CXToken.cs new file mode 100644 index 0000000000..c22b1d279e --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/Lexer/CXToken.cs @@ -0,0 +1,103 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.CX.Parser; + +public sealed record CXToken( + CXTokenKind Kind, + TextSpan FullSpan, + int LeadingTriviaLength, + int TrailingTriviaLength, + CXTokenFlags Flags, + 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 + => _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 Width => FullSpan.Length; + + int ICXNode.GraphWidth => 0; + IReadOnlyList ICXNode.Slots => []; + + private bool? _hasErrors; + + 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; + + 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.Parser/Lexer/CXTokenFlags.cs b/src/Discord.Net.ComponentDesigner.Parser/Lexer/CXTokenFlags.cs new file mode 100644 index 0000000000..cec05a7701 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/Lexer/CXTokenFlags.cs @@ -0,0 +1,10 @@ +using System; + +namespace Discord.CX.Parser; + +[Flags] +public enum CXTokenFlags : byte +{ + None = 0, + Missing = 1 << 0 +} diff --git a/src/Discord.Net.ComponentDesigner.Parser/Lexer/CXTokenKind.cs b/src/Discord.Net.ComponentDesigner.Parser/Lexer/CXTokenKind.cs new file mode 100644 index 0000000000..d42bb8cb52 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/Lexer/CXTokenKind.cs @@ -0,0 +1,21 @@ +namespace Discord.CX.Parser; + +public enum CXTokenKind : byte +{ + Invalid, + EOF, + + LessThan, + GreaterThan, + ForwardSlashGreaterThan, + LessThanForwardSlash, + Equals, + + Text, + Interpolation, + + StringLiteralStart, + StringLiteralEnd, + + Identifier, +} diff --git a/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXAttribute.cs b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXAttribute.cs new file mode 100644 index 0000000000..6c99648aac --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXAttribute.cs @@ -0,0 +1,23 @@ +using Microsoft.CodeAnalysis.Text; + +namespace Discord.CX.Parser; + +public sealed class CXAttribute : CXNode +{ + public CXToken Identifier { get; private set; } + + 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); + } +} diff --git a/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXCollection.cs b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXCollection.cs new file mode 100644 index 0000000000..9c4c6660b0 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXCollection.cs @@ -0,0 +1,36 @@ +using Microsoft.CodeAnalysis.Text; +using System.Collections; +using System.Collections.Generic; + +namespace Discord.CX.Parser; + +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]; + + public int Count => _items.Count; + + private readonly List _items; + + 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.Parser/Nodes/CXDoc.cs b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXDoc.cs new file mode 100644 index 0000000000..1f1d10fe86 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXDoc.cs @@ -0,0 +1,136 @@ +using Microsoft.CodeAnalysis.Text; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; + +namespace Discord.CX.Parser; + +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; } + + public readonly CXToken[] InterpolationTokens; + + public CXDoc( + CXParser parser, + IReadOnlyList rootElements + ) + { + Parser = parser; + Tokens = parser.Tokens; + Slot(RootElements = rootElements); + 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 CXDoc IncrementalParse( + CXSource source, + IReadOnlyList changes, + out IncrementalParseResult result, + CancellationToken token = default + ) + { + var affectedRange = TextChangeRange.Collapse(changes.Select(x => (TextChangeRange)x)); + + var parser = new CXParser(source, this, affectedRange, token); + + var context = new IncrementalParseContext(changes, affectedRange); + + var children = new List(); + + while (parser.CurrentToken.Kind is not CXTokenKind.EOF and not CXTokenKind.Invalid) + { + var element = parser.ParseElement(); + + children.Add(element); + + if (element.Width is 0) break; + } + + 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); + } + + result = new( + reusedNodes, + [..GetFlatGraph().Except(Parser.BlendedNodes)], + changes, + affectedRange + ); + + return new CXDoc(parser, 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.Parser/Nodes/CXElement.cs b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXElement.cs new file mode 100644 index 0000000000..98b9ecac60 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXElement.cs @@ -0,0 +1,43 @@ +using Microsoft.CodeAnalysis.Text; +using System; +using System.Collections.Generic; + +namespace Discord.CX.Parser; + +public sealed class CXElement : CXNode +{ + public string Identifier => ElementStartNameToken.Value; + + public CXToken ElementStartOpenToken { get; } + public CXToken ElementStartNameToken { get; } + public CXCollection Attributes { get; } + + public CXToken ElementStartCloseToken { get; } + + public CXCollection Children { get; } + + public CXToken? ElementEndOpenToken { get; } + public CXToken? ElementEndNameToken { get; } + public CXToken? ElementEndCloseToken { get; } + + public CXElement( + CXToken elementStartOpenToken, + CXToken elementStartNameToken, + CXCollection attributes, + CXToken elementStartCloseToken, + CXCollection children, + 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.Parser/Nodes/CXNode.ParseSlot.cs b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXNode.ParseSlot.cs new file mode 100644 index 0000000000..763120f808 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXNode.ParseSlot.cs @@ -0,0 +1,36 @@ +using Microsoft.CodeAnalysis.Text; +using System; + +namespace Discord.CX.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.Parser/Nodes/CXNode.cs b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXNode.cs new file mode 100644 index 0000000000..dda90ae2d4 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXNode.cs @@ -0,0 +1,395 @@ +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.CX.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)) + ]; + init + { + _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 => TryGetDocument(out var doc) ? doc : throw new InvalidOperationException(); + } + + public virtual CXParser Parser => Document.Parser; + + public CXToken? FirstTerminal + { + get + { + 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 + { + 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 + => 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 CXToken? _firstTerminal; + private CXToken? _lastTerminal; + private CXDoc? _doc; + private int? _graphWidth; + private IReadOnlyList? _descendants; + + public CXNode() + { + _diagnostics = []; + _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)) + { + 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; + } + + 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 TryGetDocument(out var doc) ? doc.Parser.Source.SourceSpan.Start : 0; + + 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.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) + { + 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 : class, 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 void ResetCachedState() + { + _offset = null; + _firstTerminal = null; + _lastTerminal = null; + _doc = null; + _graphWidth = null; + + // reset any descendants + foreach (var descendant in Descendants.OfType()) + descendant.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.Parser/Nodes/CXValue.cs b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXValue.cs new file mode 100644 index 0000000000..c099d91f32 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXValue.cs @@ -0,0 +1,52 @@ +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; +using System.Linq; + +namespace Discord.CX.Parser; + +public abstract class CXValue : CXNode +{ + 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 CXCollection Tokens { get; } + public CXToken EndToken { get; } + + public StringLiteral( + CXToken start, + CXCollection 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.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.ComponentDesigner/ComponentDesigner.cs b/src/Discord.Net.ComponentDesigner/ComponentDesigner.cs new file mode 100644 index 0000000000..c634a5a566 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner/ComponentDesigner.cs @@ -0,0 +1,29 @@ +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 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(); +} diff --git a/src/Discord.Net.ComponentDesigner/DesignerInterpolationHandler.cs b/src/Discord.Net.ComponentDesigner/DesignerInterpolationHandler.cs new file mode 100644 index 0000000000..91840daf47 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner/DesignerInterpolationHandler.cs @@ -0,0 +1,30 @@ +using System.Runtime.CompilerServices; + +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(); +} 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..2f89e1ab6d --- /dev/null +++ b/src/Discord.Net.ComponentDesigner/Discord.Net.ComponentDesigner.csproj @@ -0,0 +1,15 @@ + + + + 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 diff --git a/src/Discord.Net.Core/Discord.Net.Core.csproj b/src/Discord.Net.Core/Discord.Net.Core.csproj index dcfe178a22..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 @@ -25,4 +26,4 @@ - \ No newline at end of file +