diff --git a/Microsoft.Maui.sln b/Microsoft.Maui.sln index b4fd18748316..db17e14a366a 100644 --- a/Microsoft.Maui.sln +++ b/Microsoft.Maui.sln @@ -4,6 +4,8 @@ VisualStudioVersion = 17.0.31410.414 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core", "src\Core\src\Core.csproj", "{95BA42B5-B00E-4986-B9B5-517140378452}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core.ConfigurationSourceGen", "src\Core\src\ConfigurationSourceGen\Core.ConfigurationSourceGen.csproj", "{2A1B3C4D-5E6F-7890-ABCD-EF1234567890}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Controls.Core", "src\Controls\src\Core\Controls.Core.csproj", "{AF64451F-E2BD-41C2-B083-F60C26AE2A9F}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Compatibility", "src\Compatibility\Core\src\Compatibility.csproj", "{00A11C2F-969F-4964-8557-91ADF4B1523D}" @@ -38,6 +40,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestUtils", "src\TestUtils\ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core.UnitTests", "src\Core\tests\UnitTests\Core.UnitTests.csproj", "{92644F6F-5946-48FC-A21A-A3D6EE24E8B3}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core.ConfigurationSourceGen.UnitTests", "src\Core\tests\ConfigurationSourceGen.UnitTests\Core.ConfigurationSourceGen.UnitTests.csproj", "{1F2E3D4C-5B6A-7890-CDEF-123456789ABC}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{E8AD265B-3C67-4640-AC58-A522F9FB3361}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{C564DDD6-DE79-45CD-88EA-3F690481572A}" @@ -259,6 +263,10 @@ Global {95BA42B5-B00E-4986-B9B5-517140378452}.Debug|Any CPU.Build.0 = Debug|Any CPU {95BA42B5-B00E-4986-B9B5-517140378452}.Release|Any CPU.ActiveCfg = Release|Any CPU {95BA42B5-B00E-4986-B9B5-517140378452}.Release|Any CPU.Build.0 = Release|Any CPU + {2A1B3C4D-5E6F-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A1B3C4D-5E6F-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A1B3C4D-5E6F-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A1B3C4D-5E6F-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU {AF64451F-E2BD-41C2-B083-F60C26AE2A9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AF64451F-E2BD-41C2-B083-F60C26AE2A9F}.Debug|Any CPU.Build.0 = Debug|Any CPU {AF64451F-E2BD-41C2-B083-F60C26AE2A9F}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -301,6 +309,10 @@ Global {92644F6F-5946-48FC-A21A-A3D6EE24E8B3}.Debug|Any CPU.Build.0 = Debug|Any CPU {92644F6F-5946-48FC-A21A-A3D6EE24E8B3}.Release|Any CPU.ActiveCfg = Release|Any CPU {92644F6F-5946-48FC-A21A-A3D6EE24E8B3}.Release|Any CPU.Build.0 = Release|Any CPU + {1F2E3D4C-5B6A-7890-CDEF-123456789ABC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1F2E3D4C-5B6A-7890-CDEF-123456789ABC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1F2E3D4C-5B6A-7890-CDEF-123456789ABC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1F2E3D4C-5B6A-7890-CDEF-123456789ABC}.Release|Any CPU.Build.0 = Release|Any CPU {F7F2B379-52CE-4802-9EC9-0D7967B6BFB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F7F2B379-52CE-4802-9EC9-0D7967B6BFB7}.Debug|Any CPU.Build.0 = Debug|Any CPU {F7F2B379-52CE-4802-9EC9-0D7967B6BFB7}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -648,6 +660,7 @@ Global EndGlobalSection GlobalSection(NestedProjects) = preSolution {95BA42B5-B00E-4986-B9B5-517140378452} = {E8AD265B-3C67-4640-AC58-A522F9FB3361} + {2A1B3C4D-5E6F-7890-ABCD-EF1234567890} = {E8AD265B-3C67-4640-AC58-A522F9FB3361} {AF64451F-E2BD-41C2-B083-F60C26AE2A9F} = {50C758FE-4E10-409A-94F5-A75480960864} {00A11C2F-969F-4964-8557-91ADF4B1523D} = {446EB407-57EB-441D-9ADB-1A006CBF672A} {E1082E26-D700-4127-9329-66D673FD2D55} = {459BF674-83CB-46F6-881F-A2D2117DBF4D} @@ -659,6 +672,7 @@ Global {DAAC2822-63B6-4DE0-83AE-04873CD2F364} = {72397ADB-40A8-4B8E-8E08-2DBE2803C845} {FBB3270F-1924-4A72-845E-A6DF39C402F6} = {7AC28763-9C68-4BF9-A1BA-25CBFFD2D15C} {92644F6F-5946-48FC-A21A-A3D6EE24E8B3} = {C564DDD6-DE79-45CD-88EA-3F690481572A} + {1F2E3D4C-5B6A-7890-CDEF-123456789ABC} = {C564DDD6-DE79-45CD-88EA-3F690481572A} {E8AD265B-3C67-4640-AC58-A522F9FB3361} = {09C264E9-E3F3-4586-9151-DCBB1F6DA7AB} {C564DDD6-DE79-45CD-88EA-3F690481572A} = {09C264E9-E3F3-4586-9151-DCBB1F6DA7AB} {50C758FE-4E10-409A-94F5-A75480960864} = {459BF674-83CB-46F6-881F-A2D2117DBF4D} diff --git a/src/Core/src/ConfigurationSourceGen/AppSettingsSourceGenerator.cs b/src/Core/src/ConfigurationSourceGen/AppSettingsSourceGenerator.cs new file mode 100644 index 000000000000..1d84f6bde0c7 --- /dev/null +++ b/src/Core/src/ConfigurationSourceGen/AppSettingsSourceGenerator.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Microsoft.Maui.Core.ConfigurationSourceGen; + +[Generator] +public class AppSettingsSourceGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Get additional files (like appsettings.json) and combine them + var appSettingsProvider = context.AdditionalTextsProvider + .Where(static file => Path.GetFileName(file.Path).Equals("appsettings.json", StringComparison.OrdinalIgnoreCase)) + .Select(static (file, cancellationToken) => + { + var content = file.GetText(cancellationToken)?.ToString(); + return content; + }) + .Where(static content => !string.IsNullOrEmpty(content)) + .Collect(); // Collect all appsettings.json files into a single value + + // Generate source when we have any appsettings.json files + context.RegisterSourceOutput(appSettingsProvider, static (context, contents) => + { + try + { + if (contents.IsEmpty) + return; + + // Combine all appsettings files into one configuration + var allConfigurationEntries = new Dictionary(); + + foreach (var content in contents) + { + if (!string.IsNullOrEmpty(content)) + { + var entries = ParseJsonToConfigurationEntries(content!); + foreach (var entry in entries) + { + allConfigurationEntries[entry.Key] = entry.Value; // Later files override earlier ones + } + } + } + + // Generate the extension method source code + var sourceCode = GenerateExtensionMethod(allConfigurationEntries); + + // Add the generated source to the compilation + context.AddSource("LocalAppSettingsExtensions.g.cs", SourceText.From(sourceCode, Encoding.UTF8)); + } + catch (Exception ex) + { + // Add diagnostic for any errors + context.ReportDiagnostic(Diagnostic.Create( + new DiagnosticDescriptor( + "MAS001", + "AppSettings Source Generator Error", + "Error generating app settings extension: {0}", + "MauiAppSettings", + DiagnosticSeverity.Warning, + isEnabledByDefault: true), + Location.None, + ex.Message)); + } + }); + } + + private static Dictionary ParseJsonToConfigurationEntries(string jsonContent) + { + var entries = new Dictionary(); + + try + { + using var document = JsonDocument.Parse(jsonContent); + FlattenJsonElement(document.RootElement, entries, string.Empty); + } + catch (JsonException) + { + // If JSON parsing fails, return empty dictionary + } + + return entries; + } + + private static void FlattenJsonElement(JsonElement element, Dictionary entries, string prefix) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (var property in element.EnumerateObject()) + { + var key = string.IsNullOrEmpty(prefix) ? property.Name : $"{prefix}:{property.Name}"; + FlattenJsonElement(property.Value, entries, key); + } + break; + + case JsonValueKind.Array: + var index = 0; + foreach (var item in element.EnumerateArray()) + { + var key = $"{prefix}:{index}"; + FlattenJsonElement(item, entries, key); + index++; + } + break; + + case JsonValueKind.String: + entries[prefix] = element.GetString() ?? string.Empty; + break; + + case JsonValueKind.Number: + entries[prefix] = element.GetRawText(); + break; + + case JsonValueKind.True: + case JsonValueKind.False: + entries[prefix] = element.GetBoolean().ToString().ToLowerInvariant(); + break; + + case JsonValueKind.Null: + entries[prefix] = string.Empty; + break; + } + } + + private static string GenerateExtensionMethod(Dictionary configurationEntries) + { + var sourceBuilder = new StringBuilder(); + + sourceBuilder.AppendLine("//------------------------------------------------------------------------------"); + sourceBuilder.AppendLine("// "); + sourceBuilder.AppendLine("// This code was generated by the MAUI AppSettings source generator."); + sourceBuilder.AppendLine("//"); + sourceBuilder.AppendLine("// Changes to this file may cause incorrect behavior and will be lost if"); + sourceBuilder.AppendLine("// the code is regenerated."); + sourceBuilder.AppendLine("// "); + sourceBuilder.AppendLine("//------------------------------------------------------------------------------"); + sourceBuilder.AppendLine(); + sourceBuilder.AppendLine("using System.Collections.Generic;"); + sourceBuilder.AppendLine("using Microsoft.Extensions.Configuration;"); + sourceBuilder.AppendLine(); + sourceBuilder.AppendLine("namespace Microsoft.Extensions.Configuration"); + sourceBuilder.AppendLine("{"); + sourceBuilder.AppendLine(" /// "); + sourceBuilder.AppendLine(" /// Extension methods for IConfigurationBuilder to add local app settings."); + sourceBuilder.AppendLine(" /// "); + sourceBuilder.AppendLine(" public static class LocalAppSettingsExtensions"); + sourceBuilder.AppendLine(" {"); + sourceBuilder.AppendLine(" /// "); + sourceBuilder.AppendLine(" /// Adds configuration values from the local appsettings.json file as an in-memory collection."); + sourceBuilder.AppendLine(" /// "); + sourceBuilder.AppendLine(" /// The configuration builder."); + sourceBuilder.AppendLine(" /// The configuration builder."); + sourceBuilder.AppendLine(" public static IConfigurationBuilder AddLocalAppSettings(this IConfigurationBuilder configurationBuilder)"); + sourceBuilder.AppendLine(" {"); + sourceBuilder.AppendLine(" var configurationData = new Dictionary"); + sourceBuilder.AppendLine(" {"); + + foreach (var entry in configurationEntries) + { + var escapedKey = EscapeStringLiteral(entry.Key); + var escapedValue = EscapeStringLiteral(entry.Value); + sourceBuilder.AppendLine($" {{ \"{escapedKey}\", \"{escapedValue}\" }},"); + } + + sourceBuilder.AppendLine(" };"); + sourceBuilder.AppendLine(); + sourceBuilder.AppendLine(" return configurationBuilder.AddInMemoryCollection(configurationData);"); + sourceBuilder.AppendLine(" }"); + sourceBuilder.AppendLine(" }"); + sourceBuilder.AppendLine("}"); + + return sourceBuilder.ToString(); + } + + private static string EscapeStringLiteral(string input) + { + return input.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t"); + } +} \ No newline at end of file diff --git a/src/Core/src/ConfigurationSourceGen/Core.ConfigurationSourceGen.csproj b/src/Core/src/ConfigurationSourceGen/Core.ConfigurationSourceGen.csproj new file mode 100644 index 000000000000..30334870e132 --- /dev/null +++ b/src/Core/src/ConfigurationSourceGen/Core.ConfigurationSourceGen.csproj @@ -0,0 +1,33 @@ + + + netstandard2.0 + enable + true + Latest + Microsoft.Maui.Core.ConfigurationSourceGen + Microsoft.Maui.Core.ConfigurationSourceGen + Microsoft.Maui.Core.ConfigurationSourceGen + false + + + + false + true + true + true + + + + + + + + + + + <_CopyItems Include="$(TargetDir)*.dll" Exclude="$(TargetDir)System.*.dll" /> + <_CopyItems Include="$(TargetDir)*.pdb" Exclude="$(TargetDir)System.*.pdb" /> + + + + \ No newline at end of file diff --git a/src/Core/src/Core.csproj b/src/Core/src/Core.csproj index 902d1b31fcdb..0e5947928586 100644 --- a/src/Core/src/Core.csproj +++ b/src/Core/src/Core.csproj @@ -28,6 +28,19 @@ + + + + + + + + + + @@ -69,6 +82,7 @@ + diff --git a/src/Core/src/nuget/buildTransitive/Microsoft.Maui.Core.targets b/src/Core/src/nuget/buildTransitive/Microsoft.Maui.Core.targets index 337db9f84f00..c773574595ef 100644 --- a/src/Core/src/nuget/buildTransitive/Microsoft.Maui.Core.targets +++ b/src/Core/src/nuget/buildTransitive/Microsoft.Maui.Core.targets @@ -10,4 +10,9 @@ $(AfterMicrosoftNETSdkTargets);$(MSBuildThisFileDirectory)Microsoft.Maui.Core.After.targets + + + + + \ No newline at end of file diff --git a/src/Core/tests/ConfigurationSourceGen.UnitTests/AppSettingsSourceGeneratorTests.cs b/src/Core/tests/ConfigurationSourceGen.UnitTests/AppSettingsSourceGeneratorTests.cs new file mode 100644 index 000000000000..13a7921d41b1 --- /dev/null +++ b/src/Core/tests/ConfigurationSourceGen.UnitTests/AppSettingsSourceGeneratorTests.cs @@ -0,0 +1,211 @@ +using System.Collections.Immutable; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; +using Microsoft.Maui.Core.ConfigurationSourceGen; +using Xunit; + +namespace Microsoft.Maui.Core.ConfigurationSourceGen.UnitTests; + +public class AppSettingsSourceGeneratorTests +{ + [Fact] + public void Generator_WithValidAppSettings_GeneratesExtensionMethod() + { + // Arrange + var appSettingsJson = """ + { + "Setting1": "Value1", + "Setting2": { + "NestedSetting": "NestedValue" + }, + "Setting3": 42, + "Setting4": true + } + """; + + var compilation = CreateCompilation(); + var generator = new AppSettingsSourceGenerator(); + + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + + // Add appsettings.json as additional file + driver = driver.AddAdditionalTexts(ImmutableArray.Create( + new TestAdditionalText("appsettings.json", appSettingsJson))); + + // Act + driver = driver.RunGenerators(compilation); + var result = driver.GetRunResult(); + + // Assert + Assert.True(result.Diagnostics.IsEmpty); + Assert.Single(result.Results); + Assert.Single(result.Results[0].GeneratedSources); + + var generatedSource = result.Results[0].GeneratedSources[0]; + Assert.Equal("LocalAppSettingsExtensions.g.cs", generatedSource.HintName); + + var sourceText = generatedSource.SourceText.ToString(); + + // Verify the generated code contains expected content + Assert.Contains("public static class LocalAppSettingsExtensions", sourceText, StringComparison.Ordinal); + Assert.Contains("public static IConfigurationBuilder AddLocalAppSettings", sourceText, StringComparison.Ordinal); + Assert.Contains("{ \"Setting1\", \"Value1\" }", sourceText, StringComparison.Ordinal); + Assert.Contains("{ \"Setting2:NestedSetting\", \"NestedValue\" }", sourceText, StringComparison.Ordinal); + Assert.Contains("{ \"Setting3\", \"42\" }", sourceText, StringComparison.Ordinal); + Assert.Contains("{ \"Setting4\", \"true\" }", sourceText, StringComparison.Ordinal); + } + + [Fact] + public void Generator_WithNoAppSettings_GeneratesNothing() + { + // Arrange + var compilation = CreateCompilation(); + var generator = new AppSettingsSourceGenerator(); + + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + + // Act + driver = driver.RunGenerators(compilation); + var result = driver.GetRunResult(); + + // Assert + Assert.True(result.Diagnostics.IsEmpty); + Assert.Single(result.Results); + Assert.Empty(result.Results[0].GeneratedSources); + } + + [Fact] + public void Generator_WithEmptyAppSettings_GeneratesEmptyDictionary() + { + // Arrange + var appSettingsJson = "{}"; + + var compilation = CreateCompilation(); + var generator = new AppSettingsSourceGenerator(); + + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + + // Add appsettings.json as additional file + driver = driver.AddAdditionalTexts(ImmutableArray.Create( + new TestAdditionalText("appsettings.json", appSettingsJson))); + + // Act + driver = driver.RunGenerators(compilation); + var result = driver.GetRunResult(); + + // Assert + Assert.True(result.Diagnostics.IsEmpty); + Assert.Single(result.Results); + Assert.Single(result.Results[0].GeneratedSources); + + var generatedSource = result.Results[0].GeneratedSources[0]; + var sourceText = generatedSource.SourceText.ToString(); + + // Verify the generated code contains expected content but empty dictionary + Assert.Contains("public static class LocalAppSettingsExtensions", sourceText, StringComparison.Ordinal); + Assert.Contains("public static IConfigurationBuilder AddLocalAppSettings", sourceText, StringComparison.Ordinal); + Assert.Contains("var configurationData = new Dictionary", sourceText, StringComparison.Ordinal); + } + + [Fact] + public void Generator_WithArrayValues_FlattensCorrectly() + { + // Arrange + var appSettingsJson = """ + { + "Items": ["Item1", "Item2", "Item3"] + } + """; + + var compilation = CreateCompilation(); + var generator = new AppSettingsSourceGenerator(); + + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + + // Add appsettings.json as additional file + driver = driver.AddAdditionalTexts(ImmutableArray.Create( + new TestAdditionalText("appsettings.json", appSettingsJson))); + + // Act + driver = driver.RunGenerators(compilation); + var result = driver.GetRunResult(); + + // Assert + Assert.True(result.Diagnostics.IsEmpty); + var sourceText = result.Results[0].GeneratedSources[0].SourceText.ToString(); + + Assert.Contains("{ \"Items:0\", \"Item1\" }", sourceText, StringComparison.Ordinal); + Assert.Contains("{ \"Items:1\", \"Item2\" }", sourceText, StringComparison.Ordinal); + Assert.Contains("{ \"Items:2\", \"Item3\" }", sourceText, StringComparison.Ordinal); + } + + [Fact] + public void Generator_WithInvalidJson_GeneratesWarningAndContinues() + { + // Arrange + var invalidJson = "{ invalid json"; + + var compilation = CreateCompilation(); + var generator = new AppSettingsSourceGenerator(); + + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + + // Add appsettings.json as additional file + driver = driver.AddAdditionalTexts(ImmutableArray.Create( + new TestAdditionalText("appsettings.json", invalidJson))); + + // Act + driver = driver.RunGenerators(compilation); + var result = driver.GetRunResult(); + + // Assert - should still generate empty configuration + Assert.Single(result.Results); + Assert.Single(result.Results[0].GeneratedSources); + + var sourceText = result.Results[0].GeneratedSources[0].SourceText.ToString(); + Assert.Contains("public static class LocalAppSettingsExtensions", sourceText, StringComparison.Ordinal); + } + + private static Compilation CreateCompilation() + { + const string source = @" +using System; +namespace TestApp +{ + public class Program + { + public static void Main() { } + } +}"; + + return CSharpCompilation.Create( + "TestAssembly", + new[] { CSharpSyntaxTree.ParseText(source) }, + new[] + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Console).Assembly.Location) + }, + new CSharpCompilationOptions(OutputKind.ConsoleApplication)); + } + + private class TestAdditionalText : AdditionalText + { + private readonly string _text; + + public TestAdditionalText(string path, string text) + { + Path = path; + _text = text; + } + + public override string Path { get; } + + public override SourceText? GetText(CancellationToken cancellationToken = default) + { + return SourceText.From(_text, Encoding.UTF8); + } + } +} \ No newline at end of file diff --git a/src/Core/tests/ConfigurationSourceGen.UnitTests/Core.ConfigurationSourceGen.UnitTests.csproj b/src/Core/tests/ConfigurationSourceGen.UnitTests/Core.ConfigurationSourceGen.UnitTests.csproj new file mode 100644 index 000000000000..1a561de807d9 --- /dev/null +++ b/src/Core/tests/ConfigurationSourceGen.UnitTests/Core.ConfigurationSourceGen.UnitTests.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + enable + enable + false + true + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + \ No newline at end of file