diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 1ea259456..b19bbf995 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -7,6 +7,12 @@ "commands": [ "NuGetKeyVaultSignTool" ] + }, + "damianh.playwright.installtool": { + "version": "0.2.0", + "commands": [ + "playwright" + ] } } } \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index 818677a79..ab68ac41e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -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 diff --git a/.gitignore b/.gitignore index 8ca708e45..e7f6e2857 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Directory.Build.props b/Directory.Build.props index e35d7d54a..dbaf49611 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -43,4 +43,5 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index 7ba895698..546d307fd 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,21 +1,22 @@ - - - + + + + - + - + + - @@ -26,23 +27,26 @@ - + + + - + + @@ -51,8 +55,14 @@ + + + + + + - + @@ -67,7 +77,8 @@ - + + @@ -80,16 +91,20 @@ - + + + - - - - - - - - + + + + + + + + + + @@ -105,11 +120,13 @@ + + diff --git a/docs-mcp/README.md b/docs-mcp/README.md index 84bfc6108..8c9dd5758 100644 --- a/docs-mcp/README.md +++ b/docs-mcp/README.md @@ -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 ` | 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 { diff --git a/docs-mcp/src/Documentation.Mcp/Documentation.Mcp.csproj b/docs-mcp/src/Documentation.Mcp/Documentation.Mcp.csproj index fa5c978de..5ff218f3a 100644 --- a/docs-mcp/src/Documentation.Mcp/Documentation.Mcp.csproj +++ b/docs-mcp/src/Documentation.Mcp/Documentation.Mcp.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -16,6 +16,10 @@ $(NoWarn);CA1873 $(NoWarn);CA1812 + + $(NoWarn);CA1303 + + $(NoWarn);CA1515 @@ -31,5 +35,6 @@ + diff --git a/docs-mcp/src/Documentation.Mcp/Program.cs b/docs-mcp/src/Documentation.Mcp/Program.cs index e4b44d675..9b90a919b 100644 --- a/docs-mcp/src/Documentation.Mcp/Program.cs +++ b/docs-mcp/src/Documentation.Mcp/Program.cs @@ -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(); +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("Data Source=" + databasePath + ";Cache=Shared"); @@ -43,7 +103,7 @@ builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); -builder.Services +var mcpServerBuilder = builder.Services .AddMcpServer(options => { options.ServerInfo = new Implementation @@ -70,17 +130,41 @@ builder.Services .WithTools() .WithTools() .WithTools() - .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>(); + } +} diff --git a/docs-mcp/src/Documentation.Mcp/Properties/launchSettings.json b/docs-mcp/src/Documentation.Mcp/Properties/launchSettings.json index 7bb5589f5..84c33fdaf 100644 --- a/docs-mcp/src/Documentation.Mcp/Properties/launchSettings.json +++ b/docs-mcp/src/Documentation.Mcp/Properties/launchSettings.json @@ -5,10 +5,10 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "http://localhost:3000", + "commandLineArgs": "--with-http 3000", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } -} \ No newline at end of file +} diff --git a/products.slnx b/products.slnx index fde8d558d..8b8803f2f 100644 --- a/products.slnx +++ b/products.slnx @@ -1,16 +1,33 @@ - + + + + + + + + + + + + + + + + + + @@ -21,6 +38,7 @@ + @@ -48,6 +66,7 @@ + @@ -60,24 +79,36 @@ + + + + + + + - + + + + + + @@ -145,7 +176,9 @@ + + @@ -158,18 +191,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/shared/AppHost.Abstractions/AppHost.Abstractions.csproj b/shared/AppHost.Abstractions/AppHost.Abstractions.csproj new file mode 100644 index 000000000..6dcb12a24 --- /dev/null +++ b/shared/AppHost.Abstractions/AppHost.Abstractions.csproj @@ -0,0 +1,8 @@ + + + None + Duende.AppHost.Abstractions + Duende.Xunit.Playwright + false + + diff --git a/shared/Xunit.Playwright/IAppHostServiceRoutes.cs b/shared/AppHost.Abstractions/IAppHostServiceRoutes.cs similarity index 100% rename from shared/Xunit.Playwright/IAppHostServiceRoutes.cs rename to shared/AppHost.Abstractions/IAppHostServiceRoutes.cs diff --git a/shared/ValueObjectsGenerator/FileScanner.cs b/shared/ValueObjectsGenerator/FileScanner.cs new file mode 100644 index 000000000..1f3ad7be0 --- /dev/null +++ b/shared/ValueObjectsGenerator/FileScanner.cs @@ -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; + +/// +/// Scans a directory for C# files containing value object types annotated with +/// [StringValue] or [ValueOf<T>] and builds ValueObjectInfo descriptors. +/// +internal static class FileScanner +{ + // Well-known C# keyword → fully-qualified global type name mappings + private static readonly Dictionary 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", + }; + + /// + /// Scans recursively and returns all discovered value objects. + /// + internal static IReadOnlyList 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(); + 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(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(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 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()) + { + 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 members, string typeName) => + members.OfType().Any(c => + c.Identifier.Text == typeName && + c.Modifiers.Any(SyntaxKind.InternalKeyword)); + + private static bool HasMember(SyntaxList members, string name) => + members.Any(m => GetMemberName(m) == name); + + private static bool HasInternalProperty(SyntaxList members, string name) => + members.OfType() + .Any(p => p.Identifier.Text == name && + p.Modifiers.Any(m => m.IsKind(SyntaxKind.InternalKeyword))); + + private static bool HasMethod(SyntaxList members, string name) => + members.OfType().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; + } + + /// + /// Resolves a raw generic type argument string to a fully-qualified global:: name. + /// + private static string ResolveGenericTypeArgument( + string rawArg, + string currentNamespace, + IReadOnlyList usingNamespaces, + Dictionary 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 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 + ); +} diff --git a/shared/ValueObjectsGenerator/InfrastructureCodeGenerator.cs b/shared/ValueObjectsGenerator/InfrastructureCodeGenerator.cs new file mode 100644 index 000000000..549066f4f --- /dev/null +++ b/shared/ValueObjectsGenerator/InfrastructureCodeGenerator.cs @@ -0,0 +1,221 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.ValueObjectsGenerator; + +/// +/// 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. +/// +internal static class InfrastructureCodeGenerator +{ + internal static string GetInterfaceSource(string rootNamespace) => NormalizeLineEndings($$""" + // + // Copyright (c) Duende Software. All rights reserved. + // See LICENSE in the project root for license information. + #nullable enable + + using System.Diagnostics.CodeAnalysis; + + namespace {{rootNamespace}}; + + /// + /// Provides type-safe access to the underlying value of a value object. + /// + /// The type of the underlying value. + internal interface IValueOf + { + /// + /// Gets the underlying value. + /// + T Value { get; } + } + + /// + /// Provides type-safe access to the underlying value of a value object, + /// along with creation capabilities. + /// + /// The value object type itself. + /// The type of the underlying value. + internal interface IValueOf : IValueOf where TSelf : IValueOf + { + /// + /// Creates a value object from a string representation. + /// + static abstract TSelf Create(string s); + + /// + /// Tries to create a value object from a string representation. + /// + static abstract bool TryCreate(string? s, [NotNullWhen(true)] out TSelf? result); + } + + internal interface IStringValue : IValueOf where TSelf : IStringValue + { + } + + """); + + internal static string GetCharSetsSource(string rootNamespace) => NormalizeLineEndings($$""" + // + // 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}}; + + /// + /// Extension methods for CharSet validation + /// + internal static class CharSetExtensions + { + /// + /// Defines the set of symbols for the 'Symbols' flag. + /// + private const string SymbolSet = "!@#$%^&*()_+-=[]{}|;:',.<>/?"; + + /// + /// Gets the combined string of all allowed characters for a given CharSet. + /// + 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(); + } + + /// + /// Checks if a string contains ONLY characters defined by the CharSet. + /// + /// The charset to validate against. + /// The string to check. + /// True if the string is null, empty, or contains only allowed characters. False otherwise. + 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($$""" + // + // 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}}; + + /// + /// Generic type converter for value objects. + /// Enables ASP.NET Core model binding and IConfiguration support. + /// + internal class ValueOfTypeConverter : TypeConverter where TValue : IValueOf + { + 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); +} diff --git a/shared/ValueObjectsGenerator/Program.cs b/shared/ValueObjectsGenerator/Program.cs new file mode 100644 index 000000000..99345497f --- /dev/null +++ b/shared/ValueObjectsGenerator/Program.cs @@ -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 --namespace "); + 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(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 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 expectedFiles) +{ + const string marker = "// "; + 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; +} diff --git a/shared/ValueObjectsGenerator/StringValueCodeGenerator.cs b/shared/ValueObjectsGenerator/StringValueCodeGenerator.cs new file mode 100644 index 000000000..698ee8361 --- /dev/null +++ b/shared/ValueObjectsGenerator/StringValueCodeGenerator.cs @@ -0,0 +1,315 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.ValueObjectsGenerator; + +/// +/// Generates .g.cs content for [StringValue] types. +/// The output is identical to what the Roslyn StringValues source generator produced. +/// +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? 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 = $$""" + // + // 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" : $" : IStringValue<{structName}>"))}} + { + {{ctorDeclaration}} + + {{valueProperty}} + + {{parse}} + + {{tryParse}} + {{implicitOperatorAndParseOrDefault}}{{toStringWhenInternal}} + + {{loadFromStorage}} + {{equalityMethods}} + } + + """; + + return CollapseBlankLines(source.Replace("\r\n", "\n", StringComparison.Ordinal)); + } + + /// + /// Collapses runs of multiple blank lines into a single blank line and trims trailing whitespace from lines. + /// + internal static string CollapseBlankLines(string text) + { + var lines = text.Split('\n'); + var result = new List(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(); + + checks.Add(""" + + var validationErrors = new List(); + """); + + 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); + } + + /// + /// Adds indentation to each line (except the first) of the content block. + /// Mirrors the AddOverridable helper in the source generator. + /// + 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); + } +} diff --git a/shared/ValueObjectsGenerator/ValueObjectInfo.cs b/shared/ValueObjectsGenerator/ValueObjectInfo.cs new file mode 100644 index 000000000..27646d0c2 --- /dev/null +++ b/shared/ValueObjectsGenerator/ValueObjectInfo.cs @@ -0,0 +1,44 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.ValueObjectsGenerator; + +/// +/// Describes the kind of value object: string-based or generic. +/// +internal enum ValueObjectKind +{ + StringValue, + ValueOf +} + +/// +/// All information needed to generate code for one value object type. +/// +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 +); diff --git a/shared/ValueObjectsGenerator/ValueObjectsGenerator.csproj b/shared/ValueObjectsGenerator/ValueObjectsGenerator.csproj new file mode 100644 index 000000000..00eaed26e --- /dev/null +++ b/shared/ValueObjectsGenerator/ValueObjectsGenerator.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + Duende.ValueObjectsGenerator + Duende.ValueObjectsGenerator + enable + enable + false + + false + + + + + + + diff --git a/shared/ValueObjectsGenerator/ValueObjectsGenerator.targets b/shared/ValueObjectsGenerator/ValueObjectsGenerator.targets new file mode 100644 index 000000000..4887fca35 --- /dev/null +++ b/shared/ValueObjectsGenerator/ValueObjectsGenerator.targets @@ -0,0 +1,29 @@ + + + + + $(MSBuildThisFileDirectory)ValueObjectsGenerator.csproj + + + + + + + + + + + + + diff --git a/shared/ValueObjectsGenerator/ValueOfCodeGenerator.cs b/shared/ValueObjectsGenerator/ValueOfCodeGenerator.cs new file mode 100644 index 000000000..65ff914fd --- /dev/null +++ b/shared/ValueObjectsGenerator/ValueOfCodeGenerator.cs @@ -0,0 +1,198 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.ValueObjectsGenerator; + +/// +/// Generates .g.cs content for [ValueOf<T>] types. +/// The output is identical to what the Roslyn ValueOf source generator produced. +/// +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? 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 = $$""" + // + // 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)); + } + + /// + /// Adds indentation to each line (except the first) of the content block. + /// + 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); + } +} diff --git a/shared/Xunit.Playwright/Duende.Xunit.Playwright.v3.ncrunchproject b/shared/Xunit.Playwright/Duende.Xunit.Playwright.v3.ncrunchproject deleted file mode 100644 index f36abf246..000000000 --- a/shared/Xunit.Playwright/Duende.Xunit.Playwright.v3.ncrunchproject +++ /dev/null @@ -1,5 +0,0 @@ - - - Debug_NCrunch - - \ No newline at end of file diff --git a/shared/Xunit.Playwright/Xunit.Playwright.csproj b/shared/Xunit.Playwright/Xunit.Playwright.csproj index e9e37cea5..44b26d8b2 100644 --- a/shared/Xunit.Playwright/Xunit.Playwright.csproj +++ b/shared/Xunit.Playwright/Xunit.Playwright.csproj @@ -8,6 +8,9 @@ $(DefineConstants);DEBUG_NCRUNCH + + + diff --git a/src.props b/src.props index a2d731bb9..6f765d651 100644 --- a/src.props +++ b/src.props @@ -32,6 +32,10 @@ + + + + diff --git a/test.props b/test.props index 77c540734..4311d162d 100644 --- a/test.props +++ b/test.props @@ -10,20 +10,20 @@ true - $(NoWarn);CA1707 + $(NoWarn);CA1707;IDE1006 - - - - + + + +