From 34b3dcfe9f2e08dbbc0d56b9775002f10e3c5e7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20B=C3=BChler?= Date: Wed, 27 Sep 2023 16:37:52 +0200 Subject: [PATCH] refactor: CLI Argument parsing (#615) This adds "System.CommandLine" as default parser and spectre.console for beautiful output. BREAKING CHANGE: generator commands may define a csproj or sln file on where the entities or other elements are located. If no file is provided, the current directory is searched and the command fails if none is found. --- src/KubeOps.Cli/Arguments.cs | 37 +++++ src/KubeOps.Cli/Commands/Entrypoint.cs | 15 -- .../Commands/Generator/CrdGenerator.cs | 103 ++++++------ .../Commands/Generator/Generator.cs | 25 ++- .../Commands/Generator/RbacGenerator.cs | 97 ++++++----- src/KubeOps.Cli/Commands/Utilities/Version.cs | 44 ++--- src/KubeOps.Cli/KubeOps.Cli.csproj | 7 +- src/KubeOps.Cli/Options.cs | 33 ++++ src/KubeOps.Cli/Output/ConsoleOutput.cs | 42 ----- src/KubeOps.Cli/Output/ResultOutput.cs | 20 ++- src/KubeOps.Cli/Program.cs | 39 ++--- src/KubeOps.Cli/Roslyn/AssemblyParser.cs | 151 ++++++++++++++++++ src/KubeOps.Cli/Roslyn/TfmComparer.cs | 58 +++++++ .../SyntaxObjects/ProjectParser.cs | 85 ---------- 14 files changed, 458 insertions(+), 298 deletions(-) create mode 100644 src/KubeOps.Cli/Arguments.cs delete mode 100644 src/KubeOps.Cli/Commands/Entrypoint.cs create mode 100644 src/KubeOps.Cli/Options.cs delete mode 100644 src/KubeOps.Cli/Output/ConsoleOutput.cs create mode 100644 src/KubeOps.Cli/Roslyn/AssemblyParser.cs create mode 100644 src/KubeOps.Cli/Roslyn/TfmComparer.cs delete mode 100644 src/KubeOps.Cli/SyntaxObjects/ProjectParser.cs diff --git a/src/KubeOps.Cli/Arguments.cs b/src/KubeOps.Cli/Arguments.cs new file mode 100644 index 00000000..d9617dbb --- /dev/null +++ b/src/KubeOps.Cli/Arguments.cs @@ -0,0 +1,37 @@ +using System.CommandLine; + +namespace KubeOps.Cli; + +internal static class Arguments +{ + public static readonly Argument SolutionOrProjectFile = new( + "sln/csproj file", + () => + { + var projectFile + = Directory.EnumerateFiles( + Directory.GetCurrentDirectory(), + "*.csproj") + .Select(f => new FileInfo(f)) + .FirstOrDefault(); + var slnFile + = Directory.EnumerateFiles( + Directory.GetCurrentDirectory(), + "*.sln") + .Select(f => new FileInfo(f)) + .FirstOrDefault(); + + return (projectFile, slnFile) switch + { + ({ } prj, _) => prj, + (_, { } sln) => sln, + _ => throw new FileNotFoundException( + "No *.csproj or *.sln file found in current directory.", + Directory.GetCurrentDirectory()), + }; + }, + "A solution or project file where entities are located. " + + "If omitted, the current directory is searched for a *.csproj or *.sln file. " + + "If an *.sln file is used, all projects in the solution (with the newest framework) will be searched for entities. " + + "This behaviour can be filtered by using the --project and --target-framework option."); +} diff --git a/src/KubeOps.Cli/Commands/Entrypoint.cs b/src/KubeOps.Cli/Commands/Entrypoint.cs deleted file mode 100644 index b835c6cf..00000000 --- a/src/KubeOps.Cli/Commands/Entrypoint.cs +++ /dev/null @@ -1,15 +0,0 @@ -using McMaster.Extensions.CommandLineUtils; - -namespace KubeOps.Cli.Commands; - -[Command(Name = "kubeops", Description = "CLI for KubeOps.", UsePagerForHelpText = true)] -[Subcommand(typeof(Generator.Generator))] -[Subcommand(typeof(Utilities.Version))] -internal class Entrypoint -{ - public int OnExecute(CommandLineApplication app) - { - app.ShowHelp(); - return ExitCodes.UsageError; - } -} diff --git a/src/KubeOps.Cli/Commands/Generator/CrdGenerator.cs b/src/KubeOps.Cli/Commands/Generator/CrdGenerator.cs index 4cb166b1..186170b3 100644 --- a/src/KubeOps.Cli/Commands/Generator/CrdGenerator.cs +++ b/src/KubeOps.Cli/Commands/Generator/CrdGenerator.cs @@ -1,83 +1,80 @@ -using KubeOps.Abstractions.Kustomize; +using System.CommandLine; +using System.CommandLine.Help; +using System.CommandLine.Invocation; + +using KubeOps.Abstractions.Kustomize; using KubeOps.Cli.Output; -using KubeOps.Cli.SyntaxObjects; +using KubeOps.Cli.Roslyn; -using McMaster.Extensions.CommandLineUtils; +using Spectre.Console; namespace KubeOps.Cli.Commands.Generator; -[Command("crd", "crds", Description = "Generates the needed CRD for kubernetes. (Aliases: crds)")] -internal class CrdGenerator +internal static class CrdGenerator { - private readonly ConsoleOutput _output; - private readonly ResultOutput _result; - - public CrdGenerator(ConsoleOutput output, ResultOutput result) + public static Command Command { - _output = output; - _result = result; - } - - [Option( - Description = "The path the command will write the files to. If empty, prints output to console.", - LongName = "out")] - public string? OutputPath { get; set; } - - [Option( - CommandOptionType.SingleValue, - Description = "Sets the output format for the generator.")] - public OutputFormat Format { get; set; } + get + { + var cmd = new Command("crd", "Generates CRDs for Kubernetes based on a solution or project.") + { + Options.OutputFormat, + Options.OutputPath, + Options.SolutionProjectRegex, + Options.TargetFramework, + Arguments.SolutionOrProjectFile, + }; + cmd.AddAlias("crds"); + cmd.AddAlias("c"); + cmd.SetHandler(ctx => Handler(AnsiConsole.Console, ctx)); - [Argument( - 0, - Description = - "Path to a *.csproj file to generate the CRD from. " + - "If omitted, the current directory is searched for one and the command fails if none is found.")] - public string? ProjectFile { get; set; } + return cmd; + } + } - public async Task OnExecuteAsync() + internal static async Task Handler(IAnsiConsole console, InvocationContext ctx) { - _result.Format = Format; - var projectFile = ProjectFile ?? - Directory.EnumerateFiles( - Directory.GetCurrentDirectory(), - "*.csproj") - .FirstOrDefault(); - if (projectFile == null) - { - _output.WriteLine( - "No *.csproj file found. Either specify one or run the command in a directory with one.", - ConsoleColor.Red); - return ExitCodes.Error; - } + var file = ctx.ParseResult.GetValueForArgument(Arguments.SolutionOrProjectFile); + var outPath = ctx.ParseResult.GetValueForOption(Options.OutputPath); + var format = ctx.ParseResult.GetValueForOption(Options.OutputFormat); - _output.WriteLine($"Generate CRDs from project: {projectFile}."); + var parser = file.Extension switch + { + ".csproj" => await AssemblyParser.ForProject(console, file), + ".sln" => await AssemblyParser.ForSolution( + console, + file, + ctx.ParseResult.GetValueForOption(Options.SolutionProjectRegex), + ctx.ParseResult.GetValueForOption(Options.TargetFramework)), + _ => throw new NotSupportedException("Only *.csproj and *.sln files are supported."), + }; + var result = new ResultOutput(console, format); - var parser = new ProjectParser(projectFile); - var crds = Transpiler.Crds.Transpile(await parser.Entities().ToListAsync()).ToList(); + console.WriteLine($"Generate CRDs for {file.Name}."); + var crds = Transpiler.Crds.Transpile(parser.Entities()).ToList(); foreach (var crd in crds) { - _result.Add($"{crd.Metadata.Name.Replace('.', '_')}.{Format.ToString().ToLowerInvariant()}", crd); + result.Add($"{crd.Metadata.Name.Replace('.', '_')}.{format.ToString().ToLowerInvariant()}", crd); } - _result.Add( - $"kustomization.{Format.ToString().ToLowerInvariant()}", + result.Add( + $"kustomization.{format.ToString().ToLowerInvariant()}", new KustomizationConfig { Resources = crds - .ConvertAll(crd => $"{crd.Metadata.Name.Replace('.', '_')}.{Format.ToString().ToLower()}"), + .ConvertAll(crd => $"{crd.Metadata.Name.Replace('.', '_')}.{format.ToString().ToLower()}"), CommonLabels = new Dictionary { { "operator-element", "crd" } }, }); - if (OutputPath is not null) + if (outPath is not null) { - await _result.Write(OutputPath); + await result.Write(outPath); } else { - _result.Write(); + result.Write(); } - return ExitCodes.Success; + ctx.ExitCode = ExitCodes.Success; } } diff --git a/src/KubeOps.Cli/Commands/Generator/Generator.cs b/src/KubeOps.Cli/Commands/Generator/Generator.cs index 78e76dd3..5da90749 100644 --- a/src/KubeOps.Cli/Commands/Generator/Generator.cs +++ b/src/KubeOps.Cli/Commands/Generator/Generator.cs @@ -1,15 +1,24 @@ -using McMaster.Extensions.CommandLineUtils; +using System.CommandLine; +using System.CommandLine.Help; namespace KubeOps.Cli.Commands.Generator; -[Command("generator", "gen", "g", Description = "Generates elements related to an operator. (Aliases: gen, g)")] -[Subcommand(typeof(CrdGenerator))] -[Subcommand(typeof(RbacGenerator))] -internal class Generator +internal static class Generator { - public int OnExecute(CommandLineApplication app) + public static Command Command { - app.ShowHelp(); - return ExitCodes.UsageError; + get + { + var cmd = new Command("generator", "Generates elements related to an operator.") + { + CrdGenerator.Command, + RbacGenerator.Command, + }; + cmd.AddAlias("gen"); + cmd.AddAlias("g"); + cmd.SetHandler(ctx => ctx.HelpBuilder.Write(cmd, Console.Out)); + + return cmd; + } } } diff --git a/src/KubeOps.Cli/Commands/Generator/RbacGenerator.cs b/src/KubeOps.Cli/Commands/Generator/RbacGenerator.cs index 7c15001d..c9b2ab98 100644 --- a/src/KubeOps.Cli/Commands/Generator/RbacGenerator.cs +++ b/src/KubeOps.Cli/Commands/Generator/RbacGenerator.cs @@ -1,70 +1,65 @@ -using KubeOps.Cli.Output; -using KubeOps.Cli.SyntaxObjects; +using System.CommandLine; +using System.CommandLine.Help; +using System.CommandLine.Invocation; -using McMaster.Extensions.CommandLineUtils; +using KubeOps.Abstractions.Kustomize; +using KubeOps.Cli.Output; +using KubeOps.Cli.Roslyn; + +using Spectre.Console; namespace KubeOps.Cli.Commands.Generator; -[Command("rbac", "r", Description = "Generates rbac roles for the operator. (Aliases: r)")] -internal class RbacGenerator +internal static class RbacGenerator { - private readonly ConsoleOutput _output; - private readonly ResultOutput _result; - - public RbacGenerator(ConsoleOutput output, ResultOutput result) - { - _output = output; - _result = result; - } - - [Option( - Description = "The path the command will write the files to. If empty, prints output to console.", - LongName = "out")] - public string? OutputPath { get; set; } - - [Option( - CommandOptionType.SingleValue, - Description = "Sets the output format for the generator.")] - public OutputFormat Format { get; set; } - - [Argument( - 0, - Description = - "Path to a *.csproj file to generate the CRD from. " + - "If omitted, the current directory is searched for one and the command fails if none is found.")] - public string? ProjectFile { get; set; } - - public async Task OnExecuteAsync() + public static Command Command { - _result.Format = Format; - var projectFile = ProjectFile ?? - Directory.EnumerateFiles( - Directory.GetCurrentDirectory(), - "*.csproj") - .FirstOrDefault(); - if (projectFile == null) + get { - _output.WriteLine( - "No *.csproj file found. Either specify one or run the command in a directory with one.", - ConsoleColor.Red); - return ExitCodes.Error; + var cmd = new Command("rbac", "Generates rbac roles for the operator project or solution.") + { + Options.OutputFormat, + Options.OutputPath, + Options.SolutionProjectRegex, + Options.TargetFramework, + Arguments.SolutionOrProjectFile, + }; + cmd.AddAlias("r"); + cmd.SetHandler(ctx => Handler(AnsiConsole.Console, ctx)); + + return cmd; } + } - _output.WriteLine($"Generate CRDs from project: {projectFile}."); + internal static async Task Handler(IAnsiConsole console, InvocationContext ctx) + { + var file = ctx.ParseResult.GetValueForArgument(Arguments.SolutionOrProjectFile); + var outPath = ctx.ParseResult.GetValueForOption(Options.OutputPath); + var format = ctx.ParseResult.GetValueForOption(Options.OutputFormat); - var parser = new ProjectParser(projectFile); - var attributes = await parser.RbacAttributes().ToListAsync(); - _result.Add("file.yaml", Transpiler.Rbac.Transpile(attributes)); + var parser = file.Extension switch + { + ".csproj" => await AssemblyParser.ForProject(console, file), + ".sln" => await AssemblyParser.ForSolution( + console, + file, + ctx.ParseResult.GetValueForOption(Options.SolutionProjectRegex), + ctx.ParseResult.GetValueForOption(Options.TargetFramework)), + _ => throw new NotSupportedException("Only *.csproj and *.sln files are supported."), + }; + var result = new ResultOutput(console, format); + console.WriteLine($"Generate RBAC roles for {file.Name}."); + result.Add("file.yaml", Transpiler.Rbac.Transpile(parser.RbacAttributes())); - if (OutputPath is not null) + if (outPath is not null) { - await _result.Write(OutputPath); + await result.Write(outPath); } else { - _result.Write(); + result.Write(); } - return ExitCodes.Success; + ctx.ExitCode = ExitCodes.Success; } } diff --git a/src/KubeOps.Cli/Commands/Utilities/Version.cs b/src/KubeOps.Cli/Commands/Utilities/Version.cs index 144453aa..cc8534a3 100644 --- a/src/KubeOps.Cli/Commands/Utilities/Version.cs +++ b/src/KubeOps.Cli/Commands/Utilities/Version.cs @@ -1,29 +1,37 @@ -using k8s; +using System.CommandLine; -using McMaster.Extensions.CommandLineUtils; +using k8s; -using Microsoft.Extensions.DependencyInjection; +using Spectre.Console; namespace KubeOps.Cli.Commands.Utilities; -[Command( - "api-version", - "av", - Description = "Prints the actual server version of the connected kubernetes cluster. (Aliases: av)")] -internal class Version +internal static class Version { - public async Task OnExecuteAsync(CommandLineApplication app) + public static Command Command + { + get + { + var cmd = new Command("api-version", "Prints the actual server version of the connected kubernetes cluster."); + cmd.AddAlias("av"); + cmd.SetHandler(() => + Handler(AnsiConsole.Console, new Kubernetes(KubernetesClientConfiguration.BuildDefaultConfig()))); + + return cmd; + } + } + + internal static async Task Handler(IAnsiConsole console, IKubernetes client) { - var client = app.GetRequiredService(); var version = await client.Version.GetCodeAsync(); - await app.Out.WriteLineAsync( - $""" - The Kubernetes API reported the following version: - Git-Version: {version.GitVersion} - Major: {version.Major} - Minor: {version.Minor} - Platform: {version.Platform} - """); + console.Write(new Table() + .Title("Kubernetes API Version") + .HideHeaders() + .AddColumns("Info", "Value") + .AddRow("Git-Version", version.GitVersion) + .AddRow("Major", version.Major) + .AddRow("Minor", version.Minor) + .AddRow("Platform", version.Platform)); return ExitCodes.Success; } diff --git a/src/KubeOps.Cli/KubeOps.Cli.csproj b/src/KubeOps.Cli/KubeOps.Cli.csproj index 778cab86..8b60a9d6 100644 --- a/src/KubeOps.Cli/KubeOps.Cli.csproj +++ b/src/KubeOps.Cli/KubeOps.Cli.csproj @@ -18,11 +18,16 @@ - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/src/KubeOps.Cli/Options.cs b/src/KubeOps.Cli/Options.cs new file mode 100644 index 00000000..22ed4db1 --- /dev/null +++ b/src/KubeOps.Cli/Options.cs @@ -0,0 +1,33 @@ +using System.CommandLine; +using System.Text.RegularExpressions; + +using KubeOps.Cli.Output; + +namespace KubeOps.Cli; + +internal static class Options +{ + public static readonly Option OutputFormat = new( + "--format", + () => Output.OutputFormat.Yaml, + "The format of the generated output."); + + public static readonly Option OutputPath = new( + "--out", + "The path the command will write the files to. If omitted, prints output to console."); + + public static readonly Option TargetFramework = new( + new[] { "--target-framework", "--tfm" }, + description: "Target framework of projects in the solution to search for entities. " + + "If omitted, the newest framework is used."); + + public static readonly Option SolutionProjectRegex = new( + "--project", + parseArgument: result => + { + var value = result.Tokens.Single().Value; + return new Regex(value); + }, + description: "Regex pattern to filter projects in the solution to search for entities. " + + "If omitted, all projects are searched."); +} diff --git a/src/KubeOps.Cli/Output/ConsoleOutput.cs b/src/KubeOps.Cli/Output/ConsoleOutput.cs deleted file mode 100644 index 0a0aa95f..00000000 --- a/src/KubeOps.Cli/Output/ConsoleOutput.cs +++ /dev/null @@ -1,42 +0,0 @@ -using McMaster.Extensions.CommandLineUtils; - -namespace KubeOps.Cli.Output; - -internal class ConsoleOutput -{ - private readonly IConsole _console; - - public ConsoleOutput(IConsole console) => _console = console; - - public void Write( - string content, - ConsoleColor foreground = ConsoleColor.White, - ConsoleColor? background = null) - { - if (background != null) - { - _console.BackgroundColor = background.Value; - } - - _console.ForegroundColor = foreground; - _console.Write(content); - _console.ResetColor(); - } - - public void WriteLine( - string content, - ConsoleColor foreground = ConsoleColor.White, - ConsoleColor? background = null) - { - if (background != null) - { - _console.BackgroundColor = background.Value; - } - - _console.ForegroundColor = foreground; - _console.WriteLine(content); - _console.ResetColor(); - } - - public void WriteLine() => _console.WriteLine(); -} diff --git a/src/KubeOps.Cli/Output/ResultOutput.cs b/src/KubeOps.Cli/Output/ResultOutput.cs index 81a0271e..5f5bb38b 100644 --- a/src/KubeOps.Cli/Output/ResultOutput.cs +++ b/src/KubeOps.Cli/Output/ResultOutput.cs @@ -2,16 +2,21 @@ using k8s; +using Spectre.Console; + namespace KubeOps.Cli.Output; internal class ResultOutput { - private readonly ConsoleOutput _console; + private readonly IAnsiConsole _console; + private readonly OutputFormat _format; private readonly IDictionary _files = new Dictionary(); - public ResultOutput(ConsoleOutput console) => _console = console; - - public OutputFormat Format { get; set; } + public ResultOutput(IAnsiConsole console, OutputFormat format) + { + _console = console; + _format = format; + } public void Add(string filename, object content) => _files.Add(filename, content); @@ -31,15 +36,16 @@ public async Task Write(string outputDirectory) public void Write() { + _console.Write(new Rule()); foreach (var (filename, content) in _files) { - _console.WriteLine(filename, ConsoleColor.Cyan); + _console.MarkupLine($"[bold]File:[/] [underline]{filename}[/]"); _console.WriteLine(Serialize(content)); - _console.WriteLine(); + _console.Write(new Rule()); } } - private string Serialize(object data) => Format switch + private string Serialize(object data) => _format switch { OutputFormat.Yaml => KubernetesYaml.Serialize(data), OutputFormat.Json => KubernetesJson.Serialize(data), diff --git a/src/KubeOps.Cli/Program.cs b/src/KubeOps.Cli/Program.cs index 91ba567a..237568f0 100644 --- a/src/KubeOps.Cli/Program.cs +++ b/src/KubeOps.Cli/Program.cs @@ -1,22 +1,25 @@ -using k8s; +using System.CommandLine; +using System.CommandLine.Builder; +using System.CommandLine.Parsing; -using KubeOps.Cli.Commands; -using KubeOps.Cli.Output; +using KubeOps.Cli; +using KubeOps.Cli.Commands.Generator; -using McMaster.Extensions.CommandLineUtils; +using Spectre.Console; -using Microsoft.Extensions.DependencyInjection; +using Version = KubeOps.Cli.Commands.Utilities.Version; -var services = new ServiceCollection() - .AddSingleton(new Kubernetes(KubernetesClientConfiguration.BuildDefaultConfig())) - .AddSingleton() - .AddSingleton() - .AddSingleton(PhysicalConsole.Singleton) - .BuildServiceProvider(); - -var app = new CommandLineApplication(); -app.Conventions - .UseDefaultConventions() - .UseConstructorInjection(services); - -await app.ExecuteAsync(args); +await new CommandLineBuilder(new RootCommand( + "CLI for KubeOps. Commandline tool to help with management tasks such as generating or installing CRDs.") + { + Generator.Command, Version.Command, + }) + .UseDefaults() + .UseParseErrorReporting(ExitCodes.UsageError) + .UseExceptionHandler((ex, ctx) => + { + AnsiConsole.MarkupLine($"[red]An error ocurred whiled executing {ctx.ParseResult.CommandResult.Command}[/]"); + AnsiConsole.WriteException(ex); + }) + .Build() + .InvokeAsync(args); diff --git a/src/KubeOps.Cli/Roslyn/AssemblyParser.cs b/src/KubeOps.Cli/Roslyn/AssemblyParser.cs new file mode 100644 index 00000000..740da798 --- /dev/null +++ b/src/KubeOps.Cli/Roslyn/AssemblyParser.cs @@ -0,0 +1,151 @@ +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; + +using k8s.Models; + +using KubeOps.Abstractions.Entities.Attributes; +using KubeOps.Abstractions.Rbac; + +using Microsoft.Build.Locator; +using Microsoft.CodeAnalysis.MSBuild; + +using Spectre.Console; + +namespace KubeOps.Cli.Roslyn; + +/// +/// AssemblyParser. +/// +internal sealed partial class AssemblyParser +{ + private readonly Assembly[] _assemblies; + + static AssemblyParser() + { + MSBuildLocator.RegisterDefaults(); + } + + private AssemblyParser(params Assembly[] assemblies) => _assemblies = assemblies; + + public static Task ForProject( + IAnsiConsole console, + FileInfo projectFile) + => console.Status().StartAsync($"Compiling {projectFile.Name}...", async _ => + { + console.MarkupLine($"Compile project [aqua]{projectFile.FullName}[/]."); + using var workspace = MSBuildWorkspace.Create(); + console.WriteLine("Load project."); + var project = await workspace.OpenProjectAsync(projectFile.FullName); + console.MarkupLine("[green]Project loaded.[/]"); + console.WriteLine("Load compilation context."); + var compilation = await project.GetCompilationAsync(); + console.MarkupLine("[green]Compilation context loaded.[/]"); + if (compilation is null) + { + throw new AggregateException("Compilation could not be found."); + } + + using var assemblyStream = new MemoryStream(); + console.WriteLine("Start compilation."); + switch (compilation.Emit(assemblyStream)) + { + case { Success: false, Diagnostics: var diag }: + throw new AggregateException( + $"Compilation failed: {diag.Aggregate(new StringBuilder(), (sb, d) => sb.AppendLine(d.ToString()))}"); + } + + console.MarkupLine("[green]Compilation successful.[/]"); + console.WriteLine(); + return new AssemblyParser(Assembly.Load(assemblyStream.ToArray())); + }); + + public static Task ForSolution( + IAnsiConsole console, + FileInfo slnFile, + Regex? projectFilter = null, + string? tfm = null) + => console.Status().StartAsync($"Compiling {slnFile.Name}...", async _ => + { + projectFilter ??= DefaultRegex(); + tfm ??= "latest"; + + console.MarkupLine($"Compile solution [aqua]{slnFile.FullName}[/]."); + console.MarkupLine($"[grey]With project filter:[/] {projectFilter}"); + console.MarkupLine($"[grey]With Target Platform:[/] {tfm}"); + + using var workspace = MSBuildWorkspace.Create(); + console.WriteLine("Load solution."); + var solution = await workspace.OpenSolutionAsync(slnFile.FullName); + console.MarkupLine("[green]Solution loaded.[/]"); + + var assemblies = await Task.WhenAll(solution.Projects + .Select(p => + { + var name = TfmComparer.TfmRegex().Replace(p.Name, string.Empty); + var tfm = TfmComparer.TfmRegex().Match(p.Name).Groups["tfm"].Value; + return (name, tfm, project: p); + }) + .Where(p => projectFilter.IsMatch(p.name)) + .Where(p => tfm == "latest" || p.tfm.Length == 0 || p.tfm == tfm) + .OrderByDescending(p => p.tfm, new TfmComparer()) + .GroupBy(p => p.name) + .Select(p => p.FirstOrDefault()) + .Where(p => p != default) + .Select(async p => + { + console.MarkupLine( + $"Load compilation context for [aqua]{p.name}[/]{(p.tfm.Length > 0 ? $" [grey]{p.tfm}[/]" : string.Empty)}."); + var compilation = await p.project.GetCompilationAsync(); + console.MarkupLine($"[green]Compilation context loaded for {p.name}.[/]"); + if (compilation is null) + { + throw new AggregateException("Compilation could not be found."); + } + + using var assemblyStream = new MemoryStream(); + console.MarkupLine( + $"Start compilation for [aqua]{p.name}[/]{(p.tfm.Length > 0 ? $" [grey]{p.tfm}[/]" : string.Empty)}."); + switch (compilation.Emit(assemblyStream)) + { + case { Success: false, Diagnostics: var diag }: + throw new AggregateException( + $"Compilation failed: {diag.Aggregate(new StringBuilder(), (sb, d) => sb.AppendLine(d.ToString()))}"); + } + + console.MarkupLine($"[green]Compilation successful for {p.name}.[/]"); + return Assembly.Load(assemblyStream.ToArray()); + })); + + console.WriteLine(); + return new AssemblyParser(assemblies); + }); + + public IEnumerable Entities() => + _assemblies + .SelectMany(a => a.DefinedTypes) + .Where(t => t.GetCustomAttributes().Any()) + .Where(type => !type.GetCustomAttributes().Any()); + + public IEnumerable RbacAttributes() + { + foreach (var type in _assemblies + .SelectMany(a => a.DefinedTypes) + .SelectMany(t => + t.GetCustomAttributes())) + { + yield return type; + } + + foreach (var type in _assemblies + .SelectMany(a => a.DefinedTypes) + .SelectMany(t => + t.GetCustomAttributes())) + { + yield return type; + } + } + + [GeneratedRegex(".*")] + private static partial Regex DefaultRegex(); +} diff --git a/src/KubeOps.Cli/Roslyn/TfmComparer.cs b/src/KubeOps.Cli/Roslyn/TfmComparer.cs new file mode 100644 index 00000000..37bec917 --- /dev/null +++ b/src/KubeOps.Cli/Roslyn/TfmComparer.cs @@ -0,0 +1,58 @@ +using System.Text.RegularExpressions; + +namespace KubeOps.Cli.Roslyn; + +/// +/// Tfm Comparer. +/// +internal sealed partial class TfmComparer : IComparer +{ + [GeneratedRegex( + "[(]?(?(?(netcoreapp|net|netstandard){1})(?[0-9]+)[.](?[0-9]+))[)]?", + RegexOptions.Compiled)] + public static partial Regex TfmRegex(); + + public int Compare(string? x, string? y) + { + if (x == null || y == null) + { + return StringComparer.CurrentCulture.Compare(x, y); + } + + switch (TfmRegex().Match(x), TfmRegex().Match(y)) + { + case ({ Success: false }, _) or (_, { Success: false }): + return StringComparer.CurrentCulture.Compare(x, y); + case ({ } matchX, { } matchY): + var platformX = matchX.Groups["name"].Value; + var platformY = matchY.Groups["name"].Value; + if (platformX != platformY) + { + return (platformX, platformY) switch + { + ("netstandard", _) or (_, "net") => -1, + (_, "netstandard") or ("net", _) => 1, + _ => 0, + }; + } + + var majorX = matchX.Groups["major"].Value; + var majorY = matchY.Groups["major"].Value; + if (majorX != majorY) + { + return int.Parse(majorX) - int.Parse(majorY); + } + + var minorX = matchX.Groups["minor"].Value; + var minorY = matchY.Groups["minor"].Value; + if (minorX != minorY) + { + return int.Parse(minorX) - int.Parse(minorY); + } + + return 0; + default: + return 0; + } + } +} diff --git a/src/KubeOps.Cli/SyntaxObjects/ProjectParser.cs b/src/KubeOps.Cli/SyntaxObjects/ProjectParser.cs deleted file mode 100644 index 31f379a8..00000000 --- a/src/KubeOps.Cli/SyntaxObjects/ProjectParser.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Reflection; -using System.Text; - -using k8s.Models; - -using KubeOps.Abstractions.Entities.Attributes; -using KubeOps.Abstractions.Rbac; - -using Microsoft.Build.Locator; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Emit; -using Microsoft.CodeAnalysis.MSBuild; - -namespace KubeOps.Cli.SyntaxObjects; - -internal sealed class ProjectParser : IDisposable -{ - private readonly MSBuildWorkspace _workspace = MSBuildWorkspace.Create(); - private readonly Lazy> _assembly; - - static ProjectParser() - { - MSBuildLocator.RegisterDefaults(); - } - - public ProjectParser(string projectFile) => _assembly = new Lazy>(AssemblyLoader(projectFile)); - - public async IAsyncEnumerable Entities() - { - var assembly = await _assembly.Value; - foreach (var type in assembly - .DefinedTypes - .Where(t => t.GetCustomAttributes().Any()) - .Where(type => !type.GetCustomAttributes().Any())) - { - yield return type; - } - } - - public async IAsyncEnumerable RbacAttributes() - { - var assembly = await _assembly.Value; - - foreach (var type in assembly - .DefinedTypes - .SelectMany(t => - t.GetCustomAttributes())) - { - yield return type; - } - - foreach (var type in assembly - .DefinedTypes - .SelectMany(t => - t.GetCustomAttributes())) - { - yield return type; - } - } - - public void Dispose() => _workspace.Dispose(); - - private Func> AssemblyLoader(string projectFile) - => async () => - { - var project = await _workspace.OpenProjectAsync(projectFile); - var compilation = await project.GetCompilationAsync(); - if (compilation is null) - { - throw new AggregateException("Compilation could not be found."); - } - - using var assemblyStream = new MemoryStream(); - switch (compilation.Emit(assemblyStream)) - { - case { Success: false, Diagnostics: var diag }: - throw new AggregateException( - $"Compilation failed: {diag.Aggregate(new StringBuilder(), (sb, d) => sb.AppendLine(d.ToString()))}"); - } - - return Assembly.Load(assemblyStream.ToArray()); - }; -}