diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a51ba6..d2aed10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,12 +16,12 @@ jobs: fail-fast: false matrix: configuration: [CSharp, VisualBasic] - project: [Analyzers, CodeFixers, Extensions, HighPerformance, SourceGenerators, DynamicCast, Swagger] + project: [Analyzers, CodeFixers, Extensions, HighPerformance, SourceGenerators, DynamicCast, Kiota] exclude: - configuration: VisualBasic project: DynamicCast - configuration: VisualBasic - project: Swagger + project: Kiota env: PROJECT: ${{ matrix.project }} diff --git a/CompilerPlatform.slnx b/CompilerPlatform.slnx index 75a6797..de535ba 100644 --- a/CompilerPlatform.slnx +++ b/CompilerPlatform.slnx @@ -9,7 +9,7 @@ - + @@ -26,7 +26,7 @@ - + diff --git a/Directory.Build.props b/Directory.Build.props index a540c34..1f9add8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,8 @@ + + $(MSBuildThisFileDirectory) $(RootDirectory)src @@ -30,4 +32,5 @@ + \ No newline at end of file diff --git a/src/features/Riverside.CompilerPlatform.Features.Kiota/Kiota.props b/src/features/Riverside.CompilerPlatform.Features.Kiota/Kiota.props new file mode 100644 index 0000000..aa54407 --- /dev/null +++ b/src/features/Riverside.CompilerPlatform.Features.Kiota/Kiota.props @@ -0,0 +1,67 @@ + + + + 7.3 + 1.21.2 + 1.21.2 + + + + + + + <_Kiota_TargetFramework Include="$(TargetFramework)" Condition="'$(TargetFramework)' != ''" /> + <_Kiota_TargetFramework Include="$(TargetFrameworks)" Condition="'$(TargetFrameworks)' != ''" /> + <_Kiota_InvalidFramework Include="@(_Kiota_TargetFramework)" + Condition="!$([System.Text.RegularExpressions.Regex]::IsMatch('%(Identity)', '^netstandard2\.(0|1)$|^net462$|^net([8-9]|[1-9][0-9])(?:\.[0-9]+)?(?:[-.].*)?$'))" /> + + + + + + + + + + + + + + diff --git a/src/features/Riverside.CompilerPlatform.Features.Swagger/KiotaEngine.Enums.cs b/src/features/Riverside.CompilerPlatform.Features.Kiota/KiotaEngine.Enums.cs similarity index 91% rename from src/features/Riverside.CompilerPlatform.Features.Swagger/KiotaEngine.Enums.cs rename to src/features/Riverside.CompilerPlatform.Features.Kiota/KiotaEngine.Enums.cs index 46ee5ea..90663f7 100644 --- a/src/features/Riverside.CompilerPlatform.Features.Swagger/KiotaEngine.Enums.cs +++ b/src/features/Riverside.CompilerPlatform.Features.Kiota/KiotaEngine.Enums.cs @@ -1,4 +1,4 @@ -namespace Riverside.CompilerPlatform.Features.Swagger; +namespace Riverside.CompilerPlatform.Features.Kiota; partial class KiotaEngine { diff --git a/src/features/Riverside.CompilerPlatform.Features.Swagger/KiotaEngine.Methods.cs b/src/features/Riverside.CompilerPlatform.Features.Kiota/KiotaEngine.Methods.cs similarity index 71% rename from src/features/Riverside.CompilerPlatform.Features.Swagger/KiotaEngine.Methods.cs rename to src/features/Riverside.CompilerPlatform.Features.Kiota/KiotaEngine.Methods.cs index cf50361..ac5ead9 100644 --- a/src/features/Riverside.CompilerPlatform.Features.Swagger/KiotaEngine.Methods.cs +++ b/src/features/Riverside.CompilerPlatform.Features.Kiota/KiotaEngine.Methods.cs @@ -1,6 +1,7 @@ -using System.Text; +using Riverside.CompilerPlatform.Helpers; +using System.Text; -namespace Riverside.CompilerPlatform.Features.Swagger; +namespace Riverside.CompilerPlatform.Features.Kiota; partial class KiotaEngine { @@ -13,19 +14,19 @@ partial class KiotaEngine /// public override string ToString() { - var command = new StringBuilder().Append("kiota generate"); + var command = new StringBuilder().Append("generate"); if (!string.IsNullOrWhiteSpace(Path)) { - command.Append($" --openapi {Path}"); + command.Append($" --openapi {SanitizationHelpers.EscapeArg(Path!)}"); } if (!string.IsNullOrWhiteSpace(Manifest)) { - command.Append($" --manifest {Manifest}"); + command.Append($" --manifest {SanitizationHelpers.EscapeArg(Manifest!)}"); } if (!string.IsNullOrWhiteSpace(Output)) { - command.Append($" --output {Output}"); + command.Append($" --output {SanitizationHelpers.EscapeArg(Output!)}"); } command.Append($" --language {Language}"); if (!string.IsNullOrWhiteSpace(ClassName)) @@ -56,68 +57,68 @@ public override string ToString() { command.Append($" --additional-data {AdditionalData}"); } - if (Serializer is not null) + if (Serializer is not null && Serializer.Length > 0) { var serializers = new StringBuilder().Append(" --serializer "); foreach (var serializer in Serializer) { serializers.Append(serializer + "|"); } - serializers.Remove(serializers.Length, 1); // remove final '|' char + serializers.Remove(serializers.Length - 1, 1); // remove final '|' char command.Append(serializers.ToString()); } - if (Deserializer is not null) + if (Deserializer is not null && Deserializer.Length > 0) { var deserializers = new StringBuilder().Append(" --deserializer "); foreach (var deserializer in Deserializer) { deserializers.Append(deserializer + "|"); } - deserializers.Remove(deserializers.Length, 1); // remove final '|' char + deserializers.Remove(deserializers.Length - 1, 1); // remove final '|' char command.Append(deserializers.ToString()); } if (CleanOutput is not null) { command.Append($" --clean-output {CleanOutput}"); } - if (StructuredMimeTypes is not null) + if (StructuredMimeTypes is not null && StructuredMimeTypes.Length > 0) { var structuredMimeTypes = new StringBuilder().Append(" --structured-mime-types "); foreach (var structuredMimeType in StructuredMimeTypes) { structuredMimeTypes.Append(structuredMimeType + "|"); } - structuredMimeTypes.Remove(structuredMimeTypes.Length, 1); // remove final '|' char + structuredMimeTypes.Remove(structuredMimeTypes.Length - 1, 1); // remove final '|' char command.Append(structuredMimeTypes.ToString()); } - if (IncludePath is not null) + if (IncludePath is not null && IncludePath.Length > 0) { var includePaths = new StringBuilder().Append(" --include-path "); foreach (var includePath in IncludePath) { includePaths.Append(includePath + "|"); } - includePaths.Remove(includePaths.Length, 1); // remove final '|' char + includePaths.Remove(includePaths.Length - 1, 1); // remove final '|' char command.Append(includePaths.ToString()); } - if (ExcludePath is not null) + if (ExcludePath is not null && ExcludePath.Length > 0) { var excludePaths = new StringBuilder().Append(" --exclude-path "); foreach (var excludePath in ExcludePath) { excludePaths.Append(excludePath + "|"); } - excludePaths.Remove(excludePaths.Length, 1); // remove final '|' char + excludePaths.Remove(excludePaths.Length - 1, 1); // remove final '|' char command.Append(excludePaths.ToString()); } - if (DisableValidationRules is not null) + if (DisableValidationRules is not null && DisableValidationRules.Length > 0) { var disableValidationRules = new StringBuilder().Append(" --disable-validation-rules "); foreach (var disableValidationRule in DisableValidationRules) { disableValidationRules.Append(disableValidationRule + "|"); } - disableValidationRules.Remove(disableValidationRules.Length, 1); // remove final '|' char + disableValidationRules.Remove(disableValidationRules.Length - 1, 1); // remove final '|' char command.Append(disableValidationRules.ToString()); } if (ClearCache is not null) diff --git a/src/features/Riverside.CompilerPlatform.Features.Swagger/KiotaEngine.cs b/src/features/Riverside.CompilerPlatform.Features.Kiota/KiotaEngine.cs similarity index 93% rename from src/features/Riverside.CompilerPlatform.Features.Swagger/KiotaEngine.cs rename to src/features/Riverside.CompilerPlatform.Features.Kiota/KiotaEngine.cs index d384f22..aff3c43 100644 --- a/src/features/Riverside.CompilerPlatform.Features.Swagger/KiotaEngine.cs +++ b/src/features/Riverside.CompilerPlatform.Features.Kiota/KiotaEngine.cs @@ -1,6 +1,6 @@ -using System.Text; +using System.Diagnostics.CodeAnalysis; -namespace Riverside.CompilerPlatform.Features.Swagger; +namespace Riverside.CompilerPlatform.Features.Kiota; /// /// Represents the configuration and options for generating code using the Kiota engine. @@ -128,6 +128,7 @@ public partial class KiotaEngine /// The target programming language for code generation. /// The name of the root class to be generated. Can be null to use a default class name. /// The access modifier to apply to generated types. Can be null to use the default accessibility. + /// The namespace for the generated client class. Can be null to use the default namespace. /// The log level to use for diagnostic output during generation. Can be null to use the default log level. /// Indicates whether to use a backing store for generated models. If null, the default behavior is used. /// Indicates whether to exclude backward compatible code from the output. If null, the default behavior is used. @@ -141,7 +142,8 @@ public partial class KiotaEngine /// An array of validation rules to disable during generation. Can be null to enable all rules. /// Indicates whether to clear the internal cache before generation. If null, the default behavior is used. /// Indicates whether to disable SSL validation for network operations. If null, the default behavior is used. - public KiotaEngine(string? d, string? a, string? o, GenerationLanguage l, string? c, Accessibility? tam, ConsoleLogLevel? ll, bool? b, bool? ebc, bool? ad, string[]? s, string[]? ds, bool? co, string[]? m, string[]? i, string[]? e, ValidationRules[]? dvr, bool? cc, bool? dsv) + [SetsRequiredMembers] + public KiotaEngine(string? d, string? a, string? o, GenerationLanguage l, string? c, Accessibility? tam, string? n, ConsoleLogLevel? ll, bool? b, bool? ebc, bool? ad, string[]? s, string[]? ds, bool? co, string[]? m, string[]? i, string[]? e, ValidationRules[]? dvr, bool? cc, bool? dsv) { Path = d; Manifest = a; @@ -149,6 +151,7 @@ public KiotaEngine(string? d, string? a, string? o, GenerationLanguage l, string Language = l; ClassName = c; TypeAccessModifier = tam; + NamespaceName = n; LogLevel = ll; BackingStore = b; ExcludeBackwardCompatible = ebc; diff --git a/src/features/Riverside.CompilerPlatform.Features.Kiota/KiotaGenerator.cs b/src/features/Riverside.CompilerPlatform.Features.Kiota/KiotaGenerator.cs new file mode 100644 index 0000000..177f3a9 --- /dev/null +++ b/src/features/Riverside.CompilerPlatform.Features.Kiota/KiotaGenerator.cs @@ -0,0 +1,193 @@ +using Riverside.CompilerPlatform.Extensions; +using Riverside.CompilerPlatform.Helpers; +using Riverside.CompilerPlatform.SourceGenerators; +using System; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; + +namespace Riverside.CompilerPlatform.Features.Kiota; + +/// +/// Generates source code from OpenAPI specification files as part of the build process. +/// +[Generator] +public partial class KiotaGenerator : IncrementalGenerator +{ + private const string VersionProperty = "build_property.Kiota_Version"; + private const string LanguageProperty = "build_property.KiotaGenerator_Language"; + private const string ClassNameProperty = "build_property.KiotaGenerator_ClassName"; + private const string NamespaceNameProperty = "build_property.KiotaGenerator_NamespaceName"; + private const string TypeAccessModifierProperty = "build_property.KiotaGenerator_TypeAccessModifier"; + private const string LogLevelProperty = "build_property.KiotaGenerator_LogLevel"; + private const string BackingStoreProperty = "build_property.KiotaGenerator_BackingStore"; + private const string ExcludeBackwardCompatibleProperty = "build_property.KiotaGenerator_ExcludeBackwardCompatible"; + private const string AdditionalDataProperty = "build_property.KiotaGenerator_AdditionalData"; + private const string SerializerProperty = "build_property.KiotaGenerator_Serializer"; + private const string DeserializerProperty = "build_property.KiotaGenerator_Deserializer"; + private const string CleanOutputProperty = "build_property.KiotaGenerator_CleanOutput"; + private const string StructuredMimeTypesProperty = "build_property.KiotaGenerator_StructuredMimeTypes"; + private const string IncludePathProperty = "build_property.KiotaGenerator_IncludePath"; + private const string ExcludePathProperty = "build_property.KiotaGenerator_ExcludePath"; + private const string DisableValidationRulesProperty = "build_property.KiotaGenerator_DisableValidationRules"; + private const string ClearCacheProperty = "build_property.KiotaGenerator_ClearCache"; + private const string DisableSSLValidationProperty = "build_property.KiotaGenerator_DisableSSLValidation"; + + private static readonly string ToolDirectory = Path.Combine( + Path.GetTempPath(), "Roslyn", "Advanced Compiler Services for .NET", "KiotaGenerator"); + + /// + protected override void OnBeforeGeneration(GeneratorContext context, CancellationToken cancellationToken) + { + var options = context.AnalyzerConfigOptions.GlobalOptions; + + var specs = context.AdditionalTexts + .Where(at => at.Path.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) + || at.Path.EndsWith(".yml", StringComparison.OrdinalIgnoreCase) + || at.Path.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + .ToImmutableArray(); + + if (specs.IsEmpty) + return; + + var version = options.GetString(VersionProperty); + + // Kiota engine args + var language = options.GetNullableEnum(LanguageProperty) + ?? KiotaEngine.GenerationLanguage.CSharp; + var className = options.GetString(ClassNameProperty); + var namespaceName = options.GetString(NamespaceNameProperty); + var typeAccessModifier = options.GetNullableEnum(TypeAccessModifierProperty); + var logLevel = options.GetNullableEnum(LogLevelProperty); + var backingStore = options.GetNullableBool(BackingStoreProperty); + var excludeBackwardCompatible = options.GetNullableBool(ExcludeBackwardCompatibleProperty); + var additionalData = options.GetNullableBool(AdditionalDataProperty); + var serializers = options.GetPipeSeparatedArray(SerializerProperty); + var deserializers = options.GetPipeSeparatedArray(DeserializerProperty); + var cleanOutput = options.GetNullableBool(CleanOutputProperty); + var structuredMimeTypes = options.GetPipeSeparatedArray(StructuredMimeTypesProperty); + var includePaths = options.GetPipeSeparatedArray(IncludePathProperty); + var excludePaths = options.GetPipeSeparatedArray(ExcludePathProperty); + var disableValidationRules = options.GetPipeSeparatedEnumArray(DisableValidationRulesProperty); + var clearCache = options.GetNullableBool(ClearCacheProperty); + var disableSSLValidation = options.GetNullableBool(DisableSSLValidationProperty); + + string toolExecutable; + try + { + var (installed, installError) = NETCoreToolHelpers + .EnsureToolAsync("Microsoft.OpenApi.Kiota", ToolDirectory, version, commandName: "kiota") + .GetAwaiter().GetResult(); + + if (!installed) + { + CreateDiagnostic( + "KG0000", + "Microsoft Kiota installation failed", + installError ?? "Failed to install or locate the Microsoft Kiota tool.").Report(context); + return; + } + + toolExecutable = NETCoreToolHelpers.GetExecutablePath(ToolDirectory, "kiota"); + } + catch (Exception ex) + { + CreateDiagnostic("KG0000", "Microsoft Kiota installation failed", ex.Message).Report(context); + return; + } + + foreach (var spec in specs) + { + cancellationToken.ThrowIfCancellationRequested(); + + var specPath = spec.Path; + if (!File.Exists(specPath)) + continue; + + var specFileName = Path.GetFileNameWithoutExtension(specPath); + var effectiveNamespace = namespaceName ?? SanitizationHelpers.Sanitize(specFileName); + + var tempOut = DirectoryHelpers.CreateTemporary( + Path.Combine(Path.GetTempPath(), "Roslyn", "Advanced Compiler Services for .NET")); + + try + { + var engine = new KiotaEngine( + d: specPath, + a: null, + o: tempOut, + l: language, + c: className, + n: effectiveNamespace, + tam: typeAccessModifier, + ll: logLevel, + b: backingStore, + ebc: excludeBackwardCompatible, + ad: additionalData, + s: serializers, + ds: deserializers, + co: cleanOutput, + m: structuredMimeTypes, + i: includePaths, + e: excludePaths, + dvr: disableValidationRules, + cc: clearCache, + dsv: disableSSLValidation); + + var runResult = ProcessHelpers + .RunProcess(toolExecutable, engine.ToString(), TimeSpan.FromMinutes(2)) + .GetAwaiter().GetResult(); + + if (runResult.ExitCode != 0) + { + CreateDiagnostic( + "KG0001", + "OpenAPI generation failed", + $"Microsoft Kiota failed for spec '{specPath}' with exit code {runResult.ExitCode}: {runResult.StandardError.ReplaceLineEndings(" ")}").Report(context); + DirectoryHelpers.TryDelete(tempOut); + continue; + } + + var csFiles = Directory.EnumerateFiles(tempOut, "*.cs", SearchOption.AllDirectories).ToArray(); + if (csFiles.Length == 0) + { + CreateDiagnostic( + "KG0002", + "No C# files generated", + $"Microsoft Kiota produced no C# files for spec '{specPath}'").Report(context); + DirectoryHelpers.TryDelete(tempOut); + continue; + } + + foreach (var cs in csFiles) + { + try + { + var content = File.ReadAllText(cs, Encoding.UTF8); + var rel = Path.GetRelativePath(tempOut, cs) + .Replace(Path.DirectorySeparatorChar, '.') + .Replace(Path.AltDirectorySeparatorChar, '.'); + var hintName = $"{SanitizationHelpers.Sanitize(engine.NamespaceName!)}.{SanitizationHelpers.Sanitize(rel)}"; + AddSource(hintName, content); + } + catch (Exception ex) + { + CreateDiagnostic( + "KG0003", + "Failed to add generated file", + $"Failed to add '{cs}': {ex.Message}").Report(context); + } + } + + DirectoryHelpers.TryDelete(tempOut); + } + catch (Exception ex) + { + CreateDiagnostic("KG9999", "OpenAPI generator exception", ex.ToString()).Report(context); + DirectoryHelpers.TryDelete(tempOut); + } + } + } +} diff --git a/src/features/Riverside.CompilerPlatform.Features.Kiota/Riverside.CompilerPlatform.Features.Kiota.csproj b/src/features/Riverside.CompilerPlatform.Features.Kiota/Riverside.CompilerPlatform.Features.Kiota.csproj new file mode 100644 index 0000000..b3b6e17 --- /dev/null +++ b/src/features/Riverside.CompilerPlatform.Features.Kiota/Riverside.CompilerPlatform.Features.Kiota.csproj @@ -0,0 +1,42 @@ + + + + netstandard2.0 + CSharp + false + $(NoWarn);NU5128 + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + + + + + + diff --git a/src/features/Riverside.CompilerPlatform.Features.Swagger/KiotaGenerator.cs b/src/features/Riverside.CompilerPlatform.Features.Swagger/KiotaGenerator.cs deleted file mode 100644 index f4582d6..0000000 --- a/src/features/Riverside.CompilerPlatform.Features.Swagger/KiotaGenerator.cs +++ /dev/null @@ -1,156 +0,0 @@ -using Riverside.CompilerPlatform.SourceGenerators; -using System.Threading; -using System; -using System.Linq; -using System.Collections.Immutable; -using System.IO; -using System.Text; -using Riverside.CompilerPlatform.Extensions; -using Riverside.CompilerPlatform.Helpers; - -namespace Riverside.CompilerPlatform.Features.Swagger; - -/// -/// Generates source code from OpenAPI specification files as part of the build process. -/// -[Generator] -public partial class KiotaGenerator : IncrementalGenerator -{ - private const string VersionProperty = "build_property.KiotaGenerator_Version"; - private const string OptionsProperty = "build_property.KiotaGenerator_Options"; - private const string LanguageProperty = "build_property.KiotaGenerator_Language"; - private const string AdditionalPropertiesProperty = "build_property.KiotaGenerator_AdditionalProperties"; - - /// - protected override void OnBeforeGeneration(GeneratorContext context, CancellationToken cancellationToken) - { - var optionsProvider = context.AnalyzerConfigOptions.GlobalOptions; - - // OpenAPI specs - var specs = context.AdditionalTexts - .Where(at => at.Path.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) - || at.Path.EndsWith(".yml", StringComparison.OrdinalIgnoreCase) - || at.Path.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) - .ToImmutableArray(); - - optionsProvider.TryGetValue(VersionProperty, out var version); - optionsProvider.TryGetValue(OptionsProperty, out var cliOptions); - optionsProvider.TryGetValue(LanguageProperty, out var language); - optionsProvider.TryGetValue(AdditionalPropertiesProperty, out var additionalProps); - - version ??= string.Empty; - language ??= "csharp"; - - var jarPath = EnsureToolInstallation(version, context); - - if (string.IsNullOrWhiteSpace(jarPath)) - { - IncrementalGenerator.CreateDiagnostic( - "RS0000", - "JAR not downloaded", - "An error occured whilst downloading the JAR executable to generate the OpenAPI spec") - .Report(context); - } - - foreach (var spec in specs) - { - try - { - var specNamespace = SanitizationHelpers.Sanitize(Path.GetFileNameWithoutExtension(Path.GetFileName(spec.Path))); - - var specPath = spec.Path; - if (!File.Exists(specPath)) - continue; - - var specFileName = Path.GetFileNameWithoutExtension(specPath); - - var tempOut = Path.Combine(Path.GetTempPath(), "Roslyn", "Advanced Compiler Services for .NET", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(tempOut); - - if (!string.IsNullOrWhiteSpace(cliOptions)) - { - argsBuilder.Append(" "); - argsBuilder.Append(cliOptions); - } - - if (!string.IsNullOrWhiteSpace(additionalProps)) - { - argsBuilder.Append(" --additional-properties="); - argsBuilder.Append(SanitizationHelpers.EscapeArg(additionalProps!)); - } - - var args = argsBuilder.ToString(); - - var runResult = ProcessHelpers.RunProcess("java", args, TimeSpan.FromMinutes(2)).GetAwaiter().GetResult(); - - if (runResult.ExitCode != 0) - { - CreateDiagnostic("RS0000", "OpenAPI generator failed", $"OpenAPI generator failed for spec '{spec.Path}' with exit code {runResult.ExitCode}: {runResult.StandardError.ReplaceLineEndings(" ")}").Report(context); - TryDeleteDirectory(tempOut); - continue; - } - - var srcDir = Path.Combine(tempOut, "src", specNamespace); - var csFiles = Directory.EnumerateFiles(srcDir, "*.cs", SearchOption.AllDirectories).ToArray(); - if (csFiles.Length == 0) - { - CreateDiagnostic("RS0000", "No C# files generated", $"OpenAPI generator produced no C# files for spec '{spec.Path}'").Report(context); - TryDeleteDirectory(tempOut); - continue; - } - - foreach (var cs in csFiles) - { - try - { - var content = File.ReadAllText(cs, Encoding.UTF8); - var rel = Path.GetRelativePath(tempOut, cs) - .Replace(Path.DirectorySeparatorChar, '_') - .Replace(Path.AltDirectorySeparatorChar, '_'); - - var hintName = $"{SanitizationHelpers.Sanitize(specFileName)}_{SanitizationHelpers.Sanitize(rel)}"; - AddSource(hintName, content); - } - catch (Exception ex) - { - CreateDiagnostic("RS0000", "Failed to add generated file", $"Failed to add generated file '{cs}': {ex.Message}").Report(context); - } - } - - TryDeleteDirectory(tempOut); - - AddSource($"{SanitizationHelpers.Sanitize(specFileName)}_AnyOf", AnyOf_Polyfill(specNamespace + ".Model")); - } - catch (Exception ex) - { - CreateDiagnostic("RS9999", "OpenAPI generator exception", ex.ToString()); - } - } - } - - private static string? EnsureToolInstallation(string version, GeneratorContext context) - { - try - { - var baseDir = Path.Combine(Path.GetTempPath(), "Roslyn", "Advanced Compiler Services for .NET", "KiotaGenerator"); - Directory.CreateDirectory(baseDir); - - var jarPath = Path.Combine(baseDir, $"openapi-generator-cli-{version}.jar"); - if (File.Exists(jarPath)) - return jarPath; - - return jarPath; - } - catch (Exception ex) - { - CreateDiagnostic("RS0000", $"Failed to download OpenAPI generator JAR", $"Could not download version {version}: {ex.Message}").Report(context); - return null; - } - } - - private static void TryDeleteDirectory(string path) - { - try { if (Directory.Exists(path)) Directory.Delete(path, true); } - catch { } - } -} diff --git a/src/features/Riverside.CompilerPlatform.Features.Swagger/Riverside.CompilerPlatform.Features.Swagger.csproj b/src/features/Riverside.CompilerPlatform.Features.Swagger/Riverside.CompilerPlatform.Features.Swagger.csproj deleted file mode 100644 index f8ca381..0000000 --- a/src/features/Riverside.CompilerPlatform.Features.Swagger/Riverside.CompilerPlatform.Features.Swagger.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - netstandard2.0 - CSharp - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers - - - - diff --git a/src/roslyn/Riverside.CompilerPlatform.Extensions/Extensions/AnalyzerConfigOptionsExtensions.cs b/src/roslyn/Riverside.CompilerPlatform.Extensions/Extensions/AnalyzerConfigOptionsExtensions.cs new file mode 100644 index 0000000..858f9d4 --- /dev/null +++ b/src/roslyn/Riverside.CompilerPlatform.Extensions/Extensions/AnalyzerConfigOptionsExtensions.cs @@ -0,0 +1,92 @@ +using System; +using System.Linq; + +namespace Riverside.CompilerPlatform.Extensions; + +/// +/// Provides extension methods for reading typed values from . +/// +public static class AnalyzerConfigOptionsExtensions +{ + /// + /// Returns the string value for , or if the key is absent or whitespace. + /// + /// The analyser config options to read from. + /// The property key, typically prefixed with build_property.. + /// The trimmed string value, or . + public static string? GetString(this AnalyzerConfigOptions options, string key) + { + options.TryGetValue(key, out var value); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + /// + /// Returns a nullable for . + /// Returns when the key is absent, empty, or not a valid boolean string. + /// + /// The analyzer config options to read from. + /// The property key. + /// The parsed boolean, or . + public static bool? GetNullableBool(this AnalyzerConfigOptions options, string key) + { + var value = options.GetString(key); + return value is not null && bool.TryParse(value, out var result) ? result : null; + } + + /// + /// Returns a nullable for , parsed case-insensitively. + /// Returns when the key is absent, empty, or does not map to a valid enum member. + /// + /// The enum type to parse into. + /// The analyser config options to read from. + /// The property key. + /// The parsed enum value, or . + public static TEnum? GetNullableEnum(this AnalyzerConfigOptions options, string key) + where TEnum : struct, Enum + { + var value = options.GetString(key); + return value is not null && Enum.TryParse(value, ignoreCase: true, out var result) ? result : null; + } + + /// + /// Returns a [] by splitting 's value on the | character. + /// Empty or whitespace-only segments are discarded. + /// Returns when the key is absent, empty, or yields no usable segments. + /// + /// The analyser config options to read from. + /// The property key. + /// A non-empty trimmed array of segments, or . + public static string[]? GetPipeSeparatedArray(this AnalyzerConfigOptions options, string key) + { + var value = options.GetString(key); + if (value is null) + return null; + var parts = value.Split('|') + .Select(p => p.Trim()) + .Where(p => p.Length > 0) + .ToArray(); + return parts.Length > 0 ? parts : null; + } + + /// + /// Returns a [] by splitting 's value on | and parsing each segment case-insensitively. + /// Segments that do not match a valid enum member are silently skipped. + /// + /// The enum type to parse each segment into. + /// The analyser config options to read from. + /// The property key. + /// A non-empty array of parsed enum values, or when the is absent, empty, or yields no valid members. + public static TEnum[]? GetPipeSeparatedEnumArray(this AnalyzerConfigOptions options, string key) + where TEnum : struct, Enum + { + var raw = options.GetPipeSeparatedArray(key); + if (raw is null) + return null; + var parsed = raw + .Select(s => (ok: Enum.TryParse(s, ignoreCase: true, out var v), val: v)) + .Where(t => t.ok) + .Select(t => t.val) + .ToArray(); + return parsed.Length > 0 ? parsed : null; + } +} diff --git a/src/roslyn/Riverside.CompilerPlatform.Extensions/Helpers/DirectoryHelpers.cs b/src/roslyn/Riverside.CompilerPlatform.Extensions/Helpers/DirectoryHelpers.cs new file mode 100644 index 0000000..99a0167 --- /dev/null +++ b/src/roslyn/Riverside.CompilerPlatform.Extensions/Helpers/DirectoryHelpers.cs @@ -0,0 +1,33 @@ +using System; +using System.IO; + +namespace Riverside.CompilerPlatform.Helpers; + +/// +/// Provides utility methods for common directory operations. +/// +public static class DirectoryHelpers +{ + /// + /// Deletes and all of its contents recursively, suppressing any exception that occurs. + /// + /// The directory to delete. + public static void TryDelete(string path) + { + try { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); } + catch { } + } + + /// + /// Creates a uniquely named subdirectory under and returns its full path. + /// The subdirectory name is a compact with no formatting characters. + /// + /// The parent directory. Created if it does not already exist. + /// The full path of the newly created temporary directory. + public static string CreateTemporary(string basePath) + { + var path = Path.Combine(basePath, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } +} diff --git a/src/roslyn/Riverside.CompilerPlatform.Extensions/Helpers/NETCoreToolHelpers.cs b/src/roslyn/Riverside.CompilerPlatform.Extensions/Helpers/NETCoreToolHelpers.cs new file mode 100644 index 0000000..a80df77 --- /dev/null +++ b/src/roslyn/Riverside.CompilerPlatform.Extensions/Helpers/NETCoreToolHelpers.cs @@ -0,0 +1,80 @@ +using System; +using System.IO; +using System.Threading.Tasks; + +namespace Riverside.CompilerPlatform.Helpers; + +/// +/// Provides helpers for installing and locating .NET tools installed to a specific tool-path directory. +/// +public static class NETCoreToolHelpers +{ + /// + /// Returns the full path to the tool executable expected in . + /// Appends .exe on Windows; uses the bare name on all other platforms. + /// + /// The directory the tool was installed into via --tool-path. + /// The tool executable name (e.g. kiota). + /// The full path including the platform-appropriate extension. + public static string GetExecutablePath(string toolDirectory, string toolName) + => Path.Combine( + toolDirectory, + Environment.OSVersion.Platform == PlatformID.Win32NT ? toolName + ".exe" : toolName); + + /// + /// Ensures the specified .NET tool is available in . + /// + /// + /// + /// If the executable already exists and no specific is requested, the tool is reused immediately. + /// Otherwise dotnet tool install is attempted. If it fails because the tool is already installed, dotnet tool update is tried instead. + /// Installation succeeds when the executable is present after the above steps. + /// + /// + /// The NuGet package ID of the tool (e.g. Riverside.JsonBinder.Console). + /// The directory to install the tool into, passed to --tool-path. + /// + /// A specific version to pin. Pass to install or keep the latest. + /// + /// Maximum wait time per install or update process. Defaults to 5 minutes. + /// + /// The executable/command name to look for in (e.g. jsonbinder). + /// If , is used as the command name. + /// + /// + /// A tuple where Success is when the executable is available, and Error carries the captured stderr when installation fails. + /// + public static async Task<(bool Success, string? Error)> EnsureToolAsync( + string packageId, + string toolDirectory, + string? version = null, + TimeSpan? timeout = null, + string? commandName = null) + { + var exeName = string.IsNullOrWhiteSpace(commandName) ? packageId : commandName; + var exe = GetExecutablePath(toolDirectory, exeName!); + var effectiveTimeout = timeout ?? TimeSpan.FromMinutes(5); + + Directory.CreateDirectory(toolDirectory); + + if (File.Exists(exe) && string.IsNullOrWhiteSpace(version)) + return (true, null); + + var toolPathArg = $"--tool-path \"{toolDirectory}\""; + var versionArg = string.IsNullOrWhiteSpace(version) ? string.Empty : $" --version {version}"; + + var installResult = await ProcessHelpers.RunNETCoreCliAsync( + $"tool install {packageId} {toolPathArg}{versionArg}", effectiveTimeout); + + if (installResult.ExitCode == 0) + return (true, null); + + // install exits non-zero when the tool is already present; attempt an update instead + var updateResult = await ProcessHelpers.RunNETCoreCliAsync( + $"tool update {packageId} {toolPathArg}{versionArg}", effectiveTimeout); + + return File.Exists(exe) + ? (true, null) + : (false, updateResult.StandardError); + } +} diff --git a/src/roslyn/Riverside.CompilerPlatform.Extensions/Helpers/ProcessHelpers.cs b/src/roslyn/Riverside.CompilerPlatform.Extensions/Helpers/ProcessHelpers.cs index 4132700..9bb2dd5 100644 --- a/src/roslyn/Riverside.CompilerPlatform.Extensions/Helpers/ProcessHelpers.cs +++ b/src/roslyn/Riverside.CompilerPlatform.Extensions/Helpers/ProcessHelpers.cs @@ -49,8 +49,7 @@ public class ProcessOutput(int code, string stdout, string stderr) /// A tuple containing the process exit code, the captured standard output, and the captured standard error. /// If the process times out, the exit code is -1 and the standard error includes a timeout message. /// - public static async Task - RunProcess(string fileName, string arguments, TimeSpan timeout) + public static async Task RunProcess(string fileName, string arguments, TimeSpan timeout) { var psi = new ProcessStartInfo { @@ -82,4 +81,14 @@ public static async Task return new(proc.ExitCode, outputSb.ToString(), errorSb.ToString()); } + + /// + /// Runs a dotnet command asynchronously with the specified arguments and timeout, + /// capturing its exit code, standard output, and standard error. + /// + /// The arguments to pass after dotnet (e.g. tool install Riverside.JsonBinder.Console ...). + /// The maximum duration to wait before forcibly terminating the process. + /// A containing the exit code and captured streams. + public static Task RunNETCoreCliAsync(string arguments, TimeSpan timeout) + => RunProcess("dotnet", arguments, timeout); } diff --git a/src/roslyn/Riverside.CompilerPlatform.Extensions/Helpers/SanitizationHelpers.cs b/src/roslyn/Riverside.CompilerPlatform.Extensions/Helpers/SanitizationHelpers.cs index 74aea88..a258a42 100644 --- a/src/roslyn/Riverside.CompilerPlatform.Extensions/Helpers/SanitizationHelpers.cs +++ b/src/roslyn/Riverside.CompilerPlatform.Extensions/Helpers/SanitizationHelpers.cs @@ -1,5 +1,4 @@ -using Riverside.Extensions.Accountability; -using System; +using System; using System.IO; using System.Linq; using System.Text; @@ -36,7 +35,6 @@ public static string Sanitize(string s) /// The argument to escape. /// A string containing the escaped argument, suitable for use in a command-line context. /// Thrown if is null. - [NotMyCode] // from the internet public static string EscapeArg(string arg) { if (arg == null) diff --git a/src/roslyn/Riverside.CompilerPlatform.SourceGenerators/IncrementalGenerator.cs b/src/roslyn/Riverside.CompilerPlatform.SourceGenerators/IncrementalGenerator.cs index 0a46157..8d9f615 100644 --- a/src/roslyn/Riverside.CompilerPlatform.SourceGenerators/IncrementalGenerator.cs +++ b/src/roslyn/Riverside.CompilerPlatform.SourceGenerators/IncrementalGenerator.cs @@ -1,4 +1,5 @@ -using System; +using Riverside.CompilerPlatform.Extensions; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -249,12 +250,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context) if (!SuppressDiagnostics) { // Report exceptions that occur during generation - sourceProductionContext.ReportDiagnostic( - CreateDiagnostic( - $"RS9999", - "Source Generation Error", - $"An error occurred during source generation: {ex.Message}", - DiagnosticSeverity.Error)); + CreateDiagnostic( + $"RS9999", + "Source Generation Error", + $"An error occurred during source generation: {ex.Message}", + DiagnosticSeverity.Error).Report(Context); } } }); diff --git a/tests/Riverside.CompilerPlatform.Features.Tests/api-1.json b/tests/Riverside.CompilerPlatform.Features.Tests/Lapse.json similarity index 100% rename from tests/Riverside.CompilerPlatform.Features.Tests/api-1.json rename to tests/Riverside.CompilerPlatform.Features.Tests/Lapse.json diff --git a/tests/Riverside.CompilerPlatform.Features.Tests/Riverside.CompilerPlatform.Features.Tests.csproj b/tests/Riverside.CompilerPlatform.Features.Tests/Riverside.CompilerPlatform.Features.Tests.csproj index dba8912..b186e42 100644 --- a/tests/Riverside.CompilerPlatform.Features.Tests/Riverside.CompilerPlatform.Features.Tests.csproj +++ b/tests/Riverside.CompilerPlatform.Features.Tests/Riverside.CompilerPlatform.Features.Tests.csproj @@ -3,25 +3,28 @@ VisualBasic;CSharp netstandard2.0 + 14.0 - - - + - - - - - - + + + + - + + + + + + +