using System.Collections.Immutable;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
using Sbroenne.ExcelMcp.Generators.Common;
namespace Sbroenne.ExcelMcp.Generators;
/// <summary>
/// Generates ServiceRegistry constants and DTOs from Core command interfaces
/// marked with [ServiceCategory] attribute.
/// </summary>
[Generator]
public class ServiceRegistryGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Find all interfaces with [ServiceCategory] attribute
var interfaceDeclarations = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: static (s, _) => SyntaxHelper.IsInterfaceWithAttributes(s),
transform: static (ctx, _) => SyntaxHelper.GetServiceInterfaceOrNull(ctx))
.Where(static m => m is not null);
// Combine with compilation
var compilationAndInterfaces = context.CompilationProvider.Combine(interfaceDeclarations.Collect());
// Generate source
context.RegisterSourceOutput(compilationAndInterfaces,
static (spc, source) => Execute(source.Left, source.Right!, spc));
}
private static void Execute(Compilation compilation, ImmutableArray<InterfaceDeclarationSyntax?> interfaces, SourceProductionContext context)
{
if (interfaces.IsDefaultOrEmpty)
return;
var distinctInterfaces = interfaces
.Where(i => i is not null)
.Cast<InterfaceDeclarationSyntax>()
.Distinct()
.ToList();
if (distinctInterfaces.Count == 0)
return;
var allCategories = new List<ServiceInfo>();
foreach (var interfaceDecl in distinctInterfaces)
{
var model = compilation.GetSemanticModel(interfaceDecl.SyntaxTree);
var interfaceSymbol = model.GetDeclaredSymbol(interfaceDecl) as INamedTypeSymbol;
if (interfaceSymbol is null)
continue;
// Use shared extractor
var info = ServiceInfoExtractor.ExtractServiceInfo(interfaceSymbol);
if (info is null)
continue;
allCategories.Add(info);
// Get fully-qualified interface name for dispatch generation
var interfaceFullName = interfaceSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
if (interfaceFullName.StartsWith("global::"))
interfaceFullName = interfaceFullName.Substring(8);
// Generate ServiceRegistry partial for this category
var registryCode = GenerateServiceRegistry(info);
context.AddSource($"ServiceRegistry.{info.CategoryPascal}.g.cs", SourceText.From(registryCode, Encoding.UTF8));
// Generate service dispatch for this category
var dispatchCode = GenerateServiceDispatch(info, interfaceFullName);
context.AddSource($"ServiceRegistry.{info.CategoryPascal}.Dispatch.g.cs", SourceText.From(dispatchCode, Encoding.UTF8));
// Generate comparison file for validation (existing vs generated)
var comparisonCode = GenerateComparisonFile(info);
context.AddSource($"Comparison.{info.CategoryPascal}.g.cs", SourceText.From(comparisonCode, Encoding.UTF8));
}
// Emit a manifest file for the CLI generator to consume
if (allCategories.Count > 0)
{
var manifestCode = GenerateCliManifest(allCategories);
context.AddSource("_CliCategories.g.cs", SourceText.From(manifestCode, Encoding.UTF8));
// Emit a JSON manifest for skill documentation generation
var skillManifestCode = GenerateSkillManifest(allCategories);
context.AddSource("_SkillManifest.g.cs", SourceText.From(skillManifestCode, Encoding.UTF8));
// Emit shared dispatch helper methods (DeserializeArgs, ParseEnumValue, etc.)
var helpersCode = GenerateDispatchHelpers();
context.AddSource("ServiceRegistry.DispatchHelpers.g.cs", SourceText.From(helpersCode, Encoding.UTF8));
}
}
// =====================================================
// Code Generation Methods (local to this generator)
// =====================================================
private static string GenerateServiceRegistry(ServiceInfo info)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated />");;
sb.AppendLine("#nullable enable");
sb.AppendLine("#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member");
sb.AppendLine();
sb.AppendLine("namespace Sbroenne.ExcelMcp.Generated;");
sb.AppendLine();
// Generate the Action enum with kebab-case JSON names
sb.AppendLine("/// <summary>");
sb.AppendLine($"/// Generated action enum for {info.Category} operations.");
sb.AppendLine("/// </summary>");
sb.AppendLine("[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter<" + info.CategoryPascal + "Action>))]");
sb.AppendLine($"public enum {info.CategoryPascal}Action");
sb.AppendLine("{");
for (int i = 0; i < info.Methods.Count; i++)
{
var method = info.Methods[i];
var comma = i < info.Methods.Count - 1 ? "," : "";
sb.AppendLine($" [System.Text.Json.Serialization.JsonStringEnumMemberName(\"{method.ActionName}\")]");
sb.AppendLine($" {method.MethodName}{comma}");
}
sb.AppendLine("}");
sb.AppendLine();
sb.AppendLine("/// <summary>");
sb.AppendLine($"/// Generated service registry for {info.Category} operations.");
sb.AppendLine("/// </summary>");
sb.AppendLine($"public static partial class ServiceRegistry");
sb.AppendLine("{");
sb.AppendLine($" public static partial class {info.CategoryPascal}");
sb.AppendLine(" {");
sb.AppendLine($" public const string Category = \"{info.Category}\";");
sb.AppendLine($" public const string McpToolName = \"{info.McpToolName}\";");
sb.AppendLine($" public const bool RequiresSession = {(info.NoSession ? "false" : "true")};");
// CLI command name: remove underscores from tool name
var cliCommandName = info.McpToolName.Replace("_", "");
sb.AppendLine($" public const string CliCommandName = \"{cliCommandName}\";");
sb.AppendLine();
// Generate action constants
sb.AppendLine(" // Action constants");
foreach (var method in info.Methods)
{
sb.AppendLine($" public const string {method.MethodName}Action = \"{method.ActionName}\";");
}
sb.AppendLine();
// Generate full command strings
sb.AppendLine(" // Full command strings (category.action)");
foreach (var method in info.Methods)
{
sb.AppendLine($" public const string {method.MethodName}Command = \"{info.Category}.{method.ActionName}\";");
}
sb.AppendLine();
// Generate ValidActions array for CLI help/validation
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// All valid action strings for this category.");
sb.AppendLine(" /// </summary>");
var actionList = string.Join(", ", info.Methods.Select(m => $"\"{m.ActionName}\""));
sb.AppendLine($" public static readonly string[] ValidActions = [{actionList}];");
sb.AppendLine();
// Generate Description constant for CLI help
var escapedDescription = (info.XmlDocSummary ?? $"{info.CategoryPascal} operations")
.Replace("\\", "\\\\")
.Replace("\"", "\\\"")
.Replace("\r", "")
.Replace("\n", " ")
.Trim();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Human-readable description from interface XML documentation.");
sb.AppendLine(" /// </summary>");
sb.AppendLine($" public const string Description = \"{escapedDescription}\";");
sb.AppendLine();
// Generate TryParseAction method for this category
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Parses a kebab-case action string to the strongly-typed enum.");
sb.AppendLine(" /// </summary>");
sb.AppendLine($" public static bool TryParseAction(string actionString, out {info.CategoryPascal}Action action)");
sb.AppendLine(" {");
sb.AppendLine(" action = default;");
sb.AppendLine(" return actionString switch");
sb.AppendLine(" {");
foreach (var method in info.Methods)
{
sb.AppendLine($" \"{method.ActionName}\" => SetAndReturn(out action, {info.CategoryPascal}Action.{method.MethodName}),");
}
sb.AppendLine(" _ => false");
sb.AppendLine(" };");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" private static bool SetAndReturn<T>(out T action, T value)");
sb.AppendLine(" {");
sb.AppendLine(" action = value;");
sb.AppendLine(" return true;");
sb.AppendLine(" }");
sb.AppendLine();
// Generate ToActionString method for this category
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Converts the action enum to its kebab-case string representation.");
sb.AppendLine(" /// </summary>");
sb.AppendLine($" public static string ToActionString({info.CategoryPascal}Action action)");
sb.AppendLine(" {");
sb.AppendLine(" return action switch");
sb.AppendLine(" {");
foreach (var method in info.Methods)
{
sb.AppendLine($" {info.CategoryPascal}Action.{method.MethodName} => \"{method.ActionName}\",");
}
sb.AppendLine($" _ => throw new System.ArgumentException($\"Unknown {info.CategoryPascal}Action: {{action}}\")");
sb.AppendLine(" };");
sb.AppendLine(" }");
sb.AppendLine();
// Generate request DTOs for methods with parameters
foreach (var method in info.Methods.Where(m => m.Parameters.Count > 0))
{
sb.AppendLine($" /// <summary>Request DTO for {method.ActionName} action</summary>");
sb.AppendLine($" public sealed record {method.MethodName}Request(");
var paramLines = method.Parameters.Select((p, i) =>
{
var suffix = i < method.Parameters.Count - 1 ? "," : "";
if (p.HasDefault)
{
return $" {p.TypeName} {StringHelper.ToPascalCase(p.Name)} = {p.DefaultValue}{suffix}";
}
return $" {p.TypeName} {StringHelper.ToPascalCase(p.Name)}{suffix}";
});
sb.AppendLine(string.Join("\r\n", paramLines));
sb.AppendLine(" );");
sb.AppendLine();
}
// Generate service args classes for JSON deserialization
sb.AppendLine(" // ============================================");
sb.AppendLine(" // Service Args Classes (generated)");
sb.AppendLine(" // ============================================");
sb.AppendLine();
foreach (var method in info.Methods)
{
GenerateArgsClass(sb, method);
}
// Generate MCP forward methods
sb.AppendLine(" // ============================================");
sb.AppendLine(" // MCP Forward Methods (generated)");
sb.AppendLine(" // ============================================");
sb.AppendLine();
foreach (var method in info.Methods)
{
GenerateForwardMethod(sb, info, method);
}
// Generate the RouteAction switch
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Routes an action to the appropriate forward method.");
sb.AppendLine(" /// </summary>");
sb.AppendLine($" public static string RouteAction(");
sb.AppendLine($" {info.CategoryPascal}Action action,");
sb.AppendLine($" string sessionId,");
sb.AppendLine($" System.Func<string, string, object?, string> forwardToService,");
// Collect all unique exposed parameters across all methods
var allExposedParams = GetAllExposedParameters(info);
for (int i = 0; i < allExposedParams.Count; i++)
{
var p = allExposedParams[i];
var comma = i < allExposedParams.Count - 1 ? "," : ")";
sb.AppendLine($" {p.TypeName} {p.Name} = {p.DefaultValue ?? "null"}{comma}");
}
sb.AppendLine(" {");
sb.AppendLine(" return action switch");
sb.AppendLine(" {");
foreach (var method in info.Methods)
{
var forwardArgs = BuildForwardArgs(method, allExposedParams);
sb.AppendLine($" {info.CategoryPascal}Action.{method.MethodName} => Forward{method.MethodName}(sessionId, forwardToService{forwardArgs}),");
}
sb.AppendLine($" _ => throw new System.ArgumentException($\"Unknown action: {{action}}\")");
sb.AppendLine(" };");
sb.AppendLine(" }");
sb.AppendLine();
// Generate CLI RouteCliArgs method
GenerateCliRouteMethod(sb, info);
sb.AppendLine(" }");
sb.AppendLine("}");
return sb.ToString();
}
private static void GenerateCliRouteMethod(StringBuilder sb, ServiceInfo info)
{
sb.AppendLine(" // ============================================");
sb.AppendLine(" // CLI Routing (generated)");
sb.AppendLine(" // ============================================");
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Builds the args object for a CLI action.");
sb.AppendLine(" /// Returns (command, args) tuple for sending to service.");
sb.AppendLine(" /// </summary>");
sb.AppendLine($" public static (string Command, object? Args) RouteCliArgs(");
sb.AppendLine($" string action,");
// Collect all unique exposed parameters
var allExposedParams = GetAllExposedParameters(info);
for (int i = 0; i < allExposedParams.Count; i++)
{
var p = allExposedParams[i];
var comma = i < allExposedParams.Count - 1 ? "," : ")";
sb.AppendLine($" {p.TypeName} {p.Name} = {p.DefaultValue ?? "null"}{comma}");
}
sb.AppendLine(" {");
sb.AppendLine($" var command = $\"{info.Category}.{{action}}\";");
sb.AppendLine();
// Generate switch statement with per-action validation
sb.AppendLine(" switch (action)");
sb.AppendLine(" {");
foreach (var method in info.Methods)
{
bool hasFileOrValue = method.Parameters.Any(p => p.IsFileOrValue);
sb.AppendLine($" case \"{method.ActionName}\":");
// Use block scope when FileOrValue resolution generates local variables
// to avoid duplicate variable names across switch cases
if (hasFileOrValue)
sb.AppendLine(" {");
// Emit RequireNotEmpty for required string parameters (skip FileOrValue - they validate after resolution)
foreach (var p in method.Parameters.Where(p => !p.IsFileOrValue && (p.IsRequired || (!p.HasDefault && !p.TypeName.EndsWith("?"))) && StringHelper.IsStringType(p.TypeName)))
{
var paramName = p.IsFromString && p.IsEnum ? (p.ExposedName ?? p.Name) : p.Name;
sb.AppendLine($" Sbroenne.ExcelMcp.Core.Utilities.ParameterTransforms.RequireNotEmpty({paramName}, \"{paramName}\", \"{method.ActionName}\");");
}
// Resolve FileOrValue parameters (read file content if file path provided)
foreach (var p in method.Parameters.Where(p => p.IsFileOrValue))
{
sb.AppendLine($" var resolved{StringHelper.ToPascalCase(p.Name)} = Sbroenne.ExcelMcp.Core.Utilities.ParameterTransforms.ResolveFileOrValue({p.Name}, {p.Name}{p.FileSuffix});");
// Validate required FileOrValue parameters AFTER resolution
if (p.IsRequired || (!p.HasDefault && !p.TypeName.EndsWith("?")))
{
sb.AppendLine($" Sbroenne.ExcelMcp.Core.Utilities.ParameterTransforms.RequireNotEmpty(resolved{StringHelper.ToPascalCase(p.Name)}, \"{p.Name}\", \"{method.ActionName}\");");
}
}
var argsExpr = BuildCliArgsExpression(method);
sb.AppendLine($" return (command, {argsExpr});");
if (hasFileOrValue)
sb.AppendLine(" }");
}
sb.AppendLine($" default:");
sb.AppendLine($" throw new System.ArgumentException($\"Unknown action: {{action}}\");");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine();
// Generate RouteFromSettings (bridges CliSettings → RouteCliArgs)
GenerateRouteFromSettings(sb, info, allExposedParams);
// Generate CLI Settings class
GenerateCliSettings(sb, info, allExposedParams);
}
private static void GenerateRouteFromSettings(StringBuilder sb, ServiceInfo info, List<ExposedParameter> allExposedParams)
{
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Routes a CLI action using CliSettings (bridges Settings → RouteCliArgs).");
sb.AppendLine(" /// Used by generated CLI command classes.");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public static (string Command, object? Args) RouteFromSettings(string action, CliSettings settings)");
sb.AppendLine(" {");
sb.AppendLine(" return RouteCliArgs(");
sb.AppendLine(" action,");
for (int i = 0; i < allExposedParams.Count; i++)
{
var p = allExposedParams[i];
var comma = i < allExposedParams.Count - 1 ? "," : ");";
if (IsNestedCollectionType(p.TypeName))
{
// CLI Settings stores nested collections as string (JSON).
// Deserialize back to the original type for RouteCliArgs.
// Uses DeserializeNestedCollection which auto-wraps 1D arrays to 2D
// (e.g., ["a","b"] → [["a","b"]]) for better LLM compatibility.
var nonNullableType = p.TypeName.TrimEnd('?');
sb.AppendLine($" {p.Name}: !string.IsNullOrWhiteSpace(settings.{StringHelper.ToPascalCase(p.Name)}) ? ServiceRegistry.DeserializeNestedCollection<{nonNullableType}>(settings.{StringHelper.ToPascalCase(p.Name)}) : null{comma}");
}
else if (IsSimpleListType(p.TypeName))
{
// CLI Settings stores simple lists as string (JSON) for LLM compatibility.
// LLMs pass JSON arrays (e.g., '["a","b"]') instead of repeated CLI flags.
// Deserialize back to the original type for RouteCliArgs.
var nonNullableType = p.TypeName.TrimEnd('?');
sb.AppendLine($" {p.Name}: !string.IsNullOrWhiteSpace(settings.{StringHelper.ToPascalCase(p.Name)}) ? ServiceRegistry.DeserializeList<{nonNullableType}>(settings.{StringHelper.ToPascalCase(p.Name)}) : null{comma}");
}
else
{
sb.AppendLine($" {p.Name}: settings.{StringHelper.ToPascalCase(p.Name)}{comma}");
}
}
sb.AppendLine(" }");
sb.AppendLine();
}
/// <summary>
/// Detects nested collection types (e.g., List<List<object>>) that cannot be
/// parsed by Spectre.Console from CLI arguments. These are emitted as string? in CLI Settings
/// and deserialized from JSON in RouteFromSettings.
/// </summary>
private static bool IsNestedCollectionType(string typeName)
{
// Check for List<List<...>> pattern (possibly with global:: prefix and namespace qualifiers)
var idx = typeName.IndexOf("List<", StringComparison.Ordinal);
if (idx < 0) return false;
var afterFirst = typeName.IndexOf("List<", idx + 5, StringComparison.Ordinal);
return afterFirst >= 0;
}
/// <summary>
/// Detects simple list types (e.g., List<string>) that are NOT nested collections.
/// LLMs often pass JSON arrays (e.g., '["a","b"]') instead of repeated CLI flags
/// (--flag a --flag b), so these are emitted as string? in CLI Settings and deserialized
/// from JSON in RouteFromSettings.
/// </summary>
private static bool IsSimpleListType(string typeName)
{
return !IsNestedCollectionType(typeName) &&
typeName.IndexOf("List<", StringComparison.Ordinal) >= 0;
}
private static void GenerateCliSettings(StringBuilder sb, ServiceInfo info, List<ExposedParameter> allParams)
{
// Note: These types require Spectre.Console reference in consuming project
sb.AppendLine(" /// <summary>");
sb.AppendLine($" /// Generated CLI settings for {info.CategoryPascal} commands.");
sb.AppendLine(" /// Requires Spectre.Console package reference.");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public sealed class CliSettings : Spectre.Console.Cli.CommandSettings");
sb.AppendLine(" {");
// Action argument (always first)
sb.AppendLine(" [Spectre.Console.Cli.CommandArgument(0, \"<ACTION>\")]");
sb.AppendLine(" [System.ComponentModel.Description(\"The action to perform\")]");
sb.AppendLine(" public string Action { get; init; } = string.Empty;");
sb.AppendLine();
// Session ID (always required for session-based tools)
if (!info.NoSession)
{
sb.AppendLine(" [Spectre.Console.Cli.CommandOption(\"-s|--session <SESSION>\")]");
sb.AppendLine(" [System.ComponentModel.Description(\"Session ID from 'session open' command\")]");
sb.AppendLine(" public string SessionId { get; init; } = string.Empty;");
sb.AppendLine();
}
// Generate all exposed parameters
foreach (var p in allParams)
{
var optionName = StringHelper.ToKebabCase(p.Name);
var description = p.DescriptionWithRequired ?? StringHelper.GetParameterDescription(p.Name);
var escapedDescription = description
.Replace("[", "[[").Replace("]", "]]") // Escape Spectre.Console markup characters
.Replace("\"", "\\\"").Replace("\n", " ");
var valuePlaceholder = p.Name.ToUpperInvariant();
// Collection types (List<T>, List<List<T>>) are emitted as string? so CLI accepts
// raw JSON. LLMs pass JSON arrays instead of repeated CLI flags.
// Deserialized back to the original type in RouteFromSettings.
var isCollectionForJson = IsNestedCollectionType(p.TypeName) || IsSimpleListType(p.TypeName);
var cliTypeName = isCollectionForJson ? "string?" : p.TypeName;
if (isCollectionForJson && !escapedDescription.Contains("JSON"))
escapedDescription += " (JSON format)";
sb.AppendLine($" [Spectre.Console.Cli.CommandOption(\"--{optionName} <{valuePlaceholder}>\")]");
sb.AppendLine($" [System.ComponentModel.Description(\"{escapedDescription}\")]");
sb.AppendLine($" public {cliTypeName} {StringHelper.ToPascalCase(p.Name)} {{ get; init; }}");
sb.AppendLine();
}
// Output path option (available on all commands)
sb.AppendLine(" [Spectre.Console.Cli.CommandOption(\"-o|--output <PATH>\")]");
sb.AppendLine(" [System.ComponentModel.Description(\"Write output to file instead of stdout. For image results, decodes and saves as binary file.\")]");
sb.AppendLine(" public string? OutputPath { get; init; }");
sb.AppendLine();
sb.AppendLine(" }");
}
private static string BuildCliArgsExpression(MethodInfo method)
{
if (method.Parameters.Count == 0)
return "null";
var props = new List<string>();
foreach (var p in method.Parameters)
{
string valueName;
if (p.IsFileOrValue)
{
// Use resolved variable (ResolveFileOrValue applied in RouteCliArgs)
valueName = $"resolved{StringHelper.ToPascalCase(p.Name)}";
}
else if (p.IsFromString && p.ExposedName != null)
{
// CLI passes string directly to service using exposed name
valueName = p.ExposedName;
}
else
{
valueName = p.Name;
}
// Use ExposedName for JSON property names when FromString specifies alternate name
var propName = (p.IsFromString && p.ExposedName != null) ? p.ExposedName : p.Name;
var jsonName = char.ToLowerInvariant(propName[0]) + propName.Substring(1);
props.Add($"{jsonName} = {valueName}");
}
return $"new {{ {string.Join(", ", props)} }}";
}
private static void GenerateForwardMethod(StringBuilder sb, ServiceInfo info, MethodInfo method)
{
sb.AppendLine($" /// <summary>Forward method for {method.ActionName} action</summary>");
// Build parameter list - Core params might need transforms
var methodParams = new List<string> { "string sessionId", "System.Func<string, string, object?, string> forwardToService" };
foreach (var p in method.Parameters)
{
if (p.IsFileOrValue)
{
// Expose both value and file params
methodParams.Add($"string? {p.Name} = null");
methodParams.Add($"string? {p.Name}{p.FileSuffix} = null");
}
else if (p.IsFromString && p.IsEnum)
{
// Expose as string
var exposedName = p.ExposedName ?? p.Name;
methodParams.Add($"string? {exposedName} = null");
}
else
{
// Keep as-is
var defaultStr = p.HasDefault ? $" = {p.DefaultValue}" : "";
// Make nullable if not already
var typeName = p.TypeName.EndsWith("?") ? p.TypeName : $"{p.TypeName}?";
methodParams.Add($"{typeName} {p.Name}{defaultStr}");
}
}
sb.AppendLine($" public static string Forward{method.MethodName}({string.Join(", ", methodParams)})");
sb.AppendLine(" {");
// Generate validation for required string parameters (skip FileOrValue - they validate after resolution)
// RequireNotEmpty only works with string? parameters
foreach (var p in method.Parameters.Where(p => !p.IsFileOrValue && (p.IsRequired || (!p.HasDefault && !p.TypeName.EndsWith("?"))) && StringHelper.IsStringType(p.TypeName)))
{
var paramName = p.IsFromString && p.IsEnum ? (p.ExposedName ?? p.Name) : p.Name;
sb.AppendLine($" Sbroenne.ExcelMcp.Core.Utilities.ParameterTransforms.RequireNotEmpty({paramName}, \"{paramName}\", \"{method.ActionName}\");");
}
// Generate transforms (only for FileOrValue parameters)
// Note: FromString enum parameters are passed as-is to the service (service does parsing)
foreach (var p in method.Parameters)
{
if (p.IsFileOrValue)
{
sb.AppendLine($" var resolved{StringHelper.ToPascalCase(p.Name)} = Sbroenne.ExcelMcp.Core.Utilities.ParameterTransforms.ResolveFileOrValue({p.Name}, {p.Name}{p.FileSuffix});");
// Validate required FileOrValue parameters AFTER resolution
if (p.IsRequired || (!p.HasDefault && !p.TypeName.EndsWith("?")))
{
sb.AppendLine($" Sbroenne.ExcelMcp.Core.Utilities.ParameterTransforms.RequireNotEmpty(resolved{StringHelper.ToPascalCase(p.Name)}, \"{p.Name}\", \"{method.ActionName}\");");
}
}
// FromString enum parameters: pass raw string to service (no pre-parsing)
}
// Build the request object - always use method-specific command constant
if (method.Parameters.Count == 0)
{
sb.AppendLine($" return forwardToService({method.MethodName}Command, sessionId, null);");
}
else
{
sb.AppendLine($" return forwardToService({method.MethodName}Command, sessionId, new");
sb.AppendLine(" {");
foreach (var p in method.Parameters)
{
string valueExpr;
if (p.IsFileOrValue)
{
valueExpr = $"resolved{StringHelper.ToPascalCase(p.Name)}";
}
else if (p.IsFromString && p.ExposedName != null)
{
// FromString parameters: pass the exposed name (raw string) to service
valueExpr = p.ExposedName;
}
else
{
valueExpr = p.Name;
}
// Use ExposedName for property name when FromString attribute specifies an alternate name
var propertyName = (p.IsFromString && p.ExposedName != null) ? p.ExposedName : p.Name;
sb.AppendLine($" {StringHelper.ToPascalCase(propertyName)} = {valueExpr},");
}
sb.AppendLine(" });");
}
sb.AppendLine(" }");
sb.AppendLine();
}
private static void GenerateArgsClass(StringBuilder sb, MethodInfo method)
{
if (method.Parameters.Count == 0)
return;
sb.AppendLine($" /// <summary>Generated args class for {method.ActionName} deserialization</summary>");
sb.AppendLine($" public sealed class {method.MethodName}Args");
sb.AppendLine(" {");
foreach (var p in method.Parameters)
{
// Property name must match what Forward methods and BuildCliArgsExpression produce
var propertyName = (p.IsFromString && p.ExposedName != null) ? p.ExposedName : p.Name;
var pascalName = StringHelper.ToPascalCase(propertyName);
var argsType = MakeArgsPropertyType(p);
sb.AppendLine($" public {argsType} {pascalName} {{ get; set; }}");
}
sb.AppendLine(" }");
sb.AppendLine();
}
/// <summary>
/// Determines the property type for an args class.
/// IsFromString/IsFileOrValue/IsEnum params are always string? (service does parsing).
/// All other types are made nullable.
/// </summary>
private static string MakeArgsPropertyType(ParameterInfo p)
{
// Enums are kept as string? so service handlers can use existing Parse* methods
if (p.IsFromString || p.IsFileOrValue || p.IsEnum)
return "string?";
var typeName = p.TypeName;
if (typeName.EndsWith("?"))
return typeName;
return typeName + "?";
}
private static List<ExposedParameter> GetAllExposedParameters(ServiceInfo info)
{
var result = new Dictionary<string, ExposedParameter>(StringComparer.OrdinalIgnoreCase);
foreach (var method in info.Methods)
{
foreach (var p in method.Parameters)
{
var isRequired = p.IsRequired || (!p.HasDefault && !p.TypeName.EndsWith("?"));
if (p.IsFileOrValue)
{
// Add both value and file params - prefer non-empty descriptions
if (!result.TryGetValue(p.Name, out var existing) ||
(string.IsNullOrEmpty(existing.Description) && !string.IsNullOrEmpty(p.XmlDocDescription)))
{
var ep = new ExposedParameter(p.Name, "string?", p.XmlDocDescription, "null");
if (result.TryGetValue(p.Name, out var prev))
ep.RequiredByActions.AddRange(prev.RequiredByActions);
result[p.Name] = ep;
}
if (isRequired)
result[p.Name].RequiredByActions.Add(method.ActionName);
var fileParamName = $"{p.Name}{p.FileSuffix}";
if (!result.ContainsKey(fileParamName))
result[fileParamName] = new ExposedParameter(fileParamName, "string?", $"Path to file containing {p.Name}", "null");
}
else if (p.IsFromString && p.IsEnum)
{
var exposedName = p.ExposedName ?? p.Name;
if (!result.TryGetValue(exposedName, out var existing) ||
(string.IsNullOrEmpty(existing.Description) && !string.IsNullOrEmpty(p.XmlDocDescription)))
{
var ep = new ExposedParameter(exposedName, "string?", p.XmlDocDescription, "null");
if (result.TryGetValue(exposedName, out var prev))
ep.RequiredByActions.AddRange(prev.RequiredByActions);
result[exposedName] = ep;
}
if (isRequired)
result[exposedName].RequiredByActions.Add(method.ActionName);
}
else
{
if (!result.TryGetValue(p.Name, out var existing) ||
(string.IsNullOrEmpty(existing.Description) && !string.IsNullOrEmpty(p.XmlDocDescription)))
{
var typeName = p.TypeName.EndsWith("?") ? p.TypeName : $"{p.TypeName}?";
var defaultVal = p.HasDefault ? p.DefaultValue : "null";
var ep = new ExposedParameter(p.Name, typeName, p.XmlDocDescription, defaultVal);
if (result.TryGetValue(p.Name, out var prev))
ep.RequiredByActions.AddRange(prev.RequiredByActions);
result[p.Name] = ep;
}
if (isRequired)
result[p.Name].RequiredByActions.Add(method.ActionName);
}
}
}
// Set total action count on all params
var totalActions = info.Methods.Count;
foreach (var ep in result.Values)
{
ep.TotalActionCount = totalActions;
}
return result.Values.ToList();
}
private static string BuildForwardArgs(MethodInfo method, List<ExposedParameter> allParams)
{
if (method.Parameters.Count == 0)
return "";
var args = new List<string>();
foreach (var p in method.Parameters)
{
if (p.IsFileOrValue)
{
args.Add(p.Name);
args.Add($"{p.Name}{p.FileSuffix}");
}
else if (p.IsFromString && p.IsEnum)
{
args.Add(p.ExposedName ?? p.Name);
}
else
{
args.Add(p.Name);
}
}
return ", " + string.Join(", ", args);
}
private static string GenerateComparisonFile(ServiceInfo info)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated />");
sb.AppendLine("// This file is for COMPARISON ONLY - shows what the generator produces");
sb.AppendLine("// Compare against existing manual code to validate generation is correct");
sb.AppendLine();
sb.AppendLine($"/*");
sb.AppendLine($"GENERATED FROM: I{info.CategoryPascal}Commands");
sb.AppendLine($"CATEGORY: {info.Category}");
sb.AppendLine($"MCP TOOL: {info.McpToolName}");
sb.AppendLine($"REQUIRES SESSION: {!info.NoSession}");
sb.AppendLine();
sb.AppendLine("METHODS:");
foreach (var method in info.Methods)
{
sb.AppendLine($" {method.MethodName}()");
sb.AppendLine($" -> action: \"{method.ActionName}\"");
sb.AppendLine($" -> command: \"{info.Category}.{method.ActionName}\"");
sb.AppendLine($" -> returns: {method.ReturnType}");
if (method.Parameters.Count > 0)
{
sb.AppendLine($" -> params:");
foreach (var p in method.Parameters)
{
var defaultStr = p.HasDefault ? $" = {p.DefaultValue}" : "";
sb.AppendLine($" {p.TypeName} {p.Name}{defaultStr}");
}
}
sb.AppendLine();
}
sb.AppendLine("EXPECTED MCP SWITCH CASES:");
foreach (var method in info.Methods)
{
var enumValue = method.MethodName;
if (method.Parameters.Count > 0)
{
sb.AppendLine($" PowerQueryAction.{enumValue} => Forward{method.MethodName}(sessionId, ...),");
}
else
{
sb.AppendLine($" PowerQueryAction.{enumValue} => ExcelToolsBase.ForwardToService(\"{info.Category}.{method.ActionName}\", sessionId),");
}
}
sb.AppendLine();
sb.AppendLine("EXPECTED CLI ARGS MAPPING:");
foreach (var method in info.Methods)
{
if (method.Parameters.Count > 0)
{
var args = string.Join(", ", method.Parameters.Select(p => $"{p.Name} = settings.{StringHelper.ToPascalCase(p.Name)}"));
sb.AppendLine($" \"{method.ActionName}\" => new {{ {args} }},");
}
else
{
sb.AppendLine($" \"{method.ActionName}\" => null,");
}
}
sb.AppendLine("*/");
return sb.ToString();
}
private static string GenerateCliManifest(List<ServiceInfo> categories)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated />");
sb.AppendLine("#nullable enable");
sb.AppendLine("#pragma warning disable CS1591 // Missing XML comment");
sb.AppendLine();
sb.AppendLine("namespace Sbroenne.ExcelMcp.Generated;");
sb.AppendLine();
sb.AppendLine("/// <summary>");
sb.AppendLine("/// CLI category metadata generated from [ServiceCategory] interfaces.");
sb.AppendLine("/// Used by CLI commands (ListActionsCommand) and the CliSettingsGenerator.");
sb.AppendLine("/// </summary>");
sb.AppendLine("public static class _CliCategoryMetadata");
sb.AppendLine("{");
sb.AppendLine(" /// <summary>List of all CLI command categories and their ServiceRegistry type names.</summary>");
sb.AppendLine(" public static readonly (string CliCommandName, string RegistryTypeName, bool RequiresSession)[] Categories = new[]");
sb.AppendLine(" {");
foreach (var cat in categories.OrderBy(c => c.CategoryPascal))
{
var cliCommandName = cat.McpToolName.Replace("_", "");
sb.AppendLine($" (\"{cliCommandName}\", \"ServiceRegistry.{cat.CategoryPascal}\", {(cat.NoSession ? "false" : "true")}),");
}
sb.AppendLine(" };");
sb.AppendLine();
sb.AppendLine(" /// <summary>Maps CLI command names to their valid actions. Generated from [ServiceCategory] interfaces.</summary>");
sb.AppendLine(" public static readonly System.Collections.Generic.Dictionary<string, System.Collections.Generic.IReadOnlyList<string>> ValidActionsByCommand = new()");
sb.AppendLine(" {");
foreach (var cat in categories.OrderBy(c => c.CategoryPascal))
{
var cliCommandName = cat.McpToolName.Replace("_", "");
sb.AppendLine($" [\"{cliCommandName}\"] = ServiceRegistry.{cat.CategoryPascal}.ValidActions,");
}
sb.AppendLine(" };");
sb.AppendLine("}");
return sb.ToString();
}
/// <summary>
/// Generates a JSON manifest as a constant string for skill documentation generation.
/// The Build.Tasks project extracts this JSON to generate SKILL.md files.
/// </summary>
private static string GenerateSkillManifest(List<ServiceInfo> categories)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated />");
sb.AppendLine("#nullable enable");
sb.AppendLine("#pragma warning disable CS1591 // Missing XML comment");
sb.AppendLine();
sb.AppendLine("namespace Sbroenne.ExcelMcp.Generated;");
sb.AppendLine();
sb.AppendLine("/// <summary>");
sb.AppendLine("/// JSON manifest for skill file generation.");
sb.AppendLine("/// Used by ExcelMcp.Build.Tasks to generate SKILL.md files from templates.");
sb.AppendLine("/// </summary>");
sb.AppendLine("internal static class _SkillManifest");
sb.AppendLine("{");
sb.AppendLine(" /// <summary>JSON manifest of all commands and parameters.</summary>");
sb.AppendLine(" public const string Json = @\"");
// Build JSON manually (no System.Text.Json in source generators)
sb.AppendLine("{");
sb.AppendLine(" \"\"commands\"\": [");
var orderedCategories = categories.OrderBy(c => c.CategoryPascal).ToList();
for (int i = 0; i < orderedCategories.Count; i++)
{
var cat = orderedCategories[i];
var cliCommandName = cat.McpToolName.Replace("_", "");
sb.AppendLine(" {");
sb.AppendLine($" \"\"name\"\": \"\"{cliCommandName}\"\",");
sb.AppendLine($" \"\"mcpTool\"\": \"\"{cat.McpToolName}\"\",");
// Description from interface XML doc
var description = (cat.XmlDocSummary ?? "")
.Replace("\"", "\\\"\"")
.Replace("\r", "")
.Replace("\n", " ")
.Trim();
sb.AppendLine($" \"\"description\"\": \"\"{description}\"\",");
// Actions array
sb.Append(" \"\"actions\"\": [");
var actions = cat.Methods.Select(m => $"\"\"{m.ActionName}\"\"");
sb.Append(string.Join(", ", actions));
sb.AppendLine("],");
// Parameters array with required-by-actions tracking
sb.AppendLine(" \"\"parameters\"\": [");
// Build required-by-actions map for each param
var paramRequiredMap = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
var totalActions = cat.Methods.Count;
foreach (var method in cat.Methods)
{
foreach (var p in method.Parameters)
{
var pName = p.ExposedName ?? StringHelper.ToKebabCase(p.Name);
var isRequired = p.IsRequired || (!p.HasDefault && !p.TypeName.EndsWith("?"));
if (isRequired)
{
if (!paramRequiredMap.TryGetValue(pName, out var reqActions))
{
reqActions = new List<string>();
paramRequiredMap[pName] = reqActions;
}
reqActions.Add(method.ActionName);
}
}
}
var distinctParams = cat.Methods
.SelectMany(m => m.Parameters)
.GroupBy(p => p.ExposedName ?? StringHelper.ToKebabCase(p.Name))
.Select(g => g.First())
.ToList();
for (int j = 0; j < distinctParams.Count; j++)
{
var param = distinctParams[j];
var paramName = StringHelper.ToKebabCase(param.ExposedName ?? param.Name);
// JSON requires \" for quotes inside strings
// In verbatim C# strings: \"" outputs \" (backslash + "" for one quote)
var baseDescription = (param.XmlDocDescription ?? "")
.Replace("\r", "")
.Replace("\n", " ")
.Trim();
// Append required-by-actions info
if (paramRequiredMap.TryGetValue(paramName, out var requiredActions) && requiredActions.Count > 0)
{
var suffix = requiredActions.Count == totalActions
? "(required)"
: $"(required for: {string.Join(", ", requiredActions)})";
baseDescription = string.IsNullOrEmpty(baseDescription) ? suffix : $"{baseDescription} {suffix}";
}
var paramDescription = baseDescription
.Replace("\"", "\\\"\""); // Escape quotes for JSON in verbatim string
sb.AppendLine(" {");
sb.AppendLine($" \"\"name\"\": \"\"{paramName}\"\",");
sb.AppendLine($" \"\"description\"\": \"\"{paramDescription}\"\"");
sb.Append(" }");
sb.AppendLine(j < distinctParams.Count - 1 ? "," : "");
}
sb.AppendLine(" ]");
sb.Append(" }");
sb.AppendLine(i < orderedCategories.Count - 1 ? "," : "");
}
sb.AppendLine(" ],");
sb.AppendLine($" \"\"totalCommands\"\": {orderedCategories.Count},");
sb.AppendLine($" \"\"totalOperations\"\": {orderedCategories.Sum(c => c.Methods.Count)}");
sb.AppendLine("}\";");
sb.AppendLine("}");
return sb.ToString();
}
// NOTE: Data models (ServiceInfo, MethodInfo, ParameterInfo) are now in
// ExcelMcp.Generators.Shared and included as source files
// =====================================================
// Service Dispatch Generation
// =====================================================
/// <summary>
/// Generates the shared dispatch helper methods (DeserializeArgs, ParseEnumValue, DispatchJsonOptions).
/// Emitted once as ServiceRegistry.DispatchHelpers.g.cs.
/// </summary>
private static string GenerateDispatchHelpers()
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated />");
sb.AppendLine("#nullable enable");
sb.AppendLine("#pragma warning disable CS1591");
sb.AppendLine();
sb.AppendLine("namespace Sbroenne.ExcelMcp.Generated;");
sb.AppendLine();
sb.AppendLine("public static partial class ServiceRegistry");
sb.AppendLine("{");
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// JSON options for service dispatch serialization.");
sb.AppendLine(" /// Matches ServiceProtocol.JsonOptions (CamelCase, ignore nulls, string enums).");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" internal static readonly System.Text.Json.JsonSerializerOptions DispatchJsonOptions = new()");
sb.AppendLine(" {");
sb.AppendLine(" PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,");
sb.AppendLine(" DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,");
sb.AppendLine(" Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }");
sb.AppendLine(" };");
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Deserializes args JSON into a typed class. Returns new instance if JSON is null/empty.");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public static T DeserializeArgs<T>(string? argsJson) where T : class, new()");
sb.AppendLine(" {");
sb.AppendLine(" if (string.IsNullOrEmpty(argsJson)) return new T();");
sb.AppendLine(" return System.Text.Json.JsonSerializer.Deserialize<T>(argsJson, DispatchJsonOptions) ?? new T();");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Parses an enum value from a string with kebab-case and snake_case support.");
sb.AppendLine(" /// Returns defaultValue if the string is null, empty, or cannot be parsed.");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" internal static T ParseEnumValue<T>(string? value, T defaultValue) where T : struct, System.Enum");
sb.AppendLine(" {");
sb.AppendLine(" if (string.IsNullOrEmpty(value)) return defaultValue;");
sb.AppendLine(" var cleaned = value.Replace(\"-\", \"\").Replace(\"_\", \"\");");
sb.AppendLine(" return System.Enum.TryParse<T>(cleaned, ignoreCase: true, out var result) ? result : defaultValue;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Deserializes a nested collection (e.g., List<List<object?>>) from JSON.");
sb.AppendLine(" /// Auto-wraps a flat 1D array into a 2D array when needed.");
sb.AppendLine(" /// For example, [\"a\",\"b\"] is treated as [[\"a\",\"b\"]] (single row).");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" internal static T DeserializeNestedCollection<T>(string json) where T : class");
sb.AppendLine(" {");
sb.AppendLine(" try");
sb.AppendLine(" {");
sb.AppendLine(" return System.Text.Json.JsonSerializer.Deserialize<T>(json)");
sb.AppendLine(" ?? throw new System.Text.Json.JsonException(\"Deserialization returned null\");");
sb.AppendLine(" }");
sb.AppendLine(" catch (System.Text.Json.JsonException)");
sb.AppendLine(" {");
sb.AppendLine(" // Auto-wrap 1D array to 2D: [\"a\",\"b\"] → [[\"a\",\"b\"]]");
sb.AppendLine(" var trimmed = json.Trim();");
sb.AppendLine(" if (trimmed.StartsWith(\"[\") && !trimmed.StartsWith(\"[[\"))");
sb.AppendLine(" {");
sb.AppendLine(" var wrapped = \"[\" + trimmed + \"]\";");
sb.AppendLine(" try");
sb.AppendLine(" {");
sb.AppendLine(" return System.Text.Json.JsonSerializer.Deserialize<T>(wrapped)");
sb.AppendLine(" ?? throw new System.Text.Json.JsonException(\"Deserialization returned null\");");
sb.AppendLine(" }");
sb.AppendLine(" catch { /* fall through to error */ }");
sb.AppendLine(" }");
sb.AppendLine(" throw new System.ArgumentException(");
sb.AppendLine(" $\"Invalid JSON for nested collection. Expected 2D array (e.g., [[\\\"a\\\",\\\"b\\\"],[\\\"c\\\",\\\"d\\\"]]) or 1D array (auto-wrapped to single row). Got: {json}\");");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Deserializes a simple list (e.g., List<string>) from a JSON array string.");
sb.AppendLine(" /// Used for CLI parameters where LLMs pass JSON arrays instead of repeated flags.");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" internal static T? DeserializeList<T>(string? json) where T : class");
sb.AppendLine(" {");
sb.AppendLine(" if (string.IsNullOrWhiteSpace(json)) return null;");
sb.AppendLine(" return System.Text.Json.JsonSerializer.Deserialize<T>(json)");
sb.AppendLine(" ?? throw new System.Text.Json.JsonException($\"Failed to deserialize list from JSON: {json}\");");
sb.AppendLine(" }");
sb.AppendLine("}");
return sb.ToString();
}
/// <summary>
/// Generates the DispatchToCore method for a service category.
/// This method routes parsed actions to Core command methods,
/// replacing hand-written Handle*CommandAsync methods in ExcelMcpService.
/// </summary>
private static string GenerateServiceDispatch(ServiceInfo info, string interfaceFullName)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated />");
sb.AppendLine("#nullable enable");
sb.AppendLine("#pragma warning disable CS1591");
sb.AppendLine();
sb.AppendLine("namespace Sbroenne.ExcelMcp.Generated;");
sb.AppendLine();
sb.AppendLine("public static partial class ServiceRegistry");
sb.AppendLine("{");
sb.AppendLine($" public static partial class {info.CategoryPascal}");
sb.AppendLine(" {");
sb.AppendLine(" /// <summary>");
sb.AppendLine($" /// Dispatches a {info.CategoryPascal} action to the Core command method.");
sb.AppendLine(" /// Returns serialized JSON result, or null for void operations.");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public static string? DispatchToCore(");
sb.AppendLine($" {interfaceFullName} commands,");
sb.AppendLine($" {info.CategoryPascal}Action action,");
if (!info.NoSession)
sb.AppendLine(" Sbroenne.ExcelMcp.ComInterop.Session.IExcelBatch batch,");
sb.AppendLine(" string? argsJson)");
sb.AppendLine(" {");
sb.AppendLine(" switch (action)");
sb.AppendLine(" {");
foreach (var method in info.Methods)
{
GenerateDispatchCase(sb, info, method);
}
sb.AppendLine(" default:");
sb.AppendLine($" throw new System.ArgumentException($\"Unknown {info.CategoryPascal} action: {{action}}\");");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine("}");
return sb.ToString();
}
/// <summary>
/// Generates a single case block within the DispatchToCore switch statement.
/// Handles args deserialization, enum parsing, and Core method invocation.
/// </summary>
private static void GenerateDispatchCase(StringBuilder sb, ServiceInfo info, MethodInfo method)
{
sb.AppendLine($" case {info.CategoryPascal}Action.{method.MethodName}:");
sb.AppendLine(" {");
// Deserialize args if method has parameters
if (method.Parameters.Count > 0)
{
sb.AppendLine($" var args = DeserializeArgs<{method.MethodName}Args>(argsJson);");
}
// Parse enum parameters into local variables
foreach (var p in method.Parameters.Where(p => p.IsEnum))
{
var enumType = p.TypeName.TrimEnd('?');
var propName = (p.IsFromString && p.ExposedName != null) ? p.ExposedName : p.Name;
var pascalProp = StringHelper.ToPascalCase(propName);
var isNullableEnum = p.TypeName.EndsWith("?");
if (isNullableEnum)
{
sb.AppendLine($" var {p.Name} = !string.IsNullOrEmpty(args.{pascalProp}) ? ({enumType}?)ParseEnumValue<{enumType}>(args.{pascalProp}, default) : null;");
}
else
{
var defaultExpr = (p.HasDefault && p.DefaultValue != null) ? p.DefaultValue : $"default({enumType})";
sb.AppendLine($" var {p.Name} = ParseEnumValue<{enumType}>(args.{pascalProp}, {defaultExpr});");
}
}
// Parse [FromString] non-enum, non-string parameters into local variables
// These are stored as string? in args but need type conversion (e.g., bool?, TimeSpan?)
foreach (var p in method.Parameters.Where(p => p.IsFromString && !p.IsEnum && !StringHelper.IsStringType(p.TypeName)))
{
var propName = p.ExposedName ?? p.Name;
var pascalProp = StringHelper.ToPascalCase(propName);
var isNullable = p.TypeName.EndsWith("?");
var baseType = p.TypeName.TrimEnd('?');
if (isNullable)
{
sb.AppendLine($" var {p.Name} = !string.IsNullOrEmpty(args.{pascalProp}) ? ({p.TypeName}){baseType}.Parse(args.{pascalProp}) : null;");
}
else if (p.HasDefault && p.DefaultValue != null)
{
sb.AppendLine($" var {p.Name} = !string.IsNullOrEmpty(args.{pascalProp}) ? {baseType}.Parse(args.{pascalProp}) : {p.DefaultValue};");
}
else
{
sb.AppendLine($" var {p.Name} = {baseType}.Parse(args.{pascalProp}!);");
}
}
// Build method call argument list
var callArgs = new List<string>();
// Only pass batch if the method actually has an IExcelBatch parameter
if (!info.NoSession && method.HasBatchParameter)
callArgs.Add("batch");
foreach (var p in method.Parameters)
{
if (p.IsEnum || (p.IsFromString && !StringHelper.IsStringType(p.TypeName)))
{
// Already parsed to local variable
callArgs.Add(p.Name);
}
else
{
var propName = (p.IsFromString && p.ExposedName != null) ? p.ExposedName : p.Name;
var pascalProp = StringHelper.ToPascalCase(propName);
callArgs.Add(GetDispatchCallExpression(p, pascalProp));
}
}
var isVoid = method.ReturnType == "void";
if (isVoid)
{
sb.AppendLine($" commands.{method.MethodName}({string.Join(", ", callArgs)});");
sb.AppendLine(" return null;");
}
else
{
sb.AppendLine($" var result = commands.{method.MethodName}({string.Join(", ", callArgs)});");
sb.AppendLine(" return System.Text.Json.JsonSerializer.Serialize(result, DispatchJsonOptions);");
}
sb.AppendLine(" }");
}
/// <summary>
/// Gets the C# expression to pass a non-enum parameter from the args object to the Core method.
/// Handles nullable unwrapping, default values, and null-forgiving operators.
/// </summary>
private static string GetDispatchCallExpression(ParameterInfo p, string pascalProp)
{
var typeName = p.TypeName;
// If Core type is already nullable, pass as-is from args
if (typeName.EndsWith("?"))
return $"args.{pascalProp}";
// Non-nullable Core type. Args property is nullable (MakeArgsPropertyType adds ?).
// Need to provide a non-null value.
if (p.HasDefault && p.DefaultValue != null)
{
// Use default value when args property is null
return $"args.{pascalProp} ?? {p.DefaultValue}";
}
// Required/non-nullable parameter without default.
// For value types: use ?? default(T) to avoid nullable unwrap issues
// For reference types: use ! (null-forgiving)
if (IsKnownValueType(typeName))
{
return $"args.{pascalProp} ?? default({typeName})";
}
// Reference type: null-forgiving
return $"args.{pascalProp}!";
}
/// <summary>
/// Checks if a type name represents a known value type (struct).
/// Used to determine whether to use ?? default(T) vs ! for nullable unwrapping.
/// </summary>
private static bool IsKnownValueType(string typeName)
{
return typeName is "int" or "long" or "short" or "byte" or "sbyte"
or "float" or "double" or "decimal" or "bool" or "char"
|| typeName.Contains("TimeSpan");
}
}