diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/StringExtensions.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/StringExtensions.cs index c6bd2d3213b9..63f2b953ecfe 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/StringExtensions.cs +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/StringExtensions.cs @@ -1,10 +1,17 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Json; + namespace Microsoft.DotNet.Cli.Utils.Extensions; public static class StringExtensions { + /// + /// Strips CLI option prefixes like -, --, or / from a string to reveal the user-facing name. + /// + /// + /// public static string RemovePrefix(this string name) { int prefixLength = GetPrefixLength(name); @@ -26,4 +33,11 @@ static int GetPrefixLength(string name) return 0; } } + + /// + /// Converts a string to camel case using the JSON naming policy. Camel-case means that the first letter of the string is lowercase, and the first letter of each subsequent word is uppercase. + /// + /// A string to ensure is camel-cased + /// The camel-cased string + public static string ToCamelCase(this string value) => JsonNamingPolicy.CamelCase.ConvertName(value); } diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/TypeExtensions.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/TypeExtensions.cs new file mode 100644 index 000000000000..4c78dac7e82f --- /dev/null +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/TypeExtensions.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Cli.Utils.Extensions; + +public static class TypeExtensions +{ + /// + /// Converts a Type (potentially containing generic parameters) from CLI representation (e.g. System.Collections.Generic.List`1[[System.Int32, System.Private.CoreLib, Version=10.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e]]) + /// to a more readable string representation (e.g. System.Collections.Generic.List<System.Int32>). + /// + /// + /// This is used when outputting the Type information for the CLI schema JSON. + /// + public static string ToCliTypeString(this Type type) + { + var typeName = type.FullName ?? string.Empty; + if (!type.IsGenericType) + { + return typeName; + } + + var genericTypeName = typeName.Substring(0, typeName.IndexOf('`')); + var genericTypes = string.Join(", ", type.GenericTypeArguments.Select(generic => generic.ToCliTypeString())); + return $"{genericTypeName}<{genericTypes}>"; + } +} diff --git a/src/Cli/dotnet/CliSchema.cs b/src/Cli/dotnet/CliSchema.cs new file mode 100644 index 000000000000..0496977bbd73 --- /dev/null +++ b/src/Cli/dotnet/CliSchema.cs @@ -0,0 +1,213 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.CommandLine; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Schema; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Microsoft.DotNet.Cli.Telemetry; +using Microsoft.DotNet.Cli.Utils; +using Microsoft.DotNet.Cli.Utils.Extensions; +using Command = System.CommandLine.Command; +using CommandResult = System.CommandLine.Parsing.CommandResult; + +namespace Microsoft.DotNet.Cli; + +internal static class CliSchema +{ + // Using UnsafeRelaxedJsonEscaping because this JSON is not transmitted over the web. Therefore, HTML-sensitive characters are not encoded. + // See: https://learn.microsoft.com/dotnet/api/system.text.encodings.web.javascriptencoder.unsaferelaxedjsonescaping + // Force the newline to be "\n" instead of the default "\r\n" for consistency across platforms (and for testing) + private static readonly JsonSerializerOptions s_jsonSerializerOptions = new() + { + WriteIndented = true, + NewLine = "\n", + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + RespectNullableAnnotations = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + // needed to workaround https://github.com/dotnet/aspnetcore/issues/55692, but will need to be removed when + // we tackle AOT in favor of the source-generated JsonTypeInfo stuff + TypeInfoResolver = new DefaultJsonTypeInfoResolver() + }; + + public record ArgumentDetails(string? description, int order, bool hidden, string? helpName, string valueType, bool hasDefaultValue, object? defaultValue, ArityDetails arity); + public record ArityDetails(int minimum, int? maximum); + public record OptionDetails( + string? description, + bool hidden, + string[]? aliases, + string? helpName, + string valueType, + bool hasDefaultValue, + object? defaultValue, + ArityDetails arity, + bool required, + bool recursive + ); + public record CommandDetails( + string? description, + bool hidden, + string[]? aliases, + Dictionary? arguments, + Dictionary? options, + Dictionary? subcommands); + public record RootCommandDetails( + string name, + string version, + string? description, + bool hidden, + string[]? aliases, + Dictionary? arguments, + Dictionary? options, + Dictionary? subcommands + ) : CommandDetails(description, hidden, aliases, arguments, options, subcommands); + + + public static void PrintCliSchema(CommandResult commandResult, TextWriter outputWriter, ITelemetry? telemetryClient) + { + var command = commandResult.Command; + RootCommandDetails transportStructure = CreateRootCommandDetails(command); + var result = JsonSerializer.Serialize(transportStructure, s_jsonSerializerOptions); + outputWriter.Write(result.AsSpan()); + outputWriter.Flush(); + var commandString = CommandHierarchyAsString(commandResult); + var telemetryProperties = new Dictionary { { "command", commandString } }; + telemetryClient?.TrackEvent("schema", telemetryProperties, null); + } + + public static object GetJsonSchema() + { + var node = s_jsonSerializerOptions.GetJsonSchemaAsNode(typeof(RootCommandDetails), new JsonSchemaExporterOptions()); + return node.ToJsonString(s_jsonSerializerOptions); + } + + private static ArityDetails CreateArityDetails(ArgumentArity arity) + { + return new ArityDetails( + minimum: arity.MinimumNumberOfValues, + maximum: arity.MaximumNumberOfValues == ArgumentArity.ZeroOrMore.MaximumNumberOfValues ? null : arity.MaximumNumberOfValues + ); + } + + private static RootCommandDetails CreateRootCommandDetails(Command command) + { + var arguments = CreateArgumentsDictionary(command.Arguments); + var options = CreateOptionsDictionary(command.Options); + var subcommands = CreateSubcommandsDictionary(command.Subcommands); + + return new RootCommandDetails( + name: command.Name, + version: Product.Version, + description: command.Description?.ReplaceLineEndings("\n"), + hidden: command.Hidden, + aliases: DetermineAliases(command.Aliases), + arguments: arguments, + options: options, + subcommands: subcommands + ); + } + + private static Dictionary? CreateArgumentsDictionary(IList arguments) + { + if (arguments.Count == 0) + { + return null; + } + var dict = new Dictionary(); + foreach ((var index, var argument) in arguments.Index()) + { + dict[argument.Name] = CreateArgumentDetails(index, argument); + } + return dict; + } + + private static Dictionary? CreateOptionsDictionary(IList