diff --git a/README.md b/README.md index bf5093f..bad7249 100644 --- a/README.md +++ b/README.md @@ -288,10 +288,19 @@ A Media Gallery allows you to display media attachments in an organized format. #### Valid children - [Media Gallery Item](#media-gallery-item) +- Interpolations of supported types: + - `Uri` + - `string` + - `UnfurledMediaItemProperties` + - `IEnumerable` of the above ```html - ... + + {myUri} + {myStringUrl} + {myUnfurledItem} + {myUriCollection} ``` diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGallery/MediaGalleryComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGallery/MediaGalleryComponentNode.cs new file mode 100644 index 0000000..b5b7241 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGallery/MediaGalleryComponentNode.cs @@ -0,0 +1,430 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Discord.CX.Parser; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.CX.Nodes.Components; + +using InterpolationIndex = int; + +public sealed class MediaGalleryComponentNode : ComponentNode +{ + public sealed class MediaGalleryState : ComponentState + { + // Store interpolations with their position index in the source children + // Info is not stored - it's retrieved from context during Validate/Render + public required EquatableArray Interpolations { get; init; } + } + + public override string Name => "media-gallery"; + + public override IReadOnlyList Aliases { get; } = ["gallery"]; + + public override IReadOnlyList Properties { get; } + + public override bool HasChildren => true; + + public MediaGalleryComponentNode() + { + Properties = + [ + ComponentProperty.Id, + ]; + } + + public override MediaGalleryState? CreateState(ComponentStateInitializationContext context) + { + if (context.Node is not CXElement element) return null; + + // Add children for normal processing (media-gallery-item elements) + context.AddChildren(element.Children); + + // Extract interpolations from children for later processing, tracking their position + var interpolations = new List(); + for (var i = 0; i < element.Children.Count; i++) + { + ExtractInterpolations(element.Children[i], i, interpolations); + } + + return new MediaGalleryState + { + Source = element, + Interpolations = [..interpolations] + }; + } + + private void ExtractInterpolations(CXNode node, int childIndex, List interpolations) + { + // Extract all interpolations regardless of type - type checking happens during validation/rendering + switch (node) + { + case CXValue.Interpolation interpolation: + interpolations.Add(interpolation.InterpolationIndex); + break; + case CXValue.Multipart { HasInterpolations: true } multipart: + foreach (var token in multipart.Tokens) + { + if (token.InterpolationIndex is null) continue; + interpolations.Add(token.InterpolationIndex.Value); + } + + break; + } + } + + private static bool IsUriType(ITypeSymbol? symbol, Compilation compilation) + { + if (symbol is null) return false; + + var knownTypes = compilation.GetKnownTypes(); + var uriType = knownTypes.UriType; + if (uriType is null) + { + // Fallback: Check if the symbol's fully qualified name is System.Uri + var fullName = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + return fullName == "global::System.Uri"; + } + + return SymbolEqualityComparer.Default.Equals(symbol, uriType) || + compilation.HasImplicitConversion(symbol, uriType); + } + + private static bool IsStringType(ITypeSymbol? symbol, Compilation compilation) + => symbol?.SpecialType is SpecialType.System_String; + + private static bool IsUnfurledMediaItemType(ITypeSymbol? symbol, Compilation compilation) + { + if (symbol is null) return false; + + var knownTypes = compilation.GetKnownTypes(); + var unfurledType = knownTypes.UnfurledMediaItemPropertiesType; + if (unfurledType is null) return false; + + return SymbolEqualityComparer.Default.Equals(symbol, unfurledType) || + compilation.HasImplicitConversion(symbol, unfurledType); + } + + private static bool IsEnumerableOfSupportedType(ITypeSymbol? symbol, Compilation compilation, + out ITypeSymbol? elementType) + { + if (!symbol.TryGetEnumerableType(out elementType)) + { + elementType = null; + return false; + } + + // Check if T is one of the supported types + return IsUriType(elementType, compilation) || + IsStringType(elementType, compilation) || + IsUnfurledMediaItemType(elementType, compilation); + } + + [Flags] + public enum InterpolationType + { + Unsupported = 0, + Uri = 1, + String = 2, + UnfurledMediaItem = 4, + EnumerableOf = 1 << 3, + + EnumerableOfUri = Uri | EnumerableOf, + EnumerableOfString = String | EnumerableOf, + EnumerableOfUnfurledMediaItem = UnfurledMediaItem | EnumerableOf + } + + private static InterpolationType GetInterpolationType(ITypeSymbol? symbol, Compilation compilation) + { + if (symbol is null) return InterpolationType.Unsupported; + + var kind = InterpolationType.Unsupported; + + if (symbol.SpecialType is not SpecialType.System_String && symbol.TryGetEnumerableType(out var inner)) + { + kind |= InterpolationType.EnumerableOf; + symbol = inner; + } + + if (IsUriType(symbol, compilation)) + kind |= InterpolationType.Uri; + else if (IsStringType(symbol, compilation)) + kind |= InterpolationType.String; + else if (IsUnfurledMediaItemType(symbol, compilation)) + kind |= InterpolationType.UnfurledMediaItem; + else return InterpolationType.Unsupported; + + return kind; + } + + private static bool IsValidChild(ComponentNode node) + => node is IDynamicComponentNode + or MediaGalleryItemComponentNode; + + public override void Validate(MediaGalleryState state, IComponentContext context, IList diagnostics) + { + var validItemCount = 0; + var hasEnumerables = false; + + // Count valid children from the graph + foreach (var child in state.Children) + { + if (!IsValidChild(child.Inner)) + { + diagnostics.Add( + Diagnostics.InvalidMediaGalleryChild(child.Inner.Name), + child.State.Source + ); + } + else validItemCount++; + } + + // Count interpolations based on their type + foreach (var index in state.Interpolations) + { + var info = context.GetInterpolationInfo(index); + var interpType = GetInterpolationType(info.Symbol, context.Compilation); + + // Count items based on interpolation type + if (interpType == InterpolationType.Unsupported) + { + // Report unsupported type as diagnostic + var node = state.Source + .Document + ?.InterpolationTokens + .ElementAtOrDefault(index) + ?? state.Source; + + diagnostics.Add( + Diagnostics.InvalidMediaGalleryChild(info.Symbol?.ToDisplayString() ?? "unknown"), + node + ); + } + else if ((interpType & InterpolationType.EnumerableOf) != 0) + { + // For enumerables, assume they are empty for static validation (runtime check exists) + // But track that we have them so we don't report empty gallery error + hasEnumerables = true; + } + else + { + // Single item types + validItemCount++; + } + } + + // Only report empty gallery if there are no items AND no enumerables + if (validItemCount is 0 && !hasEnumerables) + { + diagnostics.Add( + Diagnostics.MediaGalleryIsEmpty, + state.Source + ); + } + else if (validItemCount > Constants.MAX_MEDIA_ITEMS) + { + // Report the error on items beyond the limit + var graphValidChildren = state.Children.Where(x => IsValidChild(x.Inner)).ToArray(); + + if (graphValidChildren.Length > Constants.MAX_MEDIA_ITEMS) + { + var extra = graphValidChildren.Skip(Constants.MAX_MEDIA_ITEMS).ToArray(); + var span = TextSpan.FromBounds( + extra[0].State.Source.Span.Start, + extra[extra.Length - 1].State.Source.Span.End + ); + + diagnostics.Add( + Diagnostics.TooManyItemsInMediaGallery, + span + ); + } + else + { + // If interpolations caused the overflow, report on the whole gallery + diagnostics.Add( + Diagnostics.TooManyItemsInMediaGallery, + state.Source + ); + } + } + + base.Validate(state, context, diagnostics); + } + + public override Result Render( + MediaGalleryState state, + IComponentContext context, + ComponentRenderingOptions options + ) => state + .RenderProperties(this, context, asInitializers: true) + .Combine(RenderChildrenWithUriWrapping(state, context)) + .Map(x => + { + var (props, children) = x; + + var init = new StringBuilder(props); + + if (!string.IsNullOrWhiteSpace(children)) + { + if (!string.IsNullOrWhiteSpace(props)) init.Append(',').AppendLine(); + + init.Append( + $""" + Items = + [ + {children.WithNewlinePadding(4)} + ] + """ + ); + } + + return + $"new {context.KnownTypes.MediaGalleryBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}(){ + init.ToString() + .WithNewlinePadding(4) + .PrefixIfSome($"{Environment.NewLine}{{{Environment.NewLine}".Postfix(4)) + .PostfixIfSome($"{Environment.NewLine}}}")}"; + }); + + private Result RenderChildrenWithUriWrapping( + MediaGalleryState state, + IComponentContext context + ) => GetOrderedChildrenRenderers(state, context) + .Select(renderer => renderer(state, context)) + .FlattenAll() + .Map(x => string.Join($",{Environment.NewLine}", x)); + + private static IEnumerable> GetOrderedChildrenRenderers( + MediaGalleryState state, + IComponentContext context + ) + { + if (state.Source is not CXElement element) yield break; + + var stack = new Stack(); + + // Push children in reverse order + for (var i = element.Children.Count - 1; i >= 0; i--) + stack.Push(element.Children[i]); + + var childPointer = 0; + var interpPointer = 0; + + while (stack.Count > 0) + { + var current = stack.Pop(); + + switch (current) + { + case CXElement elem: + var child = state.Children.Count > childPointer + ? state.Children[childPointer++] + : null; + + if (child is not null && ReferenceEquals(elem, child.State.Source)) + yield return (_, ctx, _) => child.Render(ctx); + + break; + + case CXValue.Multipart multi: + for (var i = multi.Tokens.Count - 1; i >= 0; i--) + stack.Push(multi.Tokens[i]); + break; + + case CXValue.Interpolation interp + when TryGetInterpolationRenderer( + interp.InterpolationIndex, + ref interpPointer, + state, + context, + out var renderer + ): + yield return renderer; + break; + + case CXToken { Kind: CXTokenKind.Interpolation, InterpolationIndex: { } idx } + when TryGetInterpolationRenderer(idx, ref interpPointer, state, context, out var renderer): + yield return renderer; + break; + } + } + } + + private static bool TryGetInterpolationRenderer( + int interpolationIndex, + ref int pointer, + MediaGalleryState state, + IComponentContext context, + out ComponentNodeRenderer renderer + ) + { + renderer = null!; + + if (pointer >= state.Interpolations.Count) + return false; + + var stateIndex = state.Interpolations[pointer]; + + if (stateIndex != interpolationIndex) + return false; + + pointer++; + + var info = context.GetInterpolationInfo(interpolationIndex); + var interpType = GetInterpolationType(info.Symbol, context.Compilation); + + if (interpType == InterpolationType.Unsupported) + return false; + + renderer = (_, ctx, _) => RenderInterpolation(ctx, info, interpType); + return true; + } + + private static string RenderInterpolation( + IComponentContext context, + DesignerInterpolationInfo info, + InterpolationType type + ) + { + var typeStr = info.Symbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var designerValue = context.GetDesignerValue(info, typeStr); + + var mediaGalleryItemType = context.KnownTypes.MediaGalleryItemPropertiesType!.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ); + + var unfurledMediaType = + context.KnownTypes.UnfurledMediaItemPropertiesType!.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat); + + var isEnumerable = (type & InterpolationType.EnumerableOf) != 0; + var baseType = type & ~InterpolationType.EnumerableOf; + + var source = isEnumerable ? "x" : designerValue; + + var renderer = baseType switch + { + InterpolationType.String => $"new {unfurledMediaType}({source})", + InterpolationType.Uri => $"new {unfurledMediaType}({source}.ToString())", + InterpolationType.UnfurledMediaItem => source, + _ => throw new InvalidOperationException($"Unsupported interpolation type: {baseType}") + }; + + renderer + = $""" + new {mediaGalleryItemType}( + media: {renderer} + ) + """; + + + if (isEnumerable) + renderer = $"..{designerValue}.Select(x => {renderer})"; + + return renderer; + } +} \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGallery/MediaGalleryItemComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGallery/MediaGalleryItemComponentNode.cs new file mode 100644 index 0000000..37ba69a --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGallery/MediaGalleryItemComponentNode.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using Microsoft.CodeAnalysis; + +namespace Discord.CX.Nodes.Components; + +public sealed class MediaGalleryItemComponentNode : ComponentNode +{ + public override string Name => "media-gallery-item"; + + public override IReadOnlyList Aliases { get; } = ["gallery-item", "media", "item"]; + + public ComponentProperty Url { get; } + public ComponentProperty Description { get; } + public ComponentProperty Spoiler { get; } + + public override IReadOnlyList Properties { get; } + + public MediaGalleryItemComponentNode() + { + Properties = + [ + Url = new( + "url", + aliases: ["media"], + renderer: Renderers.UnfurledMediaItem, + dotnetParameterName: "media" + ), + Description = new( + "description", + isOptional: true, + renderer: Renderers.String + ), + Spoiler = new( + "spoiler", + isOptional: true, + renderer: Renderers.Boolean, + dotnetParameterName: "isSpoiler" + ) + ]; + } + + public override Result Render( + ComponentState state, + IComponentContext context, + ComponentRenderingOptions options + ) => state + .RenderProperties(this, context) + .Map(x => + $""" + new {context.KnownTypes.MediaGalleryItemPropertiesType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}( + {x.WithNewlinePadding(4)} + ) + """ + ); +} \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs deleted file mode 100644 index 7644362..0000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGalleryComponentNode.cs +++ /dev/null @@ -1,162 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using Microsoft.CodeAnalysis.Text; -using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; - -namespace Discord.CX.Nodes.Components; - -public sealed class MediaGalleryComponentNode : ComponentNode -{ - public override string Name => "media-gallery"; - - public override IReadOnlyList Aliases { get; } = ["gallery"]; - - public override IReadOnlyList Properties { get; } - - public override bool HasChildren => true; - - public MediaGalleryComponentNode() - { - Properties = - [ - ComponentProperty.Id, - ]; - } - - public override void Validate(ComponentState state, IComponentContext context, IList diagnostics) - { - var validItemCount = 0; - - foreach (var child in state.Children) - { - if (!IsValidChild(child.Inner)) - { - diagnostics.Add( - Diagnostics.InvalidMediaGalleryChild(child.Inner.Name), - child.State.Source - ); - } - else validItemCount++; - } - - if (validItemCount is 0) - { - diagnostics.Add( - Diagnostics.MediaGalleryIsEmpty, - state.Source - ); - } - else if (validItemCount > Constants.MAX_MEDIA_ITEMS) - { - var extra = state - .Children - .Where(x => IsValidChild(x.Inner)) - .Skip(Constants.MAX_MEDIA_ITEMS) - .ToArray(); - - var span = TextSpan.FromBounds( - extra[0].State.Source.Span.Start, - extra[extra.Length - 1].State.Source.Span.End - ); - - diagnostics.Add( - Diagnostics.TooManyItemsInMediaGallery, - span - ); - } - - base.Validate(state, context, diagnostics); - } - - private static bool IsValidChild(ComponentNode node) - => node is IDynamicComponentNode - or MediaGalleryItemComponentNode; - - public override Result Render( - ComponentState state, - IComponentContext context, - ComponentRenderingOptions options - ) => state - .RenderProperties(this, context, asInitializers: true) - .Combine(state.RenderChildren(context)) - .Map(x => - { - var (props, children) = x; - - var init = new StringBuilder(props); - - if (!string.IsNullOrWhiteSpace(children)) - { - if (!string.IsNullOrWhiteSpace(props)) init.Append(',').AppendLine(); - - init.Append( - $""" - Items = - [ - {children.WithNewlinePadding(4)} - ] - """ - ); - } - - return - $"new {context.KnownTypes.MediaGalleryBuilderType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}(){ - init.ToString() - .WithNewlinePadding(4) - .PrefixIfSome($"{Environment.NewLine}{{{Environment.NewLine}".Postfix(4)) - .PostfixIfSome($"{Environment.NewLine}}}")}"; - }); -} - -public sealed class MediaGalleryItemComponentNode : ComponentNode -{ - public override string Name => "media-gallery-item"; - - public override IReadOnlyList Aliases { get; } = ["gallery-item", "media", "item"]; - - public ComponentProperty Url { get; } - public ComponentProperty Description { get; } - public ComponentProperty Spoiler { get; } - - public override IReadOnlyList Properties { get; } - - public MediaGalleryItemComponentNode() - { - Properties = - [ - Url = new( - "url", - aliases: ["media"], - renderer: Renderers.UnfurledMediaItem, - dotnetParameterName: "media" - ), - Description = new( - "description", - isOptional: true, - renderer: Renderers.String - ), - Spoiler = new( - "spoiler", - isOptional: true, - renderer: Renderers.Boolean, - dotnetParameterName: "isSpoiler" - ) - ]; - } - - public override Result Render( - ComponentState state, - IComponentContext context, - ComponentRenderingOptions options - ) => state - .RenderProperties(this, context) - .Map(x => - $""" - new {context.KnownTypes.MediaGalleryItemPropertiesType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}( - {x.WithNewlinePadding(4)} - ) - """ - ); -} \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Parser/CXNodeEqualityComparer.cs b/src/Discord.Net.ComponentDesigner.Parser/CXNodeEqualityComparer.cs new file mode 100644 index 0000000..a82a9de --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/CXNodeEqualityComparer.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Discord.CX.Parser; + +public sealed class CXNodeEqualityComparer(SyntaxEqualityFlags flags = SyntaxEqualityFlags.All) : + IEqualityComparer +{ + public static readonly CXNodeEqualityComparer Default = new(); + + public bool Equals(ICXNode x, ICXNode y) + { + if (ReferenceEquals(x, y)) return true; + + if ( + (flags & SyntaxEqualityFlags.CompareSourceDocument) != 0 && + (!x.Document?.Equals(y.Document) ?? y.Document is not null) + ) + { + return false; + } + + if ( + (flags & SyntaxEqualityFlags.CompareTrivia) != 0 && + ( + !x.LeadingTrivia.Equals(y.LeadingTrivia) || + !x.TrailingTrivia.Equals(y.LeadingTrivia) + ) + ) return false; + + if ( + (flags & SyntaxEqualityFlags.CompareDiagnostics) != 0 && + !x.Diagnostics.SequenceEqual(y.Diagnostics) + ) return false; + + if ( + (flags & SyntaxEqualityFlags.CompareFlags) != 0 && + x is CXToken a && y is CXToken b && + a.Flags != b.Flags + ) return false; + + if ( + (flags & SyntaxEqualityFlags.CompareLocation) != 0 && + !x.Span.Equals(y.Span) + ) return false; + + return x.Equals(y); + } + + public int GetHashCode(ICXNode obj) + => obj.GetHashCode(); +} \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Parser/ICXNode.cs b/src/Discord.Net.ComponentDesigner.Parser/ICXNode.cs index 0407bbe..a5a145d 100644 --- a/src/Discord.Net.ComponentDesigner.Parser/ICXNode.cs +++ b/src/Discord.Net.ComponentDesigner.Parser/ICXNode.cs @@ -1,9 +1,10 @@ -using Microsoft.CodeAnalysis.Text; +using System; +using Microsoft.CodeAnalysis.Text; using System.Collections.Generic; namespace Discord.CX.Parser; -public interface ICXNode +public interface ICXNode : IEquatable { TextSpan FullSpan { get; } TextSpan Span { get; } @@ -28,5 +29,4 @@ public interface ICXNode void ResetCachedState(); string ToString(bool includeLeadingTrivia, bool includeTrailingTrivia); - } diff --git a/src/Discord.Net.ComponentDesigner.Parser/Lexer/CXToken.cs b/src/Discord.Net.ComponentDesigner.Parser/Lexer/CXToken.cs index 114d9c9..6b824e4 100644 --- a/src/Discord.Net.ComponentDesigner.Parser/Lexer/CXToken.cs +++ b/src/Discord.Net.ComponentDesigner.Parser/Lexer/CXToken.cs @@ -136,15 +136,13 @@ public bool Equals(CXToken? other) if (ReferenceEquals(this, other)) return true; - return - Kind == other.Kind && - Span.Equals(other.Span) && - LeadingTrivia.Equals(other.LeadingTrivia) && - TrailingTrivia.Equals(other.TrailingTrivia) && - Flags == other.Flags && - Diagnostics.SequenceEqual(other.Diagnostics); + return Kind == other.Kind && + Value == other.Value; } + public bool Equals(ICXNode other) + => other is CXToken token && Equals(token); + public override int GetHashCode() { unchecked diff --git a/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXNode.ParseSlot.cs b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXNode.ParseSlot.cs index 763120f..98ba945 100644 --- a/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXNode.ParseSlot.cs +++ b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXNode.ParseSlot.cs @@ -19,10 +19,10 @@ public ParseSlot(int id, ICXNode node) } public static bool operator ==(ParseSlot slot, ICXNode node) - => slot.Value == node; + => slot.Value.Equals(node); public static bool operator !=(ParseSlot slot, ICXNode node) - => slot.Value != node; + => !slot.Value.Equals(node); public bool Equals(ParseSlot other) => Equals(Value, other.Value); @@ -33,4 +33,4 @@ public override bool Equals(object? obj) public override int GetHashCode() => Value.GetHashCode(); } -} +} \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXNode.cs b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXNode.cs index 06c9c14..b438630 100644 --- a/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXNode.cs +++ b/src/Discord.Net.ComponentDesigner.Parser/Nodes/CXNode.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Linq; using System.Text; +using Discord.CX.Util; namespace Discord.CX.Parser; @@ -355,6 +356,24 @@ public void ResetCachedState() _descendants = null; } + public bool Equals(CXNode? other) + { + if (other is null) return false; + + if (ReferenceEquals(this, other)) return true; + + return _slots.SequenceEqual(other._slots); + } + + public bool Equals(ICXNode other) + => other is CXNode node && Equals(node); + + public override bool Equals(object? obj) + => obj is CXNode node && Equals(node); + + public override int GetHashCode() + => _slots.Aggregate(0, Hash.Combine); + public override string ToString() => ToString(false, false); public string ToFullString() => ToString(true, true); diff --git a/src/Discord.Net.ComponentDesigner.Parser/SyntaxEqualityFlags.cs b/src/Discord.Net.ComponentDesigner.Parser/SyntaxEqualityFlags.cs new file mode 100644 index 0000000..aaa7643 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Parser/SyntaxEqualityFlags.cs @@ -0,0 +1,15 @@ +using System; + +namespace Discord.CX.Parser; + +[Flags] +public enum SyntaxEqualityFlags : byte +{ + CompareTrivia = 1 << 0, + CompareLocation = 1 << 1, + CompareSourceDocument = 1 << 2, + CompareFlags = 1 << 3, + CompareDiagnostics = 1 << 4, + + All = byte.MaxValue +} \ No newline at end of file diff --git a/tests/ComponentTests/MediaGalleryTests.cs b/tests/ComponentTests/MediaGalleryTests.cs index a6a6d74..52c9a25 100644 --- a/tests/ComponentTests/MediaGalleryTests.cs +++ b/tests/ComponentTests/MediaGalleryTests.cs @@ -1,4 +1,5 @@ -using Discord.CX; +using Discord; +using Discord.CX; using Discord.CX.Nodes.Components; using Microsoft.CodeAnalysis.Text; using Xunit.Abstractions; @@ -152,9 +153,623 @@ public void GalleryWithInvalidChild() Diagnostics.InvalidMediaGalleryChild("container"), containerNode.State.Source ); - + Diagnostic(Diagnostics.MediaGalleryIsEmpty.Id); - + + EOF(); + } + } + + [Fact] + public void GalleryWithUriInterpolation() + { + Graph( + """ + + {url1} + {url2} + + """, + pretext: """ + System.Uri url1 = new System.Uri("https://example.com/image1.png"); + System.Uri url2 = new System.Uri("https://example.com/image2.png"); + """ + ); + { + Node(); + + Validate(hasErrors: false); + + Renders( + """ + new global::Discord.MediaGalleryBuilder() + { + Items = + [ + new global::Discord.MediaGalleryItemProperties( + media: new global::Discord.UnfurledMediaItemProperties(designer.GetValue(0).ToString()) + ), + new global::Discord.MediaGalleryItemProperties( + media: new global::Discord.UnfurledMediaItemProperties(designer.GetValue(1).ToString()) + ) + ] + } + """ + ); + + EOF(); + } + } + + [Fact] + public void GalleryMixingItemsAndUriInterpolations() + { + Graph( + """ + + + {url1} + + {url2} + + """, + pretext: """ + System.Uri url1 = new System.Uri("https://example.com/image2.png"); + System.Uri url2 = new System.Uri("https://example.com/image4.png"); + """ + ); + { + Node(); + { + Node(); + Node(); + } + + Validate(hasErrors: false); + + // Just verify it renders successfully - the order should be item1, url2, item3, url4 + // but verifying exact string match is brittle due to whitespace/formatting + Renders( + """ + new global::Discord.MediaGalleryBuilder() + { + Items = + [ + new global::Discord.MediaGalleryItemProperties( + media: new global::Discord.UnfurledMediaItemProperties("https://example.com/image1.png") + ), + new global::Discord.MediaGalleryItemProperties( + media: new global::Discord.UnfurledMediaItemProperties(designer.GetValue(0).ToString()) + ), + new global::Discord.MediaGalleryItemProperties( + media: new global::Discord.UnfurledMediaItemProperties("https://example.com/image3.png") + ), + new global::Discord.MediaGalleryItemProperties( + media: new global::Discord.UnfurledMediaItemProperties(designer.GetValue(1).ToString()) + ) + ] + } + """ + ); + + EOF(); + } + } + + [Fact] + public void GalleryWithMinimumOneItem() + { + Graph( + """ + + {url} + + """, + pretext: """ + System.Uri url = new System.Uri("https://example.com/image.png"); + """ + ); + { + Node(); + + Validate(hasErrors: false); + + Renders( + """ + new global::Discord.MediaGalleryBuilder() + { + Items = + [ + new global::Discord.MediaGalleryItemProperties( + media: new global::Discord.UnfurledMediaItemProperties(designer.GetValue(0).ToString()) + ) + ] + } + """ + ); + + EOF(); + } + } + + [Fact] + public void GalleryWithMaximumTenItems() + { + Graph( + """ + + {url1} + {url2} + {url3} + {url4} + {url5} + {url6} + {url7} + {url8} + {url9} + {url10} + + """, + pretext: """ + System.Uri url1 = new System.Uri("https://example.com/1.png"); + System.Uri url2 = new System.Uri("https://example.com/2.png"); + System.Uri url3 = new System.Uri("https://example.com/3.png"); + System.Uri url4 = new System.Uri("https://example.com/4.png"); + System.Uri url5 = new System.Uri("https://example.com/5.png"); + System.Uri url6 = new System.Uri("https://example.com/6.png"); + System.Uri url7 = new System.Uri("https://example.com/7.png"); + System.Uri url8 = new System.Uri("https://example.com/8.png"); + System.Uri url9 = new System.Uri("https://example.com/9.png"); + System.Uri url10 = new System.Uri("https://example.com/10.png"); + """ + ); + { + Node(); + + Validate(hasErrors: false); + + Renders( + """ + new global::Discord.MediaGalleryBuilder() + { + Items = + [ + new global::Discord.MediaGalleryItemProperties( + media: new global::Discord.UnfurledMediaItemProperties(designer.GetValue(0).ToString()) + ), + new global::Discord.MediaGalleryItemProperties( + media: new global::Discord.UnfurledMediaItemProperties(designer.GetValue(1).ToString()) + ), + new global::Discord.MediaGalleryItemProperties( + media: new global::Discord.UnfurledMediaItemProperties(designer.GetValue(2).ToString()) + ), + new global::Discord.MediaGalleryItemProperties( + media: new global::Discord.UnfurledMediaItemProperties(designer.GetValue(3).ToString()) + ), + new global::Discord.MediaGalleryItemProperties( + media: new global::Discord.UnfurledMediaItemProperties(designer.GetValue(4).ToString()) + ), + new global::Discord.MediaGalleryItemProperties( + media: new global::Discord.UnfurledMediaItemProperties(designer.GetValue(5).ToString()) + ), + new global::Discord.MediaGalleryItemProperties( + media: new global::Discord.UnfurledMediaItemProperties(designer.GetValue(6).ToString()) + ), + new global::Discord.MediaGalleryItemProperties( + media: new global::Discord.UnfurledMediaItemProperties(designer.GetValue(7).ToString()) + ), + new global::Discord.MediaGalleryItemProperties( + media: new global::Discord.UnfurledMediaItemProperties(designer.GetValue(8).ToString()) + ), + new global::Discord.MediaGalleryItemProperties( + media: new global::Discord.UnfurledMediaItemProperties(designer.GetValue(9).ToString()) + ) + ] + } + """ + ); + + EOF(); + } + } + + [Fact] + public void GalleryWithTooManyMixedItems() + { + Graph( + """ + + + + {url3} + {url4} + {url5} + {url6} + {url7} + {url8} + {url9} + {url10} + {url11} + + """, + pretext: """ + System.Uri url3 = new System.Uri("https://example.com/3.png"); + System.Uri url4 = new System.Uri("https://example.com/4.png"); + System.Uri url5 = new System.Uri("https://example.com/5.png"); + System.Uri url6 = new System.Uri("https://example.com/6.png"); + System.Uri url7 = new System.Uri("https://example.com/7.png"); + System.Uri url8 = new System.Uri("https://example.com/8.png"); + System.Uri url9 = new System.Uri("https://example.com/9.png"); + System.Uri url10 = new System.Uri("https://example.com/10.png"); + System.Uri url11 = new System.Uri("https://example.com/11.png"); + """ + ); + { + Node(); + { + Node(); + Node(); + } + + Validate(hasErrors: true); + + Diagnostic(Diagnostics.TooManyItemsInMediaGallery.Id); + + EOF(); + } + } + + [Fact] + public void GalleryWithStringInterpolation() + { + Graph( + """ + + {url1} + {url2} + + """, + pretext: """ + string url1 = "https://example.com/image1.png"; + string url2 = "https://example.com/image2.png"; + """ + ); + { + Node(); + + Validate(hasErrors: false); + + Renders( + """ + new global::Discord.MediaGalleryBuilder() + { + Items = + [ + new global::Discord.MediaGalleryItemProperties( + media: new global::Discord.UnfurledMediaItemProperties(designer.GetValue(0)) + ), + new global::Discord.MediaGalleryItemProperties( + media: new global::Discord.UnfurledMediaItemProperties(designer.GetValue(1)) + ) + ] + } + """ + ); + + EOF(); + } + } + + [Fact] + public void GalleryWithUnfurledMediaItemInterpolation() + { + Graph( + """ + + {item} + + """, + pretext: """ + Discord.UnfurledMediaItemProperties item = new Discord.UnfurledMediaItemProperties("https://example.com/image.png"); + """ + ); + { + Node(); + + Validate(hasErrors: false); + + Renders( + """ + new global::Discord.MediaGalleryBuilder() + { + Items = + [ + new global::Discord.MediaGalleryItemProperties( + media: designer.GetValue(0) + ) + ] + } + """ + ); + + EOF(); + } + } + + [Fact] + public void GalleryWithEnumerableOfUris() + { + Graph( + """ + + {urls} + + """, + pretext: """ + System.Collections.Generic.List urls = new System.Collections.Generic.List { + new System.Uri("https://example.com/1.png"), + new System.Uri("https://example.com/2.png") + }; + """ + ); + { + Node(); + + Validate(hasErrors: false); + + Renders( + """ + new global::Discord.MediaGalleryBuilder() + { + Items = + [ + ..designer.GetValue>(0).Select(x => new global::Discord.MediaGalleryItemProperties( + media: new global::Discord.UnfurledMediaItemProperties(x.ToString()) + )) + ] + } + """ + ); + + EOF(); + } + } + + [Fact] + public void GalleryWithEnumerableOfStrings() + { + Graph( + """ + + {urls} + + """, + pretext: """ + System.Collections.Generic.List urls = new System.Collections.Generic.List { + "https://example.com/1.png", + "https://example.com/2.png" + }; + """ + ); + { + Node(); + + Validate(hasErrors: false); + + Renders( + """ + new global::Discord.MediaGalleryBuilder() + { + Items = + [ + ..designer.GetValue>(0).Select(x => new global::Discord.MediaGalleryItemProperties( + media: new global::Discord.UnfurledMediaItemProperties(x) + )) + ] + } + """ + ); + + EOF(); + } + } + + [Fact] + public void GalleryWithEnumerableOfUnfurledMediaItems() + { + Graph( + """ + + {items} + + """, + pretext: """ + System.Collections.Generic.List items = new System.Collections.Generic.List { + new Discord.UnfurledMediaItemProperties("https://example.com/1.png"), + new Discord.UnfurledMediaItemProperties("https://example.com/2.png") + }; + """ + ); + { + Node(); + + Validate(hasErrors: false); + + Renders( + """ + new global::Discord.MediaGalleryBuilder() + { + Items = + [ + ..designer.GetValue>(0).Select(x => new global::Discord.MediaGalleryItemProperties( + media: x + )) + ] + } + """ + ); + + EOF(); + } + } + + [Fact] + public void GalleryMixingAllTypes() + { + Graph( + """ + + + {uriValue} + {stringValue} + {unfurledValue} + + """, + pretext: """ + System.Uri uriValue = new System.Uri("https://example.com/2.png"); + string stringValue = "https://example.com/3.png"; + Discord.UnfurledMediaItemProperties unfurledValue = new Discord.UnfurledMediaItemProperties("https://example.com/4.png"); + """ + ); + { + Node(); + { + Node(); + } + + Validate(hasErrors: false); + + Renders( + """ + new global::Discord.MediaGalleryBuilder() + { + Items = + [ + new global::Discord.MediaGalleryItemProperties( + media: new global::Discord.UnfurledMediaItemProperties("https://static.example.com/1.png") + ), + new global::Discord.MediaGalleryItemProperties( + media: new global::Discord.UnfurledMediaItemProperties(designer.GetValue(0).ToString()) + ), + new global::Discord.MediaGalleryItemProperties( + media: new global::Discord.UnfurledMediaItemProperties(designer.GetValue(1)) + ), + new global::Discord.MediaGalleryItemProperties( + media: designer.GetValue(2) + ) + ] + } + """ + ); + + EOF(); + } + } + + [Fact] + public void GalleryWithIEnumerableOfUris() + { + Graph( + """ + + {urls} + + """, + pretext: """ + System.Collections.Generic.IEnumerable urls = System.Linq.Enumerable.Empty(); + """ + ); + { + Node(); + + Validate(hasErrors: false); + + Renders( + """ + new global::Discord.MediaGalleryBuilder() + { + Items = + [ + ..designer.GetValue>(0).Select(x => new global::Discord.MediaGalleryItemProperties( + media: new global::Discord.UnfurledMediaItemProperties(x.ToString()) + )) + ] + } + """ + ); + + EOF(); + } + } + + [Fact] + public void GalleryWithIEnumerableOfStrings() + { + Graph( + """ + + {urls} + + """, + pretext: """ + System.Collections.Generic.IEnumerable urls = System.Linq.Enumerable.Empty(); + """ + ); + { + Node(); + + Validate(hasErrors: false); + + Renders( + """ + new global::Discord.MediaGalleryBuilder() + { + Items = + [ + ..designer.GetValue>(0).Select(x => new global::Discord.MediaGalleryItemProperties( + media: new global::Discord.UnfurledMediaItemProperties(x) + )) + ] + } + """ + ); + + EOF(); + } + } + + [Fact] + public void GalleryWithIEnumerableOfUnfurledMediaItems() + { + Graph( + """ + + {items} + + """, + pretext: """ + System.Collections.Generic.IEnumerable items = System.Linq.Enumerable.Empty(); + """ + ); + { + Node(); + + Validate(hasErrors: false); + + Renders( + """ + new global::Discord.MediaGalleryBuilder() + { + Items = + [ + ..designer.GetValue>(0).Select(x => new global::Discord.MediaGalleryItemProperties( + media: x + )) + ] + } + """ + ); + EOF(); } }