├── icon.png ├── tests ├── SetSharp.Tests │ ├── Helpers │ │ ├── SetSharpJsonParserTests.cs │ │ └── SetSharpJsonReaderTests.cs │ ├── SetSharp.Tests.csproj │ ├── CodeGeneration │ │ ├── PocoGeneratorTests.cs │ │ └── OptionsPatternGeneratorTests.cs │ └── ModelBuilder │ │ └── ConfigurationModelBuilderTests.cs └── SetSharp.Demo │ ├── myCustomAppsettings.json │ ├── SetSharp.Demo.csproj │ └── Program.cs ├── src └── SetSharp │ ├── AnalyzerReleases.Unshipped.md │ ├── build │ └── SetSharp.props │ ├── Models │ ├── SetSharpSettings.cs │ ├── SettingPropertyInfo.cs │ ├── SettingClassInfo.cs │ └── SourceGenerationModel.cs │ ├── Definitions │ └── MSBuildPropertyKeys.cs │ ├── AnalyzerReleases.Shipped.md │ ├── Providers │ └── GeneratorSettingsProvider.cs │ ├── Helpers │ ├── SetSharpJsonReader.cs │ └── SetSharpJsonParser.cs │ ├── Diagnostics │ └── DiagnosticDescriptors.cs │ ├── SetSharp.csproj │ ├── CodeGeneration │ ├── PocoGenerator.cs │ └── OptionPatternGenerator.cs │ ├── ModelBuilder │ └── ConfigurationModelBuilder.cs │ └── SetSharpSourceGenerator.cs ├── LICENSE.txt ├── .github └── workflows │ └── publish.yml ├── .gitattributes ├── SetSharp.sln ├── CHANGELOG.md ├── .gitignore └── README.md /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beheshty/SetSharp/HEAD/icon.png -------------------------------------------------------------------------------- /tests/SetSharp.Tests/Helpers/SetSharpJsonParserTests.cs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beheshty/SetSharp/HEAD/tests/SetSharp.Tests/Helpers/SetSharpJsonParserTests.cs -------------------------------------------------------------------------------- /src/SetSharp/AnalyzerReleases.Unshipped.md: -------------------------------------------------------------------------------- 1 | ; Unshipped analyzer release 2 | ; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md 3 | -------------------------------------------------------------------------------- /src/SetSharp/build/SetSharp.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/SetSharp/Models/SetSharpSettings.cs: -------------------------------------------------------------------------------- 1 | 2 | namespace SetSharp.Models 3 | { 4 | internal class SetSharpSettings 5 | { 6 | public bool OptionPatternGenerationEnabled { get; set; } = true; 7 | public string SourceFile { get; set; } = "appsettings.json"; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/SetSharp/Models/SettingPropertyInfo.cs: -------------------------------------------------------------------------------- 1 | namespace SetSharp.Models 2 | { 3 | internal class SettingPropertyInfo 4 | { 5 | internal string PropertyType { get; set; } 6 | internal string PropertyName { get; set; } 7 | internal string OriginalJsonKey { get; set; } 8 | } 9 | } -------------------------------------------------------------------------------- /src/SetSharp/Models/SettingClassInfo.cs: -------------------------------------------------------------------------------- 1 | namespace SetSharp.Models 2 | { 3 | internal class SettingClassInfo 4 | { 5 | internal string ClassName { get; set; } 6 | internal string SectionPath { get; set; } 7 | internal List Properties { get; set; } = []; 8 | internal bool IsFromCollection { get; set; } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tests/SetSharp.Demo/myCustomAppsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*", 9 | "ConnectionStrings": { 10 | "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=test;Trusted_Connection=True;" 11 | }, 12 | "FeatureFlags": [ 13 | { 14 | "Name": "UseNewDashboard", 15 | "IsEnabled": true 16 | }, 17 | { 18 | "Name": "EnableExperimentalApi", 19 | "IsEnabled": false 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /src/SetSharp/Definitions/MSBuildPropertyKeys.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace SetSharp.Definitions 6 | { 7 | internal static class MSBuildPropertyKeys 8 | { 9 | private const string _prefix = "build_property."; 10 | 11 | public const string SetSharpPrefix = "SetSharp_"; 12 | 13 | public const string OptionPatternGenerationEnabled = _prefix + SetSharpPrefix + "OptionPatternGenerationEnabled"; 14 | 15 | public const string SourceFile = _prefix + SetSharpPrefix + "SourceFile"; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/SetSharp/Models/SourceGenerationModel.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis; 2 | 3 | namespace SetSharp.Models 4 | { 5 | internal class SourceGenerationModel 6 | { 7 | internal SourceGenerationModel(List? classes, 8 | SetSharpSettings setSharpSettings, 9 | Diagnostic? diagnostic) 10 | { 11 | Classes = classes; 12 | SetSharpSettings = setSharpSettings; 13 | Diagnostic = diagnostic; 14 | } 15 | 16 | internal List? Classes { get; } 17 | internal SetSharpSettings SetSharpSettings { get; } 18 | internal Diagnostic? Diagnostic { get; } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/SetSharp/AnalyzerReleases.Shipped.md: -------------------------------------------------------------------------------- 1 | ; Shipped analyzer release 2 | 3 | ### 3.0.1 4 | 5 | Rule ID | Category | Severity | Notes 6 | --------|----------|----------|------- 7 | SSG001 | Error | Error | App settings parsing failed. Triggered when internal parsing logic throws or fails due to invalid structure or unsupported types. 8 | SSG002 | Usage | Error | Missing reference to 'Microsoft.Extensions.Options.ConfigurationExtensions' required for generating IOptions extensions. 9 | SSG003 | Usage | Error | Missing reference(s) to 'Microsoft.Extensions.Configuration.Abstractions' and/or 'System.Collections.Immutable' required for basic functionality. 10 | SSG004 | Configuration | Warning | The source file specified via MSBuild property was not found as an 'AdditionalFile' in the project. 11 | -------------------------------------------------------------------------------- /tests/SetSharp.Tests/SetSharp.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/SetSharp.Demo/SetSharp.Demo.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net9.0 6 | enable 7 | enable 8 | 9 | 10 | 11 | 12 | PreserveNewest 13 | 14 | 15 | 16 | 17 | true 18 | myCustomAppsettings.json 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Amirhossein Beheshti 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/SetSharp/Providers/GeneratorSettingsProvider.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis; 2 | using Microsoft.CodeAnalysis.Diagnostics; 3 | using SetSharp.Definitions; 4 | using SetSharp.Models; 5 | 6 | namespace SetSharp.Providers 7 | { 8 | internal class GeneratorSettingsProvider 9 | { 10 | internal static IncrementalValueProvider GetSettings(IncrementalGeneratorInitializationContext context) 11 | { 12 | return context.AnalyzerConfigOptionsProvider.Select(ParseSettings); 13 | } 14 | 15 | internal static SetSharpSettings ParseSettings(AnalyzerConfigOptionsProvider provider, CancellationToken cancellationToken) 16 | { 17 | var settings = new SetSharpSettings(); 18 | 19 | if (provider.GlobalOptions.TryGetValue(MSBuildPropertyKeys.OptionPatternGenerationEnabled, out var enabledValue) 20 | && bool.TryParse(enabledValue, out var parsedBool)) 21 | { 22 | settings.OptionPatternGenerationEnabled = parsedBool; 23 | } 24 | 25 | if (provider.GlobalOptions.TryGetValue(MSBuildPropertyKeys.SourceFile, out var sourceFileValue) 26 | && !string.IsNullOrWhiteSpace(sourceFileValue)) 27 | { 28 | settings.SourceFile = sourceFileValue; 29 | } 30 | 31 | return settings; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/SetSharp/Helpers/SetSharpJsonReader.cs: -------------------------------------------------------------------------------- 1 | 2 | 3 | namespace SetSharp.Helpers 4 | { 5 | internal static class SetSharpJsonReader 6 | { 7 | /// 8 | /// Reads a value from a nested dictionary using a colon-delimited key path. 9 | /// 10 | /// The top-level dictionary representing the parsed JSON. 11 | /// The path to the desired value (e.g., "SetSharp:OptionPatternGenerationEnabled"). 12 | /// The found value as an object, or null if the path is invalid or the key is not found. 13 | internal static object? Read(Dictionary json, string keyPath) 14 | { 15 | if (json == null || string.IsNullOrWhiteSpace(keyPath)) 16 | { 17 | return null; 18 | } 19 | 20 | string[] keys = keyPath.Split(':'); 21 | object currentNode = json; 22 | 23 | foreach (var key in keys) 24 | { 25 | if (currentNode is not Dictionary currentDict) 26 | { 27 | return null; 28 | } 29 | 30 | if (!currentDict.TryGetValue(key, out currentNode)) 31 | { 32 | return null; 33 | } 34 | } 35 | 36 | return currentNode; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/SetSharp/Diagnostics/DiagnosticDescriptors.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis; 2 | 3 | namespace SetSharp.Diagnostics 4 | { 5 | internal static class DiagnosticDescriptors 6 | { 7 | internal static readonly DiagnosticDescriptor ParsingFailedError = new( 8 | "SSG001", 9 | "App settings parsing failed", 10 | "{0}", 11 | "Error", 12 | DiagnosticSeverity.Error, 13 | true); 14 | 15 | internal static readonly DiagnosticDescriptor MissingOptionsDependencyError = new( 16 | "SSG002", 17 | "Missing Dependencies", 18 | "To generate IOptions extensions, the project must reference 'Microsoft.Extensions.Options.ConfigurationExtensions'. Please install the corresponding NuGet package.", 19 | "Usage", 20 | DiagnosticSeverity.Error, 21 | true); 22 | 23 | internal static readonly DiagnosticDescriptor MissingBaseDependencyError = new( 24 | "SSG003", 25 | "Missing Dependency", 26 | "The project must reference 'Microsoft.Extensions.Configuration.Abstractions' and 'System.Collections.Immutable' for basic functionality. Please install the corresponding NuGet package.", 27 | "Usage", 28 | DiagnosticSeverity.Error, 29 | true); 30 | 31 | internal static readonly DiagnosticDescriptor SourceFileNotFoundWarning = new( 32 | "SSG004", 33 | "Source File Not Found", 34 | "The specified source file '{0}' was not found for assembly '{1}'. Ensure the file is included in your project as an 'AdditionalFiles' item in the .csproj.", 35 | "Configuration", 36 | DiagnosticSeverity.Warning, 37 | true); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish NuGet Package 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup .NET 17 | uses: actions/setup-dotnet@v4 18 | with: 19 | dotnet-version: '9.0.x' 20 | 21 | - name: Restore dependencies 22 | run: dotnet restore 23 | 24 | - name: Get the version from the .csproj file 25 | id: get_version 26 | run: | 27 | VERSION=$(grep -oPm1 "(?<=)[^<]+" src/SetSharp/SetSharp.csproj) 28 | echo "VERSION=$VERSION" >> $GITHUB_ENV 29 | 30 | - name: Get the latest published version from NuGet 31 | id: get_latest_version 32 | run: | 33 | PACKAGE_ID="SetSharp" 34 | LATEST_VERSION=$(curl -s https://api.nuget.org/v3-flatcontainer/${PACKAGE_ID,,}/index.json | jq -r '.versions | last') 35 | echo "LATEST_VERSION=$LATEST_VERSION" >> $GITHUB_ENV 36 | 37 | - name: Compare versions 38 | id: version_check 39 | run: | 40 | if [ "$VERSION" != "$LATEST_VERSION" ]; then 41 | echo "New version detected: $VERSION" 42 | echo "run_publish=true" >> $GITHUB_ENV 43 | else 44 | echo "No new version detected" 45 | echo "run_publish=false" >> $GITHUB_ENV 46 | fi 47 | 48 | - name: Build 49 | if: env.run_publish == 'true' 50 | run: dotnet build --configuration Release --no-restore 51 | 52 | - name: Pack 53 | if: env.run_publish == 'true' 54 | run: dotnet pack src/SetSharp/SetSharp.csproj --configuration Release --no-build 55 | 56 | - name: Publish to NuGet 57 | if: env.run_publish == 'true' 58 | run: dotnet nuget push "src/SetSharp/bin/Release/*.nupkg" --api-key ${{ secrets.NUGET_API_KEY }} --source "https://api.nuget.org/v3/index.json" 59 | -------------------------------------------------------------------------------- /tests/SetSharp.Demo/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Configuration; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Microsoft.Extensions.Hosting; 4 | using Microsoft.Extensions.Options; 5 | using SetSharp.Configuration; 6 | 7 | 8 | Console.WriteLine("Configuration Explorer is live! Let's see what secrets your appsettings hold...\n"); 9 | 10 | var builder = Host.CreateApplicationBuilder(args); 11 | builder.Configuration.Sources.Clear(); 12 | builder.Configuration.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true); 13 | 14 | // --- Register All Your Settings with a Single Call --- 15 | builder.Services.AddAllGeneratedOptions(builder.Configuration); 16 | 17 | using var host = builder.Build(); 18 | // --- Logging Options --- 19 | var loggingOptions = host.Services.GetRequiredService>().Value; 20 | if (loggingOptions is not null) 21 | { 22 | Console.WriteLine("Logging Options:"); 23 | Console.WriteLine($" • {nameof(loggingOptions.Default)}: {loggingOptions.Default}"); 24 | Console.WriteLine($" • {nameof(loggingOptions.MicrosoftAspNetCore)}: {loggingOptions.MicrosoftAspNetCore}"); 25 | Console.WriteLine(); 26 | } 27 | 28 | // --- Connection Strings --- 29 | var connectionOptions = host.Services.GetRequiredService>().Value; 30 | if (connectionOptions is not null) 31 | { 32 | Console.WriteLine("Connection Strings:"); 33 | Console.WriteLine($" • {nameof(connectionOptions.DefaultConnection)}: {connectionOptions.DefaultConnection}"); 34 | Console.WriteLine(); 35 | } 36 | 37 | // --- Feature Flags --- 38 | var featureFlagOptions = host.Services.GetRequiredService>>().Value; 39 | if (featureFlagOptions is not null && featureFlagOptions.Count != 0) 40 | { 41 | Console.WriteLine("Feature Flag Options:"); 42 | for (int i = 0; i < featureFlagOptions.Count; i++) 43 | { 44 | var op = featureFlagOptions[i]; 45 | Console.WriteLine($" {i + 1}."); 46 | Console.WriteLine($" • {nameof(op.Name)}: {op.Name}"); 47 | Console.WriteLine($" • {nameof(op.IsEnabled)}: {op.IsEnabled}"); 48 | Console.WriteLine(); 49 | } 50 | } 51 | 52 | Console.WriteLine("All configuration values displayed. Press any key to exit..."); 53 | Console.ReadKey(); 54 | 55 | 56 | -------------------------------------------------------------------------------- /src/SetSharp/SetSharp.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0 5 | false 6 | enable 7 | enable 8 | true 9 | Latest 10 | true 11 | 12 | SetSharp 13 | 3.1.0 14 | icon.png 15 | Amirhossein Beheshti 16 | Generates strongly typed settings classes from appsettings.json using Source Generators. 17 | README.md 18 | source-generator configuration appsettings csharp 19 | MIT 20 | https://github.com/beheshty/SharpSettings 21 | https://github.com/beheshty/SharpSettings 22 | git 23 | 24 | 25 | 26 | 27 | all 28 | runtime; build; native; contentfiles; analyzers 29 | 30 | 31 | all 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /SetSharp.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.14.36221.1 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F50C554B-27D0-43FA-9F06-86443CE8C203}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SetSharp", "src\SetSharp\SetSharp.csproj", "{4530ED82-BD77-BD0C-42CD-31D782027653}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SetSharp.Demo", "tests\SetSharp.Demo\SetSharp.Demo.csproj", "{AC4C9F1A-4ED9-2828-78DC-7F763C084010}" 13 | EndProject 14 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SetSharp.Tests", "tests\SetSharp.Tests\SetSharp.Tests.csproj", "{EAD63588-C89D-5E4D-B276-880FB86E519C}" 15 | EndProject 16 | Global 17 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 18 | Debug|Any CPU = Debug|Any CPU 19 | Release|Any CPU = Release|Any CPU 20 | EndGlobalSection 21 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 22 | {4530ED82-BD77-BD0C-42CD-31D782027653}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 23 | {4530ED82-BD77-BD0C-42CD-31D782027653}.Debug|Any CPU.Build.0 = Debug|Any CPU 24 | {4530ED82-BD77-BD0C-42CD-31D782027653}.Release|Any CPU.ActiveCfg = Release|Any CPU 25 | {4530ED82-BD77-BD0C-42CD-31D782027653}.Release|Any CPU.Build.0 = Release|Any CPU 26 | {AC4C9F1A-4ED9-2828-78DC-7F763C084010}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 27 | {AC4C9F1A-4ED9-2828-78DC-7F763C084010}.Debug|Any CPU.Build.0 = Debug|Any CPU 28 | {AC4C9F1A-4ED9-2828-78DC-7F763C084010}.Release|Any CPU.ActiveCfg = Release|Any CPU 29 | {AC4C9F1A-4ED9-2828-78DC-7F763C084010}.Release|Any CPU.Build.0 = Release|Any CPU 30 | {EAD63588-C89D-5E4D-B276-880FB86E519C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 31 | {EAD63588-C89D-5E4D-B276-880FB86E519C}.Debug|Any CPU.Build.0 = Debug|Any CPU 32 | {EAD63588-C89D-5E4D-B276-880FB86E519C}.Release|Any CPU.ActiveCfg = Release|Any CPU 33 | {EAD63588-C89D-5E4D-B276-880FB86E519C}.Release|Any CPU.Build.0 = Release|Any CPU 34 | EndGlobalSection 35 | GlobalSection(SolutionProperties) = preSolution 36 | HideSolutionNode = FALSE 37 | EndGlobalSection 38 | GlobalSection(NestedProjects) = preSolution 39 | {4530ED82-BD77-BD0C-42CD-31D782027653} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} 40 | {AC4C9F1A-4ED9-2828-78DC-7F763C084010} = {F50C554B-27D0-43FA-9F06-86443CE8C203} 41 | {EAD63588-C89D-5E4D-B276-880FB86E519C} = {F50C554B-27D0-43FA-9F06-86443CE8C203} 42 | EndGlobalSection 43 | GlobalSection(ExtensibilityGlobals) = postSolution 44 | SolutionGuid = {A2ADCB9C-D71A-452C-9B2B-CEABA5277F66} 45 | EndGlobalSection 46 | EndGlobal 47 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [3.1.0] - 2025-09-26 9 | ### Changed 10 | - The diagnostic for a missing source file has been changed from a build-halting error to a non-blocking warning. The generator now continues without generating code instead of failing the compilation. 11 | - The warning for a missing source file now includes the name of the calling assembly to provide better context in multi-project solutions. 12 | 13 | ## [3.0.0] - 2025-08-19 14 | ### Breaking Change 15 | - **Generator configuration has been moved from `appsettings.json` to MSBuild properties in the `.csproj` file.** The `SetSharp` section within `appsettings.json` is no longer read. Users upgrading to this version **must** migrate their settings to their project file to continue configuring the generator. 16 | 17 | ### Added 18 | - A new MSBuild property, `SetSharp_SourceFile`, allows you to specify a custom JSON file for generation, replacing the default `appsettings.json`. 19 | - The `SetSharp_OptionPatternGenerationEnabled` MSBuild property now controls the generation of `IOptions` extension methods. 20 | - A new diagnostic (`SSG004`) will now report an error if the specified source file cannot be found in the project's `AdditionalFiles`. 21 | 22 | ### Changed 23 | - Refactored settings logic into a dedicated `GeneratorSettingsProvider` class to separate concerns. 24 | 25 | ## [2.1.0] - 2025-08-10 26 | ### Changed 27 | - Enhanced the type inference logic for JSON arrays. The source generator now analyzes **all** objects within an array to create a single, comprehensive class for the list items. This replaces the previous behavior of only inspecting the first item, ensuring that properties from all objects are correctly captured. 28 | 29 | ## [2.0.1] - 2025-08-04 30 | ### Added 31 | - Added `Shipped.md` and `Unshipped.md` files to track analyzer diagnostics and releases. These files follow the official Roslyn analyzer release tracking format. 32 | 33 | ### Notes 34 | - This is a documentation-only update with no functional code changes. 35 | 36 | 37 | ## [2.0.0] - 2025-07-16 38 | ### Changed 39 | - **Breaking Change**: All generated classes are now `record` types instead of regular `class` types. 40 | - All properties use `init` accessors instead of `set` to support immutability. 41 | - Collections are now generated as `ImmutableList` instead of mutable `List`. 42 | 43 | ### Notes 44 | - These changes improve immutability and align with modern C# practices. 45 | - This is a major version bump due to potential breaking changes in downstream usage, especially if consumers relied on mutable types or reflection-based assumptions. 46 | 47 | ## [1.3.0] - 2025-07-13 48 | ### Added 49 | - Descriptive error messages if required dependencies are missing. 50 | - New diagnostic IDs: `SSG001`, `SSG002`, `SSG003`. 51 | 52 | 53 | -------------------------------------------------------------------------------- /src/SetSharp/CodeGeneration/PocoGenerator.cs: -------------------------------------------------------------------------------- 1 | using SetSharp.Models; 2 | using System.Reflection; 3 | using System.Text; 4 | 5 | namespace SetSharp.CodeGeneration 6 | { 7 | internal static class PocoGenerator 8 | { 9 | internal static string Generate(List classes, string @namespace = "SetSharp.Configuration") 10 | { 11 | var assemblyVersion = Assembly.GetExecutingAssembly().GetName().Version.ToString(); 12 | var sb = new StringBuilder(); 13 | 14 | sb.AppendLine("// "); 15 | sb.AppendLine("#nullable enable"); 16 | sb.AppendLine(); 17 | sb.AppendLine("using System.Collections.Generic;"); 18 | sb.AppendLine("using System.Collections.Immutable;"); 19 | sb.AppendLine(); 20 | sb.AppendLine($"namespace {@namespace}"); 21 | sb.AppendLine("{"); 22 | 23 | foreach (var SettingClassInfo in classes) 24 | { 25 | sb.AppendLine(); 26 | AddClassSummary(sb, SettingClassInfo); 27 | sb.AppendLine($" [System.CodeDom.Compiler.GeneratedCode(\"SetSharp\", \"{assemblyVersion}\")]"); 28 | sb.AppendLine($" public partial record {SettingClassInfo.ClassName}"); 29 | sb.AppendLine(" {"); 30 | 31 | AddSectionNameConstant(sb, SettingClassInfo); 32 | 33 | foreach (var property in SettingClassInfo.Properties) 34 | { 35 | AddProperty(sb, property); 36 | } 37 | 38 | sb.AppendLine(" }"); 39 | } 40 | 41 | sb.AppendLine("}"); 42 | return sb.ToString(); 43 | } 44 | 45 | private static void AddClassSummary(StringBuilder sb, SettingClassInfo SettingClassInfo) 46 | { 47 | if (!string.IsNullOrEmpty(SettingClassInfo.SectionPath)) 48 | { 49 | sb.AppendLine($" /// Represents the '{SettingClassInfo.SectionPath}' section from the configuration."); 50 | } 51 | else 52 | { 53 | sb.AppendLine(" /// Represents the root of the configuration settings."); 54 | } 55 | } 56 | 57 | private static void AddSectionNameConstant(StringBuilder sb, SettingClassInfo SettingClassInfo) 58 | { 59 | if (!string.IsNullOrEmpty(SettingClassInfo.SectionPath)) 60 | { 61 | sb.AppendLine($" /// The configuration section name for this class: \"{SettingClassInfo.SectionPath}\""); 62 | sb.AppendLine($" public const string SectionName = \"{SettingClassInfo.SectionPath}\";"); 63 | sb.AppendLine(); 64 | } 65 | } 66 | 67 | private static void AddProperty(StringBuilder sb, SettingPropertyInfo property) 68 | { 69 | sb.AppendLine($" /// Maps to the '{property.OriginalJsonKey}' configuration key."); 70 | if (property.PropertyName != property.OriginalJsonKey) 71 | { 72 | sb.AppendLine($" [Microsoft.Extensions.Configuration.ConfigurationKeyName(\"{property.OriginalJsonKey}\")]"); 73 | } 74 | sb.AppendLine($" public {property.PropertyType} {property.PropertyName} {{ get; init; }}"); 75 | sb.AppendLine(); 76 | } 77 | } 78 | } -------------------------------------------------------------------------------- /src/SetSharp/CodeGeneration/OptionPatternGenerator.cs: -------------------------------------------------------------------------------- 1 | using SetSharp.Models; 2 | using System.Reflection; 3 | using System.Text; 4 | 5 | namespace SetSharp.CodeGeneration 6 | { 7 | internal static class OptionsPatternGenerator 8 | { 9 | internal static string Generate(List classes) 10 | { 11 | var pocoNameSpace = "SetSharp.Configuration"; 12 | var assemblyVersion = Assembly.GetExecutingAssembly().GetName().Version.ToString(); 13 | 14 | var sb = new StringBuilder(); 15 | sb.AppendLine("// "); 16 | sb.AppendLine("using Microsoft.Extensions.Configuration;"); 17 | sb.AppendLine("using Microsoft.Extensions.DependencyInjection;"); 18 | sb.AppendLine($"using {pocoNameSpace};"); 19 | sb.AppendLine(); 20 | sb.AppendLine("namespace Microsoft.Extensions.DependencyInjection"); 21 | sb.AppendLine("{"); 22 | sb.AppendLine($" [System.CodeDom.Compiler.GeneratedCode(\"SetSharp\", \"{assemblyVersion}\")]"); 23 | sb.AppendLine(" public static class GeneratedOptionsExtensions"); 24 | sb.AppendLine(" {"); 25 | 26 | var optionClasses = classes.Where(c => !string.IsNullOrEmpty(c.SectionPath)).ToList(); 27 | 28 | AppendAddOptionMethods(sb, optionClasses); 29 | 30 | if (optionClasses.Any()) 31 | { 32 | AppendAddAllOptionsMethod(sb, optionClasses); 33 | } 34 | 35 | sb.AppendLine(" }"); 36 | sb.AppendLine("}"); 37 | 38 | return sb.ToString(); 39 | } 40 | 41 | private static void AppendAddAllOptionsMethod(StringBuilder sb, List optionClasses) 42 | { 43 | sb.AppendLine(" /// Registers all generated configuration classes with the dependency injection container."); 44 | sb.AppendLine(" public static IServiceCollection AddAllGeneratedOptions(this IServiceCollection services, IConfiguration configuration)"); 45 | sb.AppendLine(" {"); 46 | foreach (var classInfo in optionClasses) 47 | { 48 | sb.AppendLine($" services.Add{classInfo.ClassName}(configuration);"); 49 | } 50 | sb.AppendLine(" return services;"); 51 | sb.AppendLine(" }"); 52 | } 53 | 54 | private static void AppendAddOptionMethods(StringBuilder sb, List optionClasses) 55 | { 56 | foreach (var classInfo in optionClasses) 57 | { 58 | string configurationType = classInfo.IsFromCollection 59 | ? $"System.Collections.Generic.List<{classInfo.ClassName}>" 60 | : classInfo.ClassName; 61 | 62 | sb.AppendLine($" /// Registers the class with the dependency injection container."); 63 | sb.AppendLine($" public static IServiceCollection Add{classInfo.ClassName}(this IServiceCollection services, IConfiguration configuration)"); 64 | sb.AppendLine(" {"); 65 | 66 | sb.AppendLine($" services.Configure<{configurationType}>(configuration.GetSection({classInfo.ClassName}.SectionName));"); 67 | 68 | sb.AppendLine(" return services;"); 69 | sb.AppendLine(" }"); 70 | sb.AppendLine(); 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tests/SetSharp.Tests/CodeGeneration/PocoGeneratorTests.cs: -------------------------------------------------------------------------------- 1 | using SetSharp.CodeGeneration; 2 | using SetSharp.Models; 3 | using System.Reflection; 4 | 5 | namespace SetSharp.Tests.CodeGeneration 6 | { 7 | public class PocoGeneratorTests 8 | { 9 | [Fact] 10 | public void Generate_WithCustomNamespace_ShouldUseCustomNamespace() 11 | { 12 | // Arrange 13 | var classes = new List 14 | { 15 | new SettingClassInfo { ClassName = "MySettings" } 16 | }; 17 | var customNamespace = "My.Custom.Namespace"; 18 | 19 | // Act 20 | var result = PocoGenerator.Generate(classes, customNamespace); 21 | 22 | // Assert 23 | Assert.Contains($"namespace {customNamespace}", result); 24 | } 25 | 26 | [Fact] 27 | public void Generate_WithMultipleClasses_ShouldGenerateAllClasses() 28 | { 29 | // Arrange 30 | var classes = new List 31 | { 32 | new SettingClassInfo { ClassName = "FirstSettings" }, 33 | new SettingClassInfo { ClassName = "SecondSettings" } 34 | }; 35 | 36 | // Act 37 | var result = PocoGenerator.Generate(classes); 38 | 39 | // Assert 40 | Assert.Contains("public partial record FirstSettings", result); 41 | Assert.Contains("public partial record SecondSettings", result); 42 | } 43 | 44 | [Fact] 45 | public void Generate_WithDifferentPropertyAndJsonKey_ShouldAddConfigurationKeyNameAttribute() 46 | { 47 | // Arrange 48 | var classes = new List 49 | { 50 | new SettingClassInfo 51 | { 52 | ClassName = "MySettings", 53 | Properties = new List 54 | { 55 | new SettingPropertyInfo { PropertyName = "LogLevel", PropertyType = "string", OriginalJsonKey = "Logging:LogLevel:Default" } 56 | } 57 | } 58 | }; 59 | 60 | // Act 61 | var result = PocoGenerator.Generate(classes); 62 | 63 | // Assert 64 | Assert.Contains("[Microsoft.Extensions.Configuration.ConfigurationKeyName(\"Logging:LogLevel:Default\")]", result); 65 | Assert.Contains("public string LogLevel { get; init; }", result); 66 | } 67 | 68 | [Fact] 69 | public void Generate_WithNoSectionPath_ShouldGenerateRootClassSummaryAndNoSectionNameConstant() 70 | { 71 | // Arrange 72 | var classes = new List 73 | { 74 | new SettingClassInfo 75 | { 76 | ClassName = "RootSettings", 77 | SectionPath = "" // or null 78 | } 79 | }; 80 | 81 | // Act 82 | var result = PocoGenerator.Generate(classes); 83 | 84 | // Assert 85 | Assert.Contains("/// Represents the root of the configuration settings.", result); 86 | Assert.DoesNotContain("public const string SectionName", result); 87 | } 88 | 89 | [Fact] 90 | public void Generate_WithEmptyClassList_ShouldGenerateEmptyNamespace() 91 | { 92 | // Arrange 93 | var classes = new List(); 94 | var expected = @"// 95 | #nullable enable 96 | 97 | using System.Collections.Generic; 98 | using System.Collections.Immutable; 99 | 100 | namespace SetSharp.Configuration 101 | { 102 | } 103 | "; 104 | 105 | // Act 106 | var result = PocoGenerator.Generate(classes); 107 | 108 | // Assert 109 | Assert.Equal(expected, result); 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /tests/SetSharp.Tests/Helpers/SetSharpJsonReaderTests.cs: -------------------------------------------------------------------------------- 1 | using SetSharp.Helpers; 2 | 3 | namespace SetSharp.Tests.Helpers 4 | { 5 | public class SetSharpJsonReaderTests 6 | { 7 | private readonly Dictionary _testJson = new() 8 | { 9 | { "TopLevelString", "Hello World" }, 10 | { "TopLevelInt", 123 }, 11 | { "SetSharp", new Dictionary 12 | { 13 | { "Enabled", true }, 14 | { "Generation", new Dictionary 15 | { 16 | { "Poco", true }, 17 | { "OptionsPattern", false } 18 | } 19 | } 20 | } 21 | } 22 | }; 23 | 24 | [Fact] 25 | public void Read_WithValidTopLevelPath_ReturnsCorrectValue() 26 | { 27 | // Arrange 28 | var keyPath = "TopLevelString"; 29 | 30 | // Act 31 | var result = SetSharpJsonReader.Read(_testJson, keyPath); 32 | 33 | // Assert 34 | Assert.Equal("Hello World", result); 35 | } 36 | 37 | [Fact] 38 | public void Read_WithValidNestedPath_ReturnsCorrectValue() 39 | { 40 | // Arrange 41 | var keyPath = "SetSharp:Enabled"; 42 | 43 | // Act 44 | var result = SetSharpJsonReader.Read(_testJson, keyPath); 45 | 46 | // Assert 47 | Assert.IsType(result); 48 | Assert.Equal(true, result); 49 | } 50 | 51 | [Fact] 52 | public void Read_WithDeeplyNestedPath_ReturnsCorrectValue() 53 | { 54 | // Arrange 55 | var keyPath = "SetSharp:Generation:Poco"; 56 | 57 | // Act 58 | var result = SetSharpJsonReader.Read(_testJson, keyPath); 59 | 60 | // Assert 61 | Assert.IsType(result); 62 | Assert.Equal(true, result); 63 | } 64 | 65 | [Fact] 66 | public void Read_PathToADictionary_ReturnsDictionaryObject() 67 | { 68 | // Arrange 69 | var keyPath = "SetSharp:Generation"; 70 | 71 | // Act 72 | var result = SetSharpJsonReader.Read(_testJson, keyPath); 73 | 74 | // Assert 75 | var dictResult = Assert.IsType>(result); 76 | Assert.True((bool)dictResult["Poco"]); 77 | Assert.False((bool)dictResult["OptionsPattern"]); 78 | } 79 | 80 | [Fact] 81 | public void Read_WithNonExistentTopLevelKey_ReturnsNull() 82 | { 83 | // Arrange 84 | var keyPath = "NonExistent"; 85 | 86 | // Act 87 | var result = SetSharpJsonReader.Read(_testJson, keyPath); 88 | 89 | // Assert 90 | Assert.Null(result); 91 | } 92 | 93 | [Fact] 94 | public void Read_WithNonExistentNestedKey_ReturnsNull() 95 | { 96 | // Arrange 97 | var keyPath = "SetSharp:Generation:NonExistent"; 98 | 99 | // Act 100 | var result = SetSharpJsonReader.Read(_testJson, keyPath); 101 | 102 | // Assert 103 | Assert.Null(result); 104 | } 105 | 106 | [Fact] 107 | public void Read_PathGoesThroughLeafValue_ReturnsNull() 108 | { 109 | // Arrange 110 | // Trying to navigate deeper into "TopLevelString", which is not a dictionary 111 | var keyPath = "TopLevelString:Deeper"; 112 | 113 | // Act 114 | var result = SetSharpJsonReader.Read(_testJson, keyPath); 115 | 116 | // Assert 117 | Assert.Null(result); 118 | } 119 | 120 | [Fact] 121 | public void Read_WithNullJson_ReturnsNull() 122 | { 123 | // Arrange 124 | Dictionary nullJson = null; 125 | var keyPath = "Any:Path"; 126 | 127 | // Act 128 | var result = SetSharpJsonReader.Read(nullJson, keyPath); 129 | 130 | // Assert 131 | Assert.Null(result); 132 | } 133 | 134 | [Theory] 135 | [InlineData(null)] 136 | [InlineData("")] 137 | [InlineData(" ")] 138 | public void Read_WithInvalidKeyPath_ReturnsNull(string invalidKeyPath) 139 | { 140 | // Act 141 | var result = SetSharpJsonReader.Read(_testJson, invalidKeyPath); 142 | 143 | // Assert 144 | Assert.Null(result); 145 | } 146 | } 147 | } -------------------------------------------------------------------------------- /src/SetSharp/ModelBuilder/ConfigurationModelBuilder.cs: -------------------------------------------------------------------------------- 1 | using SetSharp.Models; 2 | using System.Text.RegularExpressions; 3 | 4 | namespace SetSharp.ModelBuilder 5 | { 6 | internal class ConfigurationModelBuilder 7 | { 8 | private readonly List _completedModels = []; 9 | 10 | internal List BuildFrom(Dictionary root) 11 | { 12 | var rootModel = new SettingClassInfo { ClassName = "RootOptions", SectionPath = "" }; 13 | ProcessObject(rootModel, root); 14 | _completedModels.Add(rootModel); 15 | 16 | return _completedModels; 17 | } 18 | 19 | private void ProcessObject(SettingClassInfo SettingClassInfo, Dictionary obj) 20 | { 21 | foreach (var item in obj) 22 | { 23 | var propertyModel = new SettingPropertyInfo 24 | { 25 | OriginalJsonKey = item.Key, 26 | PropertyName = NormalizeName(item.Key), 27 | PropertyType = InferType(SettingClassInfo, item.Key, item.Value) 28 | }; 29 | SettingClassInfo.Properties.Add(propertyModel); 30 | } 31 | } 32 | 33 | private string InferType(SettingClassInfo parentClass, string key, object value) 34 | { 35 | string HandleNestedObject(Dictionary obj) 36 | { 37 | var sectionPath = string.IsNullOrEmpty(parentClass.SectionPath) ? key : $"{parentClass.SectionPath}:{key}"; 38 | return CreateNestedClass(sectionPath, key, obj, isFromCollection: false); 39 | } 40 | 41 | return value switch 42 | { 43 | string => "string", 44 | int => "int", 45 | long => "long", 46 | double => "double", 47 | bool => "bool", 48 | Dictionary obj => HandleNestedObject(obj), 49 | List list => InferListType(parentClass.SectionPath, key, list), 50 | _ => "object" 51 | }; 52 | } 53 | 54 | private string InferListType(string parentSectionPath, string key, List list) 55 | { 56 | if (list.Count == 0) 57 | { 58 | return "ImmutableList"; 59 | } 60 | 61 | var objectItems = list.OfType>().ToList(); 62 | 63 | // If the list contains no objects (e.g., a list of strings or ints), 64 | // infer the type from the first element as a fallback. 65 | if (objectItems.Count == 0) 66 | { 67 | return InferSimpleListType(list[0]); 68 | } 69 | 70 | var mergedObject = new Dictionary(); 71 | foreach (var item in objectItems) 72 | { 73 | foreach (var prop in item) 74 | { 75 | mergedObject[prop.Key] = prop.Value; 76 | } 77 | } 78 | 79 | var sectionPath = string.IsNullOrEmpty(parentSectionPath) ? key : $"{parentSectionPath}:{key}"; 80 | var classNameKey = $"{key}Item"; 81 | var listTypeName = CreateNestedClass(sectionPath, classNameKey, mergedObject, isFromCollection: true); 82 | 83 | return $"ImmutableList<{listTypeName}>"; 84 | } 85 | 86 | // Helper for simple list types (string, int, etc.) 87 | private string InferSimpleListType(object item) 88 | { 89 | string typeName = item switch 90 | { 91 | string => "string", 92 | int => "int", 93 | long => "long", 94 | double => "double", 95 | bool => "bool", 96 | _ => "object" 97 | }; 98 | return $"ImmutableList<{typeName}>"; 99 | } 100 | 101 | private string CreateNestedClass(string sectionPath, string classNameKey, Dictionary obj, bool isFromCollection = false) 102 | { 103 | var className = $"{NormalizeName(classNameKey)}Options"; 104 | 105 | var nestedSettingClassInfo = new SettingClassInfo 106 | { 107 | ClassName = className, 108 | SectionPath = sectionPath, 109 | IsFromCollection = isFromCollection 110 | }; 111 | 112 | ProcessObject(nestedSettingClassInfo, obj); 113 | _completedModels.Add(nestedSettingClassInfo); 114 | 115 | return className; 116 | } 117 | 118 | private string NormalizeName(string input) 119 | { 120 | if (string.IsNullOrWhiteSpace(input)) 121 | { 122 | return "UnnamedProperty"; 123 | } 124 | 125 | string sanitized = Regex.Replace(input, @"[^a-zA-Z0-9_]", ""); 126 | 127 | if (string.IsNullOrEmpty(sanitized)) 128 | { 129 | return "InvalidNameProperty"; 130 | } 131 | if (char.IsDigit(sanitized[0])) 132 | { 133 | sanitized = "_" + sanitized; 134 | } 135 | 136 | return char.ToUpper(sanitized[0]) + sanitized.Substring(1); 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/SetSharp/SetSharpSourceGenerator.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis; 2 | using Microsoft.CodeAnalysis.Text; 3 | using SetSharp.CodeGeneration; 4 | using SetSharp.Diagnostics; 5 | using SetSharp.Helpers; 6 | using SetSharp.ModelBuilder; 7 | using SetSharp.Models; 8 | using SetSharp.Providers; 9 | using System.Text; 10 | 11 | namespace SetSharp 12 | { 13 | [Generator] 14 | public class SetSharpSourceGenerator : IIncrementalGenerator 15 | { 16 | public void Initialize(IncrementalGeneratorInitializationContext context) 17 | { 18 | var settingsProvider = GeneratorSettingsProvider.GetSettings(context); 19 | 20 | var settingsAndTextsProvider = settingsProvider.Combine(context.AdditionalTextsProvider.Collect()); 21 | 22 | var pocoInfoProvider = settingsAndTextsProvider.Select((source, cancellationToken) => 23 | { 24 | var (settings, allTexts) = source; 25 | 26 | var sourceFile = allTexts.FirstOrDefault(text => 27 | Path.GetFileName(text.Path).Equals(settings.SourceFile, StringComparison.OrdinalIgnoreCase)); 28 | 29 | if (sourceFile is null) 30 | { 31 | var diagnostic = Diagnostic.Create(DiagnosticDescriptors.SourceFileNotFoundWarning, Location.None, settings.SourceFile, "unknown"); 32 | return new SourceGenerationModel(null, settings, diagnostic); 33 | } 34 | 35 | var content = sourceFile.GetText(cancellationToken); 36 | if (content is null) 37 | { 38 | return new SourceGenerationModel(null, settings, null); 39 | } 40 | 41 | try 42 | { 43 | var json = SetSharpJsonParser.Parse(content.ToString()); 44 | var modelBuilder = new ConfigurationModelBuilder(); 45 | var classes = modelBuilder.BuildFrom(json); 46 | return new SourceGenerationModel(classes, settings, null); 47 | } 48 | catch (Exception e) 49 | { 50 | var diagnostic = Diagnostic.Create(DiagnosticDescriptors.ParsingFailedError, Location.None, e.Message); 51 | return new SourceGenerationModel(null, settings, diagnostic); 52 | } 53 | }); 54 | 55 | var finalProvider = pocoInfoProvider.Combine(context.CompilationProvider); 56 | 57 | context.RegisterSourceOutput(finalProvider, (spc, source) => 58 | { 59 | var (model, compilation) = source; 60 | 61 | if (model.Diagnostic?.Id == DiagnosticDescriptors.SourceFileNotFoundWarning.Id) 62 | { 63 | var warning = Diagnostic.Create( 64 | DiagnosticDescriptors.SourceFileNotFoundWarning, 65 | Location.None, 66 | model.SetSharpSettings.SourceFile, 67 | compilation.AssemblyName); 68 | 69 | spc.ReportDiagnostic(warning); 70 | } 71 | else if (model.Diagnostic != null) 72 | { 73 | spc.ReportDiagnostic(model.Diagnostic); 74 | return; 75 | } 76 | 77 | var dependencyDiagnostic = CheckDependencies(compilation, model.SetSharpSettings.OptionPatternGenerationEnabled); 78 | if (dependencyDiagnostic != null) 79 | { 80 | spc.ReportDiagnostic(dependencyDiagnostic); 81 | return; 82 | } 83 | 84 | if (model.Classes != null) 85 | { 86 | var pocoSourceCode = PocoGenerator.Generate(model.Classes); 87 | spc.AddSource("AppSettings.g.cs", SourceText.From(pocoSourceCode, Encoding.UTF8)); 88 | 89 | if (model.SetSharpSettings.OptionPatternGenerationEnabled && model.Classes.Count > 0) 90 | { 91 | var extensionsSourceCode = OptionsPatternGenerator.Generate(model.Classes); 92 | spc.AddSource("OptionsExtensions.g.cs", SourceText.From(extensionsSourceCode, Encoding.UTF8)); 93 | } 94 | } 95 | }); 96 | } 97 | 98 | /// 99 | /// Checks for required dependencies and returns a Diagnostic if any are missing. 100 | /// 101 | /// A Diagnostic object if a dependency is missing; otherwise, null. 102 | private Diagnostic? CheckDependencies(Compilation compilation, bool checkForOptionsPattern) 103 | { 104 | if (compilation.GetTypeByMetadataName("Microsoft.Extensions.Configuration.ConfigurationKeyNameAttribute") == null) 105 | { 106 | return Diagnostic.Create(DiagnosticDescriptors.MissingBaseDependencyError, Location.None); 107 | } 108 | 109 | if (compilation.GetTypeByMetadataName("System.Collections.Immutable.ImmutableList") == null) 110 | { 111 | return Diagnostic.Create(DiagnosticDescriptors.MissingBaseDependencyError, Location.None); 112 | } 113 | 114 | if (!checkForOptionsPattern) 115 | { 116 | return null; 117 | } 118 | 119 | var iServiceCollectionType = compilation.GetTypeByMetadataName("Microsoft.Extensions.DependencyInjection.IServiceCollection"); 120 | var iConfigurationType = compilation.GetTypeByMetadataName("Microsoft.Extensions.Configuration.IConfiguration"); 121 | 122 | if (iServiceCollectionType == null || iConfigurationType == null) 123 | { 124 | return Diagnostic.Create(DiagnosticDescriptors.MissingOptionsDependencyError, Location.None); 125 | } 126 | 127 | var extensionsType = compilation.GetTypeByMetadataName("Microsoft.Extensions.DependencyInjection.OptionsConfigurationServiceCollectionExtensions"); 128 | if (extensionsType == null) 129 | { 130 | return Diagnostic.Create(DiagnosticDescriptors.MissingOptionsDependencyError, Location.None); 131 | } 132 | 133 | bool hasConfigureMethod = extensionsType.GetMembers("Configure") 134 | .OfType() 135 | .Any(m => m.IsGenericMethod && 136 | m.IsExtensionMethod && 137 | m.Parameters.Length == 2 && 138 | SymbolEqualityComparer.Default.Equals(m.Parameters[0].Type, iServiceCollectionType) && 139 | SymbolEqualityComparer.Default.Equals(m.Parameters[1].Type, iConfigurationType)); 140 | 141 | return hasConfigureMethod ? null : Diagnostic.Create(DiagnosticDescriptors.MissingOptionsDependencyError, Location.None); 142 | } 143 | } 144 | 145 | 146 | } 147 | 148 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Oo]ut/ 33 | [Ll]og/ 34 | [Ll]ogs/ 35 | 36 | # Visual Studio 2015/2017 cache/options directory 37 | .vs/ 38 | # Uncomment if you have tasks that create the project's static files in wwwroot 39 | #wwwroot/ 40 | 41 | # Visual Studio 2017 auto generated files 42 | Generated\ Files/ 43 | 44 | # MSTest test Results 45 | [Tt]est[Rr]esult*/ 46 | [Bb]uild[Ll]og.* 47 | 48 | # NUnit 49 | *.VisualState.xml 50 | TestResult.xml 51 | nunit-*.xml 52 | 53 | # Build Results of an ATL Project 54 | [Dd]ebugPS/ 55 | [Rr]eleasePS/ 56 | dlldata.c 57 | 58 | # Benchmark Results 59 | BenchmarkDotNet.Artifacts/ 60 | 61 | # .NET Core 62 | project.lock.json 63 | project.fragment.lock.json 64 | artifacts/ 65 | 66 | # ASP.NET Scaffolding 67 | ScaffoldingReadMe.txt 68 | 69 | # StyleCop 70 | StyleCopReport.xml 71 | 72 | # Files built by Visual Studio 73 | *_i.c 74 | *_p.c 75 | *_h.h 76 | *.ilk 77 | *.meta 78 | *.obj 79 | *.iobj 80 | *.pch 81 | *.pdb 82 | *.ipdb 83 | *.pgc 84 | *.pgd 85 | *.rsp 86 | *.sbr 87 | *.tlb 88 | *.tli 89 | *.tlh 90 | *.tmp 91 | *.tmp_proj 92 | *_wpftmp.csproj 93 | *.log 94 | *.vspscc 95 | *.vssscc 96 | .builds 97 | *.pidb 98 | *.svclog 99 | *.scc 100 | 101 | # Chutzpah Test files 102 | _Chutzpah* 103 | 104 | # Visual C++ cache files 105 | ipch/ 106 | *.aps 107 | *.ncb 108 | *.opendb 109 | *.opensdf 110 | *.sdf 111 | *.cachefile 112 | *.VC.db 113 | *.VC.VC.opendb 114 | 115 | # Visual Studio profiler 116 | *.psess 117 | *.vsp 118 | *.vspx 119 | *.sap 120 | 121 | # Visual Studio Trace Files 122 | *.e2e 123 | 124 | # TFS 2012 Local Workspace 125 | $tf/ 126 | 127 | # Guidance Automation Toolkit 128 | *.gpState 129 | 130 | # ReSharper is a .NET coding add-in 131 | _ReSharper*/ 132 | *.[Rr]e[Ss]harper 133 | *.DotSettings.user 134 | 135 | # TeamCity is a build add-in 136 | _TeamCity* 137 | 138 | # DotCover is a Code Coverage Tool 139 | *.dotCover 140 | 141 | # AxoCover is a Code Coverage Tool 142 | .axoCover/* 143 | !.axoCover/settings.json 144 | 145 | # Coverlet is a free, cross platform Code Coverage Tool 146 | coverage*.json 147 | coverage*.xml 148 | coverage*.info 149 | 150 | # Visual Studio code coverage results 151 | *.coverage 152 | *.coveragexml 153 | 154 | # NCrunch 155 | _NCrunch_* 156 | .*crunch*.local.xml 157 | nCrunchTemp_* 158 | 159 | # MightyMoose 160 | *.mm.* 161 | AutoTest.Net/ 162 | 163 | # Web workbench (sass) 164 | .sass-cache/ 165 | 166 | # Installshield output folder 167 | [Ee]xpress/ 168 | 169 | # DocProject is a documentation generator add-in 170 | DocProject/buildhelp/ 171 | DocProject/Help/*.HxT 172 | DocProject/Help/*.HxC 173 | DocProject/Help/*.hhc 174 | DocProject/Help/*.hhk 175 | DocProject/Help/*.hhp 176 | DocProject/Help/Html2 177 | DocProject/Help/html 178 | 179 | # Click-Once directory 180 | publish/ 181 | 182 | # Publish Web Output 183 | *.[Pp]ublish.xml 184 | *.azurePubxml 185 | # Note: Comment the next line if you want to checkin your web deploy settings, 186 | # but database connection strings (with potential passwords) will be unencrypted 187 | *.pubxml 188 | *.publishproj 189 | 190 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 191 | # checkin your Azure Web App publish settings, but sensitive information contained 192 | # in these scripts will be unencrypted 193 | PublishScripts/ 194 | 195 | # NuGet Packages 196 | *.nupkg 197 | # NuGet Symbol Packages 198 | *.snupkg 199 | # The packages folder can be ignored because of Package Restore 200 | **/[Pp]ackages/* 201 | # except build/, which is used as an MSBuild target. 202 | !**/[Pp]ackages/build/ 203 | # Uncomment if necessary however generally it will be regenerated when needed 204 | #!**/[Pp]ackages/repositories.config 205 | # NuGet v3's project.json files produces more ignorable files 206 | *.nuget.props 207 | *.nuget.targets 208 | 209 | # Microsoft Azure Build Output 210 | csx/ 211 | *.build.csdef 212 | 213 | # Microsoft Azure Emulator 214 | ecf/ 215 | rcf/ 216 | 217 | # Windows Store app package directories and files 218 | AppPackages/ 219 | BundleArtifacts/ 220 | Package.StoreAssociation.xml 221 | _pkginfo.txt 222 | *.appx 223 | *.appxbundle 224 | *.appxupload 225 | 226 | # Visual Studio cache files 227 | # files ending in .cache can be ignored 228 | *.[Cc]ache 229 | # but keep track of directories ending in .cache 230 | !?*.[Cc]ache/ 231 | 232 | # Others 233 | ClientBin/ 234 | ~$* 235 | *~ 236 | *.dbmdl 237 | *.dbproj.schemaview 238 | *.jfm 239 | *.pfx 240 | *.publishsettings 241 | orleans.codegen.cs 242 | 243 | # Including strong name files can present a security risk 244 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 245 | #*.snk 246 | 247 | # Since there are multiple workflows, uncomment next line to ignore bower_components 248 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 249 | #bower_components/ 250 | 251 | # RIA/Silverlight projects 252 | Generated_Code/ 253 | 254 | # Backup & report files from converting an old project file 255 | # to a newer Visual Studio version. Backup files are not needed, 256 | # because we have git ;-) 257 | _UpgradeReport_Files/ 258 | Backup*/ 259 | UpgradeLog*.XML 260 | UpgradeLog*.htm 261 | ServiceFabricBackup/ 262 | *.rptproj.bak 263 | 264 | # SQL Server files 265 | *.mdf 266 | *.ldf 267 | *.ndf 268 | 269 | # Business Intelligence projects 270 | *.rdl.data 271 | *.bim.layout 272 | *.bim_*.settings 273 | *.rptproj.rsuser 274 | *- [Bb]ackup.rdl 275 | *- [Bb]ackup ([0-9]).rdl 276 | *- [Bb]ackup ([0-9][0-9]).rdl 277 | 278 | # Microsoft Fakes 279 | FakesAssemblies/ 280 | 281 | # GhostDoc plugin setting file 282 | *.GhostDoc.xml 283 | 284 | # Node.js Tools for Visual Studio 285 | .ntvs_analysis.dat 286 | node_modules/ 287 | 288 | # Visual Studio 6 build log 289 | *.plg 290 | 291 | # Visual Studio 6 workspace options file 292 | *.opt 293 | 294 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 295 | *.vbw 296 | 297 | # Visual Studio LightSwitch build output 298 | **/*.HTMLClient/GeneratedArtifacts 299 | **/*.DesktopClient/GeneratedArtifacts 300 | **/*.DesktopClient/ModelManifest.xml 301 | **/*.Server/GeneratedArtifacts 302 | **/*.Server/ModelManifest.xml 303 | _Pvt_Extensions 304 | 305 | # Paket dependency manager 306 | .paket/paket.exe 307 | paket-files/ 308 | 309 | # FAKE - F# Make 310 | .fake/ 311 | 312 | # CodeRush personal settings 313 | .cr/personal 314 | 315 | # Python Tools for Visual Studio (PTVS) 316 | __pycache__/ 317 | *.pyc 318 | 319 | # Cake - Uncomment if you are using it 320 | # tools/** 321 | # !tools/packages.config 322 | 323 | # Tabs Studio 324 | *.tss 325 | 326 | # Telerik's JustMock configuration file 327 | *.jmconfig 328 | 329 | # BizTalk build output 330 | *.btp.cs 331 | *.btm.cs 332 | *.odx.cs 333 | *.xsd.cs 334 | 335 | # OpenCover UI analysis results 336 | OpenCover/ 337 | 338 | # Azure Stream Analytics local run output 339 | ASALocalRun/ 340 | 341 | # MSBuild Binary and Structured Log 342 | *.binlog 343 | 344 | # NVidia Nsight GPU debugger configuration file 345 | *.nvuser 346 | 347 | # MFractors (Xamarin productivity tool) working folder 348 | .mfractor/ 349 | 350 | # Local History for Visual Studio 351 | .localhistory/ 352 | 353 | # BeatPulse healthcheck temp database 354 | healthchecksdb 355 | 356 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 357 | MigrationBackup/ 358 | 359 | # Ionide (cross platform F# VS Code tools) working folder 360 | .ionide/ 361 | 362 | # Fody - auto-generated XML schema 363 | FodyWeavers.xsd -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SetSharp 2 | 3 | [![NuGet Version](https://img.shields.io/nuget/v/SetSharp.svg?style=flat-square)](https://www.nuget.org/packages/SetSharp/) 4 | [![NuGet Downloads](https://img.shields.io/nuget/dt/SetSharp.svg?style=flat-square)](https://www.nuget.org/packages/SetSharp/) 5 | [![Build Status](https://img.shields.io/github/actions/workflow/status/beheshty/SetSharp/.github/workflows/publish.yml?branch=master&style=flat-square)](https://github.com/beheshty/SetSharp/actions) 6 | 7 | **Tired of manually mapping `appsettings.json` to C# classes? SetSharp is a powerful .NET source generator that automatically creates strongly-typed C# configuration classes directly from your JSON settings, seamlessly integrating with the `IOptions` pattern.** 8 | 9 | Say goodbye to magic strings and runtime errors. With SetSharp, your configuration becomes a first-class citizen in your codebase, complete with compile-time safety and IntelliSense support. 10 | 11 | ## Key Features 12 | 13 | - **Automatic POCO Generation:** Mirrors your `appsettings.json` structure into clean, ready-to-use C# records. 14 | - **Strongly-Typed Access:** No more `_configuration["Section:Key"]`. Access settings with `options.Value.Section.Key`. 15 | - **Seamless DI Integration:** Automatically generates extension methods to register your configuration with Dependency Injection using the `IOptions` pattern. 16 | - **Zero Runtime Overhead:** All code generation happens at compile time, adding no performance cost to your application. 17 | - **Configurable Generation:** Easily enable or disable `IOptions` pattern integration to fit your project's needs. 18 | 19 | ## Prerequisites 20 | 21 | SetSharp has the following dependencies that you need to be aware of: 22 | 23 | - **`Microsoft.Extensions.Configuration.Abstractions`** 24 | This is a fundamental dependency and is **always required** for the generator to function. If this package is missing, you will receive a compile-time error (`SSG003`). 25 | 26 | - **`Microsoft.Extensions.Options.ConfigurationExtensions`** 27 | This package is required **only if** you are using the automatic `IOptions` pattern generation (which is enabled by default). If this package is missing while the feature is active, you will receive a compile-time error (`SSG002`). 28 | 29 | *You can disable the `IOptions` pattern feature and remove this second dependency by setting the `SetSharp_OptionPatternGenerationEnabled` MSBuild property to `false` in your `.csproj` file. See the Configuration section for details.* 30 | 31 | ## Getting Started 32 | 33 | Follow these steps to integrate SetSharp into your .NET project. 34 | 35 | ### 1. Install the NuGet Package 36 | 37 | Add the SetSharp NuGet package to your project using the .NET CLI or the NuGet Package Manager. 38 | 39 | ```bash 40 | dotnet add package SetSharp 41 | ``` 42 | 43 | ### 2. Add `appsettings.json` to your Project File 44 | 45 | For the source generator to work its magic, you must explicitly tell the compiler to include your `appsettings.json` file during the build process. Edit your `.csproj` file and add the following `ItemGroup`: 46 | 47 | ```xml 48 | 49 | 50 | 51 | ``` 52 | 53 | ### 3. Build Your Project 54 | 55 | That's it! Simply build your project. SetSharp will run automatically, generating your configuration records in the background. 56 | 57 | ```bash 58 | dotnet build 59 | ``` 60 | 61 | ## How to Use 62 | 63 | SetSharp generates two key things for you: strongly-typed records and Dependency Injection extension methods. 64 | 65 | ### 1. Generated Configuration Records 66 | 67 | For an `appsettings.json` like this: 68 | 69 | ```json 70 | { 71 | "ConnectionStrings": { 72 | "DefaultConnection": "Server=.;Database=MyDb;Trusted_Connection=True;" 73 | }, 74 | "FeatureManagement": { 75 | "EnableNewDashboard": true 76 | } 77 | } 78 | ``` 79 | 80 | SetSharp will generate corresponding C# records, within the `SetSharp.Configuration` namespace: 81 | 82 | ```csharp 83 | namespace SetSharp.Configuration 84 | { 85 | public partial record RootOptions 86 | { 87 | public ConnectionStringsOptions ConnectionStrings { get; init; } 88 | public FeatureManagementOptions FeatureManagement { get; init; } 89 | } 90 | 91 | public partial record ConnectionStringsOptions 92 | { 93 | public const string SectionName = "ConnectionStrings"; 94 | public string DefaultConnection { get; init; } 95 | } 96 | 97 | public partial record FeatureManagementOptions 98 | { 99 | public const string SectionName = "FeatureManagement"; 100 | public bool EnableNewDashboard { get; init; } 101 | } 102 | } 103 | ``` 104 | 105 | ### 2. Dependency Injection with the Options Pattern 106 | 107 | SetSharp makes registering these records with your DI container incredibly simple by generating extension methods for `IServiceCollection`. 108 | 109 | You have two ways to register your settings: 110 | 111 | **A) Register a specific option:** 112 | 113 | Use the generated `Add[OptionName]` method to register a single configuration section. 114 | 115 | ```csharp 116 | // In your Program.cs or Startup.cs 117 | builder.Services.AddConnectionStringsOptions(builder.Configuration); 118 | ``` 119 | 120 | **B) Register all options at once:** 121 | 122 | Use the convenient `AddAllGeneratedOptions` method to register all settings from your `appsettings.json` in a single call. 123 | 124 | ```csharp 125 | // In your Program.cs or Startup.cs 126 | builder.Services.AddAllGeneratedOptions(builder.Configuration); 127 | ``` 128 | 129 | Once registered, you can inject your settings anywhere in your application using the standard `IOptions` interface. 130 | 131 | ```csharp 132 | public class MyService 133 | { 134 | private readonly ConnectionStringsOptions _connectionStrings; 135 | 136 | public MyService(IOptions connectionStringsOptions) 137 | { 138 | _connectionStrings = connectionStringsOptions.Value; 139 | } 140 | 141 | public void DoWork() 142 | { 143 | var connectionString = _connectionStrings.DefaultConnection; 144 | // ... use the connection string 145 | } 146 | } 147 | ``` 148 | 149 | ## Configuration (via MSBuild Properties) 150 | 151 | You can control the behavior of SetSharp by setting MSBuild properties in your project's `.csproj` file. 152 | 153 | ```xml 154 | 155 | false 156 | config/production.json 157 | 158 | ``` 159 | 160 | ### `SetSharp_OptionPatternGenerationEnabled` 161 | 162 | Controls whether the Dependency Injection extension methods for the `IOptions` pattern are generated. 163 | 164 | - **`true`** (Default): Generates `Add[OptionName]` and `AddAllGeneratedOptions` extension methods. Requires a reference to `Microsoft.Extensions.Options.ConfigurationExtensions`. 165 | - **`false`**: Skips generation of DI extension methods. This removes the dependency on `Microsoft.Extensions.Options.ConfigurationExtensions`. 166 | 167 | ### `SetSharp_SourceFile` 168 | 169 | Specifies the name of the JSON configuration file to use as the source for generation. If you use this, remember to update the `` item in your `.csproj` to match the new name. 170 | 171 | - **Default:** `appsettings.json` 172 | 173 | If this property is set but the file cannot be found in the project's `AdditionalFiles`, you will receive a compile-time error (`SSG004`). 174 | 175 | ## Future Plans 176 | 177 | SetSharp is actively being developed. Have an idea or a feature request? Feel free to open an issue on GitHub to discuss it! 178 | 179 | ## Changelog 180 | See [CHANGELOG.md](https://github.com/beheshty/SetSharp/blob/master/CHANGELOG.md) for version history. 181 | 182 | ## Contributing 183 | 184 | Contributions are welcome! Whether it's a new feature idea, a bug report, or a pull request, your input is valued. Please feel free to open an issue to discuss your ideas or submit a pull request with your improvements. 185 | 186 | ## License 187 | 188 | This project is licensed under the MIT License. See the [LICENSE](https://github.com/beheshty/SetSharp/blob/master/LICENSE.txt) file for details. 189 | -------------------------------------------------------------------------------- /tests/SetSharp.Tests/CodeGeneration/OptionsPatternGeneratorTests.cs: -------------------------------------------------------------------------------- 1 | using SetSharp.CodeGeneration; 2 | using SetSharp.Models; 3 | 4 | namespace SetSharp.Tests.CodeGeneration 5 | { 6 | public class OptionsPatternGeneratorTests 7 | { 8 | [Fact] 9 | public void Generate_WithSingleClass_GeneratesCorrectMethods() 10 | { 11 | // Arrange 12 | var classes = new List 13 | { 14 | new SettingClassInfo { ClassName = "LoggingOptions", SectionPath = "Logging" } 15 | }; 16 | 17 | // Act 18 | var result = OptionsPatternGenerator.Generate(classes); 19 | 20 | // Assert 21 | // Check for the specific Add[ClassName] method 22 | Assert.Contains("public static IServiceCollection AddLoggingOptions(this IServiceCollection services, IConfiguration configuration)", result); 23 | Assert.Contains("services.Configure(configuration.GetSection(LoggingOptions.SectionName));", result); 24 | 25 | // Check for the AddAllGeneratedOptions method 26 | Assert.Contains("public static IServiceCollection AddAllGeneratedOptions(this IServiceCollection services, IConfiguration configuration)", result); 27 | Assert.Contains("services.AddLoggingOptions(configuration);", result); 28 | } 29 | 30 | [Fact] 31 | public void Generate_WithMultipleClasses_GeneratesAllRequiredMethods() 32 | { 33 | // Arrange 34 | var classes = new List 35 | { 36 | new SettingClassInfo { ClassName = "DatabaseOptions", SectionPath = "Database" }, 37 | new SettingClassInfo { ClassName = "ApiOptions", SectionPath = "Api" } 38 | }; 39 | 40 | // Act 41 | var result = OptionsPatternGenerator.Generate(classes); 42 | 43 | // Assert 44 | // Check for the first class's methods 45 | Assert.Contains("public static IServiceCollection AddDatabaseOptions(this IServiceCollection services, IConfiguration configuration)", result); 46 | Assert.Contains("services.Configure(configuration.GetSection(DatabaseOptions.SectionName));", result); 47 | 48 | // Check for the second class's methods 49 | Assert.Contains("public static IServiceCollection AddApiOptions(this IServiceCollection services, IConfiguration configuration)", result); 50 | Assert.Contains("services.Configure(configuration.GetSection(ApiOptions.SectionName));", result); 51 | 52 | // Check that AddAllGeneratedOptions contains calls to both methods 53 | Assert.Contains("public static IServiceCollection AddAllGeneratedOptions(this IServiceCollection services, IConfiguration configuration)", result); 54 | Assert.Contains("services.AddDatabaseOptions(configuration);", result); 55 | Assert.Contains("services.AddApiOptions(configuration);", result); 56 | } 57 | 58 | [Fact] 59 | public void Generate_WithMixedClasses_IgnoresClassesWithoutSectionPath() 60 | { 61 | // Arrange 62 | var classes = new List 63 | { 64 | new SettingClassInfo { ClassName = "RootOptions", SectionPath = "" }, // Should be ignored 65 | new SettingClassInfo { ClassName = "FeaturesOptions", SectionPath = "Features" } // Should be included 66 | }; 67 | 68 | // Act 69 | var result = OptionsPatternGenerator.Generate(classes); 70 | 71 | // Assert 72 | // Verify the included class is generated 73 | Assert.Contains("public static IServiceCollection AddFeaturesOptions(this IServiceCollection services, IConfiguration configuration)", result); 74 | Assert.Contains("services.AddFeaturesOptions(configuration);", result); 75 | 76 | // Crucially, verify the ignored class is NOT generated 77 | Assert.DoesNotContain("AddRootOptions", result); 78 | } 79 | 80 | [Fact] 81 | public void Generate_WithEmptyList_GeneratesBoilerplateWithoutMethods() 82 | { 83 | // Arrange 84 | var classes = new List(); 85 | 86 | // Act 87 | var result = OptionsPatternGenerator.Generate(classes); 88 | 89 | // Assert 90 | // Check that the basic structure is there 91 | Assert.Contains("namespace Microsoft.Extensions.DependencyInjection", result); 92 | Assert.Contains("public static class GeneratedOptionsExtensions", result); 93 | 94 | // Check that no actual registration methods were created 95 | Assert.DoesNotContain("public static IServiceCollection Add", result); 96 | Assert.DoesNotContain("AddAllGeneratedOptions", result); 97 | } 98 | 99 | [Fact] 100 | public void Generate_WithOnlyRootClass_GeneratesBoilerplateWithoutMethods() 101 | { 102 | // Arrange 103 | var classes = new List 104 | { 105 | new SettingClassInfo { ClassName = "RootOptions", SectionPath = null } // Should be ignored 106 | }; 107 | 108 | // Act 109 | var result = OptionsPatternGenerator.Generate(classes); 110 | 111 | // Assert 112 | // Check that the basic structure is there 113 | Assert.Contains("public static class GeneratedOptionsExtensions", result); 114 | 115 | // Check that no methods were created for the root options class 116 | Assert.DoesNotContain("AddRootOptions", result); 117 | Assert.DoesNotContain("AddAllGeneratedOptions", result); 118 | } 119 | 120 | [Fact] 121 | public void Generate_ForStandardObject_UsesClassNameInConfigure() 122 | { 123 | // Arrange 124 | var classes = new List 125 | { 126 | new SettingClassInfo 127 | { 128 | ClassName = "DatabaseOptions", 129 | SectionPath = "Database", 130 | IsFromCollection = false // Explicitly a standard object 131 | } 132 | }; 133 | 134 | // Act 135 | var result = OptionsPatternGenerator.Generate(classes); 136 | 137 | // Assert 138 | // Verify the method signature is correct 139 | Assert.Contains("public static IServiceCollection AddDatabaseOptions(this IServiceCollection services, IConfiguration configuration)", result); 140 | 141 | // Verify it generates the standard Configure call 142 | Assert.Contains("services.Configure(configuration.GetSection(DatabaseOptions.SectionName));", result); 143 | 144 | // Verify it does NOT generate the List version 145 | Assert.DoesNotContain("services.Configure>", result); 146 | } 147 | 148 | [Fact] 149 | public void Generate_ForListObject_UsesListOfClassNameInConfigure() 150 | { 151 | // Arrange 152 | var classes = new List 153 | { 154 | new SettingClassInfo 155 | { 156 | ClassName = "EndpointOptions", 157 | SectionPath = "Endpoints", 158 | IsFromCollection = true // This class represents an item in a collection 159 | } 160 | }; 161 | 162 | // Act 163 | var result = OptionsPatternGenerator.Generate(classes); 164 | 165 | // Assert 166 | // Verify the method signature is correct (it doesn't change) 167 | Assert.Contains("public static IServiceCollection AddEndpointOptions(this IServiceCollection services, IConfiguration configuration)", result); 168 | 169 | // Verify it generates the Configure> call with the fully qualified name 170 | Assert.Contains("services.Configure>(configuration.GetSection(EndpointOptions.SectionName));", result); 171 | 172 | // Verify it does NOT generate the standard T version 173 | Assert.DoesNotContain("services.Configure(configuration.GetSection(EndpointOptions.SectionName));", result); 174 | } 175 | 176 | [Fact] 177 | public void Generate_ForMixedObjectTypes_UsesCorrectConfigureTypeForEach() 178 | { 179 | // Arrange 180 | var classes = new List 181 | { 182 | new SettingClassInfo 183 | { 184 | ClassName = "ApiOptions", 185 | SectionPath = "Api", 186 | IsFromCollection = false // Standard object 187 | }, 188 | new SettingClassInfo 189 | { 190 | ClassName = "FirewallRuleOptions", 191 | SectionPath = "FirewallRules", 192 | IsFromCollection = true // Collection item object 193 | } 194 | }; 195 | 196 | // Act 197 | var result = OptionsPatternGenerator.Generate(classes); 198 | 199 | // Assert 200 | // Check standard object generation 201 | Assert.Contains("public static IServiceCollection AddApiOptions(this IServiceCollection services, IConfiguration configuration)", result); 202 | Assert.Contains("services.Configure(configuration.GetSection(ApiOptions.SectionName));", result); 203 | 204 | // Check collection object generation 205 | Assert.Contains("public static IServiceCollection AddFirewallRuleOptions(this IServiceCollection services, IConfiguration configuration)", result); 206 | Assert.Contains("services.Configure>(configuration.GetSection(FirewallRuleOptions.SectionName));", result); 207 | 208 | // Check that the AddAll method includes both 209 | Assert.Contains("public static IServiceCollection AddAllGeneratedOptions(this IServiceCollection services, IConfiguration configuration)", result); 210 | Assert.Contains("services.AddApiOptions(configuration);", result); 211 | Assert.Contains("services.AddFirewallRuleOptions(configuration);", result); 212 | } 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/SetSharp/Helpers/SetSharpJsonParser.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using System.Text; 3 | using System.Text.RegularExpressions; 4 | 5 | namespace SetSharp.Helpers 6 | { 7 | /// 8 | /// Simple and minimal JSON parser for configuration source generation. 9 | /// 10 | internal static class SetSharpJsonParser 11 | { 12 | /// 13 | /// Parses a JSON object string into a dictionary. 14 | /// 15 | /// The JSON string. 16 | /// Dictionary representing the JSON object. 17 | internal static Dictionary Parse(string json) 18 | { 19 | if (string.IsNullOrWhiteSpace(json)) 20 | { 21 | throw new FormatException("Input JSON cannot be null or empty."); 22 | } 23 | int index = 0; 24 | SkipWhitespace(json, ref index); 25 | var result = ParseValue(json, ref index) as Dictionary; 26 | SkipWhitespace(json, ref index); 27 | 28 | if (index != json.Length) 29 | { 30 | throw new FormatException("Unexpected characters at the end of the JSON string."); 31 | } 32 | 33 | return result ?? throw new FormatException("The provided JSON is not a valid object."); 34 | } 35 | 36 | /// 37 | /// Parses a JSON array string into a list. 38 | /// 39 | /// The JSON string representing an array. 40 | /// A list representing the JSON array. 41 | internal static List ParseArray(string json) 42 | { 43 | if (string.IsNullOrWhiteSpace(json)) 44 | { 45 | throw new FormatException("Input JSON cannot be null or empty."); 46 | } 47 | int index = 0; 48 | SkipWhitespace(json, ref index); 49 | var result = ParseValue(json, ref index) as List; 50 | SkipWhitespace(json, ref index); 51 | 52 | if (index != json.Length) 53 | { 54 | throw new FormatException("Unexpected characters at the end of the JSON string."); 55 | } 56 | 57 | return result ?? throw new FormatException("The provided JSON is not a valid array."); 58 | } 59 | 60 | private static object ParseValue(string json, ref int index) 61 | { 62 | SkipWhitespace(json, ref index); 63 | char c = json[index]; 64 | 65 | switch (c) 66 | { 67 | case '{': 68 | return ParseObject(json, ref index); 69 | case '[': 70 | return ParseList(json, ref index); 71 | case '"': 72 | return ParseString(json, ref index); 73 | case 't': 74 | case 'f': 75 | return ParseBoolean(json, ref index); 76 | case 'n': 77 | return ParseNull(json, ref index); 78 | default: 79 | if (char.IsDigit(c) || c == '-') 80 | { 81 | return ParseNumber(json, ref index); 82 | } 83 | throw new FormatException($"Unexpected character '{c}' at index {index}."); 84 | } 85 | } 86 | 87 | private static Dictionary ParseObject(string json, ref int index) 88 | { 89 | var dict = new Dictionary(); 90 | index++; // Consume '{' 91 | 92 | while (index < json.Length) 93 | { 94 | SkipWhitespace(json, ref index); 95 | if (json[index] == '}') 96 | { 97 | index++; 98 | return dict; 99 | } 100 | 101 | string key = ParseString(json, ref index); 102 | SkipWhitespace(json, ref index); 103 | 104 | if (json[index] != ':') throw new FormatException($"Expected ':' after key \"{key}\" at index {index}."); 105 | index++; 106 | 107 | object value = ParseValue(json, ref index); 108 | dict[key] = value; 109 | SkipWhitespace(json, ref index); 110 | 111 | if (json[index] == ',') 112 | { 113 | index++; // Consume the comma 114 | SkipWhitespace(json, ref index); 115 | 116 | // After a comma, a closing brace is illegal (a trailing comma). 117 | if (json[index] == '}') 118 | { 119 | throw new FormatException($"Trailing comma found in object at index {index}."); 120 | } 121 | } 122 | else if (json[index] == '}') 123 | { 124 | index++; 125 | return dict; 126 | } 127 | else 128 | { 129 | throw new FormatException($"Expected ',' or '}}' in object at index {index}."); 130 | } 131 | } 132 | throw new FormatException("Unterminated JSON object."); 133 | } 134 | 135 | private static List ParseList(string json, ref int index) 136 | { 137 | var list = new List(); 138 | index++; // Consume '[' 139 | 140 | while (index < json.Length) 141 | { 142 | SkipWhitespace(json, ref index); 143 | if (json[index] == ']') 144 | { 145 | index++; 146 | return list; 147 | } 148 | 149 | object value = ParseValue(json, ref index); 150 | list.Add(value); 151 | SkipWhitespace(json, ref index); 152 | 153 | if (json[index] == ',') 154 | { 155 | index++; // Consume the comma 156 | SkipWhitespace(json, ref index); 157 | 158 | // After a comma, a closing brace is illegal (a trailing comma). 159 | if (json[index] == ']') 160 | { 161 | throw new FormatException($"Trailing comma found in object at index {index}."); 162 | } 163 | } 164 | else if (json[index] == ']') 165 | { 166 | index++; 167 | return list; 168 | } 169 | else 170 | { 171 | throw new FormatException($"Expected ',' or ']' in array at index {index}."); 172 | } 173 | } 174 | throw new FormatException("Unterminated JSON array."); 175 | } 176 | 177 | private static string ParseString(string json, ref int index) 178 | { 179 | var sb = new StringBuilder(); 180 | index++; // Consume '\"' 181 | 182 | while (index < json.Length) 183 | { 184 | char c = json[index++]; 185 | if (c == '"') 186 | { 187 | return sb.ToString(); 188 | } 189 | if (c == '\\') 190 | { 191 | if (index >= json.Length) throw new FormatException("Unterminated escape sequence."); 192 | char next = json[index++]; 193 | switch (next) 194 | { 195 | case '"': sb.Append('"'); break; 196 | case '\\': sb.Append('\\'); break; 197 | case '/': sb.Append('/'); break; 198 | case 'b': sb.Append('\b'); break; 199 | case 'f': sb.Append('\f'); break; 200 | case 'n': sb.Append('\n'); break; 201 | case 'r': sb.Append('\r'); break; 202 | case 't': sb.Append('\t'); break; 203 | case 'u': 204 | if (index + 3 >= json.Length) throw new FormatException("Invalid Unicode escape sequence."); 205 | string hex = json.Substring(index, 4); 206 | sb.Append((char)Convert.ToInt32(hex, 16)); 207 | index += 4; 208 | break; 209 | default: 210 | throw new FormatException($"Invalid escape sequence: \\{next}"); 211 | } 212 | } 213 | else 214 | { 215 | sb.Append(c); 216 | } 217 | } 218 | throw new FormatException("Unterminated string literal."); 219 | } 220 | 221 | private static object ParseNumber(string json, ref int index) 222 | { 223 | int startIndex = index; 224 | while (index < json.Length && "0123456789.-+eE".Contains(json[index])) 225 | { 226 | index++; 227 | } 228 | string numberStr = json.Substring(startIndex, index - startIndex); 229 | 230 | if (numberStr.Contains(".") || numberStr.ToLower().Contains("e")) 231 | { 232 | if (double.TryParse(numberStr, NumberStyles.Float, CultureInfo.InvariantCulture, out double d)) 233 | { 234 | return d; 235 | } 236 | } 237 | else 238 | { 239 | if (int.TryParse(numberStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out int i)) 240 | { 241 | return i; 242 | } 243 | if (long.TryParse(numberStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out long l)) 244 | { 245 | return l; 246 | } 247 | } 248 | throw new FormatException($"Invalid number format: {numberStr}"); 249 | } 250 | 251 | private static bool ParseBoolean(string json, ref int index) 252 | { 253 | if (index + 3 < json.Length && json.Substring(index, 4) == "true") 254 | { 255 | index += 4; 256 | return true; 257 | } 258 | if (index + 4 < json.Length && json.Substring(index, 5) == "false") 259 | { 260 | index += 5; 261 | return false; 262 | } 263 | throw new FormatException($"Invalid boolean literal at index {index}."); 264 | } 265 | 266 | private static object? ParseNull(string json, ref int index) 267 | { 268 | if (index + 3 < json.Length && json.Substring(index, 4) == "null") 269 | { 270 | index += 4; 271 | return null; 272 | } 273 | throw new FormatException($"Invalid null literal at index {index}."); 274 | } 275 | 276 | private static void SkipWhitespace(string json, ref int index) 277 | { 278 | while (index < json.Length && char.IsWhiteSpace(json[index])) 279 | { 280 | index++; 281 | } 282 | } 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /tests/SetSharp.Tests/ModelBuilder/ConfigurationModelBuilderTests.cs: -------------------------------------------------------------------------------- 1 | using SetSharp.ModelBuilder; 2 | 3 | 4 | namespace SetSharp.Tests.ModelBuilder 5 | { 6 | public class ConfigurationModelBuilderTests 7 | { 8 | [Fact] 9 | public void BuildFrom_WithFlatPrimitives_ShouldCreateModelCorrectly() 10 | { 11 | // Arrange 12 | var builder = new ConfigurationModelBuilder(); 13 | var root = new Dictionary 14 | { 15 | { "ConnectionString", "Server=.;Database=Test;"}, 16 | { "Timeout", 30 }, 17 | { "MaxRetries", 9223372036854775807L }, 18 | { "EnableLogging", true }, 19 | { "DefaultThreshold", 0.95 } 20 | }; 21 | 22 | // Act 23 | var result = builder.BuildFrom(root); 24 | 25 | // Assert 26 | var rootModel = Assert.Single(result); 27 | Assert.Equal("RootOptions", rootModel.ClassName); 28 | Assert.Equal("", rootModel.SectionPath); 29 | Assert.Equal(5, rootModel.Properties.Count); 30 | 31 | Assert.Equal("string", rootModel.Properties.First(p => p.PropertyName == "ConnectionString").PropertyType); 32 | Assert.Equal("long", rootModel.Properties.First(p => p.PropertyName == "MaxRetries").PropertyType); 33 | Assert.Equal("bool", rootModel.Properties.First(p => p.PropertyName == "EnableLogging").PropertyType); 34 | Assert.Equal("double", rootModel.Properties.First(p => p.PropertyName == "DefaultThreshold").PropertyType); 35 | } 36 | 37 | [Fact] 38 | public void BuildFrom_WithNestedObject_ShouldCreateSeparateClassModel() 39 | { 40 | // Arrange 41 | var builder = new ConfigurationModelBuilder(); 42 | var root = new Dictionary 43 | { 44 | { "Logging", new Dictionary { 45 | { "Level", "Info" }, 46 | { "RetainDays", 7 } 47 | }} 48 | }; 49 | 50 | // Act 51 | var result = builder.BuildFrom(root); 52 | 53 | // Assert 54 | Assert.Equal(2, result.Count); 55 | 56 | // Test Root Class 57 | var rootModel = result.First(c => c.ClassName == "RootOptions"); 58 | var loggingProperty = Assert.Single(rootModel.Properties); 59 | Assert.Equal("Logging", loggingProperty.PropertyName); 60 | Assert.Equal("LoggingOptions", loggingProperty.PropertyType); // Should point to the new class 61 | 62 | // Test Nested Class 63 | var loggingModel = result.First(c => c.ClassName == "LoggingOptions"); 64 | Assert.Equal("Logging", loggingModel.SectionPath); 65 | Assert.Equal(2, loggingModel.Properties.Count); 66 | Assert.Contains(loggingModel.Properties, p => p.PropertyName == "Level" && p.PropertyType == "string"); 67 | Assert.Contains(loggingModel.Properties, p => p.PropertyName == "RetainDays" && p.PropertyType == "int"); 68 | } 69 | 70 | [Fact] 71 | public void BuildFrom_WithListOfPrimitives_ShouldInferListType() 72 | { 73 | // Arrange 74 | var builder = new ConfigurationModelBuilder(); 75 | var root = new Dictionary 76 | { 77 | { "AllowedHosts", new List { "localhost", "127.0.0.1" } } 78 | }; 79 | 80 | // Act 81 | var result = builder.BuildFrom(root); 82 | 83 | // Assert 84 | var rootModel = Assert.Single(result); 85 | var listProperty = Assert.Single(rootModel.Properties); 86 | Assert.Equal("AllowedHosts", listProperty.PropertyName); 87 | Assert.Equal("ImmutableList", listProperty.PropertyType); 88 | } 89 | 90 | [Fact] 91 | public void BuildFrom_WithEmptyList_ShouldDefaultToListOfObject() 92 | { 93 | // Arrange 94 | var builder = new ConfigurationModelBuilder(); 95 | var root = new Dictionary 96 | { 97 | { "EmptyList", new List() } 98 | }; 99 | 100 | // Act 101 | var result = builder.BuildFrom(root); 102 | 103 | // Assert 104 | var rootModel = Assert.Single(result); 105 | var listProperty = Assert.Single(rootModel.Properties); 106 | Assert.Equal("EmptyList", listProperty.PropertyName); 107 | Assert.Equal("ImmutableList", listProperty.PropertyType); 108 | } 109 | 110 | [Fact] 111 | public void BuildFrom_WithListOfObjects_ShouldCreateSeparateClassForListItems() 112 | { 113 | // Arrange 114 | var builder = new ConfigurationModelBuilder(); 115 | var root = new Dictionary 116 | { 117 | { "Endpoints", new List { 118 | new Dictionary { 119 | { "Name", "Primary" }, 120 | { "Url", "https://api.example.com" } 121 | } 122 | }} 123 | }; 124 | 125 | // Act 126 | var result = builder.BuildFrom(root); 127 | 128 | // Assert 129 | Assert.Equal(2, result.Count); 130 | 131 | // Test Root Class 132 | var rootModel = result.First(c => c.ClassName == "RootOptions"); 133 | var listProperty = Assert.Single(rootModel.Properties); 134 | Assert.Equal("Endpoints", listProperty.PropertyName); 135 | Assert.Equal("ImmutableList", listProperty.PropertyType); 136 | 137 | // Test Nested List Item Class 138 | var endpointItemModel = result.First(c => c.ClassName == "EndpointsItemOptions"); 139 | Assert.Equal("Endpoints", endpointItemModel.SectionPath); 140 | Assert.Equal(2, endpointItemModel.Properties.Count); 141 | Assert.Contains(endpointItemModel.Properties, p => p.PropertyName == "Name" && p.PropertyType == "string"); 142 | Assert.Contains(endpointItemModel.Properties, p => p.PropertyName == "Url" && p.PropertyType == "string"); 143 | } 144 | 145 | [Fact] 146 | public void NormalizeName_WithInvalidChars_ShouldSanitizeName() 147 | { 148 | // Arrange 149 | var builder = new ConfigurationModelBuilder(); 150 | var root = new Dictionary 151 | { 152 | { "logging:level-default", "Warning" }, // Contains : and - 153 | { "1st-try", 1 }, // Starts with digit 154 | { "!@#$", "junk" } // Only invalid chars 155 | }; 156 | 157 | // Act 158 | var result = builder.BuildFrom(root); 159 | 160 | // Assert 161 | var rootModel = Assert.Single(result); 162 | Assert.Equal(3, rootModel.Properties.Count); 163 | Assert.Contains(rootModel.Properties, p => p.PropertyName == "Loggingleveldefault"); 164 | Assert.Contains(rootModel.Properties, p => p.PropertyName == "_1sttry"); 165 | Assert.Contains(rootModel.Properties, p => p.PropertyName == "InvalidNameProperty"); 166 | } 167 | 168 | [Fact] 169 | public void BuildFrom_WithDeeplyNestedObject_ShouldHandleRecursionCorrectly() 170 | { 171 | // Arrange 172 | var builder = new ConfigurationModelBuilder(); 173 | var root = new Dictionary 174 | { 175 | { "L1", new Dictionary { 176 | { "L2", new Dictionary { 177 | { "L3", new Dictionary { 178 | { "Name", "Deep" } 179 | }} 180 | }} 181 | }} 182 | }; 183 | 184 | // Act 185 | var result = builder.BuildFrom(root); 186 | 187 | // Assert 188 | Assert.Equal(4, result.Count); // Root, L1, L2, L3 189 | 190 | // L1 191 | var l1Model = result.First(c => c.ClassName == "L1Options"); 192 | Assert.Equal("L1", l1Model.SectionPath); 193 | Assert.Equal("L2Options", Assert.Single(l1Model.Properties).PropertyType); 194 | 195 | // L2 196 | var l2Model = result.First(c => c.ClassName == "L2Options"); 197 | Assert.Equal("L1:L2", l2Model.SectionPath); 198 | Assert.Equal("L3Options", Assert.Single(l2Model.Properties).PropertyType); 199 | 200 | // L3 201 | var l3Model = result.First(c => c.ClassName == "L3Options"); 202 | Assert.Equal("L1:L2:L3", l3Model.SectionPath); 203 | Assert.Equal("string", Assert.Single(l3Model.Properties).PropertyType); 204 | } 205 | 206 | [Fact] 207 | public void BuildFrom_WithEmptyDictionary_ShouldReturnRootWithNoProperties() 208 | { 209 | // Arrange 210 | var builder = new ConfigurationModelBuilder(); 211 | var root = new Dictionary(); 212 | 213 | // Act 214 | var result = builder.BuildFrom(root); 215 | 216 | // Assert 217 | var rootModel = Assert.Single(result); 218 | Assert.Equal("RootOptions", rootModel.ClassName); 219 | Assert.Empty(rootModel.Properties); 220 | } 221 | 222 | [Fact] 223 | public void BuildFrom_WithListOfObjectsHavingDifferentProperties_ShouldCreateComprehensiveClass() 224 | { 225 | // Arrange 226 | var builder = new ConfigurationModelBuilder(); 227 | var root = new Dictionary 228 | { 229 | { "Products", new List { 230 | // First item only has Name and Id 231 | new Dictionary { 232 | { "Id", 1 }, 233 | { "Name", "Gadget" } 234 | }, 235 | // Second item adds a 'Price' property 236 | new Dictionary { 237 | { "Id", 2 }, 238 | { "Name", "Widget" }, 239 | { "Price", 99.99 } 240 | }, 241 | // Third item adds an 'InStock' property 242 | new Dictionary { 243 | { "Id", 3 }, 244 | { "Name", "Doodad" }, 245 | { "InStock", true } 246 | } 247 | }} 248 | }; 249 | 250 | // Act 251 | var result = builder.BuildFrom(root); 252 | 253 | // Assert 254 | Assert.Equal(2, result.Count); 255 | 256 | var rootModel = result.First(c => c.ClassName == "RootOptions"); 257 | var listProperty = Assert.Single(rootModel.Properties); 258 | Assert.Equal("Products", listProperty.PropertyName); 259 | Assert.Equal("ImmutableList", listProperty.PropertyType); 260 | 261 | var productItemModel = result.First(c => c.ClassName == "ProductsItemOptions"); 262 | Assert.Equal("Products", productItemModel.SectionPath); 263 | 264 | Assert.Equal(4, productItemModel.Properties.Count); 265 | Assert.Contains(productItemModel.Properties, p => p.PropertyName == "Id" && p.PropertyType == "int"); 266 | Assert.Contains(productItemModel.Properties, p => p.PropertyName == "Name" && p.PropertyType == "string"); 267 | Assert.Contains(productItemModel.Properties, p => p.PropertyName == "Price" && p.PropertyType == "double"); 268 | Assert.Contains(productItemModel.Properties, p => p.PropertyName == "InStock" && p.PropertyType == "bool"); 269 | } 270 | } 271 | 272 | } 273 | --------------------------------------------------------------------------------