Publish - 2026-05-21 18:39:51 UTC

This commit is contained in:
Duende Bot 2026-05-21 18:40:01 +00:00
parent f73fbebaf9
commit d4a4e901f6
24 changed files with 1760 additions and 55 deletions

View file

@ -7,6 +7,12 @@
"commands": [
"NuGetKeyVaultSignTool"
]
},
"damianh.playwright.installtool": {
"version": "0.2.0",
"commands": [
"playwright"
]
}
}
}

View file

@ -43,6 +43,13 @@ dotnet_diagnostic.IDE0066.severity = warning # Use switch expressions
dotnet_diagnostic.IDE0073.severity = warning # Require file header
dotnet_diagnostic.IDE0130.severity = warning # Namespace does not match folder structure
dotnet_diagnostic.IDE0161.severity = warning # Use file-scoped namespace
dotnet_naming_rule.async_methods_must_end_with_async.severity = warning
dotnet_naming_rule.async_methods_must_end_with_async.style = async_method_style
dotnet_naming_rule.async_methods_must_end_with_async.symbols = async_method_symbols
dotnet_naming_style.async_method_style.capitalization = pascal_case
dotnet_naming_style.async_method_style.required_suffix = Async
dotnet_naming_symbols.async_method_symbols.applicable_kinds = method
dotnet_naming_symbols.async_method_symbols.required_modifiers = async
dotnet_sort_system_directives_first = true
dotnet_style_predefined_type_for_locals_parameters_members = true
dotnet_style_predefined_type_for_member_access = true
@ -56,3 +63,28 @@ indent_size = 4
[*.razor]
indent_size = 4
# ASP.NET Core middleware Invoke methods cannot be renamed (framework constraint)
[platform/src/Authentication.Web/OpenIdConnect/Hosting/*Middleware.cs]
dotnet_naming_rule.async_methods_must_end_with_async.severity = none
[platform/src/Authentication.Web/Hosting/DynamicProviders/DynamicSchemes/*Middleware.cs]
dotnet_naming_rule.async_methods_must_end_with_async.severity = none
# xUnit test methods use Should_* naming convention without Async suffix
[platform/test/**/*.cs]
dotnet_naming_rule.async_methods_must_end_with_async.severity = none
# Sample test projects also use xUnit test naming conventions
[platform/samples/**/test/**/*.cs]
dotnet_naming_rule.async_methods_must_end_with_async.severity = none
# gRPC service methods override generated base class methods and cannot be renamed (framework constraint)
[platform/src/AdminStudio.Server/Services/**/*GrpcService.cs]
dotnet_naming_rule.async_methods_must_end_with_async.severity = none
[platform/src/AdminStudio.Web/Services/**/*GrpcService.cs]
dotnet_naming_rule.async_methods_must_end_with_async.severity = none
[platform/src/AdminStudio.Web/Services/*GrpcService.cs]
dotnet_naming_rule.async_methods_must_end_with_async.severity = none

14
.gitignore vendored
View file

@ -4,6 +4,9 @@
# Rider
.idea
# NCrunch
*.ncrunchproject
# User-specific files
*.suo
*.user
@ -209,9 +212,6 @@ FakesAssemblies/
*.opt
docs/_build/
# Local .NET CLI tools
tools/
# Visual Studio Code workspace options
**/.vscode/settings.json
@ -243,3 +243,11 @@ reports
*.db-wal
nul
.weave
progress.txt
# AdminStudio runtime data (secrets, user stores)
faro-connections.json
adminstudio-data/
# VS Code C# Dev Kit language service cache
*.lscache

View file

@ -43,4 +43,5 @@
<ItemGroup>
<Compile Include="$(MSBuildThisFileDirectory)shared/Global.cs" Link="Global.cs" />
</ItemGroup>
</Project>

View file

@ -1,21 +1,22 @@
<Project>
<ItemGroup>
<PackageVersion Include="AngleSharp" Version="1.4.0" />
<PackageVersion Include="Aspire.Hosting.PostgreSQL" Version="13.1.2" />
<PackageVersion Include="Aspire.Hosting.SqlServer" Version="13.1.2" />
<PackageVersion Include="Aspire.Hosting.Testing" Version="13.1.2" />
<PackageVersion Include="Aspire.Hosting.PostgreSQL" Version="13.2.4" />
<PackageVersion Include="Aspire.Hosting.Redis" Version="13.2.4" />
<PackageVersion Include="Aspire.Hosting.SqlServer" Version="13.2.4" />
<PackageVersion Include="Aspire.Hosting.Testing" Version="13.2.4" />
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="Bullseye" Version="6.1.0" />
<PackageVersion Include="coverlet.collector" Version="8.0.0" />
<PackageVersion Include="Cronos" Version="0.11.1" />
<PackageVersion Include="Duende.AccessTokenManagement" Version="4.1.1" />
<PackageVersion Include="Duende.AccessTokenManagement.OpenIdConnect" Version="4.1.1" />
<PackageVersion Include="Duende.AspNetCore.Authentication.JwtBearer" Version="0.3.0" />
<PackageVersion Include="Duende.Cli.PluginAbstractions" Version="0.0.1" />
<PackageVersion Include="Duende.ConformanceReport" Version="0.1.0" />
<PackageVersion Include="Duende.IdentityModel" Version="8.0.0" />
<PackageVersion Include="Duende.Extensions.Caching.Memory" Version="10.0.0" />
<PackageVersion Include="Duende.IdentityModel" Version="8.1.0" />
<PackageVersion Include="Duende.IdentityModel.OidcClient" Version="7.0.0" />
<PackageVersion Include="Duende.IdentityServer" Version="7.4.6" />
<PackageVersion Include="Duende.Internal.ValueObjectSourceGenerator" Version="1.0.1" />
<PackageVersion Include="Duende.Private.Licensing" Version="1.1.0" />
<PackageVersion Include="Duende.RazorSlices" Version="1.0.0" />
<PackageVersion Include="Google.Protobuf" Version="3.34.0" />
@ -26,23 +27,26 @@
<PackageVersion Include="Grpc.Tools" Version="2.78.0" />
<PackageVersion Include="IdentityModel.AspNetCore.OAuth2Introspection" Version="6.2.0" />
<PackageVersion Include="Markdig" Version="1.1.1" />
<PackageVersion Include="MartinCostello.Logging.XUnit.v3" Version="0.7.1" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Certificate" Version="10.0.4" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.WsFederation" Version="10.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="10.0.4" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.4" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.4" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.Abstractions" Version="10.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.StackExchangeRedis" Version="10.0.7" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.4" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="10.0.4" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.4" />
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="10.0.4" />
<PackageVersion Include="Microsoft.Build.Tasks.Core" Version="18.4.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="3.3.4" />
<PackageVersion Include="Microsoft.Data.SqlClient" Version="6.1.4" />
<PackageVersion Include="Microsoft.Data.SqlClient" Version="7.0.1" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.4" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.4" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.4" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.0" />
@ -51,8 +55,14 @@
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.4" />
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.4" />
<PackageVersion Include="Microsoft.Extensions.Caching.SqlServer" Version="10.0.5" />
<PackageVersion Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.5" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration.CommandLine" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.4" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.4" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.Testing" Version="10.4.0" />
<PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.4" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.4" />
@ -67,7 +77,8 @@
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.4.0" />
<PackageVersion Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.0.1" />
<PackageVersion Include="Microsoft.IdentityModel.Logging" Version="8.0.1" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens.Saml" Version="8.0.1" />
<PackageVersion Include="Microsoft.IdentityModel.Xml" Version="8.0.1" />
<PackageVersion Include="Microsoft.Playwright.Xunit.v3" Version="1.58.0" />
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.5.2" />
<PackageVersion Include="Microsoft.Testing.Extensions.TrxReport" Version="2.1.0" />
@ -80,16 +91,20 @@
<PackageVersion Include="NBomber.Http" Version="6.2.0" />
<PackageVersion Include="netDumbster" Version="3.1.1" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<PackageVersion Include="Npgsql" Version="10.0.1" />
<PackageVersion Include="Npgsql" Version="10.0.2" />
<PackageVersion Include="Npgsql.DependencyInjection" Version="10.0.2" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="OpenTelemetry" Version="1.15.0" />
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.15.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.15.1" />
<PackageVersion Include="NuGet.Packaging" Version="7.6.0" />
<PackageVersion Include="NuGet.Protocol" Version="7.6.0" />
<PackageVersion Include="OpenTelemetry" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.15.2" />
<PackageVersion Include="PublicApiGenerator" Version="11.5.4" />
<PackageVersion Include="ReverseMarkdown" Version="5.2.0" />
<PackageVersion Include="RichardSzalay.MockHttp" Version="7.0.0" />
@ -105,11 +120,13 @@
<PackageVersion Include="SimpleFeedReader" Version="2.0.4" />
<PackageVersion Include="Spectre.Console.Cli" Version="0.53.1" />
<PackageVersion Include="Spectre.Console.Json" Version="0.54.0" />
<PackageVersion Include="StackExchange.Redis" Version="2.12.8" />
<PackageVersion Include="Sustainsys.Saml2.AspNetCore2" Version="2.11.0" />
<PackageVersion Include="System.CommandLine" Version="2.0.4" />
<PackageVersion Include="System.Drawing.Common" Version="10.0.4" />
<PackageVersion Include="System.Formats.Cbor" Version="10.0.4" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.0.1" />
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.7" />
<PackageVersion Include="Verify.XunitV3" Version="31.13.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="xunit.v3.core.mtp-v2" Version="3.2.2" />

View file

@ -36,6 +36,13 @@ Alternatively, you can add a `.vscode/mcp.json` file to your workspace:
The Duende Documentation MCP Server will create its database index at the path defined in the `--database` parameter.
### Command-line Options
| Flag | Description | Default |
|----------------------|---------------------------------------------------------------|------------------------------------------|
| `--database <path>` | Fully qualified path to the SQLite database file | `mcp.db` (relative to working directory) |
| `--with-http [port]` | Enable the HTTP transport (Streamable HTTP) on the given port | Disabled; port defaults to `5800` |
Next, open GitHub Copilot and select Agent Mode to work with the MCP server.
### JetBrains Rider
@ -118,7 +125,7 @@ documentation and samples at [docs.duendesoftware.com](https://docs.duendesoftwa
### Development
* Run the project. This will host a server on port 3000 (http), and with stdio bindings.
* Run the project with `--with-http 3000` to enable the HTTP transport (e.g., on port 3000).
* In VS Code, add a `.vscode/mcp.json` to your workspace:
```json
{

View file

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
@ -16,6 +16,10 @@
<NoWarn>$(NoWarn);CA1873</NoWarn>
<!-- Avoid uninstantiated internal classes (code analysis) - https://github.com/dotnet/roslyn-analyzers/issues/6561 -->
<NoWarn>$(NoWarn);CA1812</NoWarn>
<!-- Do not pass literals as localized parameters -->
<NoWarn>$(NoWarn);CA1303</NoWarn>
<!-- C# 14 extension blocks generate nested public types that trigger this rule - compiler limitation -->
<NoWarn>$(NoWarn);CA1515</NoWarn>
</PropertyGroup>
<ItemGroup>
@ -31,5 +35,6 @@
<PackageReference Include="ModelContextProtocol.AspNetCore"/>
<PackageReference Include="ReverseMarkdown"/>
<PackageReference Include="SimpleFeedReader"/>
<PackageReference Include="System.Security.Cryptography.Xml"/>
</ItemGroup>
</Project>

View file

@ -6,25 +6,24 @@ using Documentation.Mcp.Sources.Blog;
using Documentation.Mcp.Sources.Docs;
using Documentation.Mcp.Sources.Samples;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
// Build server
var builder = WebApplication.CreateBuilder(args);
// Configure all logs to go to stderr in case the MCP is used as a stdio server
builder.Logging.AddConsole(consoleLogOptions =>
{
consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace;
});
// Determine database path
// Parse command-line arguments
var databasePath = "mcp.db";
var enableHttp = false;
var httpPort = 5800;
var dbParameterIndex = -1;
var httpParameterIndex = -1;
var httpPortIsNextArg = false;
if (args.Length > 0)
{
var dbParameterIndex = args.IndexOf("--database");
dbParameterIndex = args.IndexOf("--database");
if (dbParameterIndex >= 0 && args.Length > dbParameterIndex + 1)
{
var dbPathParameter = args[dbParameterIndex + 1].Replace("\"", "", StringComparison.OrdinalIgnoreCase);
@ -33,8 +32,69 @@ if (args.Length > 0)
databasePath = dbPathParameter;
}
}
httpParameterIndex = Array.FindIndex(args, arg =>
string.Equals(arg, "--with-http", StringComparison.OrdinalIgnoreCase) ||
arg.StartsWith("--with-http=", StringComparison.OrdinalIgnoreCase));
enableHttp = httpParameterIndex >= 0;
if (httpParameterIndex >= 0)
{
string? httpPortValue = null;
var httpArgument = args[httpParameterIndex];
if (httpArgument.StartsWith("--with-http=", StringComparison.OrdinalIgnoreCase))
{
httpPortValue = httpArgument["--with-http=".Length..];
}
else if (args.Length > httpParameterIndex + 1 && !args[httpParameterIndex + 1].StartsWith("--", StringComparison.Ordinal))
{
httpPortValue = args[httpParameterIndex + 1];
httpPortIsNextArg = true;
}
if (!string.IsNullOrEmpty(httpPortValue))
{
if (!int.TryParse(httpPortValue, out var parsedPort) ||
parsedPort is < 1 or > 65535)
{
await Console.Error.WriteLineAsync("Invalid HTTP port. Specify a value between 1 and 65535.");
return 1;
}
httpPort = parsedPort;
}
}
}
// Filter out custom CLI flags before passing args to the host builder to avoid
// unintended configuration state from the default command-line config parser.
var excludedArgIndices = new HashSet<int>();
if (dbParameterIndex >= 0)
{
_ = excludedArgIndices.Add(dbParameterIndex);
if (args.Length > dbParameterIndex + 1)
{
_ = excludedArgIndices.Add(dbParameterIndex + 1);
}
}
if (httpParameterIndex >= 0)
{
_ = excludedArgIndices.Add(httpParameterIndex);
if (httpPortIsNextArg)
{
_ = excludedArgIndices.Add(httpParameterIndex + 1);
}
}
var hostArgs = args.Where((_, i) => !excludedArgIndices.Contains(i)).ToArray();
// Build server
IHostApplicationBuilder builder = enableHttp
? WebApplication.CreateBuilder(hostArgs)
: Host.CreateApplicationBuilder(hostArgs);
// Configure all logs to go to stderr in case the MCP is used as a stdio server
builder.Logging.AddConsole(consoleLogOptions =>
{
consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace;
});
// Setup services
builder.Services.AddHttpClient();
builder.Services.AddSqlite<McpDb>("Data Source=" + databasePath + ";Cache=Shared");
@ -43,7 +103,7 @@ builder.Services.AddHostedService<DocsArticleIndexer>();
builder.Services.AddHostedService<BlogArticleIndexer>();
builder.Services.AddHostedService<SamplesIndexer>();
builder.Services
var mcpServerBuilder = builder.Services
.AddMcpServer(options =>
{
options.ServerInfo = new Implementation
@ -70,17 +130,41 @@ builder.Services
.WithTools<DocsSearchTool>()
.WithTools<BlogSearchTool>()
.WithTools<SamplesSearchTool>()
.WithStdioServerTransport()
.WithHttpTransport();
.WithStdioServerTransport();
if (enableHttp)
{
_ = mcpServerBuilder.WithHttpTransport();
if (builder is WebApplicationBuilder webApplicationBuilder)
{
_ = webApplicationBuilder.WebHost.ConfigureKestrel(options => options.ListenLocalhost(httpPort));
}
}
// Setup application
var app = builder.Build();
IHost app;
if (enableHttp)
{
var webApp = (builder as WebApplicationBuilder)!.Build();
webApp.Logger.LogInformation("Transport enabled: HTTP on port {Port}", httpPort);
_ = webApp.MapMcp();
app.MapMcp();
app = webApp;
}
else
{
var consoleApp = (builder as HostApplicationBuilder)!.Build();
app = consoleApp;
}
app.Logger.LogInformation("Transport enabled: stdio");
await EnsureDb(app.Services, app.Logger);
await app.RunAsync();
return 0;
async Task EnsureDb(IServiceProvider services, ILogger logger)
{
@ -95,3 +179,11 @@ async Task EnsureDb(IServiceProvider services, ILogger logger)
logger.LogInformation("Updated database");
}
}
internal static class HostExtensions
{
extension(IHost host)
{
internal ILogger Logger => host.Services.GetRequiredService<ILogger<Program>>();
}
}

View file

@ -5,10 +5,10 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:3000",
"commandLineArgs": "--with-http 3000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
}

View file

@ -1,16 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<Solution>
<Configurations>
<Platform Name="Any CPU" />
<Platform Name="x64" />
<Platform Name="x86" />
</Configurations>
<Folder Name="/.github/">
<Project Path=".github/BuildHelpers/BuildHelpers.csproj" />
<Project Path=".github/RepoTool/RepoTool.csproj" />
</Folder>
<Folder Name="/aspnetcore-authentication-jwtbearer/" />
<Folder Name="/aspnetcore-authentication-jwtbearer/src/">
<Project Path="aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/AspNetCore.Authentication.JwtBearer.csproj" />
</Folder>
<Folder Name="/aspnetcore-authentication-jwtbearer/test/">
<Project Path="aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/AspNetCore.Authentication.JwtBearer.Tests.csproj" />
</Folder>
<Folder Name="/aspnetcore-authentication-jwtbearer/tools/">
<Project Path="aspnetcore-authentication-jwtbearer/tools/PublicApi/PublicApi.csproj" />
</Folder>
<Folder Name="/cli/" />
<Folder Name="/cli/src/">
<Project Path="cli/src/Cli/Cli.csproj" />
<Project Path="cli/src/Cli.PluginAbstractions/Cli.PluginAbstractions.csproj" />
</Folder>
<Folder Name="/cli/test/">
<Project Path="cli/test/Cli.IntegrationTests/Cli.IntegrationTests.csproj" />
<Project Path="cli/test/Cli.Tests/Cli.Tests.csproj" />
</Folder>
<Folder Name="/bff/" />
<Folder Name="/bff/hosts/">
<Project Path="bff/hosts/Hosts.AppHost/Hosts.AppHost.csproj" />
<Project Path="bff/hosts/Hosts.Bff.DPoP/Hosts.Bff.DPoP.csproj" />
@ -21,6 +38,7 @@
<Project Path="bff/hosts/Hosts.IdentityServer/Hosts.IdentityServer.csproj" />
<Project Path="bff/hosts/Hosts.ServiceDefaults/Hosts.ServiceDefaults.csproj" />
</Folder>
<Folder Name="/bff/hosts/Blazor/" />
<Folder Name="/bff/hosts/Blazor/PerComponent/">
<Project Path="bff/hosts/Blazor/PerComponent/Hosts.Bff.Blazor.PerComponent.Client/Hosts.Bff.Blazor.PerComponent.Client.csproj" />
<Project Path="bff/hosts/Blazor/PerComponent/Hosts.Bff.Blazor.PerComponent/Hosts.Bff.Blazor.PerComponent.csproj" />
@ -48,6 +66,7 @@
<Project Path="bff/src/Bff.Yarp/Bff.Yarp.csproj" />
<Project Path="bff/src/Bff/Bff.csproj" />
</Folder>
<Folder Name="/bff/templates/" />
<Folder Name="/bff/templates/src/">
<Project Path="bff/templates/src/BffLocalApi/BffLocalApi.csproj" />
<Project Path="bff/templates/src/BffRemoteApi/BffRemoteApi.csproj" />
@ -60,24 +79,36 @@
<Project Path="bff/test/Bff.Tests/Bff.Tests.csproj" />
<Project Path="bff/test/Hosts.Tests/Hosts.Tests.csproj" />
</Folder>
<Folder Name="/bff/tools/">
<Project Path="bff/tools/PublicApi/PublicApi.csproj" />
</Folder>
<Folder Name="/conformance-report/" />
<Folder Name="/conformance-report/src/">
<Project Path="conformance-report/src/ConformanceReport/ConformanceReport.csproj" />
</Folder>
<Folder Name="/conformance-report/test/">
<Project Path="conformance-report/test/ConformanceReport.Tests/ConformanceReport.Tests.csproj" />
</Folder>
<Folder Name="/conformance-report/tools/">
<Project Path="conformance-report/tools/PublicApi/PublicApi.csproj" />
</Folder>
<Folder Name="/docs-mcp/">
<Project Path="docs-mcp/src/Documentation.Mcp/Documentation.Mcp.csproj" />
<File Path="docs-mcp/README.md" />
<Project Path="docs-mcp/src/Documentation.Mcp/Documentation.Mcp.csproj" />
</Folder>
<Folder Name="/docs-mcp/tools/">
<Project Path="docs-mcp/tools/PublicApi/PublicApi.csproj" />
</Folder>
<Folder Name="/docs-mcp/.config/">
<File Path="docs-mcp/.config/dotnet-tools.json" />
</Folder>
<Folder Name="/identity-server/" />
<Folder Name="/identity-server/aspire/">
<Project Path="identity-server/aspire/AppHosts/All/All.csproj" />
<Project Path="identity-server/aspire/AppHosts/Dev/Dev.csproj" />
<Project Path="identity-server/aspire/ServiceDefaults/ServiceDefaults.csproj" />
</Folder>
<Folder Name="/identity-server/clients/" />
<Folder Name="/identity-server/clients/src/">
<Project Path="identity-server/clients/src/ConsoleCibaClient/ConsoleCibaClient.csproj" />
<Project Path="identity-server/clients/src/ConsoleClientCredentialsFlow/ConsoleClientCredentialsFlow.csproj" />
@ -145,7 +176,9 @@
<Project Path="identity-server/src/IdentityServer.ConformanceReport/IdentityServer.ConformanceReport.csproj" />
<Project Path="identity-server/src/IdentityServer/IdentityServer.csproj" />
<Project Path="identity-server/src/Storage/IdentityServer.Storage.csproj" />
<Project Path="identity-server/src/UserManagement/IdentityServer.UserManagement.csproj" />
</Folder>
<Folder Name="/identity-server/templates/" />
<Folder Name="/identity-server/templates/src/">
<Project Path="identity-server/templates/src/IdentityServer/IdentityServerTemplate.csproj" />
<Project Path="identity-server/templates/src/IdentityServerAspNetIdentity/IdentityServerAspNetIdentity.csproj" />
@ -158,18 +191,112 @@
<Project Path="identity-server/test/IdentityServer.EndToEndTests/IdentityServer.EndToEndTests.csproj" />
<Project Path="identity-server/test/IdentityServer.IntegrationTests/IdentityServer.IntegrationTests.csproj" />
<Project Path="identity-server/test/IdentityServer.UnitTests/IdentityServer.UnitTests.csproj" />
<Project Path="identity-server/test/IdentityServer.UserManagement.UnitTests/IdentityServer.UserManagement.UnitTests.csproj" />
</Folder>
<Folder Name="/identity-server/tools/">
<Project Path="identity-server/tools/PublicApi/PublicApi.csproj" />
</Folder>
<Folder Name="/ignore-this/" />
<Folder Name="/ignore-this/src/">
<Project Path="ignore-this/src/IgnoreThis/IgnoreThis.csproj" />
</Folder>
<Folder Name="/ignore-this/test/">
<Project Path="ignore-this/test/IgnoreThis.Tests/IgnoreThis.Tests.csproj" />
</Folder>
<Folder Name="/user-management/samples/">
<Project Path="user-management/samples/AspNetIdentityApp/AspNetIdentityApp.csproj" />
<Project Path="user-management/samples/ExternalAuthentication/ExternalAuthentication.csproj" />
<Project Path="user-management/samples/MfaStepUp/MfaStepUp.csproj" />
<Project Path="user-management/samples/OtpAuthentication/OtpAuthentication.csproj" />
<Project Path="user-management/samples/OtpIdentityServer/OtpIdentityServer.csproj" />
<Project Path="user-management/samples/PasskeyAuthentication.Sdk/PasskeyAuthentication.Sdk.csproj" />
<Project Path="user-management/samples/PasskeyAuthentication/PasskeyAuthentication.csproj" />
<Project Path="user-management/samples/PasskeyAuthenticationAs2FA/PasskeyAuthenticationAs2FA.csproj" />
<Project Path="user-management/samples/PasswordValidation/PasswordValidation.csproj" />
<Project Path="user-management/samples/PlatformUserManagementApp/PlatformUserManagementApp.csproj" />
<Project Path="user-management/samples/SimpleWebApp/SimpleWebApp.csproj" />
<Project Path="user-management/samples/TotpAuthentication/TotpAuthentication.csproj" />
<Project Path="user-management/samples/UserManagementSamples/UserManagementSamples.csproj" />
<Project Path="user-management/samples/UserSearch/UserSearch.csproj" />
<Project Path="user-management/samples/WebComponent/AuthenticationFlowWithWebComponent/AuthenticationFlowWithWebComponent.csproj" />
</Folder>
<Folder Name="/user-management/" />
<Folder Name="/user-management/src/">
<Project Path="user-management/src/UserManagement/UserManagement.csproj" />
</Folder>
<Folder Name="/user-management/test/">
<Project Path="user-management/test/UserManagement.Tests.Internal/UserManagement.Tests.Internal.csproj" />
<Project Path="user-management/test/UserManagement.Tests.Public/UserManagement.Tests.Public.csproj" />
</Folder>
<Folder Name="/user-management/testing/">
<Project Path="user-management/testing/UserManagement.TestInfrastructure/UserManagement.TestInfrastructure.csproj" />
</Folder>
<Folder Name="/user-management/tools/">
<Project Path="user-management/tools/PublicApi/PublicApi.csproj" />
</Folder>
<Folder Name="/storage/" />
<Folder Name="/storage/src/">
<Project Path="storage/src/Storage.CliPlugin/Storage.CliPlugin.csproj" />
<Project Path="storage/src/Storage.MsSql/Storage.MsSql.csproj" />
<Project Path="storage/src/Storage.PostgreSql/Storage.PostgreSql.csproj" />
<Project Path="storage/src/Storage.Sqlite/Storage.Sqlite.csproj" />
<Project Path="storage/src/Storage/Storage.csproj" />
</Folder>
<Folder Name="/storage/samples/">
<Project Path="storage/samples/GettingStarted/GettingStarted.csproj" />
<Project Path="storage/samples/EntityLinking/EntityLinking.csproj" />
<Project Path="storage/samples/EavSchema/EavSchema.csproj" />
</Folder>
<Folder Name="/storage/test/">
<Project Path="storage/test/Storage.MsSql.Tests/Storage.MsSql.Tests.csproj" />
<Project Path="storage/test/Storage.PostgreSql.Tests/Storage.PostgreSql.Tests.csproj" />
<Project Path="storage/test/Storage.Sqlite.Tests/Storage.Sqlite.Tests.csproj" />
<Project Path="storage/test/Storage.Tests/Storage.Tests.csproj" />
</Folder>
<Folder Name="/storage/testing/">
<Project Path="storage/testing/TestAppHost/TestAppHost.csproj" />
</Folder>
<Folder Name="/storage/benchmarks/">
<Project Path="storage/benchmarks/Duende.Storage.Benchmarks.csproj" />
</Folder>
<Folder Name="/storage/tools/">
<Project Path="storage/tools/PublicApi/PublicApi.csproj" />
</Folder>
<Folder Name="/shared/">
<Project Path="shared/AppHost.Abstractions/AppHost.Abstractions.csproj" />
<Project Path="shared/ShouldlyExtensions/ShouldlyExtensions.csproj" />
<Project Path="shared/ValueObjectsGenerator/ValueObjectsGenerator.csproj" />
<Project Path="shared/Xunit.Playwright/Xunit.Playwright.csproj" />
</Folder>
<Folder Name="/templates/">
<Project Path="templates/build/build.csproj" />
</Folder>
</Solution>
<Folder Name="/labs/" />
<Folder Name="/labs/FineGrainedAuthorization/" />
<Folder Name="/labs/FineGrainedAuthorization/src/">
<Project Path="labs/FineGrainedAuthorization/src/FGA.AspNetCore/FGA.AspNetCore.csproj" />
<Project Path="labs/FineGrainedAuthorization/src/FGA.Storage.PostgreSQL/FGA.Storage.PostgreSQL.csproj" />
<Project Path="labs/FineGrainedAuthorization/src/FGA.Storage.Sqlite/FGA.Storage.Sqlite.csproj" />
<Project Path="labs/FineGrainedAuthorization/src/FGA.Storage.SqlServer/FGA.Storage.SqlServer.csproj" />
<Project Path="labs/FineGrainedAuthorization/src/FGA.Testing/FGA.Testing.csproj" />
<Project Path="labs/FineGrainedAuthorization/src/FGA/FGA.csproj" />
</Folder>
<Folder Name="/labs/FineGrainedAuthorization/test/">
<Project Path="labs/FineGrainedAuthorization/test/FGA.Tests/FGA.Tests.csproj" />
</Folder>
<Folder Name="/labs/FineGrainedAuthorization/samples/">
<Project Path="labs/FineGrainedAuthorization/samples/AdvancedDemo/AdvancedDemo.csproj" />
<Project Path="labs/FineGrainedAuthorization/samples/DemoApp/DemoApp.csproj" />
<Project Path="labs/FineGrainedAuthorization/samples/PostgreSqlDemo/PostgreSqlDemo.csproj" />
<Project Path="labs/FineGrainedAuthorization/samples/SqliteDemo/SqliteDemo.csproj" />
<Project Path="labs/FineGrainedAuthorization/samples/SqlServerDemo/SqlServerDemo.csproj" />
<Project Path="labs/FineGrainedAuthorization/samples/WebApiDemo/WebApiDemo.csproj" />
</Folder>
<Folder Name="/labs/FineGrainedAuthorization/aspire/">
<Project Path="labs/FineGrainedAuthorization/aspire/AppHost/AppHost.csproj" />
<Project Path="labs/FineGrainedAuthorization/aspire/ServiceDefaults/ServiceDefaults.csproj" />
</Folder>
</Solution>

View file

@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AnalysisMode>None</AnalysisMode>
<AssemblyName>Duende.AppHost.Abstractions</AssemblyName>
<RootNamespace>Duende.Xunit.Playwright</RootNamespace>
<IsTestProject>false</IsTestProject>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,399 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace Duende.ValueObjectsGenerator;
/// <summary>
/// Scans a directory for C# files containing value object types annotated with
/// [StringValue] or [ValueOf&lt;T&gt;] and builds ValueObjectInfo descriptors.
/// </summary>
internal static class FileScanner
{
// Well-known C# keyword → fully-qualified global type name mappings
private static readonly Dictionary<string, string> WellKnownTypes = new(StringComparer.Ordinal)
{
["bool"] = "global::System.Boolean",
["byte"] = "global::System.Byte",
["char"] = "global::System.Char",
["decimal"] = "global::System.Decimal",
["double"] = "global::System.Double",
["float"] = "global::System.Single",
["int"] = "global::System.Int32",
["long"] = "global::System.Int64",
["sbyte"] = "global::System.SByte",
["short"] = "global::System.Int16",
["uint"] = "global::System.UInt32",
["ulong"] = "global::System.UInt64",
["ushort"] = "global::System.UInt16",
// Common non-keyword structs that are always System.*
["Guid"] = "global::System.Guid",
["DateTime"] = "global::System.DateTime",
["DateOnly"] = "global::System.DateOnly",
["TimeOnly"] = "global::System.TimeOnly",
["TimeSpan"] = "global::System.TimeSpan",
["DateTimeOffset"] = "global::System.DateTimeOffset",
["Uri"] = "global::System.Uri",
};
/// <summary>
/// Scans <paramref name="rootPath"/> recursively and returns all discovered value objects.
/// </summary>
internal static IReadOnlyList<ValueObjectInfo> Scan(string rootPath, string rootNamespace)
{
var csFiles = Directory
.EnumerateFiles(rootPath, "*.cs", SearchOption.AllDirectories)
.Where(IsEligibleFile)
.ToList();
// Pass 1: collect candidate records and build a lookup of all VO type names → namespace
var rawCandidates = new List<RawCandidate>();
foreach (var file in csFiles)
{
var text = File.ReadAllText(file);
var tree = CSharpSyntaxTree.ParseText(text);
var root = tree.GetCompilationUnitRoot();
CollectCandidates(file, root, rawCandidates);
}
// Build a lookup of all value-object type names → namespace for nested detection.
// Use a dictionary keyed by simple type name. If multiple types share the same short name
// (different namespaces), the first one wins — this is safe because cross-namespace nesting
// within a single project would be ambiguous at the C# level too.
var valueObjectTypeMap = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var c in rawCandidates)
{
_ = valueObjectTypeMap.TryAdd(c.TypeName, c.Namespace);
}
// Pass 2: resolve full info (including nested detection and FQN resolution)
var results = new List<ValueObjectInfo>(rawCandidates.Count);
foreach (var raw in rawCandidates)
{
string? genericTypeArgument = null;
var isNested = false;
if (raw.Kind == ValueObjectKind.ValueOf && raw.RawGenericArg is { } rawArg)
{
genericTypeArgument = ResolveGenericTypeArgument(rawArg, raw.Namespace, raw.UsingNamespaces, valueObjectTypeMap);
// Normalize to the simple type name (rightmost identifier) for nested detection.
// Handles "global::Some.Namespace.MyType", "Some.Namespace.MyType", and "MyType".
var simpleArg = rawArg.Trim();
if (simpleArg.StartsWith("global::", StringComparison.Ordinal))
{
simpleArg = simpleArg["global::".Length..];
}
var lastDot = simpleArg.LastIndexOf('.');
if (lastDot >= 0)
{
simpleArg = simpleArg[(lastDot + 1)..];
}
isNested = valueObjectTypeMap.ContainsKey(simpleArg);
}
results.Add(new ValueObjectInfo(
SourceFilePath: raw.SourceFilePath,
TypeName: raw.TypeName,
Namespace: raw.Namespace,
RootNamespace: rootNamespace,
Kind: raw.Kind,
GenericTypeArgument: genericTypeArgument,
IsNestedValueObject: isNested,
HasMaxLength: raw.HasMaxLength,
HasAllowedCharacters: raw.HasAllowedCharacters,
HasRegex: raw.HasRegex,
HasAllowedCharSet: raw.HasAllowedCharSet,
HasTryValidate: raw.HasTryValidate,
HasErrorMessage: raw.HasErrorMessage,
HasParse: raw.HasParse,
HasTryParse: raw.HasTryParse,
HasNormalize: raw.HasNormalize,
GenerateToString: raw.GenerateToString,
HasComparer: raw.HasComparer,
HasInternalConstructor: raw.HasInternalConstructor,
HasValue: raw.HasValue,
HasInternalValue: raw.HasInternalValue,
HasLoadFromStorage: raw.HasLoadFromStorage,
HasToString: raw.HasToString));
}
return results;
}
private static bool IsEligibleFile(string path)
{
// Skip generated files and build output
if (path.EndsWith(".g.cs", StringComparison.Ordinal))
{
return false;
}
var normalized = path.Replace('\\', '/');
if (normalized.Contains("/obj/", StringComparison.Ordinal) || normalized.Contains("/bin/", StringComparison.Ordinal) || normalized.Contains("/Generated/", StringComparison.Ordinal))
{
return false;
}
return true;
}
private static void CollectCandidates(string filePath, CompilationUnitSyntax root, List<RawCandidate> results)
{
// Collect using directives for namespace resolution
var usingNamespaces = root.Usings
.Select(u => u.Name?.ToString() ?? string.Empty)
.Where(u => !string.IsNullOrEmpty(u))
.ToList();
foreach (var record in root.DescendantNodes().OfType<RecordDeclarationSyntax>())
{
if (!record.Modifiers.Any(SyntaxKind.PartialKeyword))
{
continue;
}
if (!IsRecordClass(record))
{
continue;
}
var (kind, rawArg, generateToString) = DetectAttribute(record);
if (kind is null)
{
continue;
}
var ns = GetNamespace(record);
var typeName = record.Identifier.Text;
var members = record.Members;
var candidate = new RawCandidate(
SourceFilePath: filePath,
TypeName: typeName,
Namespace: ns,
Kind: kind.Value,
RawGenericArg: rawArg,
UsingNamespaces: usingNamespaces,
HasMaxLength: HasMember(members, "MaxLength"),
HasAllowedCharacters: HasMember(members, "AllowedCharacters"),
HasRegex: HasMethod(members, "Regex"),
HasAllowedCharSet: HasMember(members, "AllowedCharSet"),
HasTryValidate: HasMethod(members, "TryValidate"),
HasErrorMessage: HasMember(members, "ErrorMessage"),
HasParse: HasMethod(members, "Create"),
HasTryParse: HasMethod(members, "TryCreate"),
HasNormalize: HasMethod(members, "Normalize"),
HasComparer: HasMember(members, "Comparer"),
HasInternalConstructor: HasInternalConstructor(members, typeName),
HasValue: HasMember(members, "Value"),
HasInternalValue: HasInternalProperty(members, "Value"),
HasLoadFromStorage: HasMethod(members, "Load"),
HasToString: HasMethod(members, "ToString"),
GenerateToString: generateToString
);
results.Add(candidate);
}
}
private static bool IsRecordClass(RecordDeclarationSyntax record) =>
!record.ClassOrStructKeyword.IsKind(SyntaxKind.StructKeyword);
private static (ValueObjectKind? kind, string? rawArg, bool generateToString) DetectAttribute(RecordDeclarationSyntax record)
{
foreach (var attrList in record.AttributeLists)
{
foreach (var attr in attrList.Attributes)
{
var name = attr.Name.ToString();
if (name == "StringValue" || name == "StringValueAttribute" ||
name == "Duende.StringValue" || name == "Duende.StringValueAttribute")
{
return (ValueObjectKind.StringValue, null, ExtractGenerateToString(attr));
}
if (name.StartsWith("ValueOf<", StringComparison.Ordinal) ||
name.StartsWith("ValueOfAttribute<", StringComparison.Ordinal) ||
name.StartsWith("Duende.ValueOf<", StringComparison.Ordinal) ||
name.StartsWith("Duende.ValueOfAttribute<", StringComparison.Ordinal))
{
string? rawArg = null;
if (attr.Name is GenericNameSyntax generic && generic.TypeArgumentList.Arguments.Count > 0)
{
rawArg = generic.TypeArgumentList.Arguments[0].ToString().Trim();
}
return (ValueObjectKind.ValueOf, rawArg, ExtractGenerateToString(attr));
}
}
}
return (null, null, true);
}
private static bool ExtractGenerateToString(AttributeSyntax attr)
{
if (attr.ArgumentList is null)
{
return true;
}
foreach (var arg in attr.ArgumentList.Arguments)
{
if (arg.NameEquals?.Name.ToString() == "GenerateToString" &&
arg.Expression is LiteralExpressionSyntax lit)
{
return !lit.IsKind(SyntaxKind.FalseLiteralExpression);
}
}
return true;
}
private static bool HasInternalConstructor(SyntaxList<MemberDeclarationSyntax> members, string typeName) =>
members.OfType<ConstructorDeclarationSyntax>().Any(c =>
c.Identifier.Text == typeName &&
c.Modifiers.Any(SyntaxKind.InternalKeyword));
private static bool HasMember(SyntaxList<MemberDeclarationSyntax> members, string name) =>
members.Any(m => GetMemberName(m) == name);
private static bool HasInternalProperty(SyntaxList<MemberDeclarationSyntax> members, string name) =>
members.OfType<PropertyDeclarationSyntax>()
.Any(p => p.Identifier.Text == name &&
p.Modifiers.Any(m => m.IsKind(SyntaxKind.InternalKeyword)));
private static bool HasMethod(SyntaxList<MemberDeclarationSyntax> members, string name) =>
members.OfType<MethodDeclarationSyntax>().Any(m => m.Identifier.Text == name);
private static string? GetMemberName(MemberDeclarationSyntax member) => member switch
{
FieldDeclarationSyntax f => f.Declaration.Variables.FirstOrDefault()?.Identifier.Text,
PropertyDeclarationSyntax p => p.Identifier.Text,
MethodDeclarationSyntax m => m.Identifier.Text,
_ => null
};
private static string GetNamespace(BaseTypeDeclarationSyntax syntax)
{
var ns = string.Empty;
var parent = syntax.Parent;
while (parent is not null &&
parent is not NamespaceDeclarationSyntax &&
parent is not FileScopedNamespaceDeclarationSyntax)
{
parent = parent.Parent;
}
if (parent is BaseNamespaceDeclarationSyntax nsDecl)
{
ns = nsDecl.Name.ToString();
while (true)
{
if (nsDecl.Parent is not NamespaceDeclarationSyntax outerNs)
{
break;
}
ns = $"{outerNs.Name}.{ns}";
nsDecl = outerNs;
}
}
return ns;
}
/// <summary>
/// Resolves a raw generic type argument string to a fully-qualified global:: name.
/// </summary>
private static string ResolveGenericTypeArgument(
string rawArg,
string currentNamespace,
IReadOnlyList<string> usingNamespaces,
Dictionary<string, string> valueObjectTypeMap)
{
var trimmed = rawArg.Trim();
// Already fully qualified with global:: prefix
if (trimmed.StartsWith("global::", StringComparison.Ordinal))
{
return trimmed;
}
// Well-known primitive / BCL struct (e.g. int, Guid, DateTime)
if (WellKnownTypes.TryGetValue(trimmed, out var fqn))
{
return fqn;
}
// Already namespace-qualified (contains a dot, e.g. "Some.Namespace.MyType")
// — treat as fully qualified, just add global:: prefix
if (trimmed.Contains('.', StringComparison.Ordinal))
{
return $"global::{trimmed}";
}
// Check if it's a known value object type in the same project
if (valueObjectTypeMap.TryGetValue(trimmed, out var voNamespace))
{
return $"global::{voNamespace}.{trimmed}";
}
// Try to resolve from using directives — the type could be imported via a using.
// For simple (unqualified) names, check if using namespace + type name forms a known type.
foreach (var usingNs in usingNamespaces)
{
var candidate = $"{usingNs}.{trimmed}";
// Check well-known types with the fully-qualified candidate
if (WellKnownTypes.TryGetValue(candidate, out var resolved))
{
return resolved;
}
// Check if using namespace + simple name matches a known value object
if (valueObjectTypeMap.TryGetValue(trimmed, out var voNs) && voNs == usingNs)
{
return $"global::{voNs}.{trimmed}";
}
}
// Fall back to the current namespace
return $"global::{currentNamespace}.{trimmed}";
}
// Internal record for first-pass collection
private sealed record RawCandidate(
string SourceFilePath,
string TypeName,
string Namespace,
ValueObjectKind Kind,
string? RawGenericArg,
IReadOnlyList<string> UsingNamespaces,
bool HasMaxLength,
bool HasAllowedCharacters,
bool HasRegex,
bool HasAllowedCharSet,
bool HasTryValidate,
bool HasErrorMessage,
bool HasParse,
bool HasTryParse,
bool HasNormalize,
bool HasComparer,
bool HasInternalConstructor,
bool HasValue,
bool HasInternalValue,
bool HasLoadFromStorage,
bool HasToString,
bool GenerateToString
);
}

View file

@ -0,0 +1,221 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.ValueObjectsGenerator;
/// <summary>
/// Generates the per-project infrastructure files: IValueOf.g.cs,
/// ValueOfTypeConverter.g.cs, and CharsetExtensions.g.cs.
/// The content is identical to what the Roslyn source generator (Setup.cs) produced.
/// </summary>
internal static class InfrastructureCodeGenerator
{
internal static string GetInterfaceSource(string rootNamespace) => NormalizeLineEndings($$"""
// <auto-generated by="ValueObjectsGenerator"/>
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using System.Diagnostics.CodeAnalysis;
namespace {{rootNamespace}};
/// <summary>
/// Provides type-safe access to the underlying value of a value object.
/// </summary>
/// <typeparam name="T">The type of the underlying value.</typeparam>
internal interface IValueOf<T>
{
/// <summary>
/// Gets the underlying value.
/// </summary>
T Value { get; }
}
/// <summary>
/// Provides type-safe access to the underlying value of a value object,
/// along with creation capabilities.
/// </summary>
/// <typeparam name="TSelf">The value object type itself.</typeparam>
/// <typeparam name="T">The type of the underlying value.</typeparam>
internal interface IValueOf<TSelf, T> : IValueOf<T> where TSelf : IValueOf<TSelf, T>
{
/// <summary>
/// Creates a value object from a string representation.
/// </summary>
static abstract TSelf Create(string s);
/// <summary>
/// Tries to create a value object from a string representation.
/// </summary>
static abstract bool TryCreate(string? s, [NotNullWhen(true)] out TSelf? result);
}
internal interface IStringValue<TSelf> : IValueOf<TSelf, string> where TSelf : IStringValue<TSelf>
{
}
""");
internal static string GetCharSetsSource(string rootNamespace) => NormalizeLineEndings($$"""
// <auto-generated by="ValueObjectsGenerator"/>
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using System;
using System.Text;
namespace {{rootNamespace}};
/// <summary>
/// Extension methods for CharSet validation
/// </summary>
internal static class CharSetExtensions
{
/// <summary>
/// Defines the set of symbols for the 'Symbols' flag.
/// </summary>
private const string SymbolSet = "!@#$%^&*()_+-=[]{}|;:',.<>/?";
/// <summary>
/// Gets the combined string of all allowed characters for a given CharSet.
/// </summary>
internal static string GetAllowedCharacters(this CharSet charset)
{
var sb = new StringBuilder();
if (charset.HasFlag(CharSet.LowercaseLatin))
{
sb.Append("abcdefghijklmnopqrstuvwxyz");
}
if (charset.HasFlag(CharSet.UppercaseLatin))
{
sb.Append("ABCDEFGHIJKLMNOPQRSTUVWXYZ");
}
if (charset.HasFlag(CharSet.Digits))
{
sb.Append("0123456789");
}
if (charset.HasFlag(CharSet.Symbols))
{
sb.Append(SymbolSet);
}
return sb.ToString();
}
/// <summary>
/// Checks if a string contains ONLY characters defined by the CharSet.
/// </summary>
/// <param name="charset">The charset to validate against.</param>
/// <param name="input">The string to check.</param>
/// <returns>True if the string is null, empty, or contains only allowed characters. False otherwise.</returns>
internal static bool IsMatch(this CharSet charset, string input)
{
// An empty or null string contains no *invalid* characters.
if (string.IsNullOrEmpty(input))
{
return true;
}
// Cache the flags for quick lookups inside the loop
bool allowLower = charset.HasFlag(CharSet.LowercaseLatin);
bool allowUpper = charset.HasFlag(CharSet.UppercaseLatin);
bool allowDigits = charset.HasFlag(CharSet.Digits);
bool allowSymbols = charset.HasFlag(CharSet.Symbols);
foreach (char c in input)
{
// We use a series of 'continue' statements.
// If any condition is met, the character is valid, and we check the next.
// Using BCL methods is faster than c >= 'a' && c <= 'z'
if (allowLower && char.IsAsciiLetterLower(c))
{
continue;
}
if (allowUpper && char.IsAsciiLetterUpper(c))
{
continue;
}
if (allowDigits && char.IsAsciiDigit(c))
{
continue;
}
// For symbols, checking against the string is the clearest way
if (allowSymbols && SymbolSet.Contains(c))
{
continue;
}
// If we get here, the character 'c' did not match any
// allowed set, so the string is not a match.
return false;
}
// If the loop finishes, all characters were valid.
return true;
}
}
""");
internal static string GetTypeConverterSource(string rootNamespace) => NormalizeLineEndings($$"""
// <auto-generated by="ValueObjectsGenerator"/>
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
using System;
using System.ComponentModel;
using System.Globalization;
namespace {{rootNamespace}};
/// <summary>
/// Generic type converter for value objects.
/// Enables ASP.NET Core model binding and IConfiguration support.
/// </summary>
internal class ValueOfTypeConverter<TValue, TType> : TypeConverter where TValue : IValueOf<TValue, TType>
{
public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
{
return sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);
}
public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
{
if (value is string stringValue)
{
if (TValue.TryCreate(stringValue, out var result))
{
return result;
}
throw new FormatException($"'{stringValue}' is not a valid {typeof(TValue).Name}.");
}
return base.ConvertFrom(context, culture!, value);
}
public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType)
{
return destinationType == typeof(string) || base.CanConvertTo(context, destinationType);
}
public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType)
{
if (destinationType == typeof(string) && value is TValue typedValue)
{
return (typedValue.Value is IFormattable formattable)
? formattable.ToString(null, CultureInfo.InvariantCulture)
: typedValue.Value?.ToString();
}
return base.ConvertTo(context, culture, value, destinationType);
}
}
""");
private static string NormalizeLineEndings(string source) => source.Replace("\r\n", "\n", StringComparison.Ordinal);
}

View file

@ -0,0 +1,175 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.ValueObjectsGenerator;
// Simple argument parsing — this is internal tooling, no framework needed
string? path = null;
string? ns = null;
for (var i = 0; i < args.Length; i++)
{
if (args[i] == "--path" && i + 1 < args.Length)
{
path = args[++i];
}
else if (args[i] == "--namespace" && i + 1 < args.Length)
{
ns = args[++i];
}
}
if (path is null || ns is null)
{
Console.Error.WriteLine("Usage: ValueObjectsGenerator --path <projectDir> --namespace <rootNamespace>");
return 1;
}
if (!Directory.Exists(path))
{
Console.Error.WriteLine($"Directory not found: {path}");
return 1;
}
try
{
return Run(path, ns);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException)
{
Console.Error.WriteLine($"ValueObjectsGenerator failed: {ex.Message}");
return 1;
}
static int Run(string path, string ns)
{
var valueObjects = FileScanner.Scan(path, ns);
var written = 0;
var skipped = 0;
var expectedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Generate per-type .g.cs files next to their source files
foreach (var vo in valueObjects)
{
var sourceDir = Path.GetDirectoryName(vo.SourceFilePath)!;
var outputPath = Path.Combine(sourceDir, $"{vo.TypeName}.g.cs");
var content = vo.Kind switch
{
ValueObjectKind.StringValue => StringValueCodeGenerator.Generate(vo),
ValueObjectKind.ValueOf => ValueOfCodeGenerator.Generate(vo),
_ => throw new InvalidOperationException($"Unknown kind: {vo.Kind}")
};
_ = expectedFiles.Add(Path.GetFullPath(outputPath));
if (WriteIfChanged(outputPath, content))
{
written++;
}
else
{
skipped++;
}
}
// Generate infrastructure files in {path}/Internal/ValueObjects/
var valueObjectsDir = Path.Combine(path, "Internal", "ValueObjects");
_ = Directory.CreateDirectory(valueObjectsDir);
TrackAndWrite(ref written, ref skipped, expectedFiles,
Path.Combine(valueObjectsDir, "IValueOf.g.cs"), InfrastructureCodeGenerator.GetInterfaceSource(ns));
TrackAndWrite(ref written, ref skipped, expectedFiles,
Path.Combine(valueObjectsDir, "ValueOfTypeConverter.g.cs"), InfrastructureCodeGenerator.GetTypeConverterSource(ns));
TrackAndWrite(ref written, ref skipped, expectedFiles,
Path.Combine(valueObjectsDir, "CharsetExtensions.g.cs"), InfrastructureCodeGenerator.GetCharSetsSource(ns));
// Clean up orphaned .g.cs files that were previously generated by this tool
var deleted = CleanOrphanedFiles(path, expectedFiles);
Console.WriteLine($"ValueObjectsGenerator: {valueObjects.Count} value objects — {written} files written, {skipped} unchanged, {deleted} orphans deleted.");
return 0;
}
static void TrackAndWrite(ref int written, ref int skipped, HashSet<string> expectedFiles, string filePath, string content)
{
_ = expectedFiles.Add(Path.GetFullPath(filePath));
if (WriteIfChanged(filePath, content))
{
written++;
}
else
{
skipped++;
}
}
// Deletes orphaned .g.cs files that were previously generated by this tool but are no longer expected.
// Only deletes files that contain the ValueObjectsGenerator marker comment.
static int CleanOrphanedFiles(string rootPath, HashSet<string> expectedFiles)
{
const string marker = "// <auto-generated by=\"ValueObjectsGenerator\"/>";
var deleted = 0;
foreach (var file in Directory.EnumerateFiles(rootPath, "*.g.cs", SearchOption.AllDirectories))
{
var fullPath = Path.GetFullPath(file);
// Skip files in obj/ and bin/ directories
var normalized = fullPath.Replace('\\', '/');
if (normalized.Contains("/obj/", StringComparison.Ordinal) ||
normalized.Contains("/bin/", StringComparison.Ordinal))
{
continue;
}
// Skip files we just generated
if (expectedFiles.Contains(fullPath))
{
continue;
}
// Only delete files that were generated by this tool (contain our marker)
string? firstLine;
using (var reader = new StreamReader(file))
{
firstLine = reader.ReadLine();
}
if (firstLine == marker)
{
File.Delete(file);
Console.WriteLine($" Deleted orphaned file: {file}");
deleted++;
}
}
return deleted;
}
// Writes content to path only if it differs from what's already there.
// Returns true if the file was written, false if unchanged or locked.
static bool WriteIfChanged(string filePath, string content)
{
if (File.Exists(filePath))
{
var existing = File.ReadAllText(filePath);
// Normalize line endings for comparison
if (existing.Replace("\r\n", "\n", StringComparison.Ordinal) == content.Replace("\r\n", "\n", StringComparison.Ordinal))
{
return false;
}
}
// Use FileShare.ReadWrite so IDE language servers holding a shared lock don't block us.
// FileMode.OpenOrCreate + SetLength(0) avoids the truncation that FileMode.Create requires
// (which needs exclusive access on Windows).
using var stream = new FileStream(filePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite);
stream.SetLength(0);
using var writer = new StreamWriter(stream, System.Text.Encoding.UTF8);
writer.Write(content);
return true;
}

View file

@ -0,0 +1,315 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.ValueObjectsGenerator;
/// <summary>
/// Generates .g.cs content for [StringValue] types.
/// The output is identical to what the Roslyn StringValues source generator produced.
/// </summary>
internal static class StringValueCodeGenerator
{
internal static string Generate(ValueObjectInfo info)
{
var structName = info.TypeName;
var namespaceName = info.Namespace;
var internalCtor = info.HasInternalConstructor;
var validationErrorChecks = BuildValidationErrorChecks(
info.HasMaxLength, info.HasAllowedCharacters, info.HasRegex,
info.HasAllowedCharSet, info.HasTryValidate);
var normalizeCall = info.HasNormalize
? """
s = Normalize(s);
if (string.IsNullOrWhiteSpace(s))
{
errors = ["Value is empty after normalization."];
return false;
}
"""
: string.Empty;
var parse = internalCtor ? string.Empty :
info.HasParse ? string.Empty :
info.HasTryParse
? AddOverridable(
$$"""
public static {{structName}} Create(string s)
{
if (!TryCreate(s, out var result))
{
throw new FormatException($"The value '{s}' is not a valid {{structName}}.");
}
return result;
}
""",
4)
: AddOverridable(
$$"""
public static {{structName}} Create(string s)
{
if (!TryCreate(s, out var result, out var errors))
{
throw new FormatException($"The value '{s}' is not a valid {{structName}}. {string.Join(" ", errors)}");
}
return result;
}
""",
4);
var tryParse = internalCtor ? string.Empty :
info.HasTryParse ? string.Empty : AddOverridable(
$$"""
public static bool TryCreate(string? s, [NotNullWhen(true)] out {{structName}}? result)
=> TryCreate(s, out result, out _);
public static bool TryCreate(string? s, [NotNullWhen(true)] out {{structName}}? result, [NotNullWhen(false)] out IReadOnlyList<string>? errors)
{
result = null;
errors = null;
if (string.IsNullOrWhiteSpace(s))
{
errors = ["A value is required."];
return false;
}
{{normalizeCall}}{{validationErrorChecks}}
result = new {{structName}}(s);
return true;
}
""",
4);
var toString = (info.GenerateToString && !info.HasToString)
? $$"""
public override string ToString() => Value;
""" : string.Empty;
var collectionsUsing = (!internalCtor && !info.HasTryParse) ? "using System.Collections.Generic;\n" : string.Empty;
// When the type's namespace differs from the root namespace (where IStringValue etc. live),
// we need an explicit using directive so the generated code can find the infrastructure types.
var needsRootNamespace = !info.HasInternalValue &&
!string.Equals(info.Namespace, info.RootNamespace, StringComparison.Ordinal);
var rootNamespaceUsing = needsRootNamespace
? $"using {info.RootNamespace};\n"
: string.Empty;
var equalityMethods = info.HasComparer
? $$"""
public virtual bool Equals({{structName}}? other) =>
other is not null && Comparer.Equals(Value, other.Value);
public override int GetHashCode() =>
Value is null ? 0 : Comparer.GetHashCode(Value);
"""
: string.Empty;
// When HasInternalConstructor: use internal constructor, skip implicit operator and ParseOrDefault
var loadFromStorage = info.HasLoadFromStorage ? string.Empty :
$"internal static {structName} Load(string value) => new {structName}(value);";
var ctorVisibility = internalCtor ? "internal" : "private";
// When HasInternalConstructor, the user provides the constructor in the hand-written partial — skip emitting it
var ctorDeclaration = internalCtor ? string.Empty :
$"// Constructor for controlled creation\n {ctorVisibility} {structName}(string value) => Value = value;";
var valueProperty = info.HasValue ? string.Empty : "public string Value { get; }";
var implicitOperatorAndParseOrDefault = internalCtor ? string.Empty :
$$"""
public static implicit operator {{structName}}(string value) => Create(value);
{{toString}}
public static {{structName}}? CreateOrDefault(string? input)
{
if (string.IsNullOrEmpty(input))
{
return null;
}
return Create(input);
}
""";
var toStringWhenInternal = internalCtor
? $$"""
{{toString}}
"""
: string.Empty;
var source = $$"""
// <auto-generated by="ValueObjectsGenerator"/>
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
{{collectionsUsing}}{{rootNamespaceUsing}}{{(!internalCtor && !info.HasTryParse ? "using System.Diagnostics.CodeAnalysis;\n" : string.Empty)}}
namespace {{namespaceName}};
{{(internalCtor || info.HasInternalValue ? string.Empty : $"[System.ComponentModel.TypeConverter(typeof(ValueOfTypeConverter<{structName}, string>))]\n")}}partial record {{structName}}{{(info.HasInternalValue ? string.Empty : (internalCtor ? " : IValueOf<string>" : $" : IStringValue<{structName}>"))}}
{
{{ctorDeclaration}}
{{valueProperty}}
{{parse}}
{{tryParse}}
{{implicitOperatorAndParseOrDefault}}{{toStringWhenInternal}}
{{loadFromStorage}}
{{equalityMethods}}
}
""";
return CollapseBlankLines(source.Replace("\r\n", "\n", StringComparison.Ordinal));
}
/// <summary>
/// Collapses runs of multiple blank lines into a single blank line and trims trailing whitespace from lines.
/// </summary>
internal static string CollapseBlankLines(string text)
{
var lines = text.Split('\n');
var result = new List<string>(lines.Length);
var previousWasBlank = false;
foreach (var line in lines)
{
var trimmed = line.TrimEnd();
var isBlank = trimmed.Length == 0;
if (isBlank && previousWasBlank)
{
continue;
}
result.Add(trimmed);
previousWasBlank = isBlank;
}
return string.Join("\n", result);
}
private static string BuildValidationErrorChecks(
bool hasMaxLength,
bool hasAllowedCharacters,
bool hasRegex,
bool hasAllowedCharSet,
bool hasTryValidate)
{
var hasAnyRule = hasMaxLength || hasAllowedCharacters || hasRegex || hasAllowedCharSet || hasTryValidate;
if (!hasAnyRule)
{
return string.Empty;
}
var checks = new List<string>();
checks.Add("""
var validationErrors = new List<string>();
""");
if (hasMaxLength)
{
checks.Add("""
if (s.Length > MaxLength)
{
validationErrors.Add($"Must not exceed {MaxLength} characters.");
}
""");
}
if (hasAllowedCharacters)
{
checks.Add("""
if (s.Any(c => !AllowedCharacters.Contains(c)))
{
validationErrors.Add("Must only contain allowed characters.");
}
""");
}
if (hasRegex)
{
checks.Add("""
if (!Regex().IsMatch(s))
{
validationErrors.Add("Must match the required pattern.");
}
""");
}
if (hasAllowedCharSet)
{
checks.Add("""
if (!AllowedCharSet.IsMatch(s))
{
validationErrors.Add("Must only contain allowed character types.");
}
""");
}
if (hasTryValidate)
{
checks.Add("""
if (!TryValidate(s, out var tryValidateErrors))
{
if (tryValidateErrors is { Count: > 0 })
{
validationErrors.AddRange(tryValidateErrors);
}
else
{
validationErrors.Add($"The value '{s}' is not valid.");
}
}
""");
}
checks.Add("""
if (validationErrors.Count > 0)
{
errors = validationErrors;
return false;
}
""");
return string.Join(string.Empty, checks);
}
/// <summary>
/// Adds indentation to each line (except the first) of the content block.
/// Mirrors the AddOverridable helper in the source generator.
/// </summary>
private static string AddOverridable(string content, int leadingSpaces)
{
var indent = new string(' ', leadingSpaces);
var lines = content.Split('\n');
var indentedLines = lines.Select((line, index) =>
{
if (index == 0)
{
return line;
}
return string.IsNullOrEmpty(line.TrimEnd()) ? line : indent + line;
});
return string.Join("\n", indentedLines);
}
}

View file

@ -0,0 +1,44 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.ValueObjectsGenerator;
/// <summary>
/// Describes the kind of value object: string-based or generic.
/// </summary>
internal enum ValueObjectKind
{
StringValue,
ValueOf
}
/// <summary>
/// All information needed to generate code for one value object type.
/// </summary>
internal sealed record ValueObjectInfo(
string SourceFilePath,
string TypeName,
string Namespace,
string RootNamespace,
ValueObjectKind Kind,
// ValueOf only — fully-qualified generic type argument, e.g. "global::System.Guid"
string? GenericTypeArgument,
// Whether the generic type argument is itself a value object (nested)
bool IsNestedValueObject,
bool HasMaxLength,
bool HasAllowedCharacters,
bool HasRegex,
bool HasAllowedCharSet,
bool HasTryValidate,
bool HasErrorMessage,
bool HasParse,
bool HasTryParse,
bool HasNormalize,
bool GenerateToString,
bool HasComparer,
bool HasInternalConstructor,
bool HasValue,
bool HasInternalValue,
bool HasLoadFromStorage,
bool HasToString
);

View file

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<AssemblyName>Duende.ValueObjectsGenerator</AssemblyName>
<RootNamespace>Duende.ValueObjectsGenerator</RootNamespace>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<!-- Suppress packaging-related settings inherited from src.props / Directory.Build.props -->
<IsRoslynComponent>false</IsRoslynComponent>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,29 @@
<Project>
<!--
Shared MSBuild target for running the ValueObjectsGenerator CLI tool.
To opt in, set <ValueObjectsGeneratorNamespace> in a PropertyGroup:
<PropertyGroup>
<ValueObjectsGeneratorNamespace>Duende.Platform.Storage</ValueObjectsGeneratorNamespace>
</PropertyGroup>
-->
<PropertyGroup Condition="'$(ValueObjectsGeneratorNamespace)' != ''">
<ValueObjectsGeneratorProject>$(MSBuildThisFileDirectory)ValueObjectsGenerator.csproj</ValueObjectsGeneratorProject>
</PropertyGroup>
<ItemGroup Condition="'$(ValueObjectsGeneratorNamespace)' != ''">
<ProjectReference Include="$(ValueObjectsGeneratorProject)" ReferenceOutputAssembly="false" />
</ItemGroup>
<Target Name="RunValueObjectsGenerator" BeforeTargets="BeforeBuild" DependsOnTargets="ResolveProjectReferences"
Condition="'$(ValueObjectsGeneratorNamespace)' != ''"
Inputs="@(Compile->WithMetadataValue('Extension', '.cs'));$(ValueObjectsGeneratorProject)"
Outputs="$(IntermediateOutputPath)ValueObjectsGenerator.stamp">
<Exec Command="dotnet run --no-build --project &quot;$(ValueObjectsGeneratorProject)&quot; -c $(Configuration) -- --path &quot;$(MSBuildProjectDirectory)&quot; --namespace $(ValueObjectsGeneratorNamespace)" />
<MakeDir Directories="$(IntermediateOutputPath)" />
<Touch Files="$(IntermediateOutputPath)ValueObjectsGenerator.stamp" AlwaysCreate="true" />
</Target>
</Project>

View file

@ -0,0 +1,198 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.ValueObjectsGenerator;
/// <summary>
/// Generates .g.cs content for [ValueOf&lt;T&gt;] types.
/// The output is identical to what the Roslyn ValueOf source generator produced.
/// </summary>
internal static class ValueOfCodeGenerator
{
internal static string Generate(ValueObjectInfo info)
{
var structName = info.TypeName;
var namespaceName = info.Namespace;
var genericTypeArgument = info.GenericTypeArgument ?? "global::System.Object";
var isNested = info.IsNestedValueObject;
var internalCtor = info.HasInternalConstructor;
// Validation check inside TryParse errors overload when TryValidate exists
var validationErrorCheck = info.HasTryValidate
? $$"""
if (!TryValidate(value, out var tryValidateErrors))
{
errors = tryValidateErrors is { Count: > 0 } ? tryValidateErrors : [$"The value is not a valid '{nameof({{structName}})}'."];
return false;
}
"""
: string.Empty;
// Implicit operator body
var implicitOperatorCheck = info.HasTryValidate
? AddOverridable($$"""
if (!TryValidate(value, out var errors))
{
var errorMessage = $"The value '{value}' is not a valid '{nameof({{structName}})}'. {string.Join(" ", errors ?? [])}";
throw new FormatException(errorMessage);
}
return new {{structName}}(value);
""", 8)
: $"return new {structName}(value);";
// Create method
var parse = internalCtor ? string.Empty :
info.HasParse ? string.Empty :
info.HasTryParse
? AddOverridable($$"""
public static {{structName}} Create(string s)
{
if (!TryCreate(s, out var result))
{
throw new FormatException($"The value '{s}' is not a valid '{nameof({{structName}})}'.");
}
return result;
}
""", 4)
: AddOverridable($$"""
public static {{structName}} Create(string s)
{
if (!TryCreate(s, out var result, out var errors))
{
throw new FormatException($"The value '{s}' is not a valid '{nameof({{structName}})}'. {string.Join(" ", errors)}");
}
return result;
}
""", 4);
// TryParse inner call varies on whether T is itself a value object
var tryParseInnerCall = isNested
? $"{genericTypeArgument}.TryCreate(s, out var value)"
: $"{genericTypeArgument}.TryParse(s, CultureInfo.InvariantCulture, out var value)";
// TryCreate method
var tryParse = internalCtor ? string.Empty :
info.HasTryParse ? string.Empty :
AddOverridable($$"""
public static bool TryCreate(string? s, [NotNullWhen(true)] out {{structName}}? result)
=> TryCreate(s, out result, out _);
public static bool TryCreate(string? s, [NotNullWhen(true)] out {{structName}}? result, [NotNullWhen(false)] out IReadOnlyList<string>? errors)
{
result = null;
errors = null;
if (string.IsNullOrWhiteSpace(s))
{
errors = ["A value is required."];
return false;
}
if ({{tryParseInnerCall}})
{{{validationErrorCheck}}
var instance = new {{structName}}(value);
result = instance;
return true;
}
errors = ["The value could not be parsed."];
return false;
}
""", 4);
// ToString override
var toString = (info.GenerateToString && !info.HasToString)
? (isNested
? "public override string ToString() => Value.ToString();"
: "public override string ToString() => Value.ToString(null, CultureInfo.InvariantCulture);")
: string.Empty;
var collectionsUsing = (!internalCtor && !info.HasTryParse) ? "using System.Collections.Generic;\n" : string.Empty;
var rootNamespaceUsing = !string.Equals(info.Namespace, info.RootNamespace, StringComparison.Ordinal)
? $"using {info.RootNamespace};\n"
: string.Empty;
var loadFromStorage = info.HasLoadFromStorage ? string.Empty :
$"internal static {structName} Load({genericTypeArgument} value) => new {structName}(value);";
var ctorVisibility = internalCtor ? "internal" : "private";
var valueProperty = info.HasValue ? string.Empty : $"public {genericTypeArgument} Value {{ get; }}";
var implicitOperatorAndParseOrDefault = internalCtor ? string.Empty :
$$"""
public static implicit operator {{structName}}({{genericTypeArgument}} value)
{
{{implicitOperatorCheck}}
}
{{toString}}
public static {{structName}}? CreateOrDefault(string? input)
{
if (string.IsNullOrEmpty(input))
{
return null;
}
return Create(input);
}
""";
var toStringWhenInternal = internalCtor
? $$"""
{{toString}}
"""
: string.Empty;
var source = $$"""
// <auto-generated by="ValueObjectsGenerator"/>
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
#nullable enable
{{collectionsUsing}}{{rootNamespaceUsing}}{{(internalCtor ? string.Empty : "using System.Globalization;\n")}}{{(!internalCtor && !info.HasTryParse ? "using System.Diagnostics.CodeAnalysis;\n" : string.Empty)}}
namespace {{namespaceName}};
{{(internalCtor ? string.Empty : $"[System.ComponentModel.TypeConverter(typeof(ValueOfTypeConverter<{structName}, {genericTypeArgument}>))]\n")}}partial record {{structName}} : {{(internalCtor ? $"IValueOf<{genericTypeArgument}>" : $"IValueOf<{structName}, {genericTypeArgument}>")}}
{
// Constructor for controlled creation
{{ctorVisibility}} {{structName}}({{genericTypeArgument}} value) => Value = value;
{{valueProperty}}
{{parse}}
{{tryParse}}
{{implicitOperatorAndParseOrDefault}}{{toStringWhenInternal}}
{{loadFromStorage}}
}
""";
return StringValueCodeGenerator.CollapseBlankLines(source.Replace("\r\n", "\n", StringComparison.Ordinal));
}
/// <summary>
/// Adds indentation to each line (except the first) of the content block.
/// </summary>
private static string AddOverridable(string content, int leadingSpaces)
{
var indent = new string(' ', leadingSpaces);
var lines = content.Split('\n');
var indentedLines = lines.Select((line, index) =>
{
if (index == 0)
{
return line;
}
return string.IsNullOrEmpty(line.TrimEnd()) ? line : indent + line;
});
return string.Join("\n", indentedLines);
}
}

View file

@ -1,5 +0,0 @@
<ProjectConfiguration>
<Settings>
<UseBuildConfiguration>Debug_NCrunch</UseBuildConfiguration>
</Settings>
</ProjectConfiguration>

View file

@ -8,6 +8,9 @@
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug_ncrunch|AnyCPU'">
<DefineConstants>$(DefineConstants);DEBUG_NCRUNCH</DefineConstants>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\AppHost.Abstractions\AppHost.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" />
<PackageReference Include="Aspire.Hosting.Testing" />

View file

@ -32,6 +32,10 @@
<PackageReference Include="MinVer" PrivateAssets="all" />
</ItemGroup>
<ItemGroup Condition="Exists('$(MSBuildProjectDirectory)\..\..\tools\PublicApi\PublicApi.csproj')">
<InternalsVisibleTo Include="Duende.PublicApi" />
</ItemGroup>
<ItemGroup>
<None Include="../../../icon.png" Pack="true" Visible="false" PackagePath="" />
<None Include="../../../LICENSE" Pack="true" PackagePath="" />

View file

@ -10,20 +10,20 @@
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
</PropertyGroup>
<PropertyGroup>
<NoWarn>$(NoWarn);CA1707</NoWarn><!-- Identifiers should not contain underscores -->
<NoWarn>$(NoWarn);CA1707;IDE1006</NoWarn><!-- CA1707: Identifiers should not contain underscores; IDE1006: Naming rule violation (async suffix) -->
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="MartinCostello.Logging.XUnit.v3" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" />
<PackageReference Include="Microsoft.Testing.Extensions.TrxReport" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="xunit.v3.core.mtp-v2" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="Xunit.CaptureConsole" />
</ItemGroup>
<ItemGroup>
<Using Include="Microsoft.Extensions.Time.Testing" />
<Using Include="Shouldly"/>