mirror of
https://github.com/DuendeSoftware/products
synced 2026-05-23 08:58:31 +00:00
Publish - 2026-05-21 18:39:51 UTC
This commit is contained in:
parent
f73fbebaf9
commit
d4a4e901f6
24 changed files with 1760 additions and 55 deletions
6
.config/dotnet-tools.json
vendored
6
.config/dotnet-tools.json
vendored
|
|
@ -7,6 +7,12 @@
|
|||
"commands": [
|
||||
"NuGetKeyVaultSignTool"
|
||||
]
|
||||
},
|
||||
"damianh.playwright.installtool": {
|
||||
"version": "0.2.0",
|
||||
"commands": [
|
||||
"playwright"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
14
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -43,4 +43,5 @@
|
|||
<ItemGroup>
|
||||
<Compile Include="$(MSBuildThisFileDirectory)shared/Global.cs" Link="Global.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>>();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@
|
|||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:3000",
|
||||
"commandLineArgs": "--with-http 3000",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
133
products.slnx
133
products.slnx
|
|
@ -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>
|
||||
|
|
|
|||
8
shared/AppHost.Abstractions/AppHost.Abstractions.csproj
Normal file
8
shared/AppHost.Abstractions/AppHost.Abstractions.csproj
Normal 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>
|
||||
399
shared/ValueObjectsGenerator/FileScanner.cs
Normal file
399
shared/ValueObjectsGenerator/FileScanner.cs
Normal 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<T>] 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
|
||||
);
|
||||
}
|
||||
221
shared/ValueObjectsGenerator/InfrastructureCodeGenerator.cs
Normal file
221
shared/ValueObjectsGenerator/InfrastructureCodeGenerator.cs
Normal 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);
|
||||
}
|
||||
175
shared/ValueObjectsGenerator/Program.cs
Normal file
175
shared/ValueObjectsGenerator/Program.cs
Normal 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;
|
||||
}
|
||||
315
shared/ValueObjectsGenerator/StringValueCodeGenerator.cs
Normal file
315
shared/ValueObjectsGenerator/StringValueCodeGenerator.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
44
shared/ValueObjectsGenerator/ValueObjectInfo.cs
Normal file
44
shared/ValueObjectsGenerator/ValueObjectInfo.cs
Normal 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
|
||||
);
|
||||
19
shared/ValueObjectsGenerator/ValueObjectsGenerator.csproj
Normal file
19
shared/ValueObjectsGenerator/ValueObjectsGenerator.csproj
Normal 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>
|
||||
29
shared/ValueObjectsGenerator/ValueObjectsGenerator.targets
Normal file
29
shared/ValueObjectsGenerator/ValueObjectsGenerator.targets
Normal 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 "$(ValueObjectsGeneratorProject)" -c $(Configuration) -- --path "$(MSBuildProjectDirectory)" --namespace $(ValueObjectsGeneratorNamespace)" />
|
||||
<MakeDir Directories="$(IntermediateOutputPath)" />
|
||||
<Touch Files="$(IntermediateOutputPath)ValueObjectsGenerator.stamp" AlwaysCreate="true" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
198
shared/ValueObjectsGenerator/ValueOfCodeGenerator.cs
Normal file
198
shared/ValueObjectsGenerator/ValueOfCodeGenerator.cs
Normal 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<T>] 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<ProjectConfiguration>
|
||||
<Settings>
|
||||
<UseBuildConfiguration>Debug_NCrunch</UseBuildConfiguration>
|
||||
</Settings>
|
||||
</ProjectConfiguration>
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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="" />
|
||||
|
|
|
|||
10
test.props
10
test.props
|
|
@ -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"/>
|
||||
|
|
|
|||
Loading…
Reference in a new issue