Skip to content

Commit 73d5633

Browse files
MiYannibaronfel
andauthored
dotnet CLI: Add --cli-schema option for CLI structure JSON (#49118)
Co-authored-by: Chet Husk <chusk3@gmail.com>
1 parent a80d80e commit 73d5633

21 files changed

+1350
-31
lines changed

src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/StringExtensions.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Text.Json;
5+
46
namespace Microsoft.DotNet.Cli.Utils.Extensions;
57

68
public static class StringExtensions
79
{
10+
/// <summary>
11+
/// Strips CLI option prefixes like <c>-</c>, <c>--</c>, or <c>/</c> from a string to reveal the user-facing name.
12+
/// </summary>
13+
/// <param name="name"></param>
14+
/// <returns></returns>
815
public static string RemovePrefix(this string name)
916
{
1017
int prefixLength = GetPrefixLength(name);
@@ -26,4 +33,11 @@ static int GetPrefixLength(string name)
2633
return 0;
2734
}
2835
}
36+
37+
/// <summary>
38+
/// 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.
39+
/// </summary>
40+
/// <param name="value">A string to ensure is camel-cased</param>
41+
/// <returns>The camel-cased string</returns>
42+
public static string ToCamelCase(this string value) => JsonNamingPolicy.CamelCase.ConvertName(value);
2943
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.DotNet.Cli.Utils.Extensions;
5+
6+
public static class TypeExtensions
7+
{
8+
///<summary>
9+
/// 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>)
10+
/// to a more readable string representation (e.g. <c>System.Collections.Generic.List&lt;System.Int32&gt;</c>).
11+
/// </summary>
12+
///<remarks>
13+
/// This is used when outputting the Type information for the CLI schema JSON.
14+
///</remarks>
15+
public static string ToCliTypeString(this Type type)
16+
{
17+
var typeName = type.FullName ?? string.Empty;
18+
if (!type.IsGenericType)
19+
{
20+
return typeName;
21+
}
22+
23+
var genericTypeName = typeName.Substring(0, typeName.IndexOf('`'));
24+
var genericTypes = string.Join(", ", type.GenericTypeArguments.Select(generic => generic.ToCliTypeString()));
25+
return $"{genericTypeName}<{genericTypes}>";
26+
}
27+
}

src/Cli/dotnet/CliSchema.cs

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Buffers;
5+
using System.CommandLine;
6+
using System.Text.Encodings.Web;
7+
using System.Text.Json;
8+
using System.Text.Json.Schema;
9+
using System.Text.Json.Serialization;
10+
using System.Text.Json.Serialization.Metadata;
11+
using Microsoft.DotNet.Cli.Telemetry;
12+
using Microsoft.DotNet.Cli.Utils;
13+
using Microsoft.DotNet.Cli.Utils.Extensions;
14+
using Command = System.CommandLine.Command;
15+
using CommandResult = System.CommandLine.Parsing.CommandResult;
16+
17+
namespace Microsoft.DotNet.Cli;
18+
19+
internal static class CliSchema
20+
{
21+
// Using UnsafeRelaxedJsonEscaping because this JSON is not transmitted over the web. Therefore, HTML-sensitive characters are not encoded.
22+
// See: https://learn.microsoft.com/dotnet/api/system.text.encodings.web.javascriptencoder.unsaferelaxedjsonescaping
23+
// Force the newline to be "\n" instead of the default "\r\n" for consistency across platforms (and for testing)
24+
private static readonly JsonSerializerOptions s_jsonSerializerOptions = new()
25+
{
26+
WriteIndented = true,
27+
NewLine = "\n",
28+
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
29+
RespectNullableAnnotations = true,
30+
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
31+
// needed to workaround https://github.com/dotnet/aspnetcore/issues/55692, but will need to be removed when
32+
// we tackle AOT in favor of the source-generated JsonTypeInfo stuff
33+
TypeInfoResolver = new DefaultJsonTypeInfoResolver()
34+
};
35+
36+
public record ArgumentDetails(string? description, int order, bool hidden, string? helpName, string valueType, bool hasDefaultValue, object? defaultValue, ArityDetails arity);
37+
public record ArityDetails(int minimum, int? maximum);
38+
public record OptionDetails(
39+
string? description,
40+
bool hidden,
41+
string[]? aliases,
42+
string? helpName,
43+
string valueType,
44+
bool hasDefaultValue,
45+
object? defaultValue,
46+
ArityDetails arity,
47+
bool required,
48+
bool recursive
49+
);
50+
public record CommandDetails(
51+
string? description,
52+
bool hidden,
53+
string[]? aliases,
54+
Dictionary<string, ArgumentDetails>? arguments,
55+
Dictionary<string, OptionDetails>? options,
56+
Dictionary<string, CommandDetails>? subcommands);
57+
public record RootCommandDetails(
58+
string name,
59+
string version,
60+
string? description,
61+
bool hidden,
62+
string[]? aliases,
63+
Dictionary<string, ArgumentDetails>? arguments,
64+
Dictionary<string, OptionDetails>? options,
65+
Dictionary<string, CommandDetails>? subcommands
66+
) : CommandDetails(description, hidden, aliases, arguments, options, subcommands);
67+
68+
69+
public static void PrintCliSchema(CommandResult commandResult, TextWriter outputWriter, ITelemetry? telemetryClient)
70+
{
71+
var command = commandResult.Command;
72+
RootCommandDetails transportStructure = CreateRootCommandDetails(command);
73+
var result = JsonSerializer.Serialize(transportStructure, s_jsonSerializerOptions);
74+
outputWriter.Write(result.AsSpan());
75+
outputWriter.Flush();
76+
var commandString = CommandHierarchyAsString(commandResult);
77+
var telemetryProperties = new Dictionary<string, string> { { "command", commandString } };
78+
telemetryClient?.TrackEvent("schema", telemetryProperties, null);
79+
}
80+
81+
public static object GetJsonSchema()
82+
{
83+
var node = s_jsonSerializerOptions.GetJsonSchemaAsNode(typeof(RootCommandDetails), new JsonSchemaExporterOptions());
84+
return node.ToJsonString(s_jsonSerializerOptions);
85+
}
86+
87+
private static ArityDetails CreateArityDetails(ArgumentArity arity)
88+
{
89+
return new ArityDetails(
90+
minimum: arity.MinimumNumberOfValues,
91+
maximum: arity.MaximumNumberOfValues == ArgumentArity.ZeroOrMore.MaximumNumberOfValues ? null : arity.MaximumNumberOfValues
92+
);
93+
}
94+
95+
private static RootCommandDetails CreateRootCommandDetails(Command command)
96+
{
97+
var arguments = CreateArgumentsDictionary(command.Arguments);
98+
var options = CreateOptionsDictionary(command.Options);
99+
var subcommands = CreateSubcommandsDictionary(command.Subcommands);
100+
101+
return new RootCommandDetails(
102+
name: command.Name,
103+
version: Product.Version,
104+
description: command.Description?.ReplaceLineEndings("\n"),
105+
hidden: command.Hidden,
106+
aliases: DetermineAliases(command.Aliases),
107+
arguments: arguments,
108+
options: options,
109+
subcommands: subcommands
110+
);
111+
}
112+
113+
private static Dictionary<string, ArgumentDetails>? CreateArgumentsDictionary(IList<Argument> arguments)
114+
{
115+
if (arguments.Count == 0)
116+
{
117+
return null;
118+
}
119+
var dict = new Dictionary<string, ArgumentDetails>();
120+
foreach ((var index, var argument) in arguments.Index())
121+
{
122+
dict[argument.Name] = CreateArgumentDetails(index, argument);
123+
}
124+
return dict;
125+
}
126+
127+
private static Dictionary<string, OptionDetails>? CreateOptionsDictionary(IList<Option> options)
128+
{
129+
if (options.Count == 0)
130+
{
131+
return null;
132+
}
133+
var dict = new Dictionary<string, OptionDetails>();
134+
foreach (var option in options.OrderBy(o => o.Name, StringComparer.OrdinalIgnoreCase))
135+
{
136+
dict[option.Name] = CreateOptionDetails(option);
137+
}
138+
return dict;
139+
}
140+
141+
private static Dictionary<string, CommandDetails>? CreateSubcommandsDictionary(IList<Command> subcommands)
142+
{
143+
if (subcommands.Count == 0)
144+
{
145+
return null;
146+
}
147+
var dict = new Dictionary<string, CommandDetails>();
148+
foreach (var subcommand in subcommands.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase))
149+
{
150+
dict[subcommand.Name] = CreateCommandDetails(subcommand);
151+
}
152+
return dict;
153+
}
154+
155+
private static string[]? DetermineAliases(ICollection<string> aliases)
156+
{
157+
if (aliases.Count == 0)
158+
{
159+
return null;
160+
}
161+
162+
// Order the aliases to ensure consistent output.
163+
return aliases.Order().ToArray();
164+
}
165+
166+
private static CommandDetails CreateCommandDetails(Command subCommand) => new CommandDetails(
167+
subCommand.Description?.ReplaceLineEndings("\n"),
168+
subCommand.Hidden,
169+
DetermineAliases(subCommand.Aliases),
170+
CreateArgumentsDictionary(subCommand.Arguments),
171+
CreateOptionsDictionary(subCommand.Options),
172+
CreateSubcommandsDictionary(subCommand.Subcommands)
173+
);
174+
175+
private static OptionDetails CreateOptionDetails(Option option) => new OptionDetails(
176+
option.Description?.ReplaceLineEndings("\n"),
177+
option.Hidden,
178+
DetermineAliases(option.Aliases),
179+
option.HelpName,
180+
option.ValueType.ToCliTypeString(),
181+
option.HasDefaultValue,
182+
option.HasDefaultValue ? option.GetDefaultValue() : null,
183+
CreateArityDetails(option.Arity),
184+
option.Required,
185+
option.Recursive
186+
);
187+
188+
private static ArgumentDetails CreateArgumentDetails(int index, Argument argument) => new ArgumentDetails(
189+
argument.Description?.ReplaceLineEndings("\n"),
190+
index,
191+
argument.Hidden,
192+
argument.HelpName,
193+
argument.ValueType.ToCliTypeString(),
194+
argument.HasDefaultValue,
195+
argument.HasDefaultValue ? argument.GetDefaultValue() : null,
196+
CreateArityDetails(argument.Arity)
197+
);
198+
199+
// Produces a string that represents the command call.
200+
// For example, calling the workload install command produces `dotnet workload install`.
201+
private static string CommandHierarchyAsString(CommandResult commandResult)
202+
{
203+
var commands = new List<string>();
204+
var currentResult = commandResult;
205+
while (currentResult is not null)
206+
{
207+
commands.Add(currentResult.Command.Name);
208+
currentResult = currentResult.Parent as CommandResult;
209+
}
210+
211+
return string.Join(" ", commands.AsEnumerable().Reverse());
212+
}
213+
}

src/Cli/dotnet/CliStrings.resx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -811,4 +811,7 @@ The default is 'false.' However, when targeting .NET 7 or lower, the default is
811811
<data name="YesOptionDescription" xml:space="preserve">
812812
<value>Accept all confirmation prompts using "yes."</value>
813813
</data>
814-
</root>
814+
<data name="SDKSchemaCommandDefinition" xml:space="preserve">
815+
<value>Display the command schema as JSON.</value>
816+
</data>
817+
</root>

src/Cli/dotnet/Extensions/ParseResultExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ public static bool IsDotnetBuiltInCommand(this ParseResult parseResult)
9999

100100
public static bool IsTopLevelDotnetCommand(this ParseResult parseResult)
101101
{
102-
return parseResult.CommandResult.Command.Equals(Microsoft.DotNet.Cli.Parser.RootCommand) && string.IsNullOrEmpty(parseResult.RootSubCommandResult());
102+
return parseResult.CommandResult.Command.Equals(Parser.RootCommand) && string.IsNullOrEmpty(parseResult.RootSubCommandResult());
103103
}
104104

105105
public static bool CanBeInvoked(this ParseResult parseResult)

src/Cli/dotnet/Parser.cs

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
using System.CommandLine;
77
using System.CommandLine.Completions;
8+
using System.CommandLine.Invocation;
89
using System.Reflection;
910
using Microsoft.DotNet.Cli.Commands.Build;
1011
using Microsoft.DotNet.Cli.Commands.BuildServer;
@@ -98,22 +99,31 @@ public static class Parser
9899

99100
public static readonly Option<bool> VersionOption = new("--version")
100101
{
101-
Arity = ArgumentArity.Zero,
102+
Arity = ArgumentArity.Zero
102103
};
103104

104105
public static readonly Option<bool> InfoOption = new("--info")
105106
{
106-
Arity = ArgumentArity.Zero,
107+
Arity = ArgumentArity.Zero
107108
};
108109

109110
public static readonly Option<bool> ListSdksOption = new("--list-sdks")
110111
{
111-
Arity = ArgumentArity.Zero,
112+
Arity = ArgumentArity.Zero
112113
};
113114

114115
public static readonly Option<bool> ListRuntimesOption = new("--list-runtimes")
115116
{
117+
Arity = ArgumentArity.Zero
118+
};
119+
120+
public static readonly Option<bool> CliSchemaOption = new("--cli-schema")
121+
{
122+
Description = CliStrings.SDKSchemaCommandDefinition,
116123
Arity = ArgumentArity.Zero,
124+
Recursive = true,
125+
Hidden = true,
126+
Action = new PrintCliSchemaAction()
117127
};
118128

119129
// Argument
@@ -152,6 +162,7 @@ private static Command ConfigureCommandLine(RootCommand rootCommand)
152162
rootCommand.Options.Add(InfoOption);
153163
rootCommand.Options.Add(ListSdksOption);
154164
rootCommand.Options.Add(ListRuntimesOption);
165+
rootCommand.Options.Add(CliSchemaOption);
155166

156167
// Add argument
157168
rootCommand.Arguments.Add(DotnetSubCommand);
@@ -178,11 +189,8 @@ private static Command ConfigureCommandLine(RootCommand rootCommand)
178189
return rootCommand;
179190
}
180191

181-
public static Command GetBuiltInCommand(string commandName)
182-
{
183-
return Subcommands
184-
.FirstOrDefault(c => c.Name.Equals(commandName, StringComparison.OrdinalIgnoreCase));
185-
}
192+
public static Command GetBuiltInCommand(string commandName) =>
193+
Subcommands.FirstOrDefault(c => c.Name.Equals(commandName, StringComparison.OrdinalIgnoreCase));
186194

187195
/// <summary>
188196
/// Implements token-per-line response file handling for the CLI. We use this instead of the built-in S.CL handling
@@ -385,4 +393,17 @@ public override void Write(HelpContext context)
385393
}
386394
}
387395
}
396+
397+
private class PrintCliSchemaAction : SynchronousCommandLineAction
398+
{
399+
internal PrintCliSchemaAction()
400+
{
401+
Terminating = true;
402+
}
403+
public override int Invoke(ParseResult parseResult)
404+
{
405+
CliSchema.PrintCliSchema(parseResult.CommandResult, parseResult.Configuration.Output, Program.TelemetryClient);
406+
return 0;
407+
}
408+
}
388409
}

0 commit comments

Comments
 (0)