From 2bae1f5371e6584ede7da46b36a395b11c09ac13 Mon Sep 17 00:00:00 2001 From: Quin Lynch <49576606+quinchs@users.noreply.github.com> Date: Sat, 6 Dec 2025 20:19:44 -0400 Subject: [PATCH 1/2] add per node rendering options --- ...Net.ComponentDesigner.sln.DotSettings.user | 1 + .../Diagnostics.cs | 9 +- .../Graph/CXGraph.cs | 4 +- .../Nodes/ComponentContext.cs | 7 +- .../Nodes/ComponentNode.cs | 36 +- .../Nodes/ComponentRenderingOptions.cs | 21 ++ .../Nodes/ComponentState.cs | 7 +- .../Components/ActionRowComponentNode.cs | 45 +-- .../Nodes/Components/ButtonComponentNode.cs | 3 +- .../Nodes/Components/ComponentBuilderKind.cs | 265 ++++++++++++++ .../Components/ContainerComponentNode.cs | 11 +- .../Custom/ComponentChildrenAdapter.cs | 42 +-- .../Custom/FunctionalComponentNode.cs | 113 ++++-- .../Custom/ProviderComponentNode.cs | 6 +- .../Nodes/Components/FileComponentNode.cs | 2 +- .../Components/FileUploadComponentNode.cs | 2 +- .../Components/InterleavedComponentNode.cs | 324 +++--------------- .../Nodes/Components/LabelComponentNode.cs | 18 +- .../Components/MediaGalleryComponentNode.cs | 4 +- .../Nodes/Components/SectionComponentNode.cs | 20 +- .../SelectMenus/SelectMenuComponentNode.cs | 8 +- .../SelectMenuInterpolatedOption.cs | 8 +- .../SelectMenuOptionComponentNode.cs | 2 +- .../Components/SeparatorComponentNode.cs | 2 +- .../Components/TextDisplayComponentNode.cs | 2 +- .../Components/TextInputComponentNode.cs | 2 +- .../Components/ThumbnailComponentNode.cs | 2 +- .../Nodes/IComponentContext.cs | 2 + .../Nodes/Renderers/Renderers.cs | 56 +-- tests/ComponentTests/BaseComponentTest.cs | 41 ++- tests/ComponentTests/ContainerTests.cs | 6 +- tests/RendererTests/BaseRendererTest.cs | 2 + 32 files changed, 639 insertions(+), 434 deletions(-) create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentRenderingOptions.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ComponentBuilderKind.cs diff --git a/Discord.Net.ComponentDesigner.sln.DotSettings.user b/Discord.Net.ComponentDesigner.sln.DotSettings.user index 9874763..ef136f9 100644 --- a/Discord.Net.ComponentDesigner.sln.DotSettings.user +++ b/Discord.Net.ComponentDesigner.sln.DotSettings.user @@ -76,4 +76,5 @@ + \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs b/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs index 4d170e9..7eec918 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs @@ -495,5 +495,12 @@ public static Diagnostic CreateParsingDiagnostic(CXDiagnostic diagnostic, Locati true ); - + public static readonly DiagnosticDescriptor InvalidInterleavedComponentInCurrentContext = new( + "DC0055", + "Invalid interpolated component", + "'{0}' cannot be used in an expected context of '{1}'", + "Components", + DiagnosticSeverity.Error, + true + ); } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraph.cs b/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraph.cs index bc506d0..9d53b94 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraph.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Graph/CXGraph.cs @@ -409,13 +409,13 @@ public void UpdateState(ComponentContext context) Inner.UpdateState(ref _state, context); } - public string Render(IComponentContext context) + public string Render(IComponentContext context, ComponentRenderingOptions options = default) { if (_render is not null) return _render; using (context.CreateDiagnosticScope(_diagnostics)) { - return _render = Inner.Render(State, context); + return _render = Inner.Render(State, context, options); } } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentContext.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentContext.cs index 51d10ca..dbb7654 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentContext.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentContext.cs @@ -4,6 +4,7 @@ using Microsoft.CodeAnalysis.Text; using System.Collections.Generic; using System.Linq; +using Discord.CX.Nodes.Components; namespace Discord.CX.Nodes; @@ -29,14 +30,18 @@ public void Dispose() public List GlobalDiagnostics { get; init; } = []; + public ComponentTypingContext RootTypingContext { get; } + private readonly CXGraph _graph; private DiagnosticScope _scope; - public ComponentContext(CXGraph graph) + public ComponentContext(CXGraph graph, ComponentTypingContext? typingContext = null) { _graph = graph; _scope = new(GlobalDiagnostics, null, this); + + RootTypingContext = typingContext ?? ComponentTypingContext.Default; } public IDisposable CreateDiagnosticScope(List bag) diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs index 37d950e..7f81228 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs @@ -10,10 +10,22 @@ namespace Discord.CX.Nodes; +public delegate string ComponentNodeRenderer( + TState state, + IComponentContext context, + ComponentRenderingOptions options = default +) where TState : ComponentState; + +public delegate string ComponentNodeRenderer( + ComponentState state, + IComponentContext context, + ComponentRenderingOptions options = default +); + public abstract class ComponentNode : ComponentNode where TState : ComponentState { - public abstract string Render(TState state, IComponentContext context); + public abstract string Render(TState state, IComponentContext context, ComponentRenderingOptions options); public virtual void UpdateState(ref TState state, IComponentContext context) { @@ -27,8 +39,11 @@ public sealed override void UpdateState(ref ComponentState state, IComponentCont public sealed override ComponentState? Create(ComponentStateInitializationContext context) => CreateState(context); - public sealed override string Render(ComponentState state, IComponentContext context) - => Render((TState)state, context); + public sealed override string Render( + ComponentState state, + IComponentContext context, + ComponentRenderingOptions options + ) => Render((TState)state, context, options); public virtual void Validate(TState state, IComponentContext context) { @@ -39,15 +54,10 @@ public sealed override void Validate(ComponentState state, IComponentContext con => Validate((TState)state, context); } -public delegate string ComponentNodeRenderer(TState state, IComponentContext context) - where TState : ComponentState; - -public delegate string ComponentNodeRenderer(ComponentState state, IComponentContext context); - public abstract class ComponentNode { protected virtual bool IsUserAccessible => true; - + public abstract string Name { get; } public virtual IReadOnlyList Aliases { get; } = []; @@ -115,7 +125,11 @@ private bool TryGetPropertyFromName(string name, out ComponentProperty result) return false; } - public abstract string Render(ComponentState state, IComponentContext context); + public abstract string Render( + ComponentState state, + IComponentContext context, + ComponentRenderingOptions options + ); public virtual void UpdateState(ref ComponentState state, IComponentContext context) { @@ -209,7 +223,7 @@ out ComponentNode node continue; if ( - !InterleavedComponentNode.IsValidInterleavedType( + !ComponentBuilderKindUtils.IsValidComponentBuilderType( methodSymbol.ReturnType, cxSemanticModel.Compilation, out var kind diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentRenderingOptions.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentRenderingOptions.cs new file mode 100644 index 0000000..7776888 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentRenderingOptions.cs @@ -0,0 +1,21 @@ +using Discord.CX.Nodes.Components; + +namespace Discord.CX.Nodes; + +public readonly record struct ComponentRenderingOptions( + ComponentTypingContext? TypingContext = null +) +{ + public static readonly ComponentRenderingOptions Default = new(); +} + +public readonly record struct ComponentTypingContext( + bool CanSplat, + ComponentBuilderKind ConformingType +) +{ + public static readonly ComponentTypingContext Default = new( + CanSplat: true, + ConformingType: ComponentBuilderKind.CollectionOfIMessageComponentBuilders + ); +} \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentState.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentState.cs index b412531..9801b8d 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentState.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentState.cs @@ -19,6 +19,8 @@ public IReadOnlyList Children public bool IsElement => Source is CXElement; + public bool IsRootNode => OwningNode?.Parent is null; + private readonly Dictionary _properties = []; public ComponentPropertyValue GetProperty(ComponentProperty property) @@ -167,7 +169,8 @@ public string RenderInitializer( public string RenderChildren( IComponentContext context, - Func? predicate = null + Func? predicate = null, + ComponentRenderingOptions options = default ) { if (OwningNode is null || !HasChildren) return string.Empty; @@ -178,7 +181,7 @@ public string RenderChildren( return string.Join( $",{Environment.NewLine}", - children.Select(x => x.Render(context)) + children.Select(x => x.Render(context, options)) ); } } \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs index 7cf33f9..223e9da 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ActionRowComponentNode.cs @@ -21,6 +21,13 @@ public class ActionRowComponentNode : ComponentNode public override IReadOnlyList Properties { get; } + private static readonly ComponentRenderingOptions ChildRenderingOptions = new( + TypingContext: new( + CanSplat: true, + ConformingType: ComponentBuilderKind.CollectionOfIMessageComponentBuilders + ) + ); + public ActionRowComponentNode() { Properties = @@ -63,7 +70,7 @@ public override void Validate(ComponentState state, IComponentContext context) extra.State.Source ); } - + break; case SelectMenuComponentNode: foreach (var rest in state.Children.Skip(1)) @@ -101,17 +108,21 @@ private static bool IsValidChild(ComponentNode node) or SelectMenuComponentNode or IDynamicComponentNode; - public override string Render(ComponentState state, IComponentContext context) + public override string Render( + ComponentState state, + IComponentContext context, + ComponentRenderingOptions options + ) { var props = state.RenderProperties(this, context, asInitializers: true); - var children = state.RenderChildren(context); + var children = state.RenderChildren(context, options: ChildRenderingOptions); var init = new StringBuilder(props); if (!string.IsNullOrWhiteSpace(children)) { if (!string.IsNullOrWhiteSpace(props)) init.Append(',').AppendLine(); - + init.Append( $""" Components = @@ -133,35 +144,11 @@ public override string Render(ComponentState state, IComponentContext context) }} """; } - // => $$""" -// new {{context.KnownTypes.ActionRowBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}}(){{ -// $"{ -// state -// .RenderProperties(this, context, asInitializers: true) -// .PostfixIfSome(Environment.NewLine) -// }{ -// state -// .RenderChildren(context) -// .Map(x => -// $""" -// Components = -// [ -// {x.WithNewlinePadding(4)} -// ] -// """ -// ) -// }" -// .TrimEnd() -// .WithNewlinePadding(4) -// .PrefixIfSome($"{Environment.NewLine}{{{Environment.NewLine}".Postfix(4)) -// .PostfixIfSome($"{Environment.NewLine}}}") -// }} -// """; } public sealed class AutoActionRowComponentNode : ActionRowComponentNode { - public static readonly AutoActionRowComponentNode Instance = new (); + public static readonly AutoActionRowComponentNode Instance = new(); protected override bool IsUserAccessible => false; public override ComponentState? Create(ComponentStateInitializationContext context) diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs index 46bb32e..50081d3 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs @@ -300,7 +300,8 @@ label.Value is not null && base.Validate(state, context); } - public override string Render(ButtonComponentState state, IComponentContext context) + public override string Render(ButtonComponentState state, IComponentContext context, + ComponentRenderingOptions options) { string style; diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ComponentBuilderKind.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ComponentBuilderKind.cs new file mode 100644 index 0000000..906b951 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ComponentBuilderKind.cs @@ -0,0 +1,265 @@ +using System; +using System.Linq; +using Microsoft.CodeAnalysis; + +namespace Discord.CX.Nodes.Components; + +[Flags] +public enum ComponentBuilderKind +{ + None = 0, + + // ReSharper disable InconsistentNaming + IMessageComponentBuilder = 0b001, + IMessageComponent = 0b010, + // ReSharper restore InconsistentNaming + + CXMessageComponent = 0b011, + MessageComponent = 0b100, + + CollectionOf = 1 << 3, + + ComponentMask = IMessageComponentBuilder | IMessageComponent | CXMessageComponent | MessageComponent, + + CollectionOfIMessageComponentBuilders = IMessageComponentBuilder | CollectionOf, + CollectionOfIMessageComponents = IMessageComponent | CollectionOf, + CollectionOfCXComponents = CXMessageComponent | CollectionOf, + CollectionOfMessageComponents = MessageComponent | CollectionOf, +} + +public static class ComponentBuilderKindUtils +{ + public static bool SupportsCardinalityOfMany(this ComponentBuilderKind kind) + { + if (kind.HasFlag(ComponentBuilderKind.CollectionOf)) return true; + + return kind is ComponentBuilderKind.MessageComponent or ComponentBuilderKind.CXMessageComponent; + } + + public static bool IsValidComponentBuilderType( + ITypeSymbol? symbol, + Compilation compilation, + out ComponentBuilderKind kind + ) + { + kind = ComponentBuilderKind.None; + + if (symbol is null) return false; + + var current = symbol; + + var enumerableType = current + .AllInterfaces + .FirstOrDefault(x => + x.IsGenericType && + x.ConstructedFrom.Equals( + compilation.GetKnownTypes().IEnumerableOfTType!, + SymbolEqualityComparer.Default + ) + ); + + if (enumerableType is not null) + { + kind |= ComponentBuilderKind.CollectionOf; + current = enumerableType.TypeArguments[0]; + } + + if ( + compilation.HasImplicitConversion( + current, + compilation.GetKnownTypes().CXMessageComponentType + ) + ) + { + kind |= ComponentBuilderKind.CXMessageComponent; + } + else if ( + compilation.HasImplicitConversion( + current, + compilation.GetKnownTypes().IMessageComponentBuilderType + ) + ) + { + kind |= ComponentBuilderKind.IMessageComponentBuilder; + } + else if ( + compilation.HasImplicitConversion( + current, + compilation.GetKnownTypes().IMessageComponentType + ) + ) + { + kind |= ComponentBuilderKind.IMessageComponent; + } + else if ( + compilation.HasImplicitConversion( + current, + compilation.GetKnownTypes().MessageComponentType + ) + ) + { + kind |= ComponentBuilderKind.IMessageComponent; + } + + return (kind & ComponentBuilderKind.ComponentMask) is not 0; + } + + public static bool IsValidComponentBuilderType( + ITypeSymbol? symbol, + Compilation compilation + ) => IsValidComponentBuilderType(symbol, compilation, out _); + + public static bool TryConvertBasic(string source, ComponentBuilderKind from, ComponentBuilderKind to, out string result) + => (result = ConvertBasic(source, from, to)!) is not null; + + public static bool TryConvert( + string source, + ComponentBuilderKind from, + ComponentBuilderKind to, + out string result, + bool spreadCollections = false + ) => (result = Convert(source, from, to, spreadCollections)!) is not null; + + public static string? Convert( + string source, + ComponentBuilderKind from, + ComponentBuilderKind to, + bool spreadCollections = false + ) + { + if (from is ComponentBuilderKind.None || to is ComponentBuilderKind.None) return null; + + var fromCollection = from.HasFlag(ComponentBuilderKind.CollectionOf); + var toCollection = to.HasFlag(ComponentBuilderKind.CollectionOf); + + var fromBasicKind = from & ComponentBuilderKind.ComponentMask; + var toBasicKind = to & ComponentBuilderKind.ComponentMask; + + var spread = spreadCollections ? ".." : string.Empty; + + switch (fromCollection, toCollection) + { + case (false, false): + return ConvertBasic(source, from, to); + case (true, false): + { + var converter = ConvertBasic("x", fromBasicKind, toBasicKind); + return converter is not null ? $"{source}.Select(x => {converter})" : null; + } + case (false, true): + { + switch (fromBasicKind, toBasicKind) + { + case ( + ComponentBuilderKind.MessageComponent or ComponentBuilderKind.CXMessageComponent, + ComponentBuilderKind.IMessageComponent + ): + return $"{spread}{source}.Components"; + case ( + ComponentBuilderKind.MessageComponent, + ComponentBuilderKind.IMessageComponentBuilder + ): + return $"{spread}{source}.Components.Select(x => x.ToBuilder())"; + case ( + ComponentBuilderKind.CXMessageComponent, + ComponentBuilderKind.IMessageComponentBuilder + ): + return $"{spread}{source}.Builders"; + default: + var converter = ConvertBasic(source, fromBasicKind, toBasicKind); + return converter is not null + ? spreadCollections ? converter : $"[{converter}]" + : null; + } + } + case (true, true): + switch (fromBasicKind, toBasicKind) + { + case (ComponentBuilderKind.MessageComponent or ComponentBuilderKind.CXMessageComponent, ComponentBuilderKind + .IMessageComponent): + return $"{source}.SelectMany(x => x.Components)"; + case (ComponentBuilderKind.MessageComponent, ComponentBuilderKind.IMessageComponentBuilder): + return $"{source}.SelectMany(x => x.Components.Select(x => x.ToBuilder()))"; + case (ComponentBuilderKind.CXMessageComponent, ComponentBuilderKind.IMessageComponentBuilder): + return $"{source}.SelectMany(x => x.Builders)"; + default: + var converter = ConvertBasic("x", fromBasicKind, toBasicKind); + return converter is not null + ? $"{source}.Select(x => {converter})" + : null; + } + } + } + + public static string? ConvertBasic(string source, ComponentBuilderKind from, ComponentBuilderKind to) + { + const string ComponentBuilderRef = "global::Discord.ComponentBuilderV2"; + const string CXComponentRef = "global::Discord.CXMessageComponent"; + + switch (from, to) + { + case (ComponentBuilderKind.IMessageComponent, ComponentBuilderKind.IMessageComponent): + return source; + case (ComponentBuilderKind.IMessageComponent, ComponentBuilderKind.IMessageComponentBuilder): + return $"{source}.ToBuilder()"; + case (ComponentBuilderKind.IMessageComponent, ComponentBuilderKind.MessageComponent): + return $"new {ComponentBuilderRef}({source}).Build()"; + case (ComponentBuilderKind.IMessageComponent, ComponentBuilderKind.CXMessageComponent): + return $"new {CXComponentRef}({source})"; + + case (ComponentBuilderKind.MessageComponent, ComponentBuilderKind.IMessageComponent): + // no way to convert to single here + return null; + case (ComponentBuilderKind.MessageComponent, ComponentBuilderKind.IMessageComponentBuilder): + // no way to convert to single + return null; + case (ComponentBuilderKind.MessageComponent, ComponentBuilderKind.MessageComponent): + return source; + case (ComponentBuilderKind.MessageComponent, ComponentBuilderKind.CXMessageComponent): + return $"new {CXComponentRef}({source})"; + + case (ComponentBuilderKind.IMessageComponentBuilder, ComponentBuilderKind.IMessageComponent): + return $"{source}.Build()"; + case (ComponentBuilderKind.IMessageComponentBuilder, ComponentBuilderKind.IMessageComponentBuilder): + return source; + case (ComponentBuilderKind.IMessageComponentBuilder, ComponentBuilderKind.MessageComponent): + return $"new {ComponentBuilderRef}({source}).Build()"; + case (ComponentBuilderKind.IMessageComponentBuilder, ComponentBuilderKind.CXMessageComponent): + return $"new {CXComponentRef}({source})"; + + case (ComponentBuilderKind.CXMessageComponent, ComponentBuilderKind.IMessageComponent): + // no way to convert to single here + return null; + case (ComponentBuilderKind.CXMessageComponent, ComponentBuilderKind.IMessageComponentBuilder): + // no way to convert to single here + return null; + case (ComponentBuilderKind.CXMessageComponent, ComponentBuilderKind.MessageComponent): + return $"{source}.ToDiscordComponents()"; + case (ComponentBuilderKind.CXMessageComponent, ComponentBuilderKind.CXMessageComponent): + return source; + + default: return null; + } + } + + public static string ExtrapolateKindToBuilders(ComponentBuilderKind kind, string source) + { + switch (kind) + { + // case 1: standard builder, we do nothing to the source + case ComponentBuilderKind.IMessageComponentBuilder: return source; + + case ComponentBuilderKind.CollectionOfIMessageComponentBuilders: return $"..{source}"; + + case ComponentBuilderKind.CXMessageComponent: + case ComponentBuilderKind.IMessageComponent: return $"..({source}).Components.Select(x => x.ToBuilder())"; + + case ComponentBuilderKind.CollectionOfCXComponents: + case ComponentBuilderKind.CollectionOfIMessageComponents: + return $"..({source}).SelectMany(x => x.Components.Select(x => x.ToBuilder()))"; + + default: + throw new ArgumentOutOfRangeException(nameof(kind)); + } + } +} \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs index 6f61e8d..b7f4e8a 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs @@ -18,6 +18,13 @@ public sealed class ContainerComponentNode : ComponentNode public override IReadOnlyList Properties { get; } + private static readonly ComponentRenderingOptions ChildRenderingOptions = new( + TypingContext: new( + CanSplat: true, + ConformingType: ComponentBuilderKind.CollectionOfIMessageComponentBuilders + ) + ); + public ContainerComponentNode() { Properties = @@ -66,10 +73,10 @@ or MediaGalleryComponentNode or SeparatorComponentNode or FileComponentNode; - public override string Render(ComponentState state, IComponentContext context) + public override string Render(ComponentState state, IComponentContext context, ComponentRenderingOptions options) { var props = state.RenderProperties(this, context, asInitializers: true); - var children = state.RenderChildren(context); + var children = state.RenderChildren(context, options: ChildRenderingOptions); var init = new StringBuilder(props); diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Custom/ComponentChildrenAdapter.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Custom/ComponentChildrenAdapter.cs index 5baa39e..167a00e 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Custom/ComponentChildrenAdapter.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Custom/ComponentChildrenAdapter.cs @@ -23,11 +23,11 @@ IReadOnlyList children public bool IsCollectionType => _collectionInnerTypeSymbol is not null; - public bool IsCX => _interleavedKind is not InterleavedKind.None; + public bool IsCX => _componentBuilderKind is not ComponentBuilderKind.None; private readonly ITypeSymbol _targetSymbol; private readonly ITypeSymbol? _collectionInnerTypeSymbol; - private readonly InterleavedKind _interleavedKind; + private readonly ComponentBuilderKind _componentBuilderKind; private readonly ComponentNode _owner; public abstract record ComponentChild(ICXNode Node) @@ -45,7 +45,7 @@ public sealed record Element(CXElement CX) : ComponentChild(CX); public ComponentChildrenAdapter( ITypeSymbol targetSymbol, ITypeSymbol? collectionInnerTypeSymbol, - InterleavedKind interleavedKind, + ComponentBuilderKind componentBuilderKind, bool isOptional, ComponentNode owner ) @@ -53,10 +53,10 @@ ComponentNode owner IsOptional = isOptional; _targetSymbol = targetSymbol; _collectionInnerTypeSymbol = collectionInnerTypeSymbol; - _interleavedKind = interleavedKind; + _componentBuilderKind = componentBuilderKind; _owner = owner; - ChildrenRenderer = interleavedKind is not InterleavedKind.None + ChildrenRenderer = componentBuilderKind is not ComponentBuilderKind.None ? RenderComponent : RenderNonComponent; } @@ -79,7 +79,7 @@ ComponentNode owner ) ?.TypeArguments[0]; - InterleavedComponentNode.IsValidInterleavedType(inner ?? target, compilation, out var kind); + ComponentBuilderKindUtils.IsValidComponentBuilderType(inner ?? target, compilation, out var kind); return new( target, @@ -141,9 +141,9 @@ private string RenderComponent( IReadOnlyList children ) { - if (_interleavedKind is InterleavedKind.None) return string.Empty; + if (_componentBuilderKind is ComponentBuilderKind.None) return string.Empty; - var cardinalityOfMany = IsCollectionType || _interleavedKind.SupportsCardinalityOfMany(); + var cardinalityOfMany = IsCollectionType || _componentBuilderKind.SupportsCardinalityOfMany(); if (!cardinalityOfMany) { @@ -161,7 +161,7 @@ IReadOnlyList children ); return string.Empty; case 1: - return RenderComponentInner(_interleavedKind, context, children[0], state); + return RenderComponentInner(_componentBuilderKind, context, children[0], state); default: // too many children var lower = children[1].Node.Span.Start; @@ -191,7 +191,7 @@ IReadOnlyList children return string.Empty; } - return RenderChildrenAsCollection(_interleavedKind); + return RenderChildrenAsCollection(_componentBuilderKind); } else { @@ -204,11 +204,11 @@ IReadOnlyList children * Since CX renders to the builders, we'll do the same, and wrap the construction manually */ - var typeName = _interleavedKind switch + var typeName = _componentBuilderKind switch { - InterleavedKind.CXMessageComponent => "global::Discord.CXMessageComponent", - InterleavedKind.MessageComponent => "global::Discord.MessageComponent", - _ => throw new ArgumentOutOfRangeException(nameof(_interleavedKind)) + ComponentBuilderKind.CXMessageComponent => "global::Discord.CXMessageComponent", + ComponentBuilderKind.MessageComponent => "global::Discord.MessageComponent", + _ => throw new ArgumentOutOfRangeException(nameof(_componentBuilderKind)) }; if (children.Count is 0) @@ -224,14 +224,14 @@ IReadOnlyList children return string.Empty; } - var components = RenderChildrenAsCollection(InterleavedKind.IMessageComponentBuilder); + var components = RenderChildrenAsCollection(ComponentBuilderKind.IMessageComponentBuilder); if (components == string.Empty) return string.Empty; return $"new {typeName}({components})"; } - string RenderChildrenAsCollection(InterleavedKind kind) + string RenderChildrenAsCollection(ComponentBuilderKind kind) { var parts = new List(); @@ -264,7 +264,7 @@ string RenderChildrenAsCollection(InterleavedKind kind) } private string RenderComponentInner( - InterleavedKind kind, + ComponentBuilderKind kind, IComponentContext context, ComponentChild child, ComponentState state, @@ -285,12 +285,12 @@ private string RenderComponentInner( var info = context.GetInterpolationInfo(token); if ( - !InterleavedComponentNode.IsValidInterleavedType( + !ComponentBuilderKindUtils.IsValidComponentBuilderType( info.Symbol, context.Compilation, out var interpolationKind ) || - !InterleavedComponentNode.TryConvert( + !ComponentBuilderKindUtils.TryConvert( context.GetDesignerValue( info, info.Symbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) @@ -323,9 +323,9 @@ out var interpolationKind var builder = childNode.Render(context); if ( - !InterleavedComponentNode.TryConvert( + !ComponentBuilderKindUtils.TryConvert( builder, - InterleavedKind.IMessageComponentBuilder, + ComponentBuilderKind.IMessageComponentBuilder, kind, out converted, spreadCollections diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Custom/FunctionalComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Custom/FunctionalComponentNode.cs index db44a86..c4835fd 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Custom/FunctionalComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Custom/FunctionalComponentNode.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using Discord.CX.Parser; using Microsoft.CodeAnalysis; @@ -14,28 +15,29 @@ public sealed class FunctionalComponentNodeState : ComponentState public class FunctionalComponentNode : ComponentNode, IDynamicComponentNode { - public override bool HasChildren => _childrenParameter is not null; + public override bool HasChildren => ChildrenParameter is not null; - public override string Name => $""; + public override string Name => $""; public override IReadOnlyList Properties { get; } - private readonly IMethodSymbol _method; - private readonly InterleavedKind _kind; - private readonly IParameterSymbol? _childrenParameter; + public IMethodSymbol Method { get; } + public ComponentBuilderKind Kind { get; } + public IParameterSymbol? ChildrenParameter { get; } + private readonly ComponentChildrenAdapter? _adapter; private FunctionalComponentNode( IMethodSymbol method, - InterleavedKind kind, + ComponentBuilderKind kind, IReadOnlyList properties, IParameterSymbol? childrenParameter, Compilation compilation ) { - _method = method; - _kind = kind; - _childrenParameter = childrenParameter; + Method = method; + Kind = kind; + ChildrenParameter = childrenParameter; Properties = properties; _adapter = childrenParameter is not null @@ -48,7 +50,7 @@ Compilation compilation : null; } - public static FunctionalComponentNode Create(IMethodSymbol method, InterleavedKind kind, Compilation compilation) + public static FunctionalComponentNode Create(IMethodSymbol method, ComponentBuilderKind kind, Compilation compilation) { var properties = new List(); IParameterSymbol? childrenParameter = null; @@ -100,26 +102,75 @@ public static FunctionalComponentNode Create(IMethodSymbol method, InterleavedKi }; private string MethodReference => - $"{_method.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.{_method.Name}"; - - public override string Render(FunctionalComponentNodeState state, IComponentContext context) - => InterleavedComponentNode.ExtrapolateKindToBuilders( - _kind, - $"{MethodReference}({ - string - .Join( - ", ", - ((string[]) - [ - state.RenderProperties(this, context), - (_adapter?.ChildrenRenderer(context, state, state.Children) ?? string.Empty) - .PrefixIfSome($"{_childrenParameter?.Name}: ") - ]) - .Where(x => !string.IsNullOrWhiteSpace(x)) - ) - .WithNewlinePadding(4) - .PrefixIfSome(4) - .WrapIfSome("\n") - })" + $"{Method.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.{Method.Name}"; + + public override string Render( + FunctionalComponentNodeState state, + IComponentContext context, + ComponentRenderingOptions options + ) + { + var source = $"{MethodReference}({ + string + .Join( + ", ", + ((string[]) + [ + state.RenderProperties(this, context), + (_adapter?.ChildrenRenderer(context, state, state.Children) ?? string.Empty) + .PrefixIfSome($"{ChildrenParameter?.Name}: ") + ]) + .Where(x => !string.IsNullOrWhiteSpace(x)) + ) + .WithNewlinePadding(4) + .PrefixIfSome(4) + .WrapIfSome("\n") + })"; + + var typingContext = options.TypingContext; + + if (typingContext is null) + { + if (state.IsRootNode) + { + typingContext = context.RootTypingContext; + } + else + { + /* + * TODO: unknown typing context may imply a bug where a parent component isn't supplying their + * required typing information + */ + + Debug.Fail("Unknown typing context in functional node"); + typingContext = context.RootTypingContext; + } + } + + var value = ComponentBuilderKindUtils.Convert( + source, + Kind, + typingContext.Value.ConformingType, + typingContext.Value.CanSplat ); + + if (value is null) + { + /* + * we've failed to convert, this case implies that whatever the type of this interleaved node is, it doesn't + * conform to the current constraints + */ + + context.AddDiagnostic( + Diagnostics.InvalidInterleavedComponentInCurrentContext, + state.Source, + Method.ReturnType.ToDisplayString(), + typingContext.Value.ConformingType + ); + + return string.Empty; + } + + return value; + } } \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Custom/ProviderComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Custom/ProviderComponentNode.cs index 75fb19e..96b5bfb 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Custom/ProviderComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Custom/ProviderComponentNode.cs @@ -71,10 +71,8 @@ Compilation compilation - public override string Render( - ComponentState state, - IComponentContext context - ) => + public override string Render(ComponentState state, + IComponentContext context, ComponentRenderingOptions options) => $"{_providerSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.Render({CreateProviderState(state, context)})"; diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs index 49f9019..176df3f 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs @@ -36,7 +36,7 @@ public FileComponentNode() ]; } - public override string Render(ComponentState state, IComponentContext context) + public override string Render(ComponentState state, IComponentContext context, ComponentRenderingOptions options) => $""" new {context.KnownTypes.FileComponentBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({ state.RenderProperties(this, context) diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileUploadComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileUploadComponentNode.cs index 4d98da4..5d10bfb 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileUploadComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileUploadComponentNode.cs @@ -67,7 +67,7 @@ public override void Validate(ComponentState state, IComponentContext context) } } - public override string Render(ComponentState state, IComponentContext context) + public override string Render(ComponentState state, IComponentContext context, ComponentRenderingOptions options) => $""" new {context.KnownTypes.FileUploadComponentBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({ state.RenderProperties(this, context) diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterleavedComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterleavedComponentNode.cs index 34f4e44..3ffa7bc 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterleavedComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterleavedComponentNode.cs @@ -1,44 +1,14 @@ using System; using Discord.CX.Parser; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using Microsoft.CodeAnalysis; using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; namespace Discord.CX.Nodes.Components; -[Flags] -public enum InterleavedKind -{ - None = 0, - - // ReSharper disable InconsistentNaming - IMessageComponentBuilder = 0b001, - IMessageComponent = 0b010, - // ReSharper restore InconsistentNaming - - CXMessageComponent = 0b011, - MessageComponent = 0b100, - - CollectionOf = 1 << 3, - - ComponentMask = IMessageComponentBuilder | IMessageComponent | CXMessageComponent | MessageComponent, - - CollectionOfIMessageComponentBuilders = IMessageComponentBuilder | CollectionOf, - CollectionOfIMessageComponents = IMessageComponent | CollectionOf, - CollectionOfCXComponents = CXMessageComponent | CollectionOf, - CollectionOfMessageComponents = MessageComponent | CollectionOf, -} - -public static class InterleavedKindExtensions -{ - public static bool SupportsCardinalityOfMany(this InterleavedKind kind) - { - if (kind.HasFlag(InterleavedKind.CollectionOf)) return true; - - return kind is InterleavedKind.MessageComponent or InterleavedKind.CXMessageComponent; - } -} +using static ComponentBuilderKindUtils; public sealed class InterleavedState : ComponentState { @@ -47,16 +17,16 @@ public sealed class InterleavedState : ComponentState public sealed class InterleavedComponentNode : ComponentNode, IDynamicComponentNode { - public InterleavedKind Kind { get; } + public ComponentBuilderKind Kind { get; } public ITypeSymbol Symbol { get; } public bool IsSingleCardinality - => Kind == InterleavedKind.IMessageComponentBuilder; + => Kind == ComponentBuilderKind.IMessageComponentBuilder; public override string Name => ""; public InterleavedComponentNode( - InterleavedKind kind, + ComponentBuilderKind kind, ITypeSymbol symbol ) { @@ -64,86 +34,13 @@ ITypeSymbol symbol Symbol = symbol; } - public static bool IsValidInterleavedType( - ITypeSymbol? symbol, - Compilation compilation - ) => IsValidInterleavedType(symbol, compilation, out _); - - public static bool IsValidInterleavedType( - ITypeSymbol? symbol, - Compilation compilation, - out InterleavedKind kind - ) - { - kind = InterleavedKind.None; - - if (symbol is null) return false; - - var current = symbol; - - var enumerableType = current - .AllInterfaces - .FirstOrDefault(x => - x.IsGenericType && - x.ConstructedFrom.Equals( - compilation.GetKnownTypes().IEnumerableOfTType!, - SymbolEqualityComparer.Default - ) - ); - - if (enumerableType is not null) - { - kind |= InterleavedKind.CollectionOf; - current = enumerableType.TypeArguments[0]; - } - - if ( - compilation.HasImplicitConversion( - current, - compilation.GetKnownTypes().CXMessageComponentType - ) - ) - { - kind |= InterleavedKind.CXMessageComponent; - } - else if ( - compilation.HasImplicitConversion( - current, - compilation.GetKnownTypes().IMessageComponentBuilderType - ) - ) - { - kind |= InterleavedKind.IMessageComponentBuilder; - } - else if ( - compilation.HasImplicitConversion( - current, - compilation.GetKnownTypes().IMessageComponentType - ) - ) - { - kind |= InterleavedKind.IMessageComponent; - } - else if ( - compilation.HasImplicitConversion( - current, - compilation.GetKnownTypes().MessageComponentType - ) - ) - { - kind |= InterleavedKind.IMessageComponent; - } - - return (kind & InterleavedKind.ComponentMask) is not 0; - } - public static bool TryCreate( ITypeSymbol? symbol, Compilation compilation, out InterleavedComponentNode node ) { - if (IsValidInterleavedType(symbol, compilation, out var kind)) + if (IsValidComponentBuilderType(symbol, compilation, out var kind)) { node = new(kind, symbol!); return true; @@ -153,160 +50,6 @@ out InterleavedComponentNode node return false; } - public static bool TryConvertBasic(string source, InterleavedKind from, InterleavedKind to, out string result) - => (result = ConvertBasic(source, from, to)!) is not null; - - public static bool TryConvert( - string source, - InterleavedKind from, - InterleavedKind to, - out string result, - bool spreadCollections = false - ) => (result = Convert(source, from, to, spreadCollections)!) is not null; - - public static string? Convert( - string source, - InterleavedKind from, - InterleavedKind to, - bool spreadCollections = false - ) - { - if (from is InterleavedKind.None || to is InterleavedKind.None) return null; - - var fromCollection = from.HasFlag(InterleavedKind.CollectionOf); - var toCollection = to.HasFlag(InterleavedKind.CollectionOf); - - var fromBasicKind = from & InterleavedKind.ComponentMask; - var toBasicKind = to & InterleavedKind.ComponentMask; - - var spread = spreadCollections ? ".." : string.Empty; - - switch (fromCollection, toCollection) - { - case (false, false): - return ConvertBasic(source, from, to); - case (true, false): - { - var converter = ConvertBasic("x", fromBasicKind, toBasicKind); - return converter is not null ? $"{source}.Select(x => {converter})" : null; - } - case (false, true): - { - switch (fromBasicKind, toBasicKind) - { - case ( - InterleavedKind.MessageComponent or InterleavedKind.CXMessageComponent, - InterleavedKind.IMessageComponent - ): - return $"{spread}{source}.Components"; - case ( - InterleavedKind.MessageComponent, - InterleavedKind.IMessageComponentBuilder - ): - return $"{spread}{source}.Components.Select(x => x.ToBuilder())"; - case ( - InterleavedKind.CXMessageComponent, - InterleavedKind.IMessageComponentBuilder - ): - return $"{spread}{source}.Builders"; - default: - var converter = ConvertBasic(source, fromBasicKind, toBasicKind); - return converter is not null - ? spreadCollections ? converter : $"[{converter}]" - : null; - } - } - case (true, true): - switch (fromBasicKind, toBasicKind) - { - case (InterleavedKind.MessageComponent or InterleavedKind.CXMessageComponent, InterleavedKind - .IMessageComponent): - return $"{source}.SelectMany(x => x.Components)"; - case (InterleavedKind.MessageComponent, InterleavedKind.IMessageComponentBuilder): - return $"{source}.SelectMany(x => x.Components.Select(x => x.ToBuilder()))"; - case (InterleavedKind.CXMessageComponent, InterleavedKind.IMessageComponentBuilder): - return $"{source}.SelectMany(x => x.Builders)"; - default: - var converter = ConvertBasic("x", fromBasicKind, toBasicKind); - return converter is not null - ? $"{source}.Select(x => {converter})" - : null; - } - } - } - - public static string? ConvertBasic(string source, InterleavedKind from, InterleavedKind to) - { - const string ComponentBuilderRef = "global::Discord.ComponentBuilderV2"; - const string CXComponentRef = "global::Discord.CXMessageComponent"; - - switch (from, to) - { - case (InterleavedKind.IMessageComponent, InterleavedKind.IMessageComponent): - return source; - case (InterleavedKind.IMessageComponent, InterleavedKind.IMessageComponentBuilder): - return $"{source}.ToBuilder()"; - case (InterleavedKind.IMessageComponent, InterleavedKind.MessageComponent): - return $"new {ComponentBuilderRef}({source}).Build()"; - case (InterleavedKind.IMessageComponent, InterleavedKind.CXMessageComponent): - return $"new {CXComponentRef}({source})"; - - case (InterleavedKind.MessageComponent, InterleavedKind.IMessageComponent): - // no way to convert to single here - return null; - case (InterleavedKind.MessageComponent, InterleavedKind.IMessageComponentBuilder): - // no way to convert to single - return null; - case (InterleavedKind.MessageComponent, InterleavedKind.MessageComponent): - return source; - case (InterleavedKind.MessageComponent, InterleavedKind.CXMessageComponent): - return $"new {CXComponentRef}({source})"; - - case (InterleavedKind.IMessageComponentBuilder, InterleavedKind.IMessageComponent): - return $"{source}.Build()"; - case (InterleavedKind.IMessageComponentBuilder, InterleavedKind.IMessageComponentBuilder): - return source; - case (InterleavedKind.IMessageComponentBuilder, InterleavedKind.MessageComponent): - return $"new {ComponentBuilderRef}({source}).Build()"; - case (InterleavedKind.IMessageComponentBuilder, InterleavedKind.CXMessageComponent): - return $"new {CXComponentRef}({source})"; - - case (InterleavedKind.CXMessageComponent, InterleavedKind.IMessageComponent): - // no way to convert to single here - return null; - case (InterleavedKind.CXMessageComponent, InterleavedKind.IMessageComponentBuilder): - // no way to convert to single here - return null; - case (InterleavedKind.CXMessageComponent, InterleavedKind.MessageComponent): - return $"{source}.ToDiscordComponents()"; - case (InterleavedKind.CXMessageComponent, InterleavedKind.CXMessageComponent): - return source; - - default: return null; - } - } - - public static string ExtrapolateKindToBuilders(InterleavedKind kind, string source) - { - switch (kind) - { - // case 1: standard builder, we do nothing to the source - case InterleavedKind.IMessageComponentBuilder: return source; - - case InterleavedKind.CollectionOfIMessageComponentBuilders: return $"..{source}"; - - case InterleavedKind.CXMessageComponent: - case InterleavedKind.IMessageComponent: return $"..({source}).Components.Select(x => x.ToBuilder())"; - - case InterleavedKind.CollectionOfCXComponents: - case InterleavedKind.CollectionOfIMessageComponents: - return $"..({source}).SelectMany(x => x.Components.Select(x => x.ToBuilder()))"; - - default: - throw new ArgumentOutOfRangeException(nameof(kind)); - } - } - public override InterleavedState? CreateState(ComponentStateInitializationContext context) { int id; @@ -330,10 +73,57 @@ public static string ExtrapolateKindToBuilders(InterleavedKind kind, string sour } - // TODO: extrapolate the kind to correct buidler conversion - public override string Render(InterleavedState state, IComponentContext context) - => context.GetDesignerValue( + public override string Render(InterleavedState state, IComponentContext context, ComponentRenderingOptions options) + { + var designerValue = context.GetDesignerValue( state.InterpolationId, - context.KnownTypes.IMessageComponentBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + Symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) ); + + var typingContext = options.TypingContext; + + if (typingContext is null) + { + if (state.IsRootNode) + { + typingContext = context.RootTypingContext; + } + else + { + /* + * TODO: unknown typing context may imply a bug where a parent component isn't supplying their + * required typing information + */ + + Debug.Fail("Unknown typing context in dynamic node"); + typingContext = context.RootTypingContext; + } + } + + var value = Convert( + designerValue, + Kind, + typingContext.Value.ConformingType, + typingContext.Value.CanSplat + ); + + if (value is null) + { + /* + * we've failed to convert, this case implies that whatever the type of this interleaved node is, it doesn't + * conform to the current constraints + */ + + context.AddDiagnostic( + Diagnostics.InvalidInterleavedComponentInCurrentContext, + state.Source, + Symbol.ToDisplayString(), + typingContext.Value.ConformingType + ); + + return string.Empty; + } + + return value; + } } \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs index 40475aa..cee3015 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs @@ -25,6 +25,13 @@ public sealed class LabelComponentNode : ComponentNode public override bool HasChildren => true; public override IReadOnlyList Properties { get; } + + private static readonly ComponentRenderingOptions ChildRenderingOptions = new( + TypingContext: new( + CanSplat: false, + ConformingType: ComponentBuilderKind.IMessageComponentBuilder + ) + ); public LabelComponentNode() { @@ -131,7 +138,7 @@ public override void Validate(LabelComponentState state, IComponentContext conte } var labelChild = state.GetProperty(Component).Node; - + if (labelChild is not null && !IsValidLabelChild(labelChild.Inner)) { context.AddDiagnostic( @@ -147,13 +154,18 @@ or SelectMenuComponentNode or TextInputComponentNode or FileUploadComponentNode; - public override string Render(LabelComponentState state, IComponentContext context) + public override string Render( + LabelComponentState state, + IComponentContext context, + ComponentRenderingOptions options + ) { var props = string.Join( $",{Environment.NewLine}", [ state.RenderProperties(this, context), - state.Children.FirstOrDefault()?.Render(context) + state.Children.FirstOrDefault() + ?.Render(context, options: ChildRenderingOptions) .PrefixIfSome("component: ") ] ); diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs index bfeabf4..7eee808 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs @@ -75,7 +75,7 @@ private static bool IsValidChild(ComponentNode node) => node is IDynamicComponentNode or MediaGalleryItemComponentNode; - public override string Render(ComponentState state, IComponentContext context) + public override string Render(ComponentState state, IComponentContext context, ComponentRenderingOptions options) { var props = state.RenderProperties(this, context, asInitializers: true); var children = state.RenderChildren(context); @@ -145,7 +145,7 @@ public MediaGalleryItemComponentNode() ]; } - public override string Render(ComponentState state, IComponentContext context) + public override string Render(ComponentState state, IComponentContext context, ComponentRenderingOptions options) => $""" 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 index e7676c1..d22a091 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentNode.cs @@ -16,6 +16,20 @@ public sealed class SectionComponentNode : ComponentNode public ComponentProperty Accessory { get; } public override IReadOnlyList Properties { get; } + private static readonly ComponentRenderingOptions ChildrenRenderingOptions = new( + TypingContext: new( + CanSplat: true, + ConformingType: ComponentBuilderKind.CollectionOfIMessageComponentBuilders + ) + ); + + private static readonly ComponentRenderingOptions AccessoryRenderingOptions = new( + TypingContext: new( + CanSplat: false, + ConformingType: ComponentBuilderKind.IMessageComponentBuilder + ) + ); + public SectionComponentNode() { Properties = @@ -24,7 +38,7 @@ public SectionComponentNode() Accessory = new( "accessory", isOptional: true, - renderer: Renderers.ComponentAsProperty + renderer: Renderers.ComponentAsProperty(AccessoryRenderingOptions) ) ]; } @@ -116,7 +130,7 @@ static bool IsValidChildType(ComponentNode node) => node is TextDisplayComponentNode or IDynamicComponentNode; } - public override string Render(ComponentState state, IComponentContext context) + public override string Render(ComponentState state, IComponentContext context, ComponentRenderingOptions options) { var accessoryPropertyValue = state.GetProperty(Accessory); @@ -193,6 +207,6 @@ public override void Validate(ComponentState state, IComponentContext context) private static bool IsAllowedChild(ComponentNode node) => node is ButtonComponentNode or ThumbnailComponentNode; - public override string Render(ComponentState state, IComponentContext context) + public override string Render(ComponentState state, IComponentContext context, ComponentRenderingOptions options) => state.RenderChildren(context); } \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuComponentNode.cs index d1ca771..2994cda 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuComponentNode.cs @@ -113,14 +113,14 @@ public override void AddGraphNode(ComponentGraphInitializationContext context) { if ( !context.Options.EnableAutoRows || - context.ParentGraphNode is null || + context.ParentGraphNode is null || context.ParentGraphNode.Inner is AutoActionRowComponentNode ) { base.AddGraphNode(context); return; } - + context.Push(AutoActionRowComponentNode.Instance, children: [(CXNode)context.CXNode]); } @@ -334,7 +334,7 @@ child is CXValue.Interpolation private static bool IsValidStringSelectChild(ComponentNode node) => node is IDynamicComponentNode or SelectMenuOptionComponentNode; - public override string Render(ComponentState state, IComponentContext context) + public override string Render(ComponentState state, IComponentContext context, ComponentRenderingOptions options) { if (state is not SelectState selectState) return string.Empty; @@ -427,7 +427,7 @@ IComponentContext context : null; if (ReferenceEquals(element, child?.State.Source)) - yield return (_, context) => child.Render(context); + yield return (_, context, options) => child.Render(context, options); break; diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuInterpolatedOption.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuInterpolatedOption.cs index 0dc602b..83c6796 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuInterpolatedOption.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuInterpolatedOption.cs @@ -24,7 +24,11 @@ bool isBuilder IsBuilder = isBuilder; } - public string Render(SelectMenuComponentNode.SelectState state, IComponentContext context) + public string Render( + SelectMenuComponentNode.SelectState state, + IComponentContext context, + ComponentRenderingOptions options + ) { var source = context.GetDesignerValue( InterpolationId, @@ -85,7 +89,7 @@ out SelectMenuInterpolatedOption option var symbol = info.Symbol; bool isCollection; - + // ReSharper disable once AssignmentInConditionalExpression if (isCollection = symbol.TryGetEnumerableType(out var innerSymbol)) symbol = innerSymbol; diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuOptionComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuOptionComponentNode.cs index 8f8b6ba..0984216 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuOptionComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuOptionComponentNode.cs @@ -85,7 +85,7 @@ public override void Validate(ComponentState state, IComponentContext context) base.Validate(state, context); } - public override string Render(ComponentState state, IComponentContext context) + public override string Render(ComponentState state, IComponentContext context, ComponentRenderingOptions options) => $""" new {context.KnownTypes.SelectMenuOptionBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({ state.RenderProperties(this, context) diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs index 3369c76..1c0482b 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs @@ -36,7 +36,7 @@ public SeparatorComponentNode() ]; } - public override string Render(ComponentState state, IComponentContext context) + public override string Render(ComponentState state, IComponentContext context, ComponentRenderingOptions options) => $""" new {context.KnownTypes.SeparatorBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({ state.RenderProperties(this, context) diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs index 53a6411..3368230 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextDisplayComponentNode.cs @@ -39,7 +39,7 @@ public TextDisplayComponentNode() return state; } - public override string Render(ComponentState state, IComponentContext context) + public override string Render(ComponentState state, IComponentContext context, ComponentRenderingOptions options) => $""" new {context.KnownTypes.TextDisplayBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({ state.RenderProperties(this, context) diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs index 98240d9..a5f33f1 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs @@ -98,7 +98,7 @@ public override void Validate(ComponentState state, IComponentContext context) Validators.Range(context, state, MinLength, MaxLength); } - public override string Render(ComponentState state, IComponentContext context) + public override string Render(ComponentState state, IComponentContext context, ComponentRenderingOptions options) => $""" new {context.KnownTypes.TextInputBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({ state.RenderProperties(this, context) diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs index 67267b1..409b029 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs @@ -41,7 +41,7 @@ public ThumbnailComponentNode() ]; } - public override string Render(ComponentState state, IComponentContext context) + public override string Render(ComponentState state, IComponentContext context, ComponentRenderingOptions options) => $""" new {context.KnownTypes.ThumbnailBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}({ state.RenderProperties(this, context) diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/IComponentContext.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/IComponentContext.cs index 91913ef..af80a1b 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/IComponentContext.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/IComponentContext.cs @@ -26,6 +26,8 @@ public interface IComponentContext DesignerInterpolationInfo GetInterpolationInfo(int index); DesignerInterpolationInfo GetInterpolationInfo(CXToken token); + + ComponentTypingContext RootTypingContext { get; } } public static class ComponentContextExtensions diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs index c32561d..7d94d0e 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs @@ -73,18 +73,28 @@ public static bool IsLoneInterpolatedLiteral( return false; } + public static PropertyRenderer ComponentAsProperty(ComponentRenderingOptions options) + => (context, propertyValue) => ComponentAsProperty(context, propertyValue, options); + public static string ComponentAsProperty(IComponentContext context, IComponentPropertyValue propertyValue) + => ComponentAsProperty(context, propertyValue, ComponentRenderingOptions.Default); + + private static string ComponentAsProperty( + IComponentContext context, + IComponentPropertyValue propertyValue, + ComponentRenderingOptions options + ) { switch (propertyValue.Value) { case CXValue.Element when propertyValue.Node is not null: - return propertyValue.Node.Render(context); + return propertyValue.Node.Render(context, options); case CXValue.Interpolation interpolation: // ensure its a component builder var info = context.GetInterpolationInfo(interpolation); if ( - !InterleavedComponentNode.IsValidInterleavedType( + !ComponentBuilderKindUtils.IsValidComponentBuilderType( info.Symbol, context.Compilation, out var interleavedKind @@ -106,25 +116,27 @@ out var interleavedKind info.Symbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) ); - // ensure we can convert it to a builder + var target = options.TypingContext?.ConformingType ?? ComponentBuilderKind.IMessageComponentBuilder; + if ( - !InterleavedComponentNode.TryConvert( + !ComponentBuilderKindUtils.TryConvert( source, interleavedKind, - InterleavedKind.IMessageComponentBuilder, - out var converted + target, + out var converted, + spreadCollections: options.TypingContext?.CanSplat is true ) ) { source = interleavedKind switch { - InterleavedKind.CXMessageComponent => $"{source}.Builders.First()", - InterleavedKind.MessageComponent => $"{source}.Components.First().ToBuilder()", - InterleavedKind.CollectionOfCXComponents => $"{source}.First().Builders.First()", - InterleavedKind.CollectionOfIMessageComponentBuilders => $"{source}.First()", - InterleavedKind.CollectionOfIMessageComponents => $"{source}.First().ToBuilder()", - InterleavedKind.CollectionOfMessageComponents => + ComponentBuilderKind.CXMessageComponent => $"{source}.Builders.First()", + ComponentBuilderKind.MessageComponent => $"{source}.Components.First().ToBuilder()", + ComponentBuilderKind.CollectionOfCXComponents => $"{source}.First().Builders.First()", + ComponentBuilderKind.CollectionOfIMessageComponentBuilders => $"{source}.First()", + ComponentBuilderKind.CollectionOfIMessageComponents => $"{source}.First().ToBuilder()", + ComponentBuilderKind.CollectionOfMessageComponents => $"{source}.First().Components.First().ToBuilder", _ => string.Empty }; @@ -400,7 +412,7 @@ info.Symbol is not null && } uint hexColor; - + if (info.Constant.Value is string str) { if (TryGetColorPreset(context, str, out var preset)) @@ -413,7 +425,7 @@ info.Symbol is not null && { return $"new {qualifiedColor}({hexColor})"; } - + if ( context.Compilation.HasImplicitConversion( info.Symbol, @@ -423,7 +435,7 @@ info.Symbol is not null && { return $"new {qualifiedColor}({context.GetDesignerValue(info, "uint")})"; } - + context.AddDiagnostic( Diagnostics.FallbackToRuntimeValueParsing, owner, @@ -504,13 +516,13 @@ public static string Snowflake(IComponentContext context, CXValue? value) if (IsLoneInterpolatedLiteral(context, stringLiteral, out var info)) return FromInterpolation(stringLiteral, info); - + context.AddDiagnostic( Diagnostics.FallbackToRuntimeValueParsing, stringLiteral, "ulong.Parse" ); - + return UseParseMethod(RenderStringLiteral(stringLiteral)); default: return "default"; @@ -522,7 +534,7 @@ string FromInterpolation(ICXNode owner, DesignerInterpolationInfo info) if (info.Constant is { HasValue: true, Value: ulong ul }) return ul.ToString(); - + if ( info.Symbol is not null && context.Compilation.HasImplicitConversion(info.Symbol, targetType) @@ -536,7 +548,7 @@ info.Symbol is not null && owner, "ulong.Parse" ); - + return UseParseMethod(context.GetDesignerValue(info)); } @@ -549,7 +561,7 @@ string FromText(ICXNode owner, string text) owner, "ulong.Parse" ); - + return UseParseMethod(ToCSharpString(text)); } @@ -676,10 +688,8 @@ public static string RenderStringLiteral(CXValue.Multipart literal) sb.Append(quotes); return sb.ToString(); - - } - + public static int GetInterpolationDollarRequirement(string part) { var result = 0; diff --git a/tests/ComponentTests/BaseComponentTest.cs b/tests/ComponentTests/BaseComponentTest.cs index c6316a3..8559409 100644 --- a/tests/ComponentTests/BaseComponentTest.cs +++ b/tests/ComponentTests/BaseComponentTest.cs @@ -7,7 +7,7 @@ namespace UnitTests.ComponentTests; public abstract class BaseComponentTest : BaseTestWithDiagnostics { protected CXGraph CurrentGraph => _graph!.Value; - + private CXGraph? _graph; private ComponentContext? _context; private IEnumerator? _nodeEnumerator; @@ -16,27 +16,38 @@ public void Graph( string cx, string? pretext = null, bool allowParsingErrors = false, - GeneratorOptions? options = null + GeneratorOptions? options = null, + string? additionalMethods = null, + string testClassName = "TestClass", + string testFuncName = "Run" ) { - if(_graph is not null) EOF(); + if (_graph is not null) EOF(); _graph = null; _context = null; _nodeEnumerator = null; - + ClearDiagnostics(); - + var source = - $"""" - using Discord; - {pretext} - ComponentDesigner.cx( - $""" - {cx.WithNewlinePadding(5)} - """ - ); - """"; + $$"""" + using Discord; + + public class {{testClassName}} + { + public void {{testFuncName}}() + { + {{pretext}} + ComponentDesigner.cx( + $""" + {{cx.WithNewlinePadding(5)}} + """ + ); + } + {{additionalMethods?.WithNewlinePadding(4)}} + } + """"; var target = Targets.FromSource(source); @@ -79,7 +90,7 @@ protected void Validate(bool? hasErrors = null) Assert.Equal(hasErrors.Value, _graph.Value.HasErrors); AssertEmptyDiagnostics(); - + PushDiagnostics([.._context.GlobalDiagnostics, .._graph.Value.Diagnostics]); } diff --git a/tests/ComponentTests/ContainerTests.cs b/tests/ComponentTests/ContainerTests.cs index 728bc9e..53b254d 100644 --- a/tests/ComponentTests/ContainerTests.cs +++ b/tests/ComponentTests/ContainerTests.cs @@ -42,10 +42,10 @@ public void ContainerWithInterpolatedChildren() { Components = [ - designer.GetValue(0), + ..designer.GetValue(0).Builders, new global::Discord.SeparatorBuilder(), - designer.GetValue(1), - designer.GetValue(2) + ..designer.GetValue(1).Builders, + ..designer.GetValue(2).Builders ] } """ diff --git a/tests/RendererTests/BaseRendererTest.cs b/tests/RendererTests/BaseRendererTest.cs index 1d1d906..ec674a8 100644 --- a/tests/RendererTests/BaseRendererTest.cs +++ b/tests/RendererTests/BaseRendererTest.cs @@ -133,6 +133,8 @@ public void Dispose() public bool HasErrors => GlobalDiagnostics.Any(x => x.Severity is DiagnosticSeverity.Error); public IReadOnlyList GlobalDiagnostics => _globalDiagnostics; + + public ComponentTypingContext RootTypingContext => ComponentTypingContext.Default; public List AllDiagnostics { get; } private readonly List _globalDiagnostics; From d26a7a798027a0f997e38a3d17c6190ebe080311 Mon Sep 17 00:00:00 2001 From: Quin Lynch <49576606+quinchs@users.noreply.github.com> Date: Tue, 9 Dec 2025 18:42:05 -0400 Subject: [PATCH 2/2] fix tests --- Sandbox/Examples/Spyfall/PackExample.cs | 10 ++--- .../Nodes/Components/ComponentBuilderKind.cs | 43 ++++-------------- .../Utils/TypeUtils.cs | 22 +++++++++ .../FunctionalComponentTests.cs | 45 +++++++++++++++++++ 4 files changed, 80 insertions(+), 40 deletions(-) create mode 100644 tests/ComponentTests/FunctionalComponentTests.cs diff --git a/Sandbox/Examples/Spyfall/PackExample.cs b/Sandbox/Examples/Spyfall/PackExample.cs index 736560e..6b9c43a 100644 --- a/Sandbox/Examples/Spyfall/PackExample.cs +++ b/Sandbox/Examples/Spyfall/PackExample.cs @@ -5,7 +5,7 @@ namespace Sandbox.Examples.Spyfall; public class PackExample { - public static MessageComponent CreatePackInfo( + public static CXMessageComponent CreatePackInfo( Pack pack, int locationPage ) => cx( @@ -20,7 +20,7 @@ int locationPage """ ); - public static MessageComponent PageControls(Pack pack, int page) + public static CXMessageComponent PageControls(Pack pack, int page) => cx( $""" @@ -42,7 +42,7 @@ public static MessageComponent PageControls(Pack pack, int page) """ ); - public static MessageComponent Locations(IReadOnlyList locations, int page) + public static CXMessageComponent Locations(IReadOnlyList locations, int page) { const int packLocationsPerPage = 3; @@ -67,7 +67,7 @@ public static MessageComponent Locations(IReadOnlyList locations, int ); } - public static MessageComponent LocationListItem(Location location, int index, int pageLower) + public static CXMessageComponent LocationListItem(Location location, int index, int pageLower) { var content = cx( $""" @@ -130,7 +130,7 @@ Created by {user.DisplayName} ); } - public static MessageComponent PackHeader(Pack pack) + public static CXMessageComponent PackHeader(Pack pack) { var packHeaderText = cx( $""" diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ComponentBuilderKind.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ComponentBuilderKind.cs index 906b951..72d9693 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ComponentBuilderKind.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ComponentBuilderKind.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using Discord.Net.ComponentDesignerGenerator.Utils; using Microsoft.CodeAnalysis; namespace Discord.CX.Nodes.Components; @@ -64,43 +65,15 @@ out ComponentBuilderKind kind current = enumerableType.TypeArguments[0]; } - if ( - compilation.HasImplicitConversion( - current, - compilation.GetKnownTypes().CXMessageComponentType - ) - ) - { - kind |= ComponentBuilderKind.CXMessageComponent; - } - else if ( - compilation.HasImplicitConversion( - current, - compilation.GetKnownTypes().IMessageComponentBuilderType - ) - ) - { + if (current.IsInTypeTree(compilation.GetKnownTypes().MessageComponentType)) + kind |= ComponentBuilderKind.MessageComponent; + else if (current.IsInTypeTree(compilation.GetKnownTypes().IMessageComponentBuilderType)) kind |= ComponentBuilderKind.IMessageComponentBuilder; - } - else if ( - compilation.HasImplicitConversion( - current, - compilation.GetKnownTypes().IMessageComponentType - ) - ) - { + else if(current.IsInTypeTree(compilation.GetKnownTypes().IMessageComponentType)) kind |= ComponentBuilderKind.IMessageComponent; - } - else if ( - compilation.HasImplicitConversion( - current, - compilation.GetKnownTypes().MessageComponentType - ) - ) - { - kind |= ComponentBuilderKind.IMessageComponent; - } - + else if (current.IsInTypeTree(compilation.GetKnownTypes().CXMessageComponentType)) + kind |= ComponentBuilderKind.CXMessageComponent; + return (kind & ComponentBuilderKind.ComponentMask) is not 0; } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Utils/TypeUtils.cs b/src/Discord.Net.ComponentDesigner.Generator/Utils/TypeUtils.cs index ed5f9ba..b700299 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Utils/TypeUtils.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Utils/TypeUtils.cs @@ -5,6 +5,28 @@ namespace Discord.Net.ComponentDesignerGenerator.Utils; public static class TypeUtils { + public static bool IsInTypeTree(this ITypeSymbol symbol, ITypeSymbol? other) + { + if (other is null) return false; + + if (symbol.TypeKind is TypeKind.Class) + { + var current = symbol; + + while (current is not null) + { + if (other.Equals(current, SymbolEqualityComparer.Default)) return true; + + current = current.BaseType; + } + + return false; + } + + return other.Equals(symbol, SymbolEqualityComparer.Default) || + other.AllInterfaces.Contains(symbol, SymbolEqualityComparer.Default); + } + public static bool TryGetEnumerableType(this ITypeSymbol? symbol, out ITypeSymbol inner) { if (symbol is not INamedTypeSymbol named) diff --git a/tests/ComponentTests/FunctionalComponentTests.cs b/tests/ComponentTests/FunctionalComponentTests.cs new file mode 100644 index 0000000..fdb8a2d --- /dev/null +++ b/tests/ComponentTests/FunctionalComponentTests.cs @@ -0,0 +1,45 @@ +using Discord.CX.Nodes.Components; +using Discord.CX.Nodes.Components.Custom; + +namespace UnitTests.ComponentTests; + +public sealed class FunctionalComponentTests : BaseComponentTest +{ + [Fact] + public void ChildOfContainer() + { + Graph( + """ + + + + """, + additionalMethods: + """ + public static MessageComponent MyFunc() => null!; + """ + ); + { + Node(); + { + Node(); + } + + Validate(hasErrors: false); + + Renders( + """ + new global::Discord.ContainerBuilder() + { + Components = + [ + ..global::TestClass.MyFunc().Components.Select(x => x.ToBuilder()) + ] + } + """ + ); + + EOF(); + } + } +} \ No newline at end of file