Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/StringExtensions.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Strips CLI option prefixes like <c>-</c>, <c>--</c>, or <c>/</c> from a string to reveal the user-facing name.
/// </summary>
/// <param name="name"></param>
/// <returns></returns>
public static string RemovePrefix(this string name)
{
int prefixLength = GetPrefixLength(name);
Expand All @@ -26,4 +33,11 @@ static int GetPrefixLength(string name)
return 0;
}
}

/// <summary>
/// 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.
/// </summary>
/// <param name="value">A string to ensure is camel-cased</param>
/// <returns>The camel-cased string</returns>
public static string ToCamelCase(this string value) => JsonNamingPolicy.CamelCase.ConvertName(value);
}
27 changes: 27 additions & 0 deletions src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/TypeExtensions.cs
Original file line number Diff line number Diff line change
@@ -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
{
///<summary>
/// Converts a Type (potentially containing generic parameters) from CLI representation (e.g. <c>System.Collections.Generic.List`1[[System.Int32, System.Private.CoreLib, Version=10.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e]]</c>)
/// to a more readable string representation (e.g. <c>System.Collections.Generic.List&lt;System.Int32&gt;</c>).
/// </summary>
///<remarks>
/// This is used when outputting the Type information for the CLI schema JSON.
///</remarks>
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}>";
}
}
213 changes: 213 additions & 0 deletions src/Cli/dotnet/CliSchema.cs
Original file line number Diff line number Diff line change
@@ -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<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 ? option.GetDefaultValue() : null,
CreateArityDetails(option.Arity),
option.Required,
option.Recursive
);

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 ? 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());
}
}
5 changes: 4 additions & 1 deletion src/Cli/dotnet/CliStrings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -811,4 +811,7 @@ The default is 'false.' However, when targeting .NET 7 or lower, the default is
<data name="YesOptionDescription" xml:space="preserve">
<value>Accept all confirmation prompts using "yes."</value>
</data>
</root>
<data name="SDKSchemaCommandDefinition" xml:space="preserve">
<value>Display the command schema as JSON.</value>
</data>
</root>
2 changes: 1 addition & 1 deletion src/Cli/dotnet/Extensions/ParseResultExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ public static bool IsDotnetBuiltInCommand(this ParseResult parseResult)

public static bool IsTopLevelDotnetCommand(this ParseResult parseResult)
{
return parseResult.CommandResult.Command.Equals(Microsoft.DotNet.Cli.Parser.RootCommand) && string.IsNullOrEmpty(parseResult.RootSubCommandResult());
return parseResult.CommandResult.Command.Equals(Parser.RootCommand) && string.IsNullOrEmpty(parseResult.RootSubCommandResult());
}

public static bool CanBeInvoked(this ParseResult parseResult)
Expand Down
37 changes: 29 additions & 8 deletions src/Cli/dotnet/Parser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

using System.CommandLine;
using System.CommandLine.Completions;
using System.CommandLine.Invocation;
using System.Reflection;
using Microsoft.DotNet.Cli.Commands.Build;
using Microsoft.DotNet.Cli.Commands.BuildServer;
Expand Down Expand Up @@ -98,22 +99,31 @@ public static class Parser

public static readonly Option<bool> VersionOption = new("--version")
{
Arity = ArgumentArity.Zero,
Arity = ArgumentArity.Zero
};

public static readonly Option<bool> InfoOption = new("--info")
{
Arity = ArgumentArity.Zero,
Arity = ArgumentArity.Zero
};

public static readonly Option<bool> ListSdksOption = new("--list-sdks")
{
Arity = ArgumentArity.Zero,
Arity = ArgumentArity.Zero
};

public static readonly Option<bool> ListRuntimesOption = new("--list-runtimes")
{
Arity = ArgumentArity.Zero
};

public static readonly Option<bool> CliSchemaOption = new("--cli-schema")
{
Description = CliStrings.SDKSchemaCommandDefinition,
Arity = ArgumentArity.Zero,
Recursive = true,
Hidden = true,
Action = new PrintCliSchemaAction()
};

// Argument
Expand Down Expand Up @@ -152,6 +162,7 @@ private static Command ConfigureCommandLine(RootCommand rootCommand)
rootCommand.Options.Add(InfoOption);
rootCommand.Options.Add(ListSdksOption);
rootCommand.Options.Add(ListRuntimesOption);
rootCommand.Options.Add(CliSchemaOption);

// Add argument
rootCommand.Arguments.Add(DotnetSubCommand);
Expand All @@ -178,11 +189,8 @@ private static Command ConfigureCommandLine(RootCommand rootCommand)
return rootCommand;
}

public static Command GetBuiltInCommand(string commandName)
{
return Subcommands
.FirstOrDefault(c => c.Name.Equals(commandName, StringComparison.OrdinalIgnoreCase));
}
public static Command GetBuiltInCommand(string commandName) =>
Subcommands.FirstOrDefault(c => c.Name.Equals(commandName, StringComparison.OrdinalIgnoreCase));

/// <summary>
/// Implements token-per-line response file handling for the CLI. We use this instead of the built-in S.CL handling
Expand Down Expand Up @@ -385,4 +393,17 @@ public override void Write(HelpContext context)
}
}
}

private class PrintCliSchemaAction : SynchronousCommandLineAction
{
internal PrintCliSchemaAction()
{
Terminating = true;
}
public override int Invoke(ParseResult parseResult)
{
CliSchema.PrintCliSchema(parseResult.CommandResult, parseResult.Configuration.Output, Program.TelemetryClient);
return 0;
}
}
}
Loading
Loading