using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
namespace Sbroenne.ExcelMcp.Generators.Cli;
/// <summary>
/// Generates CLI command classes and registration.
/// Discovers [ServiceCategory] interfaces from referenced assemblies (Core)
/// to eliminate hard-coded category lists.
/// </summary>
[Generator]
public class CliSettingsGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Scan the compilation (including referenced assemblies) for [ServiceCategory] interfaces
context.RegisterSourceOutput(context.CompilationProvider,
static (spc, compilation) =>
{
var categories = DiscoverCategories(compilation);
if (categories.Count == 0)
return;
foreach (var (_, registryName, requiresSession) in categories)
{
var cmdCode = GenerateCommandClass(registryName, requiresSession);
spc.AddSource($"CliCommand.{registryName}.g.cs", SourceText.From(cmdCode, Encoding.UTF8));
}
var regCode = GenerateRegistration(categories);
spc.AddSource("CliCommandRegistration.Generated.g.cs", SourceText.From(regCode, Encoding.UTF8));
});
}
/// <summary>
/// Discovers [ServiceCategory] interfaces from referenced assemblies.
/// This replaces the hard-coded category list — adding a new [ServiceCategory]
/// interface to Core automatically generates the corresponding CLI command.
/// Uses string-based attribute matching for cross-compilation robustness.
/// </summary>
private static List<(string CliName, string RegistryName, bool RequiresSession)> DiscoverCategories(Compilation compilation)
{
var result = new List<(string CliName, string RegistryName, bool RequiresSession)>();
// Search referenced assemblies for interfaces with [ServiceCategory]
foreach (var reference in compilation.References)
{
if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol assembly)
continue;
foreach (var type in GetAllTypes(assembly.GlobalNamespace))
{
if (type.TypeKind != TypeKind.Interface)
continue;
// Use string-based matching (robust across compilation boundaries)
var attr = type.GetAttributes().FirstOrDefault(a =>
a.AttributeClass?.Name == "ServiceCategoryAttribute" &&
a.AttributeClass?.ContainingNamespace?.ToDisplayString() == "Sbroenne.ExcelMcp.Core.Attributes");
if (attr == null || attr.ConstructorArguments.Length < 2)
continue;
var categoryPascal = attr.ConstructorArguments[1].Value?.ToString() ?? "";
// Get McpTool name for deriving CLI command name
var mcpToolAttr = type.GetAttributes().FirstOrDefault(a =>
a.AttributeClass?.Name == "McpToolAttribute");
var mcpToolName = mcpToolAttr?.ConstructorArguments.FirstOrDefault().Value?.ToString()
?? $"{attr.ConstructorArguments[0].Value}";
// Check for NoSession attribute
var noSession = type.GetAttributes().Any(a =>
a.AttributeClass?.Name == "NoSessionAttribute");
var cliName = mcpToolName.Replace("_", "");
result.Add((cliName, categoryPascal, !noSession));
}
}
return result;
}
private static IEnumerable<INamedTypeSymbol> GetAllTypes(INamespaceSymbol ns)
{
foreach (var type in ns.GetTypeMembers())
yield return type;
foreach (var child in ns.GetNamespaceMembers())
foreach (var type in GetAllTypes(child))
yield return type;
}
private static string GenerateCommandClass(string registryName, bool requiresSession)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated />");
sb.AppendLine("#nullable enable");
sb.AppendLine();
sb.AppendLine("using Sbroenne.ExcelMcp.CLI.Infrastructure;");
sb.AppendLine("using Sbroenne.ExcelMcp.Generated;");
sb.AppendLine();
sb.AppendLine("namespace Sbroenne.ExcelMcp.CLI.Generated;");
sb.AppendLine();
sb.AppendLine($"internal sealed class {registryName}Command : ServiceCommandBase<ServiceRegistry.{registryName}.CliSettings>");
sb.AppendLine("{");
if (!requiresSession) sb.AppendLine(" protected override bool RequiresSession => false;");
// NoSession commands don't have SessionId in CliSettings — return null
if (requiresSession)
sb.AppendLine($" protected override string? GetSessionId(ServiceRegistry.{registryName}.CliSettings settings) => settings.SessionId;");
else
sb.AppendLine($" protected override string? GetSessionId(ServiceRegistry.{registryName}.CliSettings settings) => null;");
sb.AppendLine($" protected override string? GetAction(ServiceRegistry.{registryName}.CliSettings settings) => settings.Action;");
sb.AppendLine($" protected override IReadOnlyList<string> ValidActions => ServiceRegistry.{registryName}.ValidActions;");
sb.AppendLine($" protected override (string command, object? args) Route(ServiceRegistry.{registryName}.CliSettings settings, string action) => ServiceRegistry.{registryName}.RouteFromSettings(action, settings);");
sb.AppendLine("}");
return sb.ToString();
}
private static string GenerateRegistration(List<(string CliName, string RegistryName, bool RequiresSession)> categories)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated />");
sb.AppendLine("#nullable enable");
sb.AppendLine();
sb.AppendLine("using Sbroenne.ExcelMcp.Generated;");
sb.AppendLine("using Spectre.Console;");
sb.AppendLine("using Spectre.Console.Cli;");
sb.AppendLine();
sb.AppendLine("namespace Sbroenne.ExcelMcp.CLI.Generated;");
sb.AppendLine();
sb.AppendLine("internal static class CliCommandRegistration");
sb.AppendLine("{");
sb.AppendLine(" public static void RegisterCommands(IConfigurator config)");
sb.AppendLine(" {");
foreach (var (cliName, registryName, _) in categories.OrderBy(c => c.CliName))
sb.AppendLine($" config.AddCommand<{registryName}Command>(\"{cliName}\").WithDescription(ServiceRegistry.{registryName}.Description.EscapeMarkup());");
sb.AppendLine(" }");
sb.AppendLine("}");
return sb.ToString();
}
}