From 3e3c59505802aa50c757671e46763b3646c3605e Mon Sep 17 00:00:00 2001 From: Quin Lynch <49576606+quinchs@users.noreply.github.com> Date: Wed, 31 Dec 2025 11:38:46 -0400 Subject: [PATCH] refactor value renderers --- .../CXValueGenerators/BooleanGenerator.cs | 86 ++ .../CXValueGenerators/CXValueGenerator.cs | 184 +++ .../CXValueGeneratorOptions.cs | 14 + .../CXValueGeneratorTarget.cs | 18 + .../CXValueGenerators/ColorGenerator.cs | 153 +++ .../CXValueGenerators/ComponentGenerator.cs | 80 ++ .../CXValueGenerators/EmojiGenerator.cs | 154 +++ .../CXValueGenerators/EnumGenerator.cs | 246 ++++ .../CXValueGenerators/IntegerGenerator.cs | 71 ++ .../InterpolationGenerator.cs | 33 + .../CXValueGenerators/SnowflakeGenerator.cs | 64 ++ .../CXValueGenerators/StringGenerator.cs | 275 +++++ .../UnfurledMediaItemGenerator.cs | 20 + .../Diagnostics.cs | 9 + .../Nodes/ComponentNode.cs | 43 - .../Nodes/ComponentNodeOfT.cs | 40 + .../Nodes/ComponentProperty.cs | 8 +- .../Nodes/ComponentPropertyValue.cs | 2 +- .../Nodes/ComponentRenderingOptions.cs | 3 - .../Nodes/ComponentState.cs | 18 +- .../Nodes/ComponentTypingContext.cs | 5 + .../Nodes/Components/ButtonComponentNode.cs | 36 +- .../Nodes/Components/ComponentBuilderKind.cs | 487 ++++---- .../Components/ContainerComponentNode.cs | 4 +- .../Functional/FunctionalComponentNode.cs | 41 +- .../Custom/ProviderComponentNode.cs | 87 -- .../Nodes/Components/FileComponentNode.cs | 4 +- .../Components/FileUploadComponentNode.cs | 8 +- .../Components/InterleavedComponentNode.cs | 6 +- .../Nodes/Components/LabelComponentNode.cs | 8 +- .../MediaGalleryItemComponentNode.cs | 6 +- .../Nodes/Components/SectionComponentNode.cs | 10 +- .../SelectMenus/SelectMenuComponentNode.cs | 12 +- .../SelectMenuOptionComponentNode.cs | 10 +- .../Components/SeparatorComponentNode.cs | 4 +- .../Components/Text/TextControlElement.cs | 8 +- .../Text/TextDisplayComponentNode.cs | 2 +- .../Components/TextInputComponentNode.cs | 14 +- .../Components/ThumbnailComponentNode.cs | 6 +- .../Nodes/IComponentContext.cs | 12 + .../Nodes/IComponentPropertyValue.cs | 2 +- .../Nodes/Renderers/PropertyRenderer.cs | 7 - .../Renderers/PropertyRendereringOptions.cs | 11 - .../Nodes/Renderers/Renderers.cs | 1019 ----------------- .../Utils/Result.cs | 6 + .../Utils/TypeUtils.cs | 13 +- tests/ComponentTests/ButtonTests.cs | 10 +- tests/ComponentTests/SeparatorTests.cs | 8 +- tests/RendererTests/BaseRendererTest.cs | 9 +- tests/RendererTests/BooleanTests.cs | 14 +- tests/RendererTests/ColorTests.cs | 15 +- tests/RendererTests/IntegerTests.cs | 8 +- tests/RendererTests/SnowflakeTests.cs | 10 +- tests/RendererTests/StringTests.cs | 12 +- tests/RendererTests/UnfurledMediaItemTests.cs | 2 +- 55 files changed, 1880 insertions(+), 1557 deletions(-) create mode 100644 src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/BooleanGenerator.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/CXValueGenerator.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/CXValueGeneratorOptions.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/CXValueGeneratorTarget.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/ColorGenerator.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/ComponentGenerator.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/EmojiGenerator.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/EnumGenerator.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/IntegerGenerator.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/InterpolationGenerator.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/SnowflakeGenerator.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/StringGenerator.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/UnfurledMediaItemGenerator.cs create mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNodeOfT.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Custom/ProviderComponentNode.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/PropertyRenderer.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/PropertyRendereringOptions.cs delete mode 100644 src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs diff --git a/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/BooleanGenerator.cs b/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/BooleanGenerator.cs new file mode 100644 index 0000000..abf3f55 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/BooleanGenerator.cs @@ -0,0 +1,86 @@ +using Discord.CX.Parser; +using Microsoft.CodeAnalysis; + +namespace Discord.CX.Nodes; + +public sealed class BooleanGenerator : CXValueGenerator +{ + protected override Result RenderInterpolation( + IComponentContext context, + CXValueGeneratorTarget target, + CXToken token, + DesignerInterpolationInfo info, + CXValueGeneratorOptions options + ) + { + if ( + info.Constant is { HasValue: true, Value: bool v } + ) return v ? "true" : "false"; + + if (info.Constant is { HasValue: true, Value: string str }) + return FromText(token, str); + + if ( + context.Compilation.HasImplicitConversion( + info.Symbol, + context.Compilation.GetSpecialType(SpecialType.System_Boolean) + ) + ) + { + return context.GetDesignerValue(info, "bool"); + } + + + return Result.FromValue( + $"bool.Parse({context.GetDesignerValue(info)})", + Diagnostics.FallbackToRuntimeValueParsing("bool.Parse"), + token + ); + } + + protected override Result RenderScalar( + IComponentContext context, + CXValueGeneratorTarget target, + CXToken token, + CXValueGeneratorOptions options + ) => FromText(token, token.Value); + + protected override Result RenderMultipart( + IComponentContext context, + CXValueGeneratorTarget target, + CXValue.Multipart multipart, + CXValueGeneratorOptions options + ) + { + return Result.FromValue( + $"bool.Parse({StringGenerator.ToCSharpString(multipart)})", + Diagnostics.FallbackToRuntimeValueParsing("bool.Parse"), + multipart + ); + } + + protected override Result RenderMissingValue( + IComponentContext context, + CXValueGeneratorTarget target, + CXValueGeneratorOptions options + ) + { + if ( + target is CXValueGeneratorTarget.ComponentProperty { Property: { RequiresValue: false } } + ) return "true"; + + return base.RenderMissingValue(context, target, options); + } + + private static Result FromText(ICXNode owner, string text) + { + var lower = text.ToLowerInvariant(); + if (lower is not "true" and not "false") + return new DiagnosticInfo( + Diagnostics.TypeMismatch("bool", "string"), + owner + ); + + return lower; + } +} \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/CXValueGenerator.cs b/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/CXValueGenerator.cs new file mode 100644 index 0000000..ed6f883 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/CXValueGenerator.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Discord.CX.Nodes.Components; +using Discord.CX.Parser; +using Microsoft.CodeAnalysis; + +namespace Discord.CX.Nodes; + +public delegate Result CXValueGeneratorDelegate( + IComponentContext context, + CXValueGeneratorTarget target, + CXValueGeneratorOptions options +); + +public abstract class CXValueGenerator +{ + public static CXValueGeneratorDelegate Boolean => GetGenerator().Render; + public static CXValueGeneratorDelegate Color => GetGenerator().Render; + public static CXValueGeneratorDelegate Component => GetGenerator().Render; + public static CXValueGeneratorDelegate Emoji => GetGenerator().Render; + public static CXValueGeneratorDelegate Integer => GetGenerator().Render; + public static CXValueGeneratorDelegate Snowflake => GetGenerator().Render; + public static CXValueGeneratorDelegate String => GetGenerator().Render; + + public static CXValueGeneratorDelegate UnfurledMediaItem + => GetGenerator().Render; + + public static CXValueGeneratorDelegate Enum(string qualifiedEnumName, bool renderAsSymbolReference = true) + => EnumGenerator.Create(qualifiedEnumName, renderAsSymbolReference).Render; + + private static readonly Dictionary _renderers; + + static CXValueGenerator() + { + _renderers = typeof(CXValueGenerator) + .Assembly + .GetTypes() + .Where(x => + !x.IsAbstract && + x.BaseType == typeof(CXValueGenerator) && + x.GetConstructor(Type.EmptyTypes) is not null + ) + .ToDictionary( + x => x, + x => (CXValueGenerator)Activator.CreateInstance(x) + ); + } + + public static T GetGenerator() where T : CXValueGenerator + => (T)_renderers[typeof(T)]; + + public static CXValueGeneratorDelegate GetGeneratorForType( + Compilation compilation, + ITypeSymbol symbol, + bool allowComponents = false + ) + { + switch (symbol.SpecialType) + { + case SpecialType.System_String: return String; + case SpecialType.System_Int32: return Integer; + case SpecialType.System_UInt64: return Snowflake; + } + + var knownTypes = compilation.GetKnownTypes(); + + if ( + knownTypes.ColorType?.Equals(symbol, SymbolEqualityComparer.Default) ?? false + ) return Color; + + if ( + knownTypes.IEmoteType?.Equals(symbol, SymbolEqualityComparer.Default) ?? false + ) return Emoji; + + if (symbol.TypeKind is TypeKind.Enum) + return Enum(symbol.ToDisplayString()); + + if (allowComponents && ComponentBuilderKind.IsValidComponentBuilderType(symbol, compilation)) + return Component; + + + return new InterpolationGenerator(symbol).Render; + } + + public static Result Default( + IComponentContext context, + CXValueGeneratorTarget target, + CXValueGeneratorOptions options + ) => "default"; + + public virtual Result Render( + IComponentContext context, + CXValueGeneratorTarget target, + CXValueGeneratorOptions options + ) => target.Value switch + { + CXValue.Scalar scalar => RenderScalar(context, target, scalar.Token, options), + CXValue.Interpolation interpolation => RenderInterpolation( + context, + target, + interpolation.Token, + context.GetInterpolationInfo(interpolation), + options + ), + CXValue.StringLiteral stringLiteral => RenderStringLiteral(context, target, stringLiteral, options), + CXValue.Multipart multipart => ExtrapolateAndRenderMultipart(context, target, multipart, options), + CXValue.Element element => RenderElementValue(context, target, element, options), + _ => RenderMissingValue(context, target, options) + }; + + private Result ExtrapolateAndRenderMultipart( + IComponentContext context, + CXValueGeneratorTarget target, + CXValue.Multipart multipart, + CXValueGeneratorOptions options + ) + { + if (multipart is { HasInterpolations: false, Tokens.Count: 1 }) + return RenderScalar(context, target, multipart.Tokens[0], options); + + if (multipart.IsLoneInterpolatedLiteral(context, out var info)) + return RenderInterpolation(context, target, multipart.Tokens[0], info, options); + + return RenderMultipart(context, target, multipart, options); + } + + protected virtual Result RenderElementValue( + IComponentContext context, + CXValueGeneratorTarget target, + CXValue.Element element, + CXValueGeneratorOptions options + ) => new DiagnosticInfo( + Diagnostics.TypeMismatch("value", "element"), + target.Span + ); + + protected virtual Result RenderMissingValue( + IComponentContext context, + CXValueGeneratorTarget target, + CXValueGeneratorOptions options + ) => new DiagnosticInfo( + Diagnostics.TypeMismatch("value", "missing"), + target.Span + ); + + protected virtual Result RenderScalar( + IComponentContext context, + CXValueGeneratorTarget target, + CXToken token, + CXValueGeneratorOptions options + ) => new DiagnosticInfo( + Diagnostics.InvalidValue("scalar"), + token + ); + + protected virtual Result RenderInterpolation( + IComponentContext context, + CXValueGeneratorTarget target, + CXToken token, + DesignerInterpolationInfo info, + CXValueGeneratorOptions options + ) => new DiagnosticInfo( + Diagnostics.InvalidValue("interpolation"), + token + ); + + protected virtual Result RenderStringLiteral( + IComponentContext context, + CXValueGeneratorTarget target, + CXValue.StringLiteral stringLiteral, + CXValueGeneratorOptions options + ) => ExtrapolateAndRenderMultipart(context, target, stringLiteral, options); + + protected virtual Result RenderMultipart( + IComponentContext context, + CXValueGeneratorTarget target, + CXValue.Multipart multipart, + CXValueGeneratorOptions options + ) => new DiagnosticInfo( + Diagnostics.InvalidValue("multipart"), + multipart + ); +} \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/CXValueGeneratorOptions.cs b/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/CXValueGeneratorOptions.cs new file mode 100644 index 0000000..4bb77ec --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/CXValueGeneratorOptions.cs @@ -0,0 +1,14 @@ +namespace Discord.CX.Nodes; + +public readonly record struct CXValueGeneratorOptions( + ComponentTypingContext? TypingContext = null +) +{ + public static readonly CXValueGeneratorOptions Default = new(); + + public static implicit operator ComponentRenderingOptions(CXValueGeneratorOptions options) + => new(options.TypingContext); + + public static implicit operator CXValueGeneratorOptions(ComponentRenderingOptions options) + => new(options.TypingContext); +} \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/CXValueGeneratorTarget.cs b/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/CXValueGeneratorTarget.cs new file mode 100644 index 0000000..2bf2fd4 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/CXValueGeneratorTarget.cs @@ -0,0 +1,18 @@ +using Discord.CX.Parser; +using Microsoft.CodeAnalysis.Text; + +namespace Discord.CX.Nodes; + +public record CXValueGeneratorTarget( + CXValue? Value, + TextSpan Span +) +{ + public CXValueGeneratorTarget(CXValue value) : this(value, value.Span) + { + } + + public sealed record ComponentProperty( + IComponentPropertyValue Property + ) : CXValueGeneratorTarget(Property.Value, Property.Span); +} \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/ColorGenerator.cs b/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/ColorGenerator.cs new file mode 100644 index 0000000..a6f05e8 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/ColorGenerator.cs @@ -0,0 +1,153 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using Discord.CX.Parser; +using Microsoft.CodeAnalysis; + +namespace Discord.CX.Nodes; + +public sealed class ColorGenerator : CXValueGenerator +{ + private static readonly Dictionary> _fieldMaps = []; + + protected override Result RenderInterpolation( + IComponentContext context, + CXValueGeneratorTarget target, + CXToken token, + DesignerInterpolationInfo info, + CXValueGeneratorOptions options + ) + { + if (info.Constant.HasValue) + { + if (info.Constant.Value is string str) + return FromText(context, token, str); + + if ( + (info.Constant.Value?.GetType().IsNumericType() ?? false) && + uint.TryParse(info.Constant.Value.ToString(), out var hexColor) + ) + { + return $"new { + context.KnownTypes.ColorType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + }({hexColor})"; + } + } + + if ( + context.Compilation.HasImplicitConversion( + info.Symbol, + context.KnownTypes.ColorType + ) + ) + { + return context.GetDesignerValue( + info, + context.KnownTypes.ColorType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + ); + } + + return UseLibraryParseFunc(context, token, context.GetDesignerValue(info)); + } + + protected override Result RenderScalar( + IComponentContext context, + CXValueGeneratorTarget target, + CXToken token, + CXValueGeneratorOptions options + ) => FromText(context, token, token.Value); + + protected override Result RenderMultipart( + IComponentContext context, + CXValueGeneratorTarget target, + CXValue.Multipart multipart, + CXValueGeneratorOptions options + ) => UseLibraryParseFunc( + context, + multipart, + StringGenerator.ToCSharpString(multipart) + ); + + private static Result FromText(IComponentContext context, ICXNode owner, string text) + { + if (TryGetColorPreset(context, text, out var preset)) return preset; + + // check hex + var hex = text; + + if (hex.StartsWith("#")) + hex = hex.Substring(1); + + if ( + uint.TryParse( + hex, + NumberStyles.HexNumber, + null, + out var hexCode + ) + ) + { + return $"new { + context.KnownTypes.ColorType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + }({hexCode})"; + } + + return UseLibraryParseFunc(context, owner, text); + } + + private static Result UseLibraryParseFunc(IComponentContext context, ICXNode owner, string text) + => Result.FromValue( + $"{context.KnownTypes.ColorType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.Parse({ + StringGenerator.ToCSharpString(text) + })", + Diagnostics.FallbackToRuntimeValueParsing("Discord.Color.Parse"), + owner + ); + + private static bool TryGetColorPreset(IComponentContext context, string text, + [MaybeNullWhen(false)] out string preset) + { + if ( + TryGetColorPresetMap(context, out var map) && + map.TryGetValue(text.ToLowerInvariant(), out var name) + ) + { + preset = + $"{context.KnownTypes.ColorType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.{name}"; + return true; + } + + preset = null; + return false; + } + + private static bool TryGetColorPresetMap( + IComponentContext context, + [MaybeNullWhen(false)] out IReadOnlyDictionary map + ) + { + var colorType = context.Compilation.GetKnownTypes().ColorType; + + if (colorType is null) + { + map = null; + return false; + } + + var asmKey = colorType.ContainingAssembly.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + if (_fieldMaps.TryGetValue(asmKey, out map)) return true; + + map = _fieldMaps[asmKey] = colorType + .GetMembers() + .OfType() + .Where(x => + x.Type.Equals(colorType, SymbolEqualityComparer.Default) && + x.IsStatic + ) + .ToDictionary(x => x.Name.ToLowerInvariant(), x => x.Name); + + return true; + } +} \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/ComponentGenerator.cs b/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/ComponentGenerator.cs new file mode 100644 index 0000000..4f299e7 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/ComponentGenerator.cs @@ -0,0 +1,80 @@ +using Discord.CX.Nodes.Components; +using Discord.CX.Parser; + +namespace Discord.CX.Nodes; + +public sealed class ComponentGenerator : CXValueGenerator +{ + public override Result Render( + IComponentContext context, + CXValueGeneratorTarget target, + CXValueGeneratorOptions options + ) + { + if (target is not CXValueGeneratorTarget.ComponentProperty(var property)) + { + return new DiagnosticInfo( + Diagnostics.InvalidValue(target.GetType().Name), + target.Span + ); + } + + if (property.GraphNode is null) + { + return new DiagnosticInfo( + Diagnostics.InvalidPropertyValueSyntax("interpolation/element"), + target.Span + ); + } + + return base.Render(context, target, options); + } + + protected override Result RenderElementValue( + IComponentContext context, + CXValueGeneratorTarget target, + CXValue.Element element, + CXValueGeneratorOptions options + ) + { + if (target is not CXValueGeneratorTarget.ComponentProperty { Property.GraphNode: { } graphNode }) + return new DiagnosticInfo( + Diagnostics.InvalidPropertyValueSyntax("element"), + element + ); + + return graphNode.Render(context, options); + } + + protected override Result RenderInterpolation( + IComponentContext context, + CXValueGeneratorTarget target, + CXToken token, + DesignerInterpolationInfo info, + CXValueGeneratorOptions options + ) + { + if ( + !ComponentBuilderKind.IsValidComponentBuilderType( + info.Symbol, + context.Compilation, + out var kind + ) + ) + { + return new DiagnosticInfo( + Diagnostics.TypeMismatch( + "component", + info.Symbol?.ToDisplayString() ?? "unknown" + ), + token + ); + } + + return kind.Conform( + context.GetDesignerValue(info, info.Symbol), + options.TypingContext ?? ComponentTypingContext.SingleBuilder, + token + ); + } +} \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/EmojiGenerator.cs b/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/EmojiGenerator.cs new file mode 100644 index 0000000..6ad8551 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/EmojiGenerator.cs @@ -0,0 +1,154 @@ +using System.Text.RegularExpressions; +using Discord.CX.Parser; +using Microsoft.CodeAnalysis; + +namespace Discord.CX.Nodes; + +public sealed class EmojiGenerator : CXValueGenerator +{ + protected override Result RenderInterpolation( + IComponentContext context, + CXValueGeneratorTarget target, + CXToken token, + DesignerInterpolationInfo info, + CXValueGeneratorOptions options + ) + { + if ( + context.Compilation.HasImplicitConversion( + info.Symbol, + context.KnownTypes.IEmoteType + ) + ) + { + return context.GetDesignerValue( + info, + context.KnownTypes.IEmoteType + ); + } + + if (info.Constant is { HasValue: true, Value: string str }) + { + var builder = new Result.Builder(); + + LightlyValidateEmote(ref builder, str, token, out var isDiscordEmote, out var isUnicodeEmoji); + UseLibraryParse(ref builder, context, token, StringGenerator.ToCSharpString(str), isUnicodeEmoji, isDiscordEmote); + + return builder.Build(); + } + + return UseLibraryParse( + context, + token, + context.GetDesignerValue(info) + ); + } + + protected override Result RenderScalar( + IComponentContext context, + CXValueGeneratorTarget target, + CXToken token, + CXValueGeneratorOptions options + ) + { + var builder = new Result.Builder(); + + LightlyValidateEmote(ref builder, token.Value, token, out var isDiscordEmote, out var isUnicodeEmoji); + UseLibraryParse( + ref builder, + context, + token, + StringGenerator.ToCSharpString(token.Value), + isUnicodeEmoji, + isDiscordEmote + ); + + return builder.Build(); + } + + protected override Result RenderMultipart( + IComponentContext context, + CXValueGeneratorTarget target, + CXValue.Multipart multipart, + CXValueGeneratorOptions options + ) => UseLibraryParse( + context, + multipart, + StringGenerator.ToCSharpString(multipart) + ); + + private static void LightlyValidateEmote( + ref Result.Builder builder, + string emote, + ICXNode node, + out bool isDiscordEmote, + out bool isUnicodeEmoji + ) + { + isDiscordEmote = IsDiscordEmote.IsMatch(emote); + isUnicodeEmoji = !isDiscordEmote && IsEmoji.IsMatch(emote); + + if (!isDiscordEmote && !isUnicodeEmoji) + { + builder.AddDiagnostic( + Diagnostics.PossibleInvalidEmote(emote), + node + ); + } + } + + private static Result UseLibraryParse( + IComponentContext context, + ICXNode owner, + string code, + bool isUnicodeEmoji = false, + bool isDiscordEmote = false + ) + { + var builder = new Result.Builder(); + UseLibraryParse(ref builder, context, owner, code, isUnicodeEmoji, isDiscordEmote); + return builder.Build(); + } + + private static void UseLibraryParse( + ref Result.Builder builder, + IComponentContext context, + ICXNode owner, + string code, + bool isUnicodeEmoji = false, + bool isDiscordEmote = false + ) + { + string parseFunc; + + if (isUnicodeEmoji) + parseFunc = $"global::Discord.Emoji.Parse({code})"; + else if (isDiscordEmote) + parseFunc = $"global::Discord.Emote.Parse({code})"; + else + { + var varName = context.GetVariableName("emoji"); + parseFunc = + $""" + global::Discord.Emoji.TryParse({code}, out var {varName}) + ? (global::Discord.IEmote){varName} + : global::Discord.Emote.Parse({context}) + """; + + builder.AddDiagnostic( + Diagnostics.FallbackToRuntimeValueParsing("Emoji.Parse/Emote.Parse"), + owner + ); + } + + + builder.WithValue(parseFunc); + } + + private static readonly Regex IsEmoji = new( + @"^(?>(?>[\uD800-\uDBFF][\uDC00-\uDFFF]\p{M}*){1,5}|\p{So})$", + RegexOptions.Compiled + ); + + private static readonly Regex IsDiscordEmote = new Regex(@"^<(?>a|):.+:\d+>$", RegexOptions.Compiled); +} \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/EnumGenerator.cs b/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/EnumGenerator.cs new file mode 100644 index 0000000..edf16cd --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/EnumGenerator.cs @@ -0,0 +1,246 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Discord.CX.Parser; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Discord.CX.Nodes; + +public sealed class EnumGenerator : CXValueGenerator +{ + private readonly record struct RendererKey(string Name, bool RenderAsSymbolReference); + + private readonly record struct FieldMapKey(string Name, string Assembly); + + private readonly record struct EnumFieldInfo(string Name, Optional Constant); + + private sealed record EnumInfo( + string Name, + string FullyQualifiedName, + string? BaseType, + string? QualifiedBaseType, + IReadOnlyDictionary Fields + ); + + private static readonly Dictionary _renderers = []; + private static readonly Dictionary _enumInfos = []; + + public string QualifiedName { get; } + public bool RenderAsSymbolReference { get; } + + public EnumGenerator( + string qualifiedName, + bool renderAsSymbolReference + ) + { + QualifiedName = qualifiedName; + RenderAsSymbolReference = renderAsSymbolReference; + } + + public static EnumGenerator Create( + string qualifiedEnumName, + bool renderAsSymbolReference = true + ) + { + var key = new RendererKey(qualifiedEnumName, renderAsSymbolReference); + + if (_renderers.TryGetValue(key, out var renderer)) return renderer; + + return _renderers[key] = new(qualifiedEnumName, renderAsSymbolReference); + } + + protected override Result RenderScalar( + IComponentContext context, + CXValueGeneratorTarget target, + CXToken token, + CXValueGeneratorOptions options + ) => FromText(context, target.Span, token.Value); + + protected override Result RenderInterpolation( + IComponentContext context, + CXValueGeneratorTarget target, + CXToken token, + DesignerInterpolationInfo info, + CXValueGeneratorOptions options + ) + { + if ( + !TryGetEnumInfo(context, QualifiedName, out var enumInfo) || + context.Compilation.GetTypeByMetadataName(QualifiedName) is not { } enumSymbol + ) + { + return UseEnumParseMethod( + target.Span, + context.GetDesignerValue(info) + ); + } + + if (info.Constant.HasValue) + { + if ( + enumInfo.BaseType is not null && + context.Compilation.GetTypeByMetadataName(enumInfo.BaseType) is { } baseSymbol && + context.Compilation.HasImplicitConversion( + info.Symbol, + baseSymbol + ) + ) + { + var constStr = info.Constant.Value is string str + ? StringGenerator.ToCSharpString(str) + : info.Constant.ToString(); + + return $"({( + RenderAsSymbolReference + ? enumInfo.FullyQualifiedName + : enumInfo.QualifiedBaseType + )}){constStr}"; + } + + if (info.Constant.Value is string constantValue) + return FromText( + context, + target.Span, + constantValue, + enumInfo + ); + } + + if ( + context.Compilation.HasImplicitConversion( + info.Symbol, + enumSymbol + ) + ) + { + var designer = context.GetDesignerValue( + info, + enumInfo.FullyQualifiedName + ); + + if (RenderAsSymbolReference || enumInfo.QualifiedBaseType is null) + { + return designer; + } + + return $"({enumInfo.QualifiedBaseType}){designer}"; + } + + return UseEnumParseMethod( + target.Span, + context.GetDesignerValue(info) + ); + } + + protected override Result RenderMultipart( + IComponentContext context, + CXValueGeneratorTarget target, + CXValue.Multipart multipart, + CXValueGeneratorOptions options + ) => UseEnumParseMethod( + target.Span, + StringGenerator.ToCSharpString(multipart) + ); + + private Result FromText( + IComponentContext context, + TextSpan span, + string text, + EnumInfo? info = null + ) + { + if (info is null) + { + TryGetEnumInfo(context, QualifiedName, out info); + } + + if (info is not null && info.Fields.TryGetValue(text.ToLowerInvariant(), out var field)) + return RenderField(info, field); + + if (info is not null) + return new DiagnosticInfo( + Diagnostics.InvalidEnumVariant( + text, + info.Name + ), + span + ); + + return UseEnumParseMethod(span, StringGenerator.ToCSharpString(text)); + } + + private Result RenderField(EnumInfo info, EnumFieldInfo field) + { + var enumRef = $"{info.FullyQualifiedName}.{field.Name}"; + + if (RenderAsSymbolReference) + return enumRef; + + if (field.Constant.HasValue) + { + if (info.QualifiedBaseType is not null) + return $"({info.QualifiedBaseType}){field.Constant.Value}"; + + return field.Constant.Value.ToString(); + } + + if (info.QualifiedBaseType is not null) + return $"({info.QualifiedBaseType}){enumRef}"; + + return enumRef; + } + + private Result UseEnumParseMethod( + TextSpan span, + string code + ) => Result.FromValue( + $"global::System.Enum.Parse<{QualifiedName}>({code})", + Diagnostics.FallbackToRuntimeValueParsing("Enum.Parse"), + span + ); + + private static bool TryGetEnumInfo( + IComponentContext context, + string name, + [MaybeNullWhen(false)] out EnumInfo info + ) + { + var symbol = context.Compilation.GetTypeByMetadataName(name); + + if (symbol is not INamedTypeSymbol { TypeKind: TypeKind.Enum }) + { + info = null; + return false; + } + + var asmKey = symbol.ContainingAssembly.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + var key = new FieldMapKey(name, asmKey); + + if (_enumInfos.TryGetValue(key, out info)) return true; + + info = _enumInfos[key] = new( + name, + symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + symbol.EnumUnderlyingType?.ToDisplayString(), + symbol.EnumUnderlyingType?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + symbol + .GetMembers() + .OfType() + .Where(x => + x.Type.Equals(symbol, SymbolEqualityComparer.Default) && + x.IsStatic + ) + .ToDictionary( + x => x.Name.ToLowerInvariant(), + x => new EnumFieldInfo( + x.Name, + x.HasConstantValue ? new(x.ConstantValue) : default + ) + ) + ); + + return true; + } +} \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/IntegerGenerator.cs b/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/IntegerGenerator.cs new file mode 100644 index 0000000..6c75945 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/IntegerGenerator.cs @@ -0,0 +1,71 @@ +using Discord.CX.Parser; +using Microsoft.CodeAnalysis; + +namespace Discord.CX.Nodes; + +public sealed class IntegerGenerator : CXValueGenerator +{ + protected override Result RenderScalar( + IComponentContext context, + CXValueGeneratorTarget target, + CXToken token, + CXValueGeneratorOptions options + ) => FromText(token, token.Value); + + protected override Result RenderMultipart( + IComponentContext context, + CXValueGeneratorTarget target, + CXValue.Multipart multipart, + CXValueGeneratorOptions options + ) + { + return Result.FromValue( + $"int.Parse({StringGenerator.ToCSharpString(multipart)})", + Diagnostics.FallbackToRuntimeValueParsing("int.Parse"), + multipart + ); + } + + protected override Result RenderInterpolation( + IComponentContext context, + CXValueGeneratorTarget target, + CXToken token, + DesignerInterpolationInfo info, + CXValueGeneratorOptions options) + { + if ( + info.Constant.HasValue && + ( + info.Constant.Value is int || + int.TryParse(info.Constant.Value?.ToString(), out _) + ) + ) return info.Constant.Value!.ToString(); + + if ( + context.Compilation.HasImplicitConversion( + info.Symbol, + context.Compilation.GetSpecialType(SpecialType.System_Int32) + ) + ) + { + return context.GetDesignerValue(info, "int"); + } + + return Result.FromValue( + $"int.Parse({context.GetDesignerValue(info)})", + Diagnostics.FallbackToRuntimeValueParsing("int.Parse"), + token + ); + } + + private static Result FromText(ICXNode owner, string text) + { + if (int.TryParse(text, out _)) return text; + + return Result.FromValue( + $"int.Parse({StringGenerator.ToCSharpString(text)})", + Diagnostics.FallbackToRuntimeValueParsing("int.Parse"), + owner + ); + } +} \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/InterpolationGenerator.cs b/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/InterpolationGenerator.cs new file mode 100644 index 0000000..804fb68 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/InterpolationGenerator.cs @@ -0,0 +1,33 @@ +using Discord.CX.Parser; +using Microsoft.CodeAnalysis; + +namespace Discord.CX.Nodes; + +public sealed class InterpolationGenerator(ITypeSymbol target) : CXValueGenerator +{ + public ITypeSymbol Target { get; } = target; + + protected override Result RenderInterpolation( + IComponentContext context, + CXValueGeneratorTarget target, + CXToken token, + DesignerInterpolationInfo info, + CXValueGeneratorOptions options + ) + { + if ( + !context.Compilation.HasImplicitConversion( + info.Symbol, + Target + ) + ) + { + return new DiagnosticInfo( + Diagnostics.TypeMismatch(Target.ToDisplayString(), info.Symbol?.ToDisplayString() ?? "unknown"), + token + ); + } + + return context.GetDesignerValue(info, Target); + } +} \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/SnowflakeGenerator.cs b/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/SnowflakeGenerator.cs new file mode 100644 index 0000000..5bf7069 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/SnowflakeGenerator.cs @@ -0,0 +1,64 @@ +using System; +using Discord.CX.Parser; +using Microsoft.CodeAnalysis; + +namespace Discord.CX.Nodes; + +public sealed class SnowflakeGenerator : CXValueGenerator +{ + protected override Result RenderInterpolation( + IComponentContext context, + CXValueGeneratorTarget target, + CXToken token, + DesignerInterpolationInfo info, + CXValueGeneratorOptions options + ) + { + if ( + info.Constant.HasValue && + ulong.TryParse(info.Constant.Value?.ToString(), out var ul) + ) return ul.ToString(); + + if ( + context.Compilation.HasImplicitConversion( + info.Symbol, + context.Compilation.GetSpecialType(SpecialType.System_UInt64) + ) + ) + { + return context.GetDesignerValue(info, "ulong"); + } + + return UseParseMethod(token, context.GetDesignerValue(info, info.Symbol)); + } + + protected override Result RenderScalar( + IComponentContext context, + CXValueGeneratorTarget target, + CXToken token, + CXValueGeneratorOptions options + ) => FromText(token, token.Value); + + protected override Result RenderMultipart( + IComponentContext context, + CXValueGeneratorTarget target, + CXValue.Multipart multipart, + CXValueGeneratorOptions options + ) => UseParseMethod(multipart, StringGenerator.ToCSharpString(multipart)); + + private static Result FromText(ICXNode owner, string text) + { + if (ulong.TryParse(text, out _)) return text; + + return UseParseMethod(owner, StringGenerator.ToCSharpString(text)); + } + + private static Result UseParseMethod( + ICXNode owner, + string code + ) => Result.FromValue( + $"ulong.Parse({code})", + Diagnostics.FallbackToRuntimeValueParsing("ulong.Parse"), + owner + ); +} \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/StringGenerator.cs b/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/StringGenerator.cs new file mode 100644 index 0000000..dc00608 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/StringGenerator.cs @@ -0,0 +1,275 @@ +using System; +using System.Linq; +using System.Text; +using Discord.CX.Parser; + +namespace Discord.CX.Nodes; + +public sealed class StringGenerator : CXValueGenerator +{ + protected override Result RenderInterpolation( + IComponentContext context, + CXValueGeneratorTarget target, + CXToken token, + DesignerInterpolationInfo info, + CXValueGeneratorOptions options + ) + { + if (info.Constant.HasValue) return info.Constant.Value?.ToString() ?? string.Empty; + + return context.GetDesignerValue(info); + } + + protected override Result RenderMultipart( + IComponentContext context, + CXValueGeneratorTarget target, + CXValue.Multipart multipart, + CXValueGeneratorOptions options + ) => ToCSharpString(multipart); + + protected override Result RenderScalar( + IComponentContext context, + CXValueGeneratorTarget target, + CXToken token, + CXValueGeneratorOptions options + ) => ToCSharpString(token.Value); + + public static string ToCSharpString(CXValue.Multipart literal, CXValueGeneratorOptions? options = null) + { + if (literal.Tokens.Count is 0) return "string.Empty"; + + var sb = new StringBuilder(); + + var literalParts = literal.Tokens + .Where(x => x.Kind is CXTokenKind.Text) + .Select(x => x.Value) + .ToArray(); + + if (literalParts.Length > 0) + { + literalParts[0] = literalParts[0].TrimStart(); + + literalParts[literalParts.Length - 1] = literalParts[literalParts.Length - 1].TrimEnd(); + } + + var quoteCount = literalParts.Length is 0 + ? 1 + : literalParts.Select(x => x.Count(x => x is '"')).Max() + 1; + + var hasInterpolations = literal.Tokens.Any(x => x.Kind is CXTokenKind.Interpolation); + + var dollars = hasInterpolations + ? new string( + '$', + literalParts.Length is 0 + ? 1 + : Math.Max(1, literalParts.Select(GetInterpolationDollarRequirement).Max()) + ) + : string.Empty; + + var startInterpolation = dollars.Length > 0 + ? new string('{', dollars.Length) + : string.Empty; + + var endInterpolation = dollars.Length > 0 + ? new string('}', dollars.Length) + : string.Empty; + + var isMultiline = false; + + for (var i = 0; i < literal.Tokens.Count; i++) + { + var token = literal.Tokens[i]; + + // first and last token allow one newline before/after as syntax trivia + var leadingTrivia = token.LeadingTrivia; + var trailingTrivia = token.TrailingTrivia; + + for (var j = 0; j < leadingTrivia.Count; j++) + { + var trivia = leadingTrivia[j]; + if (trivia is not CXTrivia.Token { Kind: CXTriviaTokenKind.Newline }) continue; + + if (i != 0) continue; + + // remove all trivia leading up to this newline + leadingTrivia = leadingTrivia.RemoveRange(0, j + 1); + break; + } + + for (var j = trailingTrivia.Count - 1; j >= 0; j--) + { + var trivia = trailingTrivia[j]; + if (trivia is not CXTrivia.Token { Kind: CXTriviaTokenKind.Newline }) continue; + + if (i != literal.Tokens.Count - 1) continue; + + // remove all trivia after the newline + trailingTrivia = trailingTrivia.RemoveRange(j, trailingTrivia.Count - j); + break; + } + + isMultiline |= + ( + trailingTrivia.ContainsNewlines || + leadingTrivia.ContainsNewlines || + token.Value.Contains("\n") + ); + + switch (token.Kind) + { + case CXTokenKind.Text: + sb + .Append(leadingTrivia) + .Append(EscapeBackslashes(token.Value)) + .Append(trailingTrivia); + break; + case CXTokenKind.Interpolation: + var index = literal.Document!.InterpolationTokens.IndexOf(token); + + // TODO: handle better + if (index is -1) throw new InvalidOperationException(); + + sb + .Append(leadingTrivia) + .Append(startInterpolation) + .Append($"designer.GetValueAsString({index})") + .Append(endInterpolation) + .Append(trailingTrivia); + break; + + default: continue; + } + } + + // normalize the value indentation + var value = sb.ToString().NormalizeIndentation().Trim(['\r', '\n']); + + // pad the value to the amount of dollar signs we have to properly align the value text to the + // multi-line string literal + if (hasInterpolations && isMultiline) + value = value.Indent(dollars.Length); + + sb.Clear(); + + if (isMultiline) + { + sb.AppendLine(); + quoteCount = Math.Max(quoteCount, 3); + } + + var quotes = new string('"', quoteCount); + + sb.Append(dollars).Append(quotes); + + if (isMultiline) sb.AppendLine(); + + sb.Append(value); + + // ending quotes are on a different line + if (isMultiline) sb.AppendLine(); + + // if it has interpolations, offset the ending quotes by the amount of dollar signs + if (hasInterpolations && isMultiline) sb.Append("".PadLeft(dollars.Length)); + sb.Append(quotes); + + return sb.ToString(); + } + + public static int GetInterpolationDollarRequirement(string part) + { + var result = 0; + + var count = 0; + char? last = null; + + foreach (var ch in part) + { + if (ch is '{' or '}') + { + if (last is null) + { + last = ch; + count = 1; + continue; + } + + if (last == ch) + { + count++; + continue; + } + } + + if (count > 0) + { + result = Math.Max(result, count); + last = null; + count = 0; + } + } + + return result; + } + + public static string ToCSharpString(string text) + { + var quoteCount = (GetSequentialQuoteCount(text) + 1) switch + { + 2 => 3, + var r => r + }; + + text = text.NormalizeIndentation().Trim(['\r', '\n']); + + var isMultiline = text.Contains('\n'); + + if (isMultiline) + quoteCount = Math.Max(3, quoteCount); + + var quotes = new string('"', quoteCount); + + var sb = new StringBuilder(); + + if (isMultiline) sb.AppendLine(); + + sb.Append(quotes); + + if (isMultiline) sb.AppendLine(); + + sb.Append(text); + + if (isMultiline) + sb.AppendLine(); + + sb.Append(quotes); + + return sb.ToString(); + } + + private static string EscapeBackslashes(string text) + => text.Replace("\\", @"\\"); + + public static int GetSequentialQuoteCount(string text) + { + var result = 0; + var count = 0; + + foreach (var ch in text) + { + if (ch is '"') + { + count++; + continue; + } + + if (count > 0) + { + result = Math.Max(result, count); + count = 0; + } + } + + return result; + } +} \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/UnfurledMediaItemGenerator.cs b/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/UnfurledMediaItemGenerator.cs new file mode 100644 index 0000000..1e11ce5 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/CXValueGenerators/UnfurledMediaItemGenerator.cs @@ -0,0 +1,20 @@ +using SymbolDisplayFormat = Microsoft.CodeAnalysis.SymbolDisplayFormat; + +namespace Discord.CX.Nodes; + +public sealed class UnfurledMediaItemGenerator : CXValueGenerator +{ + public override Result Render( + IComponentContext context, + CXValueGeneratorTarget target, + CXValueGeneratorOptions options + ) => String(context, target, options) + .Map(x => + $"new { + context + .KnownTypes + .UnfurledMediaItemPropertiesType! + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + }({x})" + ); +} \ 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 b37c771..9110860 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Diagnostics.cs @@ -602,4 +602,13 @@ public static DiagnosticDescriptor DiagnosticSeverity.Error, true ); + + public static DiagnosticDescriptor InvalidValue(string type) => new( + "DC0066", + $"Invalid value '{type}'", + $"'{type}' cannot be used here", + "Components", + DiagnosticSeverity.Error, + true + ); } \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs index eb2d457..8ad91fc 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNode.cs @@ -17,54 +17,11 @@ public delegate Result ComponentNodeRenderer( ComponentRenderingOptions options = default ) where TState : ComponentState; -public delegate Result ComponentNodeRenderer( - ComponentState state, - IComponentContext context, - ComponentRenderingOptions options = default -); - public delegate Result BoundComponentNodeRenderer( IComponentContext context, ComponentRenderingOptions options = default ); -public abstract class ComponentNode : ComponentNode - where TState : ComponentState -{ - public abstract Result Render(TState state, IComponentContext context, ComponentRenderingOptions options); - - public virtual TState UpdateState(TState state, IComponentContext context, IList diagnostics) - => state; - - public sealed override ComponentState UpdateState( - ComponentState state, - IComponentContext context, - IList diagnostics - ) => UpdateState((TState)state, context, diagnostics); - - public abstract TState? CreateState(ComponentStateInitializationContext context, IList diagnostics); - - public sealed override ComponentState? Create( - ComponentStateInitializationContext context, - IList diagnostics - ) => CreateState(context, diagnostics); - - public sealed override Result Render( - ComponentState state, - IComponentContext context, - ComponentRenderingOptions options - ) => Render((TState)state, context, options); - - public virtual void Validate(TState state, IComponentContext context, IList diagnostics) - { - base.Validate(state, context, diagnostics); - } - - public sealed override void Validate(ComponentState state, IComponentContext context, - IList diagnostics) - => Validate((TState)state, context, diagnostics); -} - public abstract class ComponentNode { protected virtual bool IsUserAccessible => true; diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNodeOfT.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNodeOfT.cs new file mode 100644 index 0000000..7cb0391 --- /dev/null +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentNodeOfT.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; + +namespace Discord.CX.Nodes; + +public abstract class ComponentNode : ComponentNode + where TState : ComponentState +{ + public abstract Result Render(TState state, IComponentContext context, ComponentRenderingOptions options); + + public virtual TState UpdateState(TState state, IComponentContext context, IList diagnostics) + => state; + + public sealed override ComponentState UpdateState( + ComponentState state, + IComponentContext context, + IList diagnostics + ) => UpdateState((TState)state, context, diagnostics); + + public abstract TState? CreateState(ComponentStateInitializationContext context, IList diagnostics); + + public sealed override ComponentState? Create( + ComponentStateInitializationContext context, + IList diagnostics + ) => CreateState(context, diagnostics); + + public sealed override Result Render( + ComponentState state, + IComponentContext context, + ComponentRenderingOptions options + ) => Render((TState)state, context, options); + + public virtual void Validate(TState state, IComponentContext context, IList diagnostics) + { + base.Validate(state, context, diagnostics); + } + + public sealed override void Validate(ComponentState state, IComponentContext context, + IList diagnostics) + => Validate((TState)state, context, diagnostics); +} \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs index 5680fcc..39917fa 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentProperty.cs @@ -12,7 +12,7 @@ public sealed class ComponentProperty : IEquatable public static ComponentProperty Id => new( "id", isOptional: true, - renderer: Renderers.Integer, + renderer: CXValueGenerator.Integer, dotnetPropertyName: "Id" ); @@ -24,7 +24,7 @@ public sealed class ComponentProperty : IEquatable public bool Synthetic { get; } public string DotnetPropertyName { get; } public string DotnetParameterName { get; } - public PropertyRenderer Renderer { get; } + public CXValueGeneratorDelegate Renderer { get; } public IReadOnlyList Validators { get; } @@ -34,7 +34,7 @@ public ComponentProperty( bool requiresValue = true, IEnumerable? aliases = null, IEnumerable? validators = null, - PropertyRenderer? renderer = null, + CXValueGeneratorDelegate? renderer = null, string? dotnetParameterName = null, string? dotnetPropertyName = null, bool synthetic = false @@ -47,7 +47,7 @@ public ComponentProperty( Synthetic = synthetic; DotnetPropertyName = dotnetPropertyName ?? name; DotnetParameterName = dotnetParameterName ?? name; - Renderer = renderer ?? Renderers.DefaultRenderer; + Renderer = renderer ?? CXValueGenerator.Default; Validators = [..validators ?? []]; } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs index 8a3307e..ca37b7d 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentPropertyValue.cs @@ -9,7 +9,7 @@ public sealed record ComponentPropertyValue( ComponentProperty Property, CXAttribute? Attribute, TextSpan SourceSpan, - GraphNode? Node = null + GraphNode? GraphNode = null ) : IComponentPropertyValue { public TextSpan Span diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentRenderingOptions.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentRenderingOptions.cs index 971b230..8b88942 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentRenderingOptions.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentRenderingOptions.cs @@ -5,7 +5,4 @@ public readonly record struct ComponentRenderingOptions( ) { public static readonly ComponentRenderingOptions Default = new(); - - public PropertyRenderingOptions ToPropertyOptions() - => new(TypingContext: TypingContext); } \ 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 eaeeeb7..37617f1 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentState.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentState.cs @@ -138,15 +138,15 @@ public Result RenderProperties( IComponentContext context, bool asInitializers = false, Predicate? ignorePredicate = null, - PropertyRenderingOptions? options = null + CXValueGeneratorOptions? options = null ) => RenderProperties(node.Properties, context, asInitializers, ignorePredicate, options); - + public Result RenderProperties( IEnumerable properties, IComponentContext context, bool asInitializers = false, Predicate? ignorePredicate = null, - PropertyRenderingOptions? options = null + CXValueGeneratorOptions? options = null ) { // TODO: correct handling? @@ -165,7 +165,11 @@ public Result RenderProperties( if (propertyValue.CanOmitFromSource) continue; - var propertyResult = property.Renderer(context, propertyValue, options ?? PropertyRenderingOptions.Default); + var propertyResult = property.Renderer( + context, + new CXValueGeneratorTarget.ComponentProperty(propertyValue), + options ?? CXValueGeneratorOptions.Default + ); if (propertyResult.HasResult) { @@ -217,12 +221,12 @@ public Result RenderChildren( } public Func> ConformResult(ComponentBuilderKind kind, ComponentTypingContext? context) - => ComponentBuilderKindUtils.Conform(Source, kind, context); - + => kind.Conform(Source, context); + public virtual bool Equals(ComponentState? other) { if (other is null) return false; - + if (ReferenceEquals(this, other)) return true; return diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentTypingContext.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentTypingContext.cs index 186dac3..2f2f212 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentTypingContext.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/ComponentTypingContext.cs @@ -11,4 +11,9 @@ ComponentBuilderKind ConformingType CanSplat: true, ConformingType: ComponentBuilderKind.CollectionOfIMessageComponentBuilders ); + + public static readonly ComponentTypingContext SingleBuilder = new( + CanSplat: false, + ConformingType: ComponentBuilderKind.IMessageComponentBuilder + ); } \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs index 66e5bc9..0a5be3b 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ButtonComponentNode.cs @@ -21,11 +21,12 @@ public bool Equals(ButtonComponentState? other) return InferredKind == other.InferredKind && base.Equals(other); } - + public override int GetHashCode() => Hash.Combine(InferredKind, base.GetHashCode()); } + public enum ButtonKind { Default, @@ -44,7 +45,7 @@ public sealed class ButtonComponentNode : ComponentNode "link", "premium" ]; - + public const string BUTTON_STYLE_ENUM = "Discord.ButtonStyle"; public const int BUTTON_STYLE_LINK_VALUE = 5; public const int BUTTON_STYLE_PREMIUM_VALUE = 6; @@ -72,43 +73,43 @@ public ButtonComponentNode() Style = new ComponentProperty( "style", isOptional: true, - renderer: Renderers.RenderEnum(BUTTON_STYLE_ENUM) + renderer: CXValueGenerator.Enum(BUTTON_STYLE_ENUM) ), Label = new ComponentProperty( "label", isOptional: true, validators: [Validators.StringRange(upper: Constants.BUTTON_MAX_LABEL_LENGTH)], - renderer: Renderers.String + renderer: CXValueGenerator.String ), Emoji = new ComponentProperty( "emoji", isOptional: true, aliases: ["emote"], - renderer: Renderers.Emoji, + renderer: CXValueGenerator.Emoji, dotnetParameterName: "emote" ), CustomId = new( "customId", isOptional: true, validators: [Validators.StringRange(upper: Constants.CUSTOM_ID_MAX_LENGTH)], - renderer: Renderers.String + renderer: CXValueGenerator.String ), SkuId = new( "skuId", aliases: ["sku"], isOptional: true, - renderer: Renderers.Snowflake + renderer: CXValueGenerator.Snowflake ), Url = new( "url", isOptional: true, validators: [Validators.StringRange(upper: Constants.BUTTON_URL_MAX_LENGTH)], - renderer: Renderers.String + renderer: CXValueGenerator.String ), Disabled = new( "disabled", isOptional: true, - renderer: Renderers.Boolean, + renderer: CXValueGenerator.Boolean, dotnetParameterName: "isDisabled" ) ]; @@ -116,7 +117,7 @@ public ButtonComponentNode() public override void AddGraphNode(ComponentGraphInitializationContext context) { - if(!AutoActionRowComponentNode.AddPossibleAutoRowNode(this, context)) + if (!AutoActionRowComponentNode.AddPossibleAutoRowNode(this, context)) base.AddGraphNode(context); } @@ -134,13 +135,13 @@ IList diagnostics if (element.Children.Count > 0 && element.Children[0] is CXValue value) state.SubstitutePropertyValue(Label, value); - + return state with { InferredKind = InferButtonKind(context.GraphContext, state, diagnostics) }; } - + private ButtonKind? InferButtonKind( IComponentContext context, @@ -182,11 +183,12 @@ when multipart.IsLoneInterpolatedLiteral(context, out var info): case var invalid when !ValidButtonStyles.Contains(invalid.ToLowerInvariant()): return null; } + break; } return ButtonKind.Default; - + ButtonKind FromInterpolation(DesignerInterpolationInfo info) { var constant = info.Constant; @@ -216,7 +218,7 @@ ButtonKind FromInterpolation(DesignerInterpolationInfo info) return ButtonKind.Default; } } - + public override void Validate( ButtonComponentState state, @@ -306,7 +308,11 @@ ComponentRenderingOptions options style = stylePropertyValue.Value is null ? $"global::{BUTTON_STYLE_ENUM}.Primary" - : Style.Renderer(context, stylePropertyValue, PropertyRenderingOptions.Default); + : Style.Renderer( + context, + new CXValueGeneratorTarget.ComponentProperty(stylePropertyValue), + CXValueGeneratorOptions.Default + ); } return style diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ComponentBuilderKind.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ComponentBuilderKind.cs index c18f4f0..a316911 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ComponentBuilderKind.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ComponentBuilderKind.cs @@ -28,278 +28,297 @@ public enum ComponentBuilderKind CollectionOfMessageComponents = MessageComponent | CollectionOf, } -public static class ComponentBuilderKindUtils +public static class ComponentBuilderKindExtensions { - public static bool SupportsCardinalityOfMany(this ComponentBuilderKind kind) + extension(ComponentBuilderKind extKind) { - if (kind.HasFlag(ComponentBuilderKind.CollectionOf)) return true; + public bool SupportsCardinalityOfMany + { + get + { + if (extKind.HasFlag(ComponentBuilderKind.CollectionOf)) return true; - return kind is ComponentBuilderKind.MessageComponent or ComponentBuilderKind.CXMessageComponent; - } + return extKind is ComponentBuilderKind.MessageComponent or ComponentBuilderKind.CXMessageComponent; + } + } - public static bool IsValidComponentBuilderType( - ITypeSymbol? symbol, - Compilation compilation, - out ComponentBuilderKind kind - ) - { - kind = ComponentBuilderKind.None; + public static bool IsValidComponentBuilderType( + ITypeSymbol? symbol, + Compilation compilation, + out ComponentBuilderKind kind + ) + { + kind = ComponentBuilderKind.None; - if (symbol is null) return false; + if (symbol is null) return false; - var current = symbol; - ITypeSymbol? enumerableType = null; + var current = symbol; + ITypeSymbol? enumerableType = null; - if (current.SpecialType is not SpecialType.System_String) - current.TryGetEnumerableType(out enumerableType); + if (current.SpecialType is not SpecialType.System_String) + current.TryGetEnumerableType(out enumerableType); - if (enumerableType is not null) - { - kind |= ComponentBuilderKind.CollectionOf; - current = enumerableType; - } + if (enumerableType is not null) + { + kind |= ComponentBuilderKind.CollectionOf; + current = enumerableType; + } - if (current.IsInTypeTree(compilation.GetKnownTypes().MessageComponentType)) - kind |= ComponentBuilderKind.MessageComponent; - else if (current.IsInTypeTree(compilation.GetKnownTypes().IMessageComponentBuilderType)) - kind |= ComponentBuilderKind.IMessageComponentBuilder; - else if (current.IsInTypeTree(compilation.GetKnownTypes().IMessageComponentType)) - kind |= ComponentBuilderKind.IMessageComponent; - else if (current.IsInTypeTree(compilation.GetKnownTypes().CXMessageComponentType)) - kind |= ComponentBuilderKind.CXMessageComponent; + if (current.IsInTypeTree(compilation.GetKnownTypes().MessageComponentType)) + kind |= ComponentBuilderKind.MessageComponent; + else if (current.IsInTypeTree(compilation.GetKnownTypes().IMessageComponentBuilderType)) + kind |= ComponentBuilderKind.IMessageComponentBuilder; + else if (current.IsInTypeTree(compilation.GetKnownTypes().IMessageComponentType)) + kind |= ComponentBuilderKind.IMessageComponent; + else if (current.IsInTypeTree(compilation.GetKnownTypes().CXMessageComponentType)) + kind |= ComponentBuilderKind.CXMessageComponent; - return (kind & ComponentBuilderKind.ComponentMask) is not 0; - } + 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 Result ConvertAsResult( - ICXNode node, - string source, - ComponentBuilderKind from, - ComponentBuilderKind to, - bool spreadCollections = false - ) - { - var converted = Convert(source, from, to, spreadCollections); + 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 Result ConvertAsResult( + ICXNode node, + string source, + ComponentBuilderKind from, + ComponentBuilderKind to, + bool spreadCollections = false + ) + { + var converted = Convert(source, from, to, spreadCollections); - if (converted is not null) return converted; + if (converted is not null) return converted; - return new DiagnosticInfo( - Diagnostics.InvalidInterleavedComponentInCurrentContext( - from.ToString(), - to.ToString() - ), - node - ); - } + return new DiagnosticInfo( + Diagnostics.InvalidInterleavedComponentInCurrentContext( + from.ToString(), + to.ToString() + ), + node + ); + } - public static string? Convert( - string source, - ComponentBuilderKind from, - ComponentBuilderKind to, - bool spreadCollections = false - ) - { - if (from is ComponentBuilderKind.None || to is ComponentBuilderKind.None) return 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 fromCollection = from.HasFlag(ComponentBuilderKind.CollectionOf); + var toCollection = to.HasFlag(ComponentBuilderKind.CollectionOf); - var fromBasicKind = from & ComponentBuilderKind.ComponentMask; - var toBasicKind = to & ComponentBuilderKind.ComponentMask; + var fromBasicKind = from & ComponentBuilderKind.ComponentMask; + var toBasicKind = to & ComponentBuilderKind.ComponentMask; - var spread = spreadCollections ? ".." : string.Empty; + var spread = spreadCollections ? ".." : string.Empty; - switch (fromCollection, toCollection) - { - case (false, false): - return ConvertBasic(source, from, to); - case (true, false): + switch (fromCollection, toCollection) { - var converter = ConvertBasic("x", fromBasicKind, toBasicKind); - return converter is not null - ? converter is not "x" - ? $"{source}.Select(x => {converter})" - : source - : null; - } - case (false, true): - { - switch (fromBasicKind, toBasicKind) + case (false, false): + return ConvertBasic(source, from, to); + case (true, false): { - 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; + var converter = ConvertBasic("x", fromBasicKind, toBasicKind); + return converter is not null + ? converter is not "x" + ? $"{source}.Select(x => {converter})" + : source + : null; } - } - case (true, true): - switch (fromBasicKind, toBasicKind) + case (false, true): { - case (ComponentBuilderKind.MessageComponent or ComponentBuilderKind.CXMessageComponent, - ComponentBuilderKind - .IMessageComponent): - return $"{spread}{source}.SelectMany(x => x.Components)"; - case (ComponentBuilderKind.MessageComponent, ComponentBuilderKind.IMessageComponentBuilder): - return $"{spread}{source}.SelectMany(x => x.Components.Select(x => x.ToBuilder()))"; - case (ComponentBuilderKind.CXMessageComponent, ComponentBuilderKind.IMessageComponentBuilder): - return $"{spread}{source}.SelectMany(x => x.Builders)"; - default: - var converter = ConvertBasic("x", fromBasicKind, toBasicKind); - return converter is not null - ? converter is not "x" - ? $"{spread}{source}.Select(x => {converter})" - : $"{spread}{source}" - : null; + 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 $"{spread}{source}.SelectMany(x => x.Components)"; + case (ComponentBuilderKind.MessageComponent, ComponentBuilderKind.IMessageComponentBuilder): + return $"{spread}{source}.SelectMany(x => x.Components.Select(x => x.ToBuilder()))"; + case (ComponentBuilderKind.CXMessageComponent, ComponentBuilderKind.IMessageComponentBuilder): + return $"{spread}{source}.SelectMany(x => x.Builders)"; + default: + var converter = ConvertBasic("x", fromBasicKind, toBasicKind); + return converter is not null + ? converter is not "x" + ? $"{spread}{source}.Select(x => {converter})" + : $"{spread}{source}" + : 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) + public static string? ConvertBasic(string source, ComponentBuilderKind from, ComponentBuilderKind 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; + 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) + public string ExtrapolateKindToBuilders(string source) { - // case 1: standard builder, we do nothing to the source - case ComponentBuilderKind.IMessageComponentBuilder: return source; + switch (extKind) + { + // case 1: standard builder, we do nothing to the source + case ComponentBuilderKind.IMessageComponentBuilder: return source; - case ComponentBuilderKind.CollectionOfIMessageComponentBuilders: return $"..{source}"; + case ComponentBuilderKind.CollectionOfIMessageComponentBuilders: return $"..{source}"; - case ComponentBuilderKind.CXMessageComponent: - case ComponentBuilderKind.IMessageComponent: return $"..({source}).Components.Select(x => x.ToBuilder())"; + 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()))"; + case ComponentBuilderKind.CollectionOfCXComponents: + case ComponentBuilderKind.CollectionOfIMessageComponents: + return $"..({source}).SelectMany(x => x.Components.Select(x => x.ToBuilder()))"; - default: - throw new ArgumentOutOfRangeException(nameof(kind)); + default: + throw new ArgumentOutOfRangeException(nameof(extKind)); + } } - } - - public static Func> Conform( - ICXNode node, - ComponentBuilderKind sourceKind, - ComponentTypingContext? context - ) - { - if (context is null) return x => x; + + + public Func> Conform( + ICXNode node, + ComponentTypingContext? context + ) => Conform(node, extKind, context); + + public static Func> Conform( + ICXNode node, + ComponentBuilderKind sourceKind, + ComponentTypingContext? context + ) + { + if (context is null) return x => x; - return code => Conform(code, sourceKind, context.Value, node); - } + return code => Conform(code, sourceKind, context.Value, node); + } - public static Result Conform( - string code, - ComponentBuilderKind kind, - ComponentTypingContext typingContext, - ICXNode source - ) - { - var value = Convert( - code, - kind, - typingContext.ConformingType, - typingContext.CanSplat - ); - - if (value is null) + public Result Conform( + string source, + ComponentTypingContext typingContext, + ICXNode owner + ) => Conform(source, extKind, typingContext, owner); + + public static Result Conform( + string code, + ComponentBuilderKind kind, + ComponentTypingContext typingContext, + ICXNode source + ) { - /* - * 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 - */ - - return Result.FromDiagnostic( - Diagnostics.InvalidInterleavedComponentInCurrentContext( - kind.ToString(), - typingContext.ConformingType.ToString() - ), - source + var value = Convert( + code, + kind, + typingContext.ConformingType, + typingContext.CanSplat ); - } - return value; + 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 + */ + + return Result.FromDiagnostic( + Diagnostics.InvalidInterleavedComponentInCurrentContext( + kind.ToString(), + typingContext.ConformingType.ToString() + ), + source + ); + } + + return value; + } } } \ 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 49e50c7..c209167 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ContainerComponentNode.cs @@ -35,14 +35,14 @@ public ContainerComponentNode() "accentColor", isOptional: true, aliases: ["color", "accent"], - renderer: Renderers.Color, + renderer: CXValueGenerator.Color, dotnetPropertyName: "AccentColor" ), Spoiler = new( "spoiler", isOptional: true, requiresValue: false, - renderer: Renderers.Boolean, + renderer: CXValueGenerator.Boolean, dotnetPropertyName: "IsSpoiler" ) ]; diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Custom/Functional/FunctionalComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Custom/Functional/FunctionalComponentNode.cs index b1ff31a..4cf1d69 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Custom/Functional/FunctionalComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Custom/Functional/FunctionalComponentNode.cs @@ -153,7 +153,7 @@ IList diagnostics continue; } - if (!ComponentBuilderKindUtils.IsValidComponentBuilderType(method.ReturnType, context.Compilation)) + if (!ComponentBuilderKind.IsValidComponentBuilderType(method.ReturnType, context.Compilation)) { results.Add(new(candidate, SearchResultKind.InvalidComponentReturnKind)); continue; @@ -243,7 +243,7 @@ IList diagnostics ) { if ( - !ComponentBuilderKindUtils.IsValidComponentBuilderType( + !ComponentBuilderKind.IsValidComponentBuilderType( methodSymbol.ReturnType, compilation, out var returnKind @@ -283,7 +283,7 @@ out var returnKind .Equals(x.AttributeClass, SymbolEqualityComparer.Default) ); - PropertyRenderer renderer; + CXValueGeneratorDelegate renderer; if (childParameterAttribute is not null && childrenParameter is null) { @@ -298,7 +298,7 @@ out substituteChildValue } else { - renderer = Renderers.CreateRenderer( + renderer = CXValueGenerator.GetGeneratorForType( compilation, parameter.Type ); @@ -344,7 +344,7 @@ out substituteChildValue return state; } - private static PropertyRenderer CreateChildrenRenderer( + private static CXValueGeneratorDelegate CreateChildrenRenderer( Compilation compilation, ITypeSymbol childrenType, CXElement source, @@ -355,16 +355,16 @@ out CXValue? childValue { childValue = null; - if (ComponentBuilderKindUtils.IsValidComponentBuilderType(childrenType, compilation, out var kind)) + if (ComponentBuilderKind.IsValidComponentBuilderType(childrenType, compilation, out var kind)) { childrenKind = kind; - return Renderers.DefaultRenderer; + return CXValueGenerator.Default; } childrenKind = null; // extract a single value, if any - if (source.Children.Count is 0) return Renderers.DefaultRenderer; + if (source.Children.Count is 0) return CXValueGenerator.Default; if (source.Children[0] is not CXValue value) { @@ -373,7 +373,7 @@ out CXValue? childValue source.Children[0] ); - return Renderers.DefaultRenderer; + return CXValueGenerator.Default; } childValue = value; @@ -390,13 +390,13 @@ out CXValue? childValue ); } - return Renderers.CreateRenderer(compilation, childrenType); + return CXValueGenerator.GetGeneratorForType(compilation, childrenType); } private static Result RenderElementChildren( FunctionalComponentState state, IComponentContext context, - PropertyRenderingOptions options + CXValueGeneratorOptions options ) { if (state.Source.Children.Count is 0 || state.ChildrenComponentKind is null or ComponentBuilderKind.None) @@ -477,7 +477,7 @@ static BoundComponentNodeRenderer Interpolation(ICXNode node, DesignerInterpolat => (context, options) => { if ( - !ComponentBuilderKindUtils.IsValidComponentBuilderType( + !ComponentBuilderKind.IsValidComponentBuilderType( info.Symbol, context.Compilation, out var kind @@ -492,13 +492,11 @@ out var kind var source = context.GetDesignerValue(info, info.Symbol!.ToDisplayString()); - return ComponentBuilderKindUtils - .Conform( - source, - kind, - options.TypingContext ?? context.RootTypingContext, - node - ); + return kind.Conform( + source, + options.TypingContext ?? context.RootTypingContext, + node + ); }; } @@ -636,7 +634,7 @@ ComponentRenderingOptions options ) .Combine( state.HasComponentChildren - ? RenderElementChildren(state, context, options.ToPropertyOptions()) + ? RenderElementChildren(state, context, options) .Map(x => $"{state.ChildrenParameter.Name}: {x}") : string.Empty ) @@ -652,11 +650,10 @@ ComponentRenderingOptions options props += tuple.Right; } - return ComponentBuilderKindUtils.Conform( + return state.ReturnKind.Conform( $"{state.MethodReference}({ props.WithNewlinePadding(4).PrefixIfSome(4).WrapIfSome(Environment.NewLine) })", - state.ReturnKind, options.TypingContext ?? context.RootTypingContext, state.Source ); diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Custom/ProviderComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Custom/ProviderComponentNode.cs deleted file mode 100644 index 7a87891..0000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Custom/ProviderComponentNode.cs +++ /dev/null @@ -1,87 +0,0 @@ -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using Discord.CX.Parser; -using Microsoft.CodeAnalysis; - -namespace Discord.CX.Nodes.Components.Custom; - -public sealed class ProviderComponentNode : ComponentNode -{ - private readonly INamedTypeSymbol _stateSymbol; - private readonly INamedTypeSymbol _providerSymbol; - public override string Name => $""; - - public override ImmutableArray Properties { get; } - - public ProviderComponentNode( - INamedTypeSymbol stateSymbol, - INamedTypeSymbol providerSymbol, - Compilation compilation - ) - { - _stateSymbol = stateSymbol; - _providerSymbol = providerSymbol; - - var properties = new List(); - - foreach (var property in _stateSymbol.GetMembers().OfType()) - { - var attribute = property - .GetAttributes() - .FirstOrDefault(x => - compilation - .GetKnownTypes() - .CXPropertyAttributeType! - .Equals(x.AttributeClass, SymbolEqualityComparer.Default) - ); - - if (attribute is null) continue; - - var attributeProperties = attribute - .NamedArguments - .ToDictionary(x => x.Key, x => x.Value); - - var isOptional = attributeProperties.TryGetValue("IsOptional", out var val) && - ((val.Value as bool?) ?? false); - - var propName = attributeProperties.TryGetValue("Name", out val) - ? (val.Value as string) ?? property.Name - : property.Name; - - var aliases = attributeProperties - .TryGetValue("Aliases", out val) - ? val.Values - .Select(x => (x.Value as string)) - .Where(x => !string.IsNullOrWhiteSpace(x)) - .ToArray() - : []; - - properties.Add( - new ComponentProperty( - propName, - isOptional, - aliases: aliases!, - renderer: Renderers.CreateRenderer(compilation, property.Type) - ) - ); - } - - Properties = [..properties]; - } - - - public override Result Render( - ComponentState state, - IComponentContext context, - ComponentRenderingOptions options - ) => - $"{_providerSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.Render({CreateProviderState(state, context)})"; - - - private string CreateProviderState( - ComponentState state, - IComponentContext context - ) => - $"new {_stateSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}(){state.RenderInitializer(this, context)}"; -} \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs index 823a2ed..cf576a3 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileComponentNode.cs @@ -25,13 +25,13 @@ public FileComponentNode() Url = new( "url", aliases: ["media"], - renderer: Renderers.UnfurledMediaItem, + renderer: CXValueGenerator.UnfurledMediaItem, dotnetParameterName: "media" ), Spoiler = new( "spoiler", isOptional: true, - renderer: Renderers.Boolean, + renderer: CXValueGenerator.Boolean, dotnetParameterName: "isSpoiler" ) ]; diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileUploadComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileUploadComponentNode.cs index 39a18f5..76c76bf 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileUploadComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/FileUploadComponentNode.cs @@ -18,14 +18,14 @@ public FileUploadComponentNode() ComponentProperty.Id, new( "customId", - renderer: Renderers.String, + renderer: CXValueGenerator.String, validators: [Validators.StringRange(upper: Constants.CUSTOM_ID_MAX_LENGTH)] ), new( "min", isOptional: true, aliases: ["minValues"], - renderer: Renderers.Integer, + renderer: CXValueGenerator.Integer, validators: [ Validators.IntRange(Constants.FILE_UPLOAD_MIN_VALUES_LOWER, Constants.FILE_UPLOAD_MIN_VALUES_UPPER) @@ -35,7 +35,7 @@ public FileUploadComponentNode() "max", isOptional: true, aliases: ["maxValues"], - renderer: Renderers.Integer, + renderer: CXValueGenerator.Integer, validators: [ Validators.IntRange(Constants.FILE_UPLOAD_MAX_VALUES_LOWER, Constants.FILE_UPLOAD_MAX_VALUES_UPPER) @@ -44,7 +44,7 @@ public FileUploadComponentNode() new( "required", isOptional: true, - renderer: Renderers.Boolean + renderer: CXValueGenerator.Boolean ) ]; } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterleavedComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterleavedComponentNode.cs index ff2d03e..9ef9a1c 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterleavedComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/InterleavedComponentNode.cs @@ -9,8 +9,6 @@ namespace Discord.CX.Nodes.Components; -using static ComponentBuilderKindUtils; - public sealed record InterleavedState( GraphNode GraphNode, ICXNode Source, @@ -55,7 +53,7 @@ public static bool TryCreate( out InterleavedComponentNode node ) { - if (IsValidComponentBuilderType(symbol, compilation, out var kind)) + if (ComponentBuilderKind.IsValidComponentBuilderType(symbol, compilation, out var kind)) { node = new(kind, symbol!); return true; @@ -120,7 +118,7 @@ ComponentRenderingOptions options } } - var value = Convert( + var value = ComponentBuilderKind.Convert( designerValue, Kind, typingContext.Value.ConformingType, diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs index 419b08c..ff24a55 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/LabelComponentNode.cs @@ -57,16 +57,16 @@ public LabelComponentNode() ComponentProperty.Id, Component = new( "component", - renderer: Renderers.ComponentAsProperty + renderer: CXValueGenerator.Component ), Value = new( "value", - renderer: Renderers.String + renderer: CXValueGenerator.String ), Description = new( "description", isOptional: true, - renderer: Renderers.String + renderer: CXValueGenerator.String ) ]; } @@ -160,7 +160,7 @@ IList diagnostics } } - var labelChild = state.GetProperty(Component).Node; + var labelChild = state.GetProperty(Component).GraphNode; if (labelChild is not null && !IsValidLabelChild(labelChild.Inner)) { diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGallery/MediaGalleryItemComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGallery/MediaGalleryItemComponentNode.cs index 4e72111..0a7c659 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGallery/MediaGalleryItemComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/MediaGallery/MediaGalleryItemComponentNode.cs @@ -23,18 +23,18 @@ public MediaGalleryItemComponentNode() Url = new( "url", aliases: ["media"], - renderer: Renderers.UnfurledMediaItem, + renderer: CXValueGenerator.UnfurledMediaItem, dotnetParameterName: "media" ), Description = new( "description", isOptional: true, - renderer: Renderers.String + renderer: CXValueGenerator.String ), Spoiler = new( "spoiler", isOptional: true, - renderer: Renderers.Boolean, + renderer: CXValueGenerator.Boolean, dotnetParameterName: "isSpoiler" ) ]; diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentNode.cs index 17fa015..75648d9 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SectionComponentNode.cs @@ -39,7 +39,7 @@ public SectionComponentNode() Accessory = new( "accessory", isOptional: true, - renderer: Renderers.ComponentAsProperty + renderer: CXValueGenerator.Component ) ]; } @@ -140,7 +140,11 @@ ComponentRenderingOptions options var renderedAccessory = ( accessoryPropertyValue.HasValue - ? Accessory.Renderer(context, accessoryPropertyValue, AccessoryRenderingOptions.ToPropertyOptions()) + ? Accessory.Renderer( + context, + new CXValueGeneratorTarget.ComponentProperty(accessoryPropertyValue), + AccessoryRenderingOptions + ) : ( state .Children @@ -220,5 +224,5 @@ public override Result Render( ComponentState state, IComponentContext context, ComponentRenderingOptions options - ) => state.RenderChildren(context); + ) => 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 7c21b58..6876f13 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuComponentNode.cs @@ -96,18 +96,18 @@ public SelectMenuComponentNode() CustomId = new( "customId", isOptional: false, - renderer: Renderers.String + renderer: CXValueGenerator.String ), Placeholder = new( "placeholder", isOptional: true, - renderer: Renderers.String + renderer: CXValueGenerator.String ), MinValues = new( "minValues", isOptional: true, aliases: ["min"], - renderer: Renderers.Integer, + renderer: CXValueGenerator.Integer, validators: [ Validators.IntRange( @@ -120,7 +120,7 @@ public SelectMenuComponentNode() "maxValues", isOptional: true, aliases: ["max"], - renderer: Renderers.Integer, + renderer: CXValueGenerator.Integer, validators: [ Validators.IntRange( @@ -132,12 +132,12 @@ public SelectMenuComponentNode() Required = new( "required", isOptional: true, - renderer: Renderers.Boolean + renderer: CXValueGenerator.Boolean ), Disabled = new( "disabled", isOptional: true, - renderer: Renderers.Boolean, + renderer: CXValueGenerator.Boolean, dotnetParameterName: "isDisabled" ) ]; 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 232798f..ced10aa 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuOptionComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SelectMenus/SelectMenuOptionComponentNode.cs @@ -28,7 +28,7 @@ public SelectMenuOptionComponentNode() [ Label = new( "label", - renderer: Renderers.String, + renderer: CXValueGenerator.String, validators: [ Validators.StringRange(upper: Constants.STRING_SELECT_OPTION_LABEL_MAX_LENGTH) @@ -36,7 +36,7 @@ public SelectMenuOptionComponentNode() ), Value = new( "value", - renderer: Renderers.String, + renderer: CXValueGenerator.String, validators: [ Validators.StringRange(upper: Constants.STRING_SELECT_OPTION_VALUE_MAX_LENGTH) @@ -45,7 +45,7 @@ public SelectMenuOptionComponentNode() Description = new( "description", isOptional: true, - renderer: Renderers.String, + renderer: CXValueGenerator.String, validators: [ Validators.StringRange(upper: Constants.STRING_SELECT_OPTION_DESCRIPTION_MAX_LENGTH) @@ -54,12 +54,12 @@ public SelectMenuOptionComponentNode() Emoji = new( "emoji", isOptional: true, - renderer: Renderers.Emoji + renderer: CXValueGenerator.Emoji ), Default = new( "default", isOptional: true, - renderer: Renderers.Boolean, + renderer: CXValueGenerator.Boolean, dotnetParameterName: "isDefault", requiresValue: false ) diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs index 3dfd48e..09c932c 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/SeparatorComponentNode.cs @@ -25,14 +25,14 @@ public SeparatorComponentNode() Divider = new( "divider", isOptional: true, - renderer: Renderers.Boolean, + renderer: CXValueGenerator.Boolean, dotnetParameterName: "isDivider" ), Spacing = new( "spacing", aliases: ["size"], isOptional: true, - renderer: Renderers.RenderEnum(SEPARATOR_SPACING_QUALIFIED_NAME) + renderer: CXValueGenerator.Enum(SEPARATOR_SPACING_QUALIFIED_NAME) ) ]; } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Text/TextControlElement.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Text/TextControlElement.cs index 05200db..eb4f93a 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Text/TextControlElement.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Text/TextControlElement.cs @@ -106,7 +106,7 @@ public static bool TryCreate( { if ( token.InterpolationIndex is { } index && - ComponentBuilderKindUtils.IsValidComponentBuilderType( + ComponentBuilderKind.IsValidComponentBuilderType( context.GetInterpolationInfo(index).Symbol, context.Compilation ) @@ -120,7 +120,7 @@ token.InterpolationIndex is { } index && case CXValue.Interpolation interpolation: if ( isRoot && - ComponentBuilderKindUtils.IsValidComponentBuilderType( + ComponentBuilderKind.IsValidComponentBuilderType( context.GetInterpolationInfo(interpolation).Symbol, context.Compilation ) @@ -469,7 +469,7 @@ TextControlRenderingOptions options ? 1 : Math.Max( 1, - literalParts.Select(x => Renderers.GetInterpolationDollarRequirement(x.Value)).Max() + literalParts.Select(x => StringGenerator.GetInterpolationDollarRequirement(x.Value)).Max() ); @@ -498,7 +498,7 @@ TextControlRenderingOptions options result = result .Map(x => { - var quoteCount = (Renderers.GetSequentialQuoteCount(x.Value) + 1) switch + var quoteCount = (StringGenerator.GetSequentialQuoteCount(x.Value) + 1) switch { 2 => 3, var r => r diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Text/TextDisplayComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Text/TextDisplayComponentNode.cs index 1548bb4..f495c44 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Text/TextDisplayComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/Text/TextDisplayComponentNode.cs @@ -32,7 +32,7 @@ public TextDisplayComponentNode() ComponentProperty.Id, Content = new( "content", - renderer: Renderers.String, + renderer: CXValueGenerator.String, isOptional: true ) ]; diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs index 3ae6b58..8e9b55d 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/TextInputComponentNode.cs @@ -32,18 +32,18 @@ public TextInputComponentNode() CustomId = new( "customId", isOptional: false, - renderer: Renderers.String + renderer: CXValueGenerator.String ), Style = new( "style", isOptional: true, - renderer: Renderers.RenderEnum(LIBRARY_TEXT_INPUT_STYLE_ENUM) + renderer: CXValueGenerator.Enum(LIBRARY_TEXT_INPUT_STYLE_ENUM) ), MinLength = new( "minLength", aliases: ["min"], isOptional: true, - renderer: Renderers.Integer, + renderer: CXValueGenerator.Integer, validators: [ Validators.IntRange( @@ -56,7 +56,7 @@ public TextInputComponentNode() "maxLength", aliases: ["max"], isOptional: true, - renderer: Renderers.Integer, + renderer: CXValueGenerator.Integer, validators: [ Validators.IntRange( @@ -68,13 +68,13 @@ public TextInputComponentNode() Required = new( "required", isOptional: true, - renderer: Renderers.Boolean, + renderer: CXValueGenerator.Boolean, requiresValue: false ), Value = new( "value", isOptional: true, - renderer: Renderers.String, + renderer: CXValueGenerator.String, validators: [ Validators.StringRange(upper: Constants.TEXT_INPUT_VALUE_MAX_LENGTH) @@ -83,7 +83,7 @@ public TextInputComponentNode() Placeholder = new( "placeholder", isOptional: true, - renderer: Renderers.String, + renderer: CXValueGenerator.String, validators: [ Validators.StringRange(upper: Constants.TEXT_INPUT_PLACEHOLDER_MAX_LENGTH) diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs index 550c43c..ee34392 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Components/ThumbnailComponentNode.cs @@ -24,18 +24,18 @@ public ThumbnailComponentNode() Media = new( "media", aliases: ["href", "url"], - renderer: Renderers.UnfurledMediaItem + renderer: CXValueGenerator.UnfurledMediaItem ), Description = new( "description", isOptional: true, - renderer: Renderers.String, + renderer: CXValueGenerator.String, validators: [Validators.StringRange(upper: Constants.THUMBNAIL_DESCRIPTION_MAX_LENGTH)] ), Spoiler = new( "spoiler", isOptional: true, - renderer: Renderers.Boolean, + renderer: CXValueGenerator.Boolean, dotnetParameterName: "isSpoiler", requiresValue: false ) diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/IComponentContext.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/IComponentContext.cs index 1545c13..0888e4e 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/IComponentContext.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/IComponentContext.cs @@ -40,9 +40,21 @@ public static string GetDesignerValue( string? type = null ) => context.GetDesignerValue(interpolation.InterpolationIndex, type); + public static string GetDesignerValue( + this IComponentContext context, + CXValue.Interpolation interpolation, + ITypeSymbol? type + ) => context.GetDesignerValue(interpolation.InterpolationIndex, type?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + public static string GetDesignerValue( this IComponentContext context, DesignerInterpolationInfo interpolation, string? type = null ) => context.GetDesignerValue(interpolation.Id, type); + + public static string GetDesignerValue( + this IComponentContext context, + DesignerInterpolationInfo interpolation, + ITypeSymbol? type + ) => context.GetDesignerValue(interpolation.Id, type?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); } \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/IComponentPropertyValue.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/IComponentPropertyValue.cs index 676997a..f67b399 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/IComponentPropertyValue.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Nodes/IComponentPropertyValue.cs @@ -9,7 +9,7 @@ public interface IComponentPropertyValue CXValue? Value { get; } - GraphNode? Node { get; } + GraphNode? GraphNode { get; } bool IsSpecified { get; } diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/PropertyRenderer.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/PropertyRenderer.cs deleted file mode 100644 index 599dd4b..0000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/PropertyRenderer.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Discord.CX.Nodes; - -public delegate Result PropertyRenderer( - IComponentContext context, - IComponentPropertyValue value, - PropertyRenderingOptions options -); \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/PropertyRendereringOptions.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/PropertyRendereringOptions.cs deleted file mode 100644 index 9155b67..0000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/PropertyRendereringOptions.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Discord.CX.Nodes; - -public readonly record struct PropertyRenderingOptions( - ComponentTypingContext? TypingContext = null -) -{ - public static readonly PropertyRenderingOptions Default = new(); - - public ComponentRenderingOptions ToComponentOptions() - => new(TypingContext: TypingContext); -} \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs b/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs deleted file mode 100644 index f5ef4c3..0000000 --- a/src/Discord.Net.ComponentDesigner.Generator/Nodes/Renderers/Renderers.cs +++ /dev/null @@ -1,1019 +0,0 @@ -using Discord.CX.Parser; -using Microsoft.CodeAnalysis; -using System; -using System.CodeDom.Compiler; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Text; -using System.Text.RegularExpressions; -using Discord.CX.Nodes.Components; - -namespace Discord.CX.Nodes; - -using static Result; -using static DiagnosticInfo; - -public static class Renderers -{ - public static Result DefaultRenderer( - IComponentContext context, - IComponentPropertyValue value, - PropertyRenderingOptions options - ) => "default"; - - public static PropertyRenderer CreateRenderer( - Compilation compilation, - ITypeSymbol type - ) - { - if (type.SpecialType == SpecialType.System_String) - return Renderers.String; - - if (type.SpecialType is SpecialType.System_Int32) - return Integer; - - if (compilation.GetKnownTypes().ColorType!.Equals(type, SymbolEqualityComparer.Default)) - return Color; - - // TODO: more ways to extract renderers - return (context, propValue, options) => - { - switch (propValue.Value) - { - case CXValue.Interpolation interpolation: - var builder = new Builder(); - - var info = context.GetInterpolationInfo(interpolation); - - if ( - !context.Compilation.HasImplicitConversion( - info.Symbol, - type - ) - ) - { - builder.AddDiagnostic( - Diagnostics.TypeMismatch(type.ToDisplayString(), info.Symbol!.ToDisplayString()), - interpolation - ); - } - - return builder.WithValue( - context.GetDesignerValue( - interpolation, - type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) - ) - ); - default: - return FromDiagnostic( - Diagnostics.TypeMismatch("", propValue.Value?.GetType().Name ?? "unknown"), - propValue.Span - ); - } - }; - } - - public static Result ComponentAsProperty( - IComponentContext context, - IComponentPropertyValue propertyValue, - PropertyRenderingOptions options - ) - { - switch (propertyValue.Value) - { - case CXValue.Element when propertyValue.Node is not null: - return propertyValue.Node.Render(context, options.ToComponentOptions()); - case CXValue.Interpolation interpolation: - { - // ensure its a component builder - var info = context.GetInterpolationInfo(interpolation); - - if ( - !ComponentBuilderKindUtils.IsValidComponentBuilderType( - info.Symbol, - context.Compilation, - out var interleavedKind - ) - ) - { - return FromDiagnostic( - Diagnostics.TypeMismatch( - $"{context.KnownTypes.IMessageComponentBuilderType} | {context.KnownTypes.MessageComponentType}", - info.Symbol!.ToString() - ), - interpolation - ); - } - - var source = context.GetDesignerValue( - info, - info.Symbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) - ); - - // ensure we can convert it to a builder - var target = - options.TypingContext?.ConformingType ?? - ComponentBuilderKind.IMessageComponentBuilder; - - var result = new Builder(); - if ( - !ComponentBuilderKindUtils.TryConvert( - source, - interleavedKind, - target, - out var converted, - spreadCollections: options.TypingContext?.CanSplat is true - ) - ) - { - source = interleavedKind switch - { - 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 - }; - - if (source != string.Empty) - { - result.AddDiagnostic( - Diagnostics.CardinalityForcedToRuntime(info.Symbol.ToDisplayString()), - interpolation - ); - } - else - { - result.AddDiagnostic( - Diagnostics.InvalidChildComponentCardinality(propertyValue.PropertyName), - interpolation - ); - } - } - - return result.WithValue(source); - } - default: - { - var result = new Builder(); - - if (propertyValue.Value is not null) - { - result.AddDiagnostic( - Diagnostics.InvalidPropertyValueSyntax("interpolation"), - propertyValue.Value - ); - } - - return result; - } - } - } - - public static Result UnfurledMediaItem( - IComponentContext context, - IComponentPropertyValue propertyValue, - PropertyRenderingOptions options - ) => String(context, propertyValue, options) - .Map(x => - $"new { - context.KnownTypes - .UnfurledMediaItemPropertiesType - !.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) - }({x})" - ); - - public static Result Integer( - IComponentContext context, - IComponentPropertyValue propertyValue, - PropertyRenderingOptions options - ) - { - switch (propertyValue.Value) - { - case CXValue.Scalar scalar: - return FromText(scalar, scalar.Value); - - case CXValue.Interpolation interpolation: - return FromInterpolation(interpolation, context.GetInterpolationInfo(interpolation)); - - case CXValue.Multipart multipart: - if (!multipart.HasInterpolations) - return FromText(multipart, multipart.Tokens.ToValueString().Trim()); - - if (multipart.IsLoneInterpolatedLiteral(context, out var info)) - return FromInterpolation(multipart, info); - - return FromValue( - $"int.Parse({RenderStringLiteral(multipart)})", - Diagnostics.FallbackToRuntimeValueParsing("int.Parse"), - multipart - ); - default: return "default"; - } - - Result FromInterpolation(ICXNode owner, DesignerInterpolationInfo info) - { - if (info.Constant.Value is int || int.TryParse(info.Constant.Value?.ToString(), out _)) - return info.Constant.Value!.ToString(); - - if ( - context.Compilation.HasImplicitConversion( - info.Symbol, - context.Compilation.GetSpecialType(SpecialType.System_Int32) - ) - ) - { - return context.GetDesignerValue(info, "int"); - } - - return FromValue( - $"int.Parse({context.GetDesignerValue(info)})", - Diagnostics.FallbackToRuntimeValueParsing("int.Parse"), - owner - ); - } - - Result FromText(ICXNode owner, string text) - { - if (int.TryParse(text, out _)) return text; - - return FromValue( - $"int.Parse({ToCSharpString(text)})", - Diagnostics.FallbackToRuntimeValueParsing("int.Parse"), - owner - ); - } - } - - public static Result Boolean( - IComponentContext context, - IComponentPropertyValue propertyValue, - PropertyRenderingOptions options - ) - { - switch (propertyValue.Value) - { - case CXValue.Interpolation interpolation: - return FromInterpolation(interpolation, context.GetInterpolationInfo(interpolation)); - - case CXValue.Scalar scalar: - return FromText(scalar, scalar.Value.Trim().ToLowerInvariant()); - - case CXValue.Multipart multipart: - if (!multipart.HasInterpolations) - return FromText(multipart, multipart.Tokens.ToValueString().Trim().ToLowerInvariant()); - - if (multipart.IsLoneInterpolatedLiteral(context, out var info)) - return FromInterpolation(multipart, info); - - return FromValue( - $"bool.Parse({context.GetDesignerValue(info)})", - Diagnostics.FallbackToRuntimeValueParsing("bool.Parse"), - multipart - ); - - case null when !propertyValue.RequiresValue: - return "true"; - - default: return "default"; - } - - Result FromInterpolation(ICXNode node, DesignerInterpolationInfo info) - { - if (info.Constant.Value is bool b) return b ? "true" : "false"; - - if ( - context.Compilation.HasImplicitConversion( - info.Symbol, - context.Compilation.GetSpecialType(SpecialType.System_Boolean) - ) - ) - { - return context.GetDesignerValue(info, "bool"); - } - - if (info.Constant.Value?.ToString().Trim().ToLowerInvariant() is { } str and ("true" or "false")) - return str; - - return FromValue( - $"bool.Parse({context.GetDesignerValue(info)})", - Diagnostics.FallbackToRuntimeValueParsing("bool.Parse"), - node - ); - } - - Result FromText(ICXNode owner, string value) - { - if (value is not "true" and not "false") - { - return Create( - Diagnostics.TypeMismatch("bool", "string"), - owner - ); - } - - return value; - } - } - - private static readonly Dictionary _colorPresets = []; - - private static bool TryGetColorPreset( - IComponentContext context, - string value, - out string fieldName) - { - var colorSymbol = context.KnownTypes.ColorType; - - if (colorSymbol is null) - { - fieldName = null!; - return false; - } - - if (_colorPresets.Count is 0) - { - foreach ( - var field - in colorSymbol.GetMembers() - .OfType() - .Where(x => - x.Type.Equals(colorSymbol, SymbolEqualityComparer.Default) && - x.IsStatic - ) - ) - { - _colorPresets[field.Name.ToLowerInvariant()] = field.Name; - } - } - - return _colorPresets.TryGetValue(value.ToLowerInvariant(), out fieldName); - } - - public static Result Color( - IComponentContext context, - IComponentPropertyValue propertyValue, - PropertyRenderingOptions options - ) - { - var colorSymbol = context.KnownTypes.ColorType; - var qualifiedColor = colorSymbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - - switch (propertyValue.Value) - { - case CXValue.Interpolation interpolation: - return FromInterpolation(interpolation, context.GetInterpolationInfo(interpolation)); - - case CXValue.Scalar scalar: - return FromText(scalar, scalar.Value); - - case CXValue.Multipart multipart: - - if (!multipart.HasInterpolations) - return FromText(multipart, multipart.Tokens.ToValueString()); - - if (multipart.IsLoneInterpolatedLiteral(context, out var info)) - return FromInterpolation(multipart, info); - - return FromValue( - UseLibraryParser(RenderStringLiteral(multipart)), - Diagnostics.FallbackToRuntimeValueParsing("Discord.Color.Parse"), - multipart - ); - default: return "default"; - } - - Result FromInterpolation(ICXNode owner, DesignerInterpolationInfo info) - { - if ( - info.Symbol is not null && - context.Compilation.HasImplicitConversion( - info.Symbol, - colorSymbol - ) - ) - { - return context.GetDesignerValue( - info, - qualifiedColor - ); - } - - uint hexColor; - - if (info.Constant.Value is string str) - { - if (TryGetColorPreset(context, str, out var preset)) - return $"{qualifiedColor}.{preset}"; - - if (TryParseHexColor(str, out hexColor)) - return $"new {qualifiedColor}({hexColor})"; - } - else if (info.Constant.HasValue && uint.TryParse(info.Constant.Value?.ToString(), out hexColor)) - { - return $"new {qualifiedColor}({hexColor})"; - } - - if ( - context.Compilation.HasImplicitConversion( - info.Symbol, - context.Compilation.GetSpecialType(SpecialType.System_UInt32) - ) - ) - { - return $"new {qualifiedColor}({context.GetDesignerValue(info, "uint")})"; - } - - return FromValue( - UseLibraryParser(context.GetDesignerValue(info)), - Diagnostics.FallbackToRuntimeValueParsing("Discord.Color.Parse"), - owner - ); - } - - Result FromText(ICXNode owner, string rawValue) - { - if (TryGetColorPreset(context, rawValue, out var presetName)) - { - return $"{qualifiedColor}.{presetName}"; - } - - // maybe hex? - var hex = rawValue; - - if (hex.StartsWith("#")) - hex = hex.Substring(1); - - if ( - uint.TryParse( - hex, - NumberStyles.HexNumber, - null, - out var hexCode - ) - ) return $"new {qualifiedColor}({hexCode})"; - - return FromValue( - UseLibraryParser(ToCSharpString(rawValue)), - Diagnostics.FallbackToRuntimeValueParsing("Discord.Color.Parse"), - owner - ); - } - - string UseLibraryParser(string source) - => $"{qualifiedColor}.Parse({source})"; - - static bool TryParseHexColor(string hexColor, out uint color) - { - if (string.IsNullOrWhiteSpace(hexColor)) - { - color = 0; - return false; - } - - if (hexColor[0] is '#') - hexColor = hexColor.Substring(1); - else if (hexColor.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) - hexColor = hexColor.Substring(2); - - return uint.TryParse(hexColor, NumberStyles.HexNumber, null, out color); - } - } - - public static Result Snowflake( - IComponentContext context, - IComponentPropertyValue propertyValue, - PropertyRenderingOptions options - ) => Snowflake(context, propertyValue.Value, options); - - public static Result Snowflake(IComponentContext context, CXValue? value, PropertyRenderingOptions options) - { - switch (value) - { - case CXValue.Interpolation interpolation: - return FromInterpolation(interpolation, context.GetInterpolationInfo(interpolation)); - - case CXValue.Scalar scalar: - return FromText(scalar, scalar.Value.Trim()); - - case CXValue.Multipart multipart: - if (!multipart.HasInterpolations) - return FromText(multipart, multipart.Tokens.ToValueString().Trim()); - - if (multipart.IsLoneInterpolatedLiteral(context, out var info)) - return FromInterpolation(multipart, info); - - return FromValue( - UseParseMethod(RenderStringLiteral(multipart)), - Diagnostics.FallbackToRuntimeValueParsing("ulong.Parse"), - multipart - ); - - default: return "default"; - } - - Result FromInterpolation(ICXNode owner, DesignerInterpolationInfo info) - { - var targetType = context.Compilation.GetSpecialType(SpecialType.System_UInt64); - - if (info.Constant is { HasValue: true, Value: ulong ul }) - return ul.ToString(); - - if ( - info.Symbol is not null && - context.Compilation.HasImplicitConversion(info.Symbol, targetType) - ) - { - return context.GetDesignerValue(info, "ulong"); - } - - return FromValue( - UseParseMethod(context.GetDesignerValue(info)), - Diagnostics.FallbackToRuntimeValueParsing("ulong.Parse"), - owner - ); - } - - Result FromText(ICXNode owner, string text) - { - if (ulong.TryParse(text, out _)) return text; - - return FromValue( - UseParseMethod(ToCSharpString(text)), - Diagnostics.FallbackToRuntimeValueParsing("ulong.Parse"), - owner - ); - } - - static string UseParseMethod(string input) - => $"ulong.Parse({input})"; - } - - public static Result String( - IComponentContext context, - IComponentPropertyValue propertyValue, - PropertyRenderingOptions options - ) => String(context, propertyValue.Value, options); - - public static Result String( - IComponentContext context, - CXValue? value, - PropertyRenderingOptions options - ) - { - switch (value) - { - default: return "string.Empty"; - - case CXValue.Interpolation interpolation: - if (context.GetInterpolationInfo(interpolation).Constant.Value is string constant) - return ToCSharpString(constant); - - return context.GetDesignerValue(interpolation); - case CXValue.Multipart literal: return RenderStringLiteral(literal); - case CXValue.Scalar scalar: - return ToCSharpString(scalar.Value); - } - } - - public static string RenderStringLiteral(CXValue.Multipart literal) - { - if (literal.Tokens.Count is 0) return "string.Empty"; - - var sb = new StringBuilder(); - - var literalParts = literal.Tokens - .Where(x => x.Kind is CXTokenKind.Text) - .Select(x => x.Value) - .ToArray(); - - if (literalParts.Length > 0) - { - literalParts[0] = literalParts[0].TrimStart(); - - literalParts[literalParts.Length - 1] = literalParts[literalParts.Length - 1].TrimEnd(); - } - - var quoteCount = literalParts.Length is 0 - ? 1 - : literalParts.Select(x => x.Count(x => x is '"')).Max() + 1; - - var hasInterpolations = literal.Tokens.Any(x => x.Kind is CXTokenKind.Interpolation); - - var dollars = hasInterpolations - ? new string( - '$', - literalParts.Length is 0 - ? 1 - : Math.Max(1, literalParts.Select(GetInterpolationDollarRequirement).Max()) - ) - : string.Empty; - - var startInterpolation = dollars.Length > 0 - ? new string('{', dollars.Length) - : string.Empty; - - var endInterpolation = dollars.Length > 0 - ? new string('}', dollars.Length) - : string.Empty; - - var isMultiline = false; - - for (var i = 0; i < literal.Tokens.Count; i++) - { - var token = literal.Tokens[i]; - - // first and last token allow one newline before/after as syntax trivia - var leadingTrivia = token.LeadingTrivia; - var trailingTrivia = token.TrailingTrivia; - - for (var j = 0; j < leadingTrivia.Count; j++) - { - var trivia = leadingTrivia[j]; - if (trivia is not CXTrivia.Token { Kind: CXTriviaTokenKind.Newline }) continue; - - if (i != 0) continue; - - // remove all trivia leading up to this newline - leadingTrivia = leadingTrivia.RemoveRange(0, j + 1); - break; - } - - for (var j = trailingTrivia.Count - 1; j >= 0; j--) - { - var trivia = trailingTrivia[j]; - if (trivia is not CXTrivia.Token { Kind: CXTriviaTokenKind.Newline }) continue; - - if (i != literal.Tokens.Count - 1) continue; - - // remove all trivia after the newline - trailingTrivia = trailingTrivia.RemoveRange(j, trailingTrivia.Count - j); - break; - } - - isMultiline |= - ( - trailingTrivia.ContainsNewlines || - leadingTrivia.ContainsNewlines || - token.Value.Contains("\n") - ); - - switch (token.Kind) - { - case CXTokenKind.Text: - sb - .Append(leadingTrivia) - .Append(EscapeBackslashes(token.Value)) - .Append(trailingTrivia); - break; - case CXTokenKind.Interpolation: - var index = literal.Document!.InterpolationTokens.IndexOf(token); - - // TODO: handle better - if (index is -1) throw new InvalidOperationException(); - - sb - .Append(leadingTrivia) - .Append(startInterpolation) - .Append($"designer.GetValueAsString({index})") - .Append(endInterpolation) - .Append(trailingTrivia); - break; - - default: continue; - } - } - - // normalize the value indentation - var value = sb.ToString().NormalizeIndentation().Trim(['\r', '\n']); - - // pad the value to the amount of dollar signs we have to properly align the value text to the - // multi-line string literal - if (hasInterpolations && isMultiline) - value = value.Indent(dollars.Length); - - sb.Clear(); - - if (isMultiline) - { - sb.AppendLine(); - quoteCount = Math.Max(quoteCount, 3); - } - - var quotes = new string('"', quoteCount); - - sb.Append(dollars).Append(quotes); - - if (isMultiline) sb.AppendLine(); - - sb.Append(value); - - // ending quotes are on a different line - if (isMultiline) sb.AppendLine(); - - // if it has interpolations, offset the ending quotes by the amount of dollar signs - if (hasInterpolations && isMultiline) sb.Append("".PadLeft(dollars.Length)); - sb.Append(quotes); - - return sb.ToString(); - } - - public static int GetInterpolationDollarRequirement(string part) - { - var result = 0; - - var count = 0; - char? last = null; - - foreach (var ch in part) - { - if (ch is '{' or '}') - { - if (last is null) - { - last = ch; - count = 1; - continue; - } - - if (last == ch) - { - count++; - continue; - } - } - - if (count > 0) - { - result = Math.Max(result, count); - last = null; - count = 0; - } - } - - return result; - } - - public static string ToCSharpString(string text) - { - var quoteCount = (GetSequentialQuoteCount(text) + 1) switch - { - 2 => 3, - var r => r - }; - - text = text.NormalizeIndentation().Trim(['\r', '\n']); - - var isMultiline = text.Contains('\n'); - - if (isMultiline) - quoteCount = Math.Max(3, quoteCount); - - var quotes = new string('"', quoteCount); - - var sb = new StringBuilder(); - - if (isMultiline) sb.AppendLine(); - - sb.Append(quotes); - - if (isMultiline) sb.AppendLine(); - - sb.Append(text); - - if (isMultiline) - sb.AppendLine(); - - sb.Append(quotes); - - return sb.ToString(); - } - - private static string EscapeBackslashes(string text) - => text.Replace("\\", @"\\"); - - public static int GetSequentialQuoteCount(string text) - { - var result = 0; - var count = 0; - - foreach (var ch in text) - { - if (ch is '"') - { - count++; - continue; - } - - if (count > 0) - { - result = Math.Max(result, count); - count = 0; - } - } - - return result; - } - - public static PropertyRenderer RenderEnum(string fullyQualifiedName) - { - var renderer = CreateEnumRenderer(fullyQualifiedName); - - return (context, propertyValue, options) => renderer(context, propertyValue.Value); - } - - public static Func> CreateEnumRenderer(string fullyQualifiedName) - { - ITypeSymbol? symbol = null; - Dictionary variants = []; - - return (context, value) => - { - if (symbol is null || variants.Count is 0) - { - symbol = context.Compilation.GetTypeByMetadataName(fullyQualifiedName); - - if (symbol is null) throw new InvalidOperationException($"Unknown type '{fullyQualifiedName}'"); - - if (symbol.TypeKind is not TypeKind.Enum) - throw new InvalidOperationException($"'{symbol}' is not an enum type."); - - variants = symbol - .GetMembers() - .OfType() - .Where(x => x.Type.Equals(symbol, SymbolEqualityComparer.Default)) - .ToDictionary(x => x.Name.ToLowerInvariant(), x => x.Name); - } - - switch (value) - { - case CXValue.Scalar scalar: - return FromText(scalar.Value.Trim(), scalar); - case CXValue.Interpolation interpolation: - return FromInterpolation(interpolation, context.GetInterpolationInfo(interpolation)); - case CXValue.Multipart multipart: - if (!multipart.HasInterpolations) - return FromText(multipart.Tokens.ToValueString().Trim().ToLowerInvariant(), multipart); - - if (multipart.IsLoneInterpolatedLiteral(context, out var info)) - return FromInterpolation(multipart, info); - - return - $"global::System.Enum.Parse<{symbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}>({RenderStringLiteral(multipart)})"; - default: return "default"; - } - - Result FromInterpolation(ICXNode owner, DesignerInterpolationInfo info) - { - if ( - context.Compilation.HasImplicitConversion( - info.Symbol, - symbol - ) - ) - { - return context.GetDesignerValue( - info, - symbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) - ); - } - - if (info.Constant.Value?.ToString() is { } str) - { - return FromText(str.Trim().ToLowerInvariant(), owner); - } - - return - $"global::System.Enum.Parse<{symbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}>({context.GetDesignerValue(info)})"; - } - - Result FromText(string text, ICXNode owner) - { - if (variants.TryGetValue(text, out var name)) - return $"{symbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.{name}"; - - return FromValue( - $"global::System.Enum.Parse<{symbol!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}>({ToCSharpString(text)})", - Diagnostics.InvalidEnumVariant(text, symbol.ToDisplayString()), - owner - ); - } - }; - } - - public static Result Emoji( - IComponentContext context, - IComponentPropertyValue propertyValue, - PropertyRenderingOptions options - ) - { - var isDiscordEmote = false; - var isEmoji = false; - - switch (propertyValue.Value) - { - case CXValue.Interpolation interpolation: - var interpolationInfo = context.GetInterpolationInfo(interpolation); - - if ( - interpolationInfo.Symbol is not null && - context.Compilation.HasImplicitConversion( - interpolationInfo.Symbol, - context.KnownTypes.IEmoteType - ) - ) - { - return context.GetDesignerValue( - interpolation, - $"{context.KnownTypes.IEmoteType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}" - ); - } - - return UseLibraryParser( - context, - context.GetDesignerValue(interpolation) - ); - - case CXValue.Scalar scalar: - { - var builder = new Builder(); - LightlyValidateEmote(ref builder, scalar.Value, scalar, out isDiscordEmote, out isEmoji); - return builder.WithValue( - UseLibraryParser(context, ToCSharpString(scalar.Value), isEmoji, isDiscordEmote) - ); - } - - case CXValue.Multipart stringLiteral: - { - var builder = new Builder(); - if (!stringLiteral.HasInterpolations) - LightlyValidateEmote( - ref builder, - stringLiteral.Tokens.ToValueString(), - stringLiteral.Tokens, - out isDiscordEmote, - out isEmoji - ); - - return builder.WithValue( - UseLibraryParser(context, RenderStringLiteral(stringLiteral), isEmoji, isDiscordEmote) - ); - } - - default: return "null"; - } - - void LightlyValidateEmote( - ref Builder builder, - string emote, - ICXNode node, - out bool isDiscordEmote, - out bool isEmoji - ) - { - isDiscordEmote = IsDiscordEmote.IsMatch(emote); - isEmoji = IsEmoji.IsMatch(emote); - - if (!isDiscordEmote && !isEmoji) - { - builder.AddDiagnostic( - Diagnostics.PossibleInvalidEmote(emote), - node - ); - } - } - - static string UseLibraryParser( - IComponentContext context, - string source, - bool? isEmoji = null, - bool? isDiscordEmote = null - ) - { - if (isEmoji is true) - return $"global::Discord.Emoji.Parse({source})"; - - if (isDiscordEmote is true) - return $"global::Discord.Emote.Parse({source})"; - - var varName = context.GetVariableName("emoji"); - - return - $""" - global::Discord.Emoji.TryParse({source}, out var {varName}) - ? (global::Discord.IEmote){varName} - : global::Discord.Emote.Parse({source}) - """; - } - } - - private static readonly Regex IsEmoji = new Regex(@"^(?>(?>[\uD800-\uDBFF][\uDC00-\uDFFF]\p{M}*){1,5}|\p{So})$", - RegexOptions.Compiled); - - private static readonly Regex IsDiscordEmote = new Regex(@"^<(?>a|):.+:\d+>$", RegexOptions.Compiled); -} \ No newline at end of file diff --git a/src/Discord.Net.ComponentDesigner.Generator/Utils/Result.cs b/src/Discord.Net.ComponentDesigner.Generator/Utils/Result.cs index 44e1f7c..b04e029 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Utils/Result.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Utils/Result.cs @@ -57,6 +57,12 @@ public Result(EquatableArray diagnostics) Diagnostics = diagnostics; } + public Result AddDiagnostics(params ReadOnlySpan diagnostics) + => new(_result, HasResult, [..Diagnostics, ..diagnostics]); + + public Result AddDiagnostics(DiagnosticInfo diagnostic) + => new(_result, HasResult, [..Diagnostics, diagnostic]); + public T? GetValueOrDefault(T? defaultValue = default) => HasResult ? Value : defaultValue; diff --git a/src/Discord.Net.ComponentDesigner.Generator/Utils/TypeUtils.cs b/src/Discord.Net.ComponentDesigner.Generator/Utils/TypeUtils.cs index c0f55a9..47b1bc0 100644 --- a/src/Discord.Net.ComponentDesigner.Generator/Utils/TypeUtils.cs +++ b/src/Discord.Net.ComponentDesigner.Generator/Utils/TypeUtils.cs @@ -1,10 +1,21 @@ -using System.Linq; +using System; +using System.Linq; using Microsoft.CodeAnalysis; namespace Discord.CX; public static class TypeUtils { + public static bool IsNumericType(this Type type) + => type == typeof(byte) || + type == typeof(sbyte) || + type == typeof(ushort) || + type == typeof(short) || + type == typeof(uint) || + type == typeof(int) || + type == typeof(ulong) || + type == typeof(long); + public static bool IsInTypeTree(this ITypeSymbol symbol, ITypeSymbol? other) { if (other is null) return false; diff --git a/tests/ComponentTests/ButtonTests.cs b/tests/ComponentTests/ButtonTests.cs index 4d3b31d..6e84c6d 100644 --- a/tests/ComponentTests/ButtonTests.cs +++ b/tests/ComponentTests/ButtonTests.cs @@ -374,15 +374,7 @@ public void UnknownButtonStyle() Validate(); - Renders( - """ - new global::Discord.ButtonBuilder( - style: global::System.Enum.Parse("invalid"), - label: "button", - customId: "button" - ) - """ - ); + Renders(); Diagnostic( Diagnostics.InvalidEnumVariant("invalid", "Discord.ButtonStyle"), diff --git a/tests/ComponentTests/SeparatorTests.cs b/tests/ComponentTests/SeparatorTests.cs index 05b45af..03e40f3 100644 --- a/tests/ComponentTests/SeparatorTests.cs +++ b/tests/ComponentTests/SeparatorTests.cs @@ -111,13 +111,7 @@ public void InvalidSpacing() Validate(); - Renders( - """ - new global::Discord.SeparatorBuilder( - spacing: global::System.Enum.Parse("abc") - ) - """ - ); + Renders(); Diagnostic( Diagnostics.InvalidEnumVariant("abc", "Discord.SeparatorSpacingSize"), diff --git a/tests/RendererTests/BaseRendererTest.cs b/tests/RendererTests/BaseRendererTest.cs index 8876b44..74890fe 100644 --- a/tests/RendererTests/BaseRendererTest.cs +++ b/tests/RendererTests/BaseRendererTest.cs @@ -22,7 +22,7 @@ protected enum ParseMode protected void AssertRenders( string? cx, - PropertyRenderer renderer, + CXValueGeneratorDelegate renderer, string? expected, ParseMode mode = ParseMode.AttributeValue, DesignerInterpolationInfo[]? interpolations = null, @@ -31,7 +31,7 @@ protected void AssertRenders( bool isOptional = false, string? propertyName = null, int? wrappingQuoteCount = null, - PropertyRenderingOptions? options = null + CXValueGeneratorOptions? options = null ) { AssertEmptyDiagnostics(); @@ -74,7 +74,6 @@ protected void AssertRenders( ); } - var context = new MockComponentContext( Compilation, new CXDesignerGeneratorState( @@ -101,7 +100,7 @@ protected void AssertRenders( value?.Span ?? default ); - var result = renderer(context, propValue, options ?? PropertyRenderingOptions.Default); + var result = renderer(context, new CXValueGeneratorTarget.ComponentProperty(propValue), options ?? CXValueGeneratorOptions.Default); PushDiagnostics(result.Diagnostics); @@ -114,7 +113,7 @@ protected void AssertRenders( private sealed record MockPropertyValue( CXValue? Value, - GraphNode? Node, + GraphNode? GraphNode, bool IsSpecified, bool HasValue, bool IsAttributeValue, diff --git a/tests/RendererTests/BooleanTests.cs b/tests/RendererTests/BooleanTests.cs index cd3ed98..2d11f22 100644 --- a/tests/RendererTests/BooleanTests.cs +++ b/tests/RendererTests/BooleanTests.cs @@ -12,13 +12,13 @@ public void BasicBoolean() { AssertRenders( "'true'", - Renderers.Boolean, + CXValueGenerator.Boolean, "true" ); AssertRenders( "'false'", - Renderers.Boolean, + CXValueGenerator.Boolean, "false" ); } @@ -28,13 +28,13 @@ public void BooleanWithOddCasing() { AssertRenders( "'tRUe'", - Renderers.Boolean, + CXValueGenerator.Boolean, "true" ); AssertRenders( "'FALSE'", - Renderers.Boolean, + CXValueGenerator.Boolean, "false" ); } @@ -44,7 +44,7 @@ public void UnspecifiedBooleanProperty() { AssertRenders( cx: null, - Renderers.Boolean, + CXValueGenerator.Boolean, "true", requiresValue: false ); @@ -55,7 +55,7 @@ public void InvalidBooleanValue() { AssertRenders( "'blah'", - Renderers.Boolean, + CXValueGenerator.Boolean, null ); { @@ -70,7 +70,7 @@ public void InterpolatedConstant() { AssertRenders( "'{Interp}'", - Renderers.Boolean, + CXValueGenerator.Boolean, "false", interpolations: [ diff --git a/tests/RendererTests/ColorTests.cs b/tests/RendererTests/ColorTests.cs index 2677505..caea6e3 100644 --- a/tests/RendererTests/ColorTests.cs +++ b/tests/RendererTests/ColorTests.cs @@ -12,13 +12,13 @@ public void PredefinedColors() { AssertRenders( "'red'", - Renderers.Color, + CXValueGenerator.Color, "global::Discord.Color.Red" ); AssertRenders( "'blue'", - Renderers.Color, + CXValueGenerator.Color, "global::Discord.Color.Blue" ); } @@ -28,7 +28,7 @@ public void NotAColorRuntimeFallback() { AssertRenders( "'blah'", - Renderers.Color, + CXValueGenerator.Color, "global::Discord.Color.Parse(\"blah\")" ); { @@ -42,13 +42,13 @@ public void Hex() { AssertRenders( "'00FF00'", - Renderers.Color, + CXValueGenerator.Color, "new global::Discord.Color(65280)" ); AssertRenders( "'#00FF00'", - Renderers.Color, + CXValueGenerator.Color, "new global::Discord.Color(65280)" ); } @@ -56,11 +56,10 @@ public void Hex() [Fact] public void InterpolatedConstantHex() { - // color has an implicit conversion from uint AssertRenders( "'{Interp}'", - Renderers.Color, - "designer.GetValue(0)", + CXValueGenerator.Color, + "new global::Discord.Color(65280)", interpolations: [ new DesignerInterpolationInfo( diff --git a/tests/RendererTests/IntegerTests.cs b/tests/RendererTests/IntegerTests.cs index 6f26dbd..b392f87 100644 --- a/tests/RendererTests/IntegerTests.cs +++ b/tests/RendererTests/IntegerTests.cs @@ -12,13 +12,13 @@ public void BasicIntegers() { AssertRenders( "'123'", - Renderers.Integer, + CXValueGenerator.Integer, "123" ); AssertRenders( "'-456'", - Renderers.Integer, + CXValueGenerator.Integer, "-456" ); } @@ -28,7 +28,7 @@ public void RuntimeFallback() { AssertRenders( $"'{uint.MaxValue}'", - Renderers.Integer, + CXValueGenerator.Integer, $"int.Parse(\"{uint.MaxValue}\")" ); { @@ -43,7 +43,7 @@ public void InterpolatedConstant() { AssertRenders( "'{Interp}'", - Renderers.Integer, + CXValueGenerator.Integer, "123", interpolations: [ diff --git a/tests/RendererTests/SnowflakeTests.cs b/tests/RendererTests/SnowflakeTests.cs index 2f80764..481f1e6 100644 --- a/tests/RendererTests/SnowflakeTests.cs +++ b/tests/RendererTests/SnowflakeTests.cs @@ -12,13 +12,13 @@ public void BasicSnowflake() { AssertRenders( "'123'", - Renderers.Snowflake, + CXValueGenerator.Snowflake, "123" ); AssertRenders( $"'{ulong.MaxValue}'", - Renderers.Snowflake, + CXValueGenerator.Snowflake, $"{ulong.MaxValue}" ); } @@ -28,7 +28,7 @@ public void SnowflakeOutOfRangeUsingFallback() { AssertRenders( "'-1'", - Renderers.Snowflake, + CXValueGenerator.Snowflake, "ulong.Parse(\"-1\")" ); { @@ -38,7 +38,7 @@ public void SnowflakeOutOfRangeUsingFallback() AssertRenders( $"'18446744073709551616'", - Renderers.Snowflake, + CXValueGenerator.Snowflake, "ulong.Parse(\"18446744073709551616\")" ); { @@ -52,7 +52,7 @@ public void InterpolatedConstant() { AssertRenders( "'{Interp}'", - Renderers.Snowflake, + CXValueGenerator.Snowflake, "123", interpolations: [ diff --git a/tests/RendererTests/StringTests.cs b/tests/RendererTests/StringTests.cs index 3d02116..d9686a3 100644 --- a/tests/RendererTests/StringTests.cs +++ b/tests/RendererTests/StringTests.cs @@ -13,13 +13,13 @@ public void BasicString() { AssertRenders( "\"Hello, World!\"", - Renderers.String, + CXValueGenerator.String, "\"Hello, World!\"" ); AssertRenders( "\'Hello, World!\'", - Renderers.String, + CXValueGenerator.String, "\"Hello, World!\"" ); } @@ -32,7 +32,7 @@ public void MultilineString() "Hello, World!" """, - Renderers.String, + CXValueGenerator.String, // the empty line above the string literal is expected """" @@ -55,7 +55,7 @@ public void MultilineString() breaks" """, - Renderers.String, + CXValueGenerator.String, """" """ @@ -80,7 +80,7 @@ public void StringWithInterpolations() """ 'Hello, {World}!' """, - Renderers.String, + CXValueGenerator.String, """ $"Hello, {designer.GetValueAsString(0)}!" """, @@ -103,7 +103,7 @@ public void StringWithInterpolations() AssertRenders( builder.StringBuilder.ToString(), - Renderers.String, + CXValueGenerator.String, """" $""" diff --git a/tests/RendererTests/UnfurledMediaItemTests.cs b/tests/RendererTests/UnfurledMediaItemTests.cs index 69bf3f6..5f5bc0b 100644 --- a/tests/RendererTests/UnfurledMediaItemTests.cs +++ b/tests/RendererTests/UnfurledMediaItemTests.cs @@ -12,7 +12,7 @@ public void BasicUnfurledMediaItem() """ 'https://example.com' """, - Renderers.UnfurledMediaItem, + CXValueGenerator.UnfurledMediaItem, """ new global::Discord.UnfurledMediaItemProperties("https://example.com") """