File: GeneratorTests.cs
Web Access
Project: src\tests\ConfigurationSchemaGenerator.Tests\ConfigurationSchemaGenerator.Tests.csproj (ConfigurationSchemaGenerator.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections.Immutable;
using System.ComponentModel;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.Binder.SourceGeneration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Xunit;
 
namespace ConfigurationSchemaGenerator.Tests;
 
public partial class GeneratorTests
{
    private static readonly SyntaxTree s_implicitUsingsSyntaxTree = SyntaxFactory.ParseSyntaxTree(SourceText.From(
        """
        // <auto-generated/>
        global using global::System;
        global using global::System.Collections.Generic;
        global using global::System.IO;
        global using global::System.Linq;
        global using global::System.Net.Http;
        global using global::System.Threading;
        global using global::System.Threading.Tasks;
        """));
 
    private static readonly SyntaxTree s_attributesSyntaxTree = CSharpSyntaxTree.ParseText(File.ReadAllText("ConfigurationSchemaAttributes.cs"));
 
    private static readonly ImmutableArray<MetadataReference> s_defaultReferences =
    [
        MetadataReference.CreateFromFile(typeof(Attribute).Assembly.Location),
        MetadataReference.CreateFromFile(typeof(Attribute).Assembly.Location.Replace("System.Private.CoreLib", "System.Runtime")),
        MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location),
        MetadataReference.CreateFromFile(typeof(HttpContent).Assembly.Location)
    ];
 
    [Theory]
    [InlineData("abc\n  def", "abc def")]
    [InlineData("\n  def", "def")]
    [InlineData(" \n  def", "def")]
    [InlineData("  \n  def", "def")]
    [InlineData("abc\n", "abc")]
    [InlineData("abc\n ", "abc")]
    [InlineData("abc\n  ", "abc")]
    [InlineData("abc\n\n  ", "abc")]
    [InlineData("\n\n\t  def", "def")]
    [InlineData("abc\n def  \n ghi", "abc def ghi")]
    [InlineData("abc\r\n  def", "abc def")]
    [InlineData("\r\n  def", "def")]
    [InlineData(" \r\n  def", "def")]
    [InlineData("  \r\n  def", "def")]
    [InlineData("abc\r\n", "abc")]
    [InlineData("abc\r\n ", "abc")]
    [InlineData("abc\r\n  ", "abc")]
    [InlineData("abc\t\r\n\r\n  ", "abc")]
    [InlineData("\r\n\r\n  def", "def")]
    [InlineData("\r\nabc\r\ndef\r\nghi\r\n", "abc def ghi")]
    [InlineData("abc\r\n def  \r\n ghi", "abc def ghi")]
    [InlineData(" \r\n  \r\n ", "")]
    [InlineData(" abc \n \n def ", "abc\n\ndef")]
    public void ShouldRemoveInsignificantWhitespace(string input, string expected)
    {
        var summaryElement = ConvertToSummaryElement(input);
 
        var description = ConfigSchemaEmitter.FormatDescription(summaryElement);
        Assert.Equal(expected, description);
    }
 
    [Theory]
    [InlineData("\n<para>abc</para><p>def</p>\n", "abc\n\ndef")]
    [InlineData("<para>\nabc\n</para><para>\ndef\n</para>", "abc\n\ndef")]
    [InlineData("abc<para>def</para>ghi", "abc\n\ndef\n\nghi")]
    [InlineData("abc\n<para>\ndef\n</para>\nghi", "abc\n\ndef\n\nghi")]
    [InlineData("abc<br/>def<br/>ghi", "abc\ndef\nghi")]
    [InlineData("<br/>abc<br/>def<br/>ghi<br/>", "abc\ndef\nghi")]
    [InlineData("abc\n<br />\ndef", "abc\ndef")]
    [InlineData("abc\n\ndef", "abc\n\ndef")]
    public void ShouldInsertLineBreaks(string input, string expected)
    {
        var summaryElement = ConvertToSummaryElement(input);
 
        var description = ConfigSchemaEmitter.FormatDescription(summaryElement);
        Assert.Equal(expected, description);
    }
 
    [Theory]
    [InlineData("Do <b><i>not</i></b> use!", "Do not use!")]
    [InlineData("A valid <see cref=\"T:System.Uri\"/>. See remarks.", "A valid 'System.Uri'. See remarks.")]
    [InlineData("The <see cref=\"T:System.Uri\"/> or <c>null</c>.", "The 'System.Uri' or null.")]
    [InlineData("Use <example><code>Console.WriteLine();</code></example> to print.", "Use Console.WriteLine(); to print.")]
    [InlineData("Generic <example><code><![CDATA[List<string>]]></code></example>", "Generic List<string>")]
    public void ShouldStripHtmlTags(string input, string expected)
    {
        var summaryElement = ConvertToSummaryElement(input);
 
        var description = ConfigSchemaEmitter.FormatDescription(summaryElement);
        Assert.Equal(expected, description);
    }
 
    [Theory]
    [InlineData("<see cref=\"N:System.Diagnostics\"/>", "'System.Diagnostics'")]
    [InlineData("<see cref=\"T:System.Uri\"/>", "'System.Uri'")]
    [InlineData("<see cref=\"T:Azure.Core.Extensions.IAzureClientBuilder`2\"/>", "'Azure.Core.Extensions.IAzureClientBuilder`2'")]
    [InlineData("<see cref=\"F:Azure.Storage.Queues.QueueMessageEncoding.None\"/>", "'Azure.Storage.Queues.QueueMessageEncoding.None'")]
    [InlineData("<see cref=\"P:Aspire.Azure.Storage.Blobs.AzureStorageBlobsSettings.ConnectionString\"/>", "'Aspire.Azure.Storage.Blobs.AzureStorageBlobsSettings.ConnectionString'")]
    [InlineData("<see cref=\"M:System.Diagnostics.Debug.Assert(bool)\"/>", "'System.Diagnostics.Debug.Assert(bool)'")]
    [InlineData("<see cref=\"E:System.Windows.Input.ICommand.CanExecuteChanged\"/>", "'System.Windows.Input.ICommand.CanExecuteChanged'")]
    [InlineData("<exception cref=\"T:System.InvalidOperationException\" />", "'System.InvalidOperationException'")]
    public void ShouldQuoteCrefAttributes(string input, string expected)
    {
        var summaryElement = ConvertToSummaryElement(input);
 
        var description = ConfigSchemaEmitter.FormatDescription(summaryElement);
        Assert.Equal(expected, description);
    }
 
    [Theory]
    [InlineData("<see href=\"https://aka.ms/azsdk/blog/vault-uri\"/>", "https://aka.ms/azsdk/blog/vault-uri")]
    [InlineData("<see langword=\"true\"/>", "true")]
    public void ShouldNotQuoteNonCrefAttributes(string input, string expected)
    {
        var summaryElement = ConvertToSummaryElement(input);
 
        var description = ConfigSchemaEmitter.FormatDescription(summaryElement);
        Assert.Equal(expected, description);
    }
 
    [Theory]
    [InlineData("<param name=\"connectionName\">A name used to retrieve the connection string from the ConnectionStrings configuration section.</param>", "A name used to retrieve the connection string from the ConnectionStrings configuration section.")]
    [InlineData("<paramref name=\"arg\"/>", "arg")]
    public void ShouldNotFormatMiscAttributes(string input, string expected)
    {
        var summaryElement = ConvertToSummaryElement(input);
 
        var description = ConfigSchemaEmitter.FormatDescription(summaryElement);
        Assert.Equal(expected, description);
    }
 
    private static XElement ConvertToSummaryElement(string input)
    {
        var bytes = Encoding.UTF8.GetBytes($"<summary>{input}</summary>");
        using var stream = new MemoryStream(bytes);
        using var reader = XmlReader.Create(stream);
        reader.MoveToContent();
        return (XElement)XNode.ReadFrom(reader);
    }
 
    [Theory]
    [InlineData("bool",
        """
        "type": "boolean"
        """)]
    [InlineData("bool?",
        """
        "type": "boolean"
        """)]
    [InlineData("byte",
        """
        "type": "integer"
        """)]
    [InlineData("sbyte",
        """
        "type": "integer"
        """)]
    [InlineData("char",
        """
        "type": "integer"
        """)]
    [InlineData("int",
        """
        "type": "integer"
        """)]
    [InlineData("uint",
        """
        "type": "integer"
        """)]
    [InlineData("long",
        """
        "type": "integer"
        """)]
    [InlineData("ulong",
        """
        "type": "integer"
        """)]
    [InlineData("short",
        """
        "type": "integer"
        """)]
    [InlineData("ushort",
        """
        "type": "integer"
        """)]
    [InlineData("decimal",
        """
        "type": ["number", "string"]
        """)]
    [InlineData("double",
        """
        "type": ["number", "string"]
        """)]
    [InlineData("float",
        """
        "type": ["number", "string"]
        """)]
    [InlineData("byte[]?",
        """
        "oneOf": [
          {
            "type": "string",
            "pattern": "^[-A-Za-z0-9+/]*={0,3}$"
          },
          {
            "type": "array",
            "items": {
              "type": "integer"
            }
          }
        ]
        """)]
    [InlineData("object?",
        """
        "type": "object"
        """)]
    [InlineData("string?",
        """
        "type": "string"
        """)]
    [InlineData("Version?",
        """
        "type": "string"
        """)]
    [InlineData("CultureInfo?",
        """
        "type": "string"
        """)]
    [InlineData("DateTime",
        """
        "type": "string"
        """)]
    [InlineData("DateTimeOffset",
        """
        "type": "string"
        """)]
    [InlineData("TimeSpan",
        """
        "type": "string",
        "pattern": "^-?(\\d{1,7}|((\\d{1,7}[\\.:])?(([01]?\\d|2[0-3]):[0-5]?\\d|([01]?\\d|2[0-3]):[0-5]?\\d:[0-5]?\\d)(\\.\\d{1,7})?))$"
        """)]
    [InlineData("Guid",
        """
        "type": "string",
        "format": "uuid"
        """)]
    [InlineData("Uri?",
        """
        "type": "string",
        "format": "uri"
        """)]
    [InlineData("DayOfWeek",
        """
        "enum": [
          "Sunday",
          "Monday",
          "Tuesday",
          "Wednesday",
          "Thursday",
          "Friday",
          "Saturday"
        ]
        """)]
    [InlineData("int[]?",
        """
        "type": "array",
        "items": {
         "type": "integer"
        }
        """)]
    [InlineData("ICollection<int>?",
        """
        "type": "array",
        "items": {
          "type": "integer"
        }
        """)]
    [InlineData("IDictionary<string, int>?",
        """
        "type": "object",
        "additionalProperties": {
          "type": "integer"
        }
        """)]
    [InlineData("string[]?",
        """
        "type": "array",
        "items": {
         "type": "string"
        }
        """)]
    [InlineData("IEnumerable<string>?",
        """
        "type": "array",
        "items": {
          "type": "string"
        }
        """)]
    [InlineData("IDictionary<string, string>?",
        """
        "type": "object",
        "additionalProperties": {
          "type": "string"
        }
        """)]
    [InlineData("object[]?",
        """
        "type": "array",
        "items": {
         "type": "object"
        }
        """)]
    [InlineData("IList<object>?",
        """
        "type": "array",
        "items": {
          "type": "object"
        }
        """)]
    [InlineData("IDictionary<string, object>?",
        """
        "type": "object",
        "additionalProperties": {
          "type": "object"
        }
        """)]
    [InlineData("ChildType?",
        """
        "type": "object",
        "properties": {
          "ChildValue": {
            "type": "string"
          },
          "Parent": {}
        }
        """)]
    [InlineData("ChildType[]?",
        """
        "type": "array",
        "items": {
          "type": "object",
          "properties": {
            "ChildValue": {
              "type": "string"
            },
            "Parent": {}
          }
        }
        """)]
    [InlineData("IList<ChildType>?",
        """
        "type": "array",
        "items": {
          "type": "object",
          "properties": {
            "ChildValue": {
              "type": "string"
            },
            "Parent": {}
          }
        }
        """)]
    [InlineData("IDictionary<string, ChildType>?",
        """
        "type": "object",
        "additionalProperties": {
          "type": "object",
          "properties": {
            "ChildValue": {
              "type": "string"
            },
            "Parent": {}
          }
        }
        """)]
    [InlineData("Int128",
        """
        "type": "integer"
        """)]
    [InlineData("UInt128",
        """
        "type": "integer"
        """)]
    [InlineData("Half",
        """
        "type": [
          "number",
          "string"
        ]
        """)]
    [InlineData("DateOnly",
        """
        "type": "string"
        """)]
    [InlineData("TimeOnly",
        """
        "type": "string"
        """)]
    public void ShouldConvertTypes(string propertyType, string expected)
    {
        var source =
            $$"""
            using System.Globalization;
            using System.Security.Cryptography.X509Certificates;
 
            [assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings), ["TestProperty:ClientCertificate"])]
 
            public class TestSettings
            {
                public {{propertyType}} TestProperty { get; set; }
            }
 
            public record ChildType
            {
                public string? ChildValue { get; set; }
 
                // Simple read-only property, ignored.
                public bool HasParent => Parent != null;
 
                // Circular reference, included but without type.
                public ChildType? Parent { get; set; }
 
                // Ignored via exclusionPaths.
                public X509Certificate2? ClientCertificate { get; set; }
            }
            """;
 
        ImmutableArray<MetadataReference> references = [
            MetadataReference.CreateFromFile(typeof(Uri).Assembly.Location),
            MetadataReference.CreateFromFile(typeof(X509Certificate2).Assembly.Location)
        ];
        var schema = GenerateSchemaFromCode(source, references);
 
        AssertIsJson(schema,
          $$"""
          {
            "type": "object",
            "properties": {
              "TestComponent": {
                "type": "object",
                "properties": {
                  "TestProperty": {
                    {{expected}}
                  }
                }
              }
            }
          }
          """);
    }
 
    [Fact]
    public void ShouldAllowGetOnlyObject()
    {
        var source =
            """
            [assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings))]
 
            public class TestSettings
            {
                public JsonFormattingOptions FormatSettings { get; } = new();
            }
 
            public class JsonFormattingOptions
            {
                public bool WriteIndented { get; set; }
            }
            """;
 
        var schema = GenerateSchemaFromCode(source, []);
 
        AssertIsJson(schema,
            """
            {
              "type": "object",
              "properties": {
                "TestComponent": {
                  "type": "object",
                  "properties": {
                    "FormatSettings": {
                      "type": "object",
                      "properties": {
                        "WriteIndented": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                }
              }
            }
            """{
              "type": "object",
              "properties": {
                "TestComponent": {
                  "type": "object",
                  "properties": {
                    "FormatSettings": {
                      "type": "object",
                      "properties": {
                        "WriteIndented": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                }
              }
            }
            """);
    }
 
    [Fact]
    public void ShouldAllowGetOnlyCollections()
    {
        var source =
            """
            [assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings))]
 
            public class TestSettings
            {
                public ICollection<int> CollectionOfInt { get; } = [];
                public IDictionary<string, int> DictionaryOfStringToInt { get; } = new Dictionary<string, int>();
            }
            """;
 
        var schema = GenerateSchemaFromCode(source, []);
 
        AssertIsJson(schema,
            """
            {
              "type": "object",
              "properties": {
                "TestComponent": {
                  "type": "object",
                  "properties": {
                    "CollectionOfInt": {
                      "type": "array",
                      "items": {
                        "type": "integer"
                      }
                    },
                    "DictionaryOfStringToInt": {
                      "type": "object",
                      "additionalProperties": {
                        "type": "integer"
                      }
                    }
                  }
                }
              }
            }
            """{
              "type": "object",
              "properties": {
                "TestComponent": {
                  "type": "object",
                  "properties": {
                    "CollectionOfInt": {
                      "type": "array",
                      "items": {
                        "type": "integer"
                      }
                    },
                    "DictionaryOfStringToInt": {
                      "type": "object",
                      "additionalProperties": {
                        "type": "integer"
                      }
                    }
                  }
                }
              }
            }
            """);
    }
 
    [Fact]
    public void ShouldAllowFreeFormatViaRecursiveSubtree()
    {
        var source =
            """
            using System;
            using System.ComponentModel;
            using System.Diagnostics;
            using System.Globalization;
            using Microsoft.Extensions.Hosting;
            using Microsoft.Extensions.DependencyInjection;
            using Microsoft.Extensions.Configuration;
            using Microsoft.Extensions.Options;
 
            [assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings))]
 
            public sealed class TestSettings
            {
                public TreeOfString TestProperty1 { get; } = new();
                public IDictionary<string, TreeOfString> TestProperty2 { get; } = new Dictionary<string, TreeOfString>(StringComparer.OrdinalIgnoreCase);
            }
 
            [DebuggerDisplay("{ToString(),nq}")]
            [TypeConverter(typeof(TreeOfStringConverter))]
            public sealed class TreeOfString : Dictionary<string, TreeOfString>
            {
                public string? Value { get; }
 
                public TreeOfString() : base(StringComparer.OrdinalIgnoreCase)
                {
                }
 
                public TreeOfString(string value)
                {
                    Value = value;
                }
 
                public override string ToString()
                {
                    return Value != null ? $"Value = '{Value}'" : $"Subtree of {Count} items";
                }
            }
 
            public sealed class TreeOfStringConverter : TypeConverter
            {
                public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
                {
                    return sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);
                }
 
                public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
                {
                    return value is string stringValue ? new TreeOfString(stringValue) : base.ConvertFrom(context, culture, value);
                }
            }
 
            public static class AppUsageDemo
            {
                public static void Run()
                {
                    var builder = Host.CreateApplicationBuilder();
                    
                    builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
                    {
                        ["TestComponent:TestSettings:TestProperty1:UserName"] = "john.doe@email.com",
                        ["TestComponent:TestSettings:TestProperty1:Password"] = "p@ssw0rd1",
                        ["TestComponent:TestSettings:TestProperty1:Options:RequireSSL"] = "false",
                        ["TestComponent:TestSettings:TestProperty2:UserName"] = "jane.doe@email.com",
                        ["TestComponent:TestSettings:TestProperty2:Auth:Token:Key"] = "39B4D4E4-AC46-471D-A89A-EBEAB4CA1697",
                        ["TestComponent:TestSettings:TestProperty2:Options:UseSingleSignOn"] = "true"
                    });
                    
                    builder.Services.AddOptions<TestSettings>().BindConfiguration("TestComponent:TestSettings");
                    
                    using var host = builder.Build();
                    
                    var optionsMonitor = host.Services.GetRequiredService<IOptionsMonitor<TestSettings>>();
                    var testSettings = optionsMonitor.CurrentValue;
                    
                    Console.WriteLine(nameof(TestSettings.TestProperty1));
                    Print(testSettings.TestProperty1, 1);
                    Console.WriteLine(nameof(TestSettings.TestProperty2));
                    Print(testSettings.TestProperty2, 1);
                    
                    /*
                    When run, prints the following to the console:
                    TestProperty1
                      Options
                        RequireSSL = false
                      Password = p@ssw0rd1
                      UserName = john.doe@email.com
                    TestProperty2
                      Auth
                        Token
                          Key = 39B4D4E4-AC46-471D-A89A-EBEAB4CA1697
                      Options
                        UseSingleSignOn = true
                      UserName = jane.doe@email.com        
                    */
                }
                
                private static void Print(IDictionary<string, TreeOfString> source, int depth = 0)
                {
                    var indent = new string(' ', depth * 2);
                
                    foreach ((string key, TreeOfString subTree) in source)
                    {
                        if (!string.IsNullOrEmpty(subTree.Value))
                        {
                            Console.WriteLine($"{indent}{key} = {subTree.Value}");
                        }
                        else
                        {
                            Console.WriteLine($"{indent}{key}");
                            Print(subTree, depth + 1);
                        }
                    }
                }
            }
            """;
 
        ImmutableArray<MetadataReference> references = [
            MetadataReference.CreateFromFile(typeof(TypeConverter).Assembly.Location),
            MetadataReference.CreateFromFile(typeof(TypeConverterAttribute).Assembly.Location),
            MetadataReference.CreateFromFile(typeof(Host).Assembly.Location),
            MetadataReference.CreateFromFile(typeof(IHost).Assembly.Location),
            MetadataReference.CreateFromFile(typeof(IServiceCollection).Assembly.Location),
            MetadataReference.CreateFromFile(typeof(IServiceProvider).Assembly.Location),
            MetadataReference.CreateFromFile(typeof(IConfigurationBuilder).Assembly.Location),
            MetadataReference.CreateFromFile(typeof(ConfigurationManager).Assembly.Location),
            MetadataReference.CreateFromFile(typeof(IOptionsMonitor<>).Assembly.Location),
            MetadataReference.CreateFromFile(typeof(OptionsBuilderConfigurationExtensions).Assembly.Location),
            MetadataReference.CreateFromFile(typeof(BinderOptions).Assembly.Location),
            MetadataReference.CreateFromFile(typeof(Console).Assembly.Location)
        ];
        var schema = GenerateSchemaFromCode(source, references);
 
        AssertIsJson(schema,
            """
            {
              "type": "object",
              "properties": {
                "TestComponent": {
                  "type": "object",
                  "properties": {
                    "TestProperty1": {},
                    "TestProperty2": {
                      "type": "object",
                      "additionalProperties": {}
                    }
                  }
                }
              }
            }
            """{
              "type": "object",
              "properties": {
                "TestComponent": {
                  "type": "object",
                  "properties": {
                    "TestProperty1": {},
                    "TestProperty2": {
                      "type": "object",
                      "additionalProperties": {}
                    }
                  }
                }
              }
            }
            """);
    }
 
    [Fact]
    public void ShouldSkipProperties()
    {
        var source =
            """
            using System.ComponentModel;
            using System.Text.Json;
 
            [assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings))]
 
            public class TestSettings
            {
                public static IList<string> StaticReadOnlyList { get; } = [];
 
                public int ReadOnlyProperty { get; } = 42;
 
                public dynamic? DynamicProperty { get; set; }
 
                public Delegate? DelegateProperty { get; set; }
 
                public JsonNamingPolicy? EmptyProperty { get; set; } // has no settable properties
 
                public IList<JsonNamingPolicy> CollectionOfEmptyProperty { get; } = [];
 
                [Obsolete]
                public string? ObsoleteProperty { get; }
 
                [EditorBrowsable(EditorBrowsableState.Never)]
                public string? HiddenProperty { get; }
 
                public bool Field;
            }
            """;
 
        ImmutableArray<MetadataReference> references = [
            MetadataReference.CreateFromFile(typeof(DynamicAttribute).Assembly.Location),
            MetadataReference.CreateFromFile(typeof(JsonNamingPolicy).Assembly.Location)
        ];
        var schema = GenerateSchemaFromCode(source, references);
 
        AssertIsJson(schema,
            """
            {}
            """);
    }
 
    [Fact]
    public void ShouldTakeConfigurationKeyName()
    {
        var source =
            """
            using Microsoft.Extensions.Configuration;
 
            [assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings))]
 
            public class TestSettings
            {
                [ConfigurationKeyName("AlternateName")]
                public string? TestProperty { get; set; }
            }
            """;
 
        ImmutableArray<MetadataReference> references =
        [
            MetadataReference.CreateFromFile(typeof(ConfigurationKeyNameAttribute).Assembly.Location)
        ];
        var schema = GenerateSchemaFromCode(source, references);
 
        AssertIsJson(schema,
            """
            {
              "type": "object",
              "properties": {
                "TestComponent": {
                  "type": "object",
                  "properties": {
                    "AlternateName": {
                      "type": "string"
                    }
                  }
                }
              }
            }
            """{
              "type": "object",
              "properties": {
                "TestComponent": {
                  "type": "object",
                  "properties": {
                    "AlternateName": {
                      "type": "string"
                    }
                  }
                }
              }
            }
            """);
    }
 
    [Fact]
    public void ShouldUseConfigurationKeyNameInExclusionPaths()
    {
        var source =
            """
            using Microsoft.Extensions.Configuration;
 
            [assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings), ["AlternateName"])]
 
            public class TestSettings
            {
                [ConfigurationKeyName("AlternateName")]
                public string? TestProperty { get; set; }
            }
            """;
 
        ImmutableArray<MetadataReference> references =
        [
            MetadataReference.CreateFromFile(typeof(ConfigurationKeyNameAttribute).Assembly.Location)
        ];
        var schema = GenerateSchemaFromCode(source, references);
 
        AssertIsJson(schema,
            """
            {}
            """);
    }
 
    [Fact]
    public void ShouldAllowEmptyComponentPath()
    {
        var source =
            """
            [assembly: Aspire.ConfigurationSchema("", typeof(SettingsOne))]
            [assembly: Aspire.ConfigurationSchema("", typeof(SettingsTwo))]
 
            public class SettingsOne
            {
                public string? PropertyOne { get; set; }
            }
 
            public class SettingsTwo
            {
                public int? PropertyTwo { get; set; }
            }
            """;
 
        var schema = GenerateSchemaFromCode(source, []);
 
        AssertIsJson(schema,
            """
            {
              "type": "object",
              "properties": {
                "PropertyOne": {
                  "type": "string"
                },
                "PropertyTwo": {
                  "type": "integer"
                }
              }
            }
            """{
              "type": "object",
              "properties": {
                "PropertyOne": {
                  "type": "string"
                },
                "PropertyTwo": {
                  "type": "integer"
                }
              }
            }
            """);
    }
 
    [Fact]
    public void ShouldAllowNestedComponentPath()
    {
        var source =
            """
            [assembly: Aspire.ConfigurationSchema("One:Two:Three", typeof(OneTwoThreeSettings))]
            [assembly: Aspire.ConfigurationSchema("One", typeof(OneSettings))]
 
            public class OneTwoThreeSettings
            {
                public string? PropertyInThree { get; set; }
            }
 
            public class OneSettings
            {
                public int? PropertyInOne { get; set; }
            }
            """;
 
        var schema = GenerateSchemaFromCode(source, []);
 
        AssertIsJson(schema,
            """
            {
              "type": "object",
              "properties": {
                "One": {
                  "type": "object",
                  "properties": {
                    "PropertyInOne": {
                      "type": "integer"
                    },
                    "Two": {
                      "type": "object",
                      "properties": {
                        "Three": {
                          "type": "object",
                          "properties": {
                            "PropertyInThree": {
                              "type": "string"
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
            """{
              "type": "object",
              "properties": {
                "One": {
                  "type": "object",
                  "properties": {
                    "PropertyInOne": {
                      "type": "integer"
                    },
                    "Two": {
                      "type": "object",
                      "properties": {
                        "Three": {
                          "type": "object",
                          "properties": {
                            "PropertyInThree": {
                              "type": "string"
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
            """);
    }
 
    [Fact]
    public void ShouldAllowSimpleTypeInComponentPath()
    {
        var source =
            """
            [assembly: Aspire.ConfigurationSchema("One:Two:Three", typeof(int))]
            """;
 
        var schema = GenerateSchemaFromCode(source, []);
 
        AssertIsJson(schema,
            """
            {
              "type": "object",
              "properties": {
                "One": {
                  "type": "object",
                  "properties": {
                    "Two": {
                      "type": "object",
                      "properties": {
                        "Three": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
            """{
              "type": "object",
              "properties": {
                "One": {
                  "type": "object",
                  "properties": {
                    "Two": {
                      "type": "object",
                      "properties": {
                        "Three": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
            """);
    }
 
    [Fact]
    public void MergesPropertiesAtSamePath()
    {
        var source =
            """
            [assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings1))]
            [assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings2))]
 
            public record TestSettings1
            {
                /// <summary>1</summary>
                public string? TestProperty1 { get; set; }
            }
 
            public record TestSettings2
            {
                /// <summary>2</summary>
                public int? TestProperty2 { get; set; }
            }
            """;
 
        var schema = GenerateSchemaFromCode(source, []);
 
        AssertIsJson(schema,
            """
            {
              "type": "object",
              "properties": {
                "TestComponent": {
                  "type": "object",
                  "properties": {
                    "TestProperty1": {
                      "type": "string",
                      "description": "1"
                    },
                    "TestProperty2": {
                      "type": "integer",
                      "description": "2"
                    }
                  }
                }
              }
            }
            """{
              "type": "object",
              "properties": {
                "TestComponent": {
                  "type": "object",
                  "properties": {
                    "TestProperty1": {
                      "type": "string",
                      "description": "1"
                    },
                    "TestProperty2": {
                      "type": "integer",
                      "description": "2"
                    }
                  }
                }
              }
            }
            """);
    }
 
    [Fact]
    public void PreservesExistingComponentTypeWhenSkipped()
    {
        var source =
            """
            [assembly: Aspire.ConfigurationSchema("TestComponent", typeof(IList<string>))]
            [assembly: Aspire.ConfigurationSchema("TestComponent:One", typeof(Empty))]
            [assembly: Aspire.ConfigurationSchema("OtherComponent", typeof(Empty))]
 
            public record Empty;
            """;
 
        var schema = GenerateSchemaFromCode(source, []);
 
        AssertIsJson(schema,
            """
            {
              "type": "object",
              "properties": {
                "TestComponent": {
                  "type": "array",
                  "items": {
                    "type": "string"
                  }
                }
              }
            }
            """{
              "type": "object",
              "properties": {
                "TestComponent": {
                  "type": "array",
                  "items": {
                    "type": "string"
                  }
                }
              }
            }
            """);
    }
 
    [Fact]
    public void PreservesExistingPropertyTypeWhenSkipped()
    {
        var source =
            """
            [assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings1))]
            [assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings2))]
            [assembly: Aspire.ConfigurationSchema("TestComponent:TestProperty", typeof(IList<Empty>))]
 
            public class TestSettings1
            {
                public string? TestProperty { get; set; }
            }
 
            public class TestSettings2
            {
                public Empty? TestProperty { get; set; }
            }
 
            public record Empty;
            """;
 
        var schema = GenerateSchemaFromCode(source, []);
 
        AssertIsJson(schema,
            """
            {
              "type": "object",
              "properties": {
                "TestComponent": {
                  "type": "object",
                  "properties": {
                    "TestProperty": {
                      "type": "string"
                    }
                  }
                }
              }
            }
            """{
              "type": "object",
              "properties": {
                "TestComponent": {
                  "type": "object",
                  "properties": {
                    "TestProperty": {
                      "type": "string"
                    }
                  }
                }
              }
            }
            """);
    }
 
    [Fact]
    public void PreservesExistingPropertyWhenSkipped()
    {
        var source =
            """
            [assembly: Aspire.ConfigurationSchema("One:Two", typeof(TestSettings))]
            [assembly: Aspire.ConfigurationSchema("One", typeof(Empty))]
 
            public class TestSettings
            {
                public string? TestProperty { get; set; }
            }
 
            /// <summary>Empty</summary>
            public record Empty;
            """;
 
        var schema = GenerateSchemaFromCode(source, []);
 
        AssertIsJson(schema,
            """
            {
              "type": "object",
              "properties": {
                "One": {
                  "type": "object",
                  "properties": {
                    "Two": {
                      "type": "object",
                      "properties": {
                        "TestProperty": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
            """{
              "type": "object",
              "properties": {
                "One": {
                  "type": "object",
                  "properties": {
                    "Two": {
                      "type": "object",
                      "properties": {
                        "TestProperty": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
            """);
    }
 
    [Fact]
    public void ReadsInheritDocFromInterfaceInSource()
    {
        var source =
            """
            [assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings))]
 
            /// <inheritdoc cref="ITestSettings" />
            public class TestSettings : ITestSettings
            {
                /// <inheritdoc />
                public string? AppName { get; set; }
            }
 
            /// <summary>
            /// Contains configuration settings.
            /// </summary>
            public interface ITestSettings
            {
                /// <summary>
                /// Gets or sets the name of this app.
                /// </summary>
                public string? AppName { get; set; }
            }
            """;
 
        var schema = GenerateSchemaFromCode(source, []);
 
        AssertIsJson(schema,
            """
            {
              "type": "object",
              "properties": {
                "TestComponent": {
                  "type": "object",
                  "properties": {
                    "AppName": {
                      "type": "string",
                      "description": "Gets or sets the name of this app."
                    }
                  },
                  "description": "Contains configuration settings."
                }
              }
            }
            """{
              "type": "object",
              "properties": {
                "TestComponent": {
                  "type": "object",
                  "properties": {
                    "AppName": {
                      "type": "string",
                      "description": "Gets or sets the name of this app."
                    }
                  },
                  "description": "Contains configuration settings."
                }
              }
            }
            """);
 
    }
 
    [Fact]
    public void CanClearInheritDocFromInterfaceInSource()
    {
        var source =
            """
            [assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings))]
 
            public class TestSettings : ITestSettings
            {
                /// <summary />
                public string? AppName { get; set; }
            }
 
            public interface ITestSettings
            {
                /// <summary>
                /// Gets or sets the name of this app.
                /// </summary>
                public string? AppName { get; set; }
            }
            """;
 
        var schema = GenerateSchemaFromCode(source, []);
 
        Assert.DoesNotContain("Gets or sets the name of this app.", schema);
    }
 
    [Fact]
    public void ReadsInheritDocFromInterfaceInNuGetPackage()
    {
        // Requires <CopyDocumentationFilesFromPackages>true</CopyDocumentationFilesFromPackages> in csproj.
 
        var source =
            """
            using Newtonsoft.Json;
 
            [assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings))]
 
            /// <inheritdoc cref="IJsonLineInfo" />
            public class TestSettings : IJsonLineInfo
            {
                /// <inheritdoc />
                public int LineNumber { get; set; }
 
                /// <inheritdoc />
                public int LinePosition { get; set; }
 
                public bool HasLineInfo() => throw new NotImplementedException();
            }
            """;
 
        ImmutableArray<MetadataReference> references = [
            ConfigSchemaGenerator.CreateMetadataReference(typeof(IJsonLineInfo).Assembly.Location)
        ];
        var schema = GenerateSchemaFromCode(source, references);
 
        AssertIsJson(schema,
            """
            {
              "type": "object",
              "properties": {
                "TestComponent": {
                  "type": "object",
                  "properties": {
                    "LineNumber": {
                      "type": "integer",
                      "description": "Gets the current line number."
                    },
                    "LinePosition": {
                      "type": "integer",
                      "description": "Gets the current line position."
                    }
                  },
                  "description": "Provides an interface to enable a class to return line and position information."
                }
              }
            }
            """{
              "type": "object",
              "properties": {
                "TestComponent": {
                  "type": "object",
                  "properties": {
                    "LineNumber": {
                      "type": "integer",
                      "description": "Gets the current line number."
                    },
                    "LinePosition": {
                      "type": "integer",
                      "description": "Gets the current line position."
                    }
                  },
                  "description": "Provides an interface to enable a class to return line and position information."
                }
              }
            }
            """);
    }
 
    [Fact]
    public void DetectsBooleanDefaultValueInDocComments()
    {
        var source =
            """
            [assembly: Aspire.ConfigurationSchema("TestComponent", typeof(TestSettings))]
 
            public class TestSettings
            {
                /// <summary />
                /// <value>
                /// The default value is <c>false</c>.
                /// </value>
                public bool FalseByDefault { get; set; } = false;
 
                /// <summary />
                /// <value>
                /// The default value is <c>true</c>.
                /// </value>
                public bool TrueByDefault { get; set; } = true;
 
                /// <summary />
                /// <value>
                /// The default value is <c>true</c>,
                /// which is not <c>false</c>.
                /// </value>
                public bool UnknownDefault { get; set; } = true;
            }
            """;
 
        var schema = GenerateSchemaFromCode(source, []);
 
        AssertIsJson(schema,
            """
            {
              "type": "object",
              "properties": {
                "TestComponent": {
                  "type": "object",
                  "properties": {
                    "FalseByDefault": {
                      "type": "boolean",
                      "default": false
                    },
                    "TrueByDefault": {
                      "type": "boolean",
                      "default": true
                    },
                    "UnknownDefault": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
            """{
              "type": "object",
              "properties": {
                "TestComponent": {
                  "type": "object",
                  "properties": {
                    "FalseByDefault": {
                      "type": "boolean",
                      "default": false
                    },
                    "TrueByDefault": {
                      "type": "boolean",
                      "default": true
                    },
                    "UnknownDefault": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
            """);
    }
 
    private static string GenerateSchemaFromCode(string sourceText, IEnumerable<MetadataReference> references)
    {
        var sourceSyntaxTree = SyntaxFactory.ParseSyntaxTree(SourceText.From(sourceText));
        var compilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary).WithNullableContextOptions(NullableContextOptions.Enable);
        var compilation = CSharpCompilation.Create("Test", [s_implicitUsingsSyntaxTree, s_attributesSyntaxTree, sourceSyntaxTree], s_defaultReferences.AddRange(references), compilationOptions);
        Assert.Empty(compilation.GetDiagnostics().Where(diagnostic => diagnostic.Severity > DiagnosticSeverity.Hidden));
 
        var configSchemaInfo = ConfigSchemaGenerator.GetConfigurationSchema(compilation.Assembly);
        Assert.NotNull(configSchemaInfo);
 
        var parser = new ConfigurationBindingGenerator.Parser(configSchemaInfo, new KnownTypeSymbols(compilation));
        var spec = parser.GetSchemaGenerationSpec(CancellationToken.None);
        Assert.NotNull(spec);
 
        var emitter = new ConfigSchemaEmitter(spec, compilation);
        return emitter.GenerateSchema();
    }
 
    private static void AssertIsJson(string actual, string expected)
    {
        var actualJson = JsonNode.Parse(actual)!;
        var expectedJson = JsonNode.Parse(expected)!;
 
        var serializerOptions = new JsonSerializerOptions { WriteIndented = true };
        var expectedText = expectedJson.ToJsonString(serializerOptions);
        var actualText = actualJson.ToJsonString(serializerOptions);
 
        Assert.Equal(expectedText, actualText);
    }
 
    [Fact]
    public void IntegrationTest()
    {
        // the 'refs' folder is populated by PreserveCompilationContext in the .csproj
        var referenceAssemblies = Directory.GetFiles(Directory.GetCurrentDirectory(), "*.dll", SearchOption.AllDirectories)
            .ToArray();
 
        var outputPath = Path.Combine(Directory.GetCurrentDirectory(), "IntegrationTest.json");
        ConfigSchemaGenerator.GenerateSchema(
            typeof(GeneratorTests).Assembly.Location,
            referenceAssemblies,
            outputPath);
 
        // Compare the two, normalizing line endings.
        var actual = File.ReadAllText(outputPath).ReplaceLineEndings();
        var baseline = File.ReadAllText(Path.Combine("Baselines", "IntegrationTest.baseline.json")).ReplaceLineEndings();
        Assert.Equal(baseline, actual);
    }
 
    [GeneratedRegex(ConfigSchemaEmitter.TimeSpanRegex)]
    private static partial Regex TimeSpanRegex();
 
    [Theory]
    [InlineData("6")]
    [InlineData("6:12")]
    [InlineData("6:12:14")]
    [InlineData("6:12:14.45")]
    [InlineData("6.12:14:45")]
    [InlineData("6:12:14:45")]
    [InlineData("6.12:14:45.3448")]
    [InlineData("6:12:14:45.3448")]
    [InlineData("-6:12:14:45.3448")]
    [InlineData("9999999")]
    [InlineData("9:7")]
    public void TestTimeSpanRegexValid(string validTimeSpanString)
    {
        Assert.Matches(TimeSpanRegex(), validTimeSpanString);
        Assert.True(TimeSpan.TryParse(validTimeSpanString, CultureInfo.InvariantCulture, out _));
    }
 
    [Theory]
    [InlineData("24:00")]
    [InlineData("23:61")]
    [InlineData("6:12:60")]
    [InlineData("19999999")]
    [InlineData("+6:12:14:45.3448")]
    public void TestTimeSpanRegexInvalid(string invalidTimeSpanString)
    {
        Assert.DoesNotMatch(TimeSpanRegex(), invalidTimeSpanString);
        Assert.False(TimeSpan.TryParse(invalidTimeSpanString, CultureInfo.InvariantCulture, out _));
    }
}