File: CliSchema.cs
Web Access
Project: ..\..\..\src\Cli\dotnet\dotnet.csproj (dotnet)
// 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<string, ArgumentDetails>? arguments,
        Dictionary<string, OptionDetails>? options,
        Dictionary<string, CommandDetails>? subcommands);
    public record RootCommandDetails(
        string name,
        string version,
        string? description,
        bool hidden,
        string[]? aliases,
        Dictionary<string, ArgumentDetails>? arguments,
        Dictionary<string, OptionDetails>? options,
        Dictionary<string, CommandDetails>? 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<string, string> { { "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<string, ArgumentDetails>? CreateArgumentsDictionary(IList<Argument> arguments)
    {
        if (arguments.Count == 0)
        {
            return null;
        }
        var dict = new Dictionary<string, ArgumentDetails>();
        foreach ((var index, var argument) in arguments.Index())
        {
            dict[argument.Name] = CreateArgumentDetails(index, argument);
        }
        return dict;
    }
 
    private static Dictionary<string, OptionDetails>? CreateOptionsDictionary(IList<Option> options)
    {
        if (options.Count == 0)
        {
            return null;
        }
        var dict = new Dictionary<string, OptionDetails>();
        foreach (var option in options.OrderBy(o => o.Name, StringComparer.OrdinalIgnoreCase))
        {
            dict[option.Name] = CreateOptionDetails(option);
        }
        return dict;
    }
 
    private static Dictionary<string, CommandDetails>? CreateSubcommandsDictionary(IList<Command> subcommands)
    {
        if (subcommands.Count == 0)
        {
            return null;
        }
        var dict = new Dictionary<string, CommandDetails>();
        foreach (var subcommand in subcommands.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase))
        {
            dict[subcommand.Name] = CreateCommandDetails(subcommand);
        }
        return dict;
    }
 
    private static string[]? DetermineAliases(ICollection<string> aliases)
    {
        if (aliases.Count == 0)
        {
            return null;
        }
 
        // Order the aliases to ensure consistent output.
        return aliases.Order().ToArray();
    }
 
    private static CommandDetails CreateCommandDetails(Command subCommand) => new CommandDetails(
                subCommand.Description?.ReplaceLineEndings("\n"),
                subCommand.Hidden,
                DetermineAliases(subCommand.Aliases),
                CreateArgumentsDictionary(subCommand.Arguments),
                CreateOptionsDictionary(subCommand.Options),
                CreateSubcommandsDictionary(subCommand.Subcommands)
            );
 
    private static OptionDetails CreateOptionDetails(Option option) => new OptionDetails(
                option.Description?.ReplaceLineEndings("\n"),
                option.Hidden,
                DetermineAliases(option.Aliases),
                option.HelpName,
                option.ValueType.ToCliTypeString(),
                option.HasDefaultValue,
                option.HasDefaultValue ? HumanizeValue(option.GetDefaultValue()) : null,
                CreateArityDetails(option.Arity),
                option.Required,
                option.Recursive
            );
 
    /// <summary>
    /// Maps some types that don't serialize well to more human-readable strings.
    /// For example, <see cref="VerbosityOptions"/> is serialized as a string instead of an integer.
    /// </summary>
    private static object? HumanizeValue(object? v) => v switch
    {
        VerbosityOptions o => Enum.GetName(o),
        null => null,
        _ => v // For other types, return as is
    };
 
    private static ArgumentDetails CreateArgumentDetails(int index, Argument argument) => new ArgumentDetails(
                argument.Description?.ReplaceLineEndings("\n"),
                index,
                argument.Hidden,
                argument.HelpName,
                argument.ValueType.ToCliTypeString(),
                argument.HasDefaultValue,
                argument.HasDefaultValue ? HumanizeValue(argument.GetDefaultValue()) : null,
                CreateArityDetails(argument.Arity)
            );
 
    // Produces a string that represents the command call.
    // For example, calling the workload install command produces `dotnet workload install`.
    private static string CommandHierarchyAsString(CommandResult commandResult)
    {
        var commands = new List<string>();
        var currentResult = commandResult;
        while (currentResult is not null)
        {
            commands.Add(currentResult.Command.Name);
            currentResult = currentResult.Parent as CommandResult;
        }
 
        return string.Join(" ", commands.AsEnumerable().Reverse());
    }
}