File: CliSchema.cs
Web Access
Project: src\src\sdk\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 Microsoft.DotNet.Cli.Extensions;
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)
    // Using a source-generated JsonSerializerContext as the TypeInfoResolver so that
    // the options are AOT compatible and have a resolver set (avoiding https://github.com/dotnet/aspnetcore/issues/55692).
    private static readonly CliSchemaJsonSerializerContext s_jsonContext = new(new JsonSerializerOptions
    {
        WriteIndented = true,
        NewLine = "\n",
        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
        RespectNullableAnnotations = true,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    });

    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(ParseResult parseResult, TextWriter outputWriter, ITelemetryClient? telemetryClient)
    {
        var command = parseResult.CommandResult.Command;
        RootCommandDetails transportStructure = CreateRootCommandDetails(command);
        var result = JsonSerializer.Serialize(transportStructure, s_jsonContext.RootCommandDetails);
        outputWriter.Write(result.AsSpan());
        outputWriter.Flush();
        var commandString = parseResult.GetCommandName();
        var telemetryProperties = new Dictionary<string, string?> { { "command", commandString } };
        telemetryClient?.TrackEvent("schema", telemetryProperties);
    }

    public static object GetJsonSchema()
    {
        var node = s_jsonContext.Options.GetJsonSchemaAsNode(typeof(RootCommandDetails), new JsonSchemaExporterOptions());
        return node.ToJsonString(s_jsonContext.Options);
    }

    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)
            );
}

[JsonSerializable(typeof(CliSchema.RootCommandDetails))]
internal partial class CliSchemaJsonSerializerContext : JsonSerializerContext;