From 6bd4990e2ff977f60106b44ffacd84bdb8a0c2d8 Mon Sep 17 00:00:00 2001 From: Michael Yanni Date: Thu, 22 May 2025 16:33:55 -0700 Subject: [PATCH 01/16] Added the code to allow --cli-schema to work on any CLI command and output the structure of that command in JSON. This still has work to be done to refine the output. --- .../Extensions/ArgumentExtensions.cs | 16 +++ .../Extensions/CommandExtensions.cs | 27 +++++ .../Extensions/OptionExtensions.cs | 18 ++++ .../Extensions/StringExtensions.cs | 5 + src/Cli/dotnet/CommandLineInfo.cs | 100 ++++++++++++++++++ .../Extensions/ParseResultExtensions.cs | 2 +- src/Cli/dotnet/Parser.cs | 20 ++-- src/Cli/dotnet/Program.cs | 17 +-- 8 files changed, 188 insertions(+), 17 deletions(-) create mode 100644 src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/ArgumentExtensions.cs create mode 100644 src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/CommandExtensions.cs create mode 100644 src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/OptionExtensions.cs diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/ArgumentExtensions.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/ArgumentExtensions.cs new file mode 100644 index 000000000000..2f3144e4a9f3 --- /dev/null +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/ArgumentExtensions.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.Reflection; + +namespace Microsoft.DotNet.Cli.Utils.Extensions; + +public static class ArgumentExtensions +{ + private static readonly PropertyInfo[] s_nonPublicProperties = typeof(Argument).GetProperties(BindingFlags.Instance | BindingFlags.NonPublic); + + public static bool? GetHasValidators(this Argument argument) => + s_nonPublicProperties.First(pi => pi.Name == "HasValidators").GetValue(argument) as bool?; +} + diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/CommandExtensions.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/CommandExtensions.cs new file mode 100644 index 000000000000..eef56a09a491 --- /dev/null +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/CommandExtensions.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. + +using System.Reflection; + +namespace Microsoft.DotNet.Cli.Utils.Extensions; + +#pragma warning disable IDE0065 // Misplaced using directive +using Command = System.CommandLine.Command; +#pragma warning restore IDE0065 // Misplaced using directive + +public static class CommandExtensions +{ + private static readonly PropertyInfo[] s_nonPublicProperties = typeof(Command).GetProperties(BindingFlags.Instance | BindingFlags.NonPublic); + + public static bool? GetHasArguments(this Command command) => + s_nonPublicProperties.First(pi => pi.Name == "HasArguments").GetValue(command) as bool?; + + public static bool? GetHasOptions(this Command command) => + s_nonPublicProperties.First(pi => pi.Name == "HasOptions").GetValue(command) as bool?; + + public static bool? GetHasSubcommands(this Command command) => + s_nonPublicProperties.First(pi => pi.Name == "HasSubcommands").GetValue(command) as bool?; + + public static bool? GetHasValidators(this Command command) => + s_nonPublicProperties.First(pi => pi.Name == "HasValidators").GetValue(command) as bool?; +} diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/OptionExtensions.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/OptionExtensions.cs new file mode 100644 index 000000000000..bce98b90476d --- /dev/null +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/OptionExtensions.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.Reflection; + +namespace Microsoft.DotNet.Cli.Utils.Extensions; + +public static class OptionExtensions +{ + private static readonly PropertyInfo[] s_nonPublicProperties = typeof(Option).GetProperties(BindingFlags.Instance | BindingFlags.NonPublic); + + public static Argument? GetArgument(this Option option) => + s_nonPublicProperties.First(pi => pi.Name == "Argument").GetValue(option) as Argument; + + public static bool? GetHasValidators(this Option option) => + s_nonPublicProperties.First(pi => pi.Name == "HasValidators").GetValue(option) as bool?; +} diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/StringExtensions.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/StringExtensions.cs index c6bd2d3213b9..0f2c5d24d233 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/StringExtensions.cs +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/StringExtensions.cs @@ -1,6 +1,8 @@ // 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 @@ -26,4 +28,7 @@ static int GetPrefixLength(string name) return 0; } } + + // https://stackoverflow.com/a/66342091/294804 + public static string ToCamelCase(this string value) => JsonNamingPolicy.CamelCase.ConvertName(value); } diff --git a/src/Cli/dotnet/CommandLineInfo.cs b/src/Cli/dotnet/CommandLineInfo.cs index 84f0b4ba1eff..6b603cf61732 100644 --- a/src/Cli/dotnet/CommandLineInfo.cs +++ b/src/Cli/dotnet/CommandLineInfo.cs @@ -3,8 +3,11 @@ #nullable disable +using System.Text.Json; using Microsoft.DotNet.Cli.Commands.Workload; using Microsoft.DotNet.Cli.Utils; +using Microsoft.DotNet.Cli.Utils.Extensions; +using Command = System.CommandLine.Command; using LocalizableStrings = Microsoft.DotNet.Cli.Utils.LocalizableStrings; using RuntimeEnvironment = Microsoft.DotNet.Cli.Utils.RuntimeEnvironment; @@ -55,4 +58,101 @@ private static string GetDisplayRid(DotnetVersionFile versionFile) currentRid : versionFile.BuildRid; } + + public static void PrintCliSchema(Command command) + { + var options = new JsonWriterOptions { Indented = true }; + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream, options); + + writer.WriteStartObject(); + TraverseCli(command, writer); + writer.WriteEndObject(); + writer.Flush(); + + string json = Encoding.UTF8.GetString(stream.ToArray()); + Console.WriteLine(json); + } + + private static void TraverseCli(Command command, Utf8JsonWriter writer) + { + writer.WriteString(nameof(command.Description).ToCamelCase(), command.Description); + writer.WriteBoolean(nameof(command.Hidden).ToCamelCase(), command.Hidden); + writer.WriteBoolean("hasValidators", command.GetHasValidators() ?? false); + + writer.WriteStartArray(nameof(command.Aliases).ToCamelCase()); + foreach (var alias in command.Aliases.Order()) + { + writer.WriteStringValue(alias); + } + writer.WriteEndArray(); + + writer.WriteBoolean(nameof(command.TreatUnmatchedTokensAsErrors).ToCamelCase(), command.TreatUnmatchedTokensAsErrors); + + writer.WriteStartObject(nameof(command.Arguments).ToCamelCase()); + foreach (var argument in command.Arguments.OrderBy(a => a.Name)) + { + // TODO: Check these + writer.WriteStartObject(argument.Name); + + writer.WriteString(nameof(argument.Description).ToCamelCase(), argument.Description); + writer.WriteBoolean(nameof(argument.Hidden).ToCamelCase(), argument.Hidden); + writer.WriteBoolean("hasValidators", argument.GetHasValidators() ?? false); + writer.WriteString(nameof(argument.HelpName).ToCamelCase(), argument.HelpName); + writer.WriteString(nameof(argument.ValueType).ToCamelCase(), argument.ValueType.FullName); + writer.WriteBoolean(nameof(argument.HasDefaultValue).ToCamelCase(), argument.HasDefaultValue); + + writer.WriteStartObject(nameof(argument.Arity).ToCamelCase()); + writer.WriteNumber(nameof(argument.Arity.MinimumNumberOfValues).ToCamelCase(), argument.Arity.MinimumNumberOfValues); + writer.WriteNumber(nameof(argument.Arity.MaximumNumberOfValues).ToCamelCase(), argument.Arity.MaximumNumberOfValues); + writer.WriteEndObject(); + + writer.WriteEndObject(); + } + writer.WriteEndObject(); + + writer.WriteStartObject(nameof(command.Options).ToCamelCase()); + foreach (var option in command.Options.OrderBy(o => o.Name)) + { + // TODO: Check these + writer.WriteStartObject(option.Name); + + writer.WriteString(nameof(option.Description).ToCamelCase(), option.Description); + writer.WriteBoolean(nameof(option.Hidden).ToCamelCase(), option.Hidden); + writer.WriteBoolean("hasValidators", option.GetHasValidators() ?? false); + + writer.WriteStartArray(nameof(option.Aliases).ToCamelCase()); + foreach (var alias in option.Aliases.Order()) + { + writer.WriteStringValue(alias); + } + writer.WriteEndArray(); + + writer.WriteString(nameof(option.HelpName).ToCamelCase(), option.HelpName); + var internalArgument = option.GetArgument(); + writer.WriteString(nameof(internalArgument.ValueType).ToCamelCase(), internalArgument.ValueType.FullName); + writer.WriteBoolean(nameof(option.HasDefaultValue).ToCamelCase(), option.HasDefaultValue); + + writer.WriteStartObject(nameof(option.Arity).ToCamelCase()); + writer.WriteNumber(nameof(option.Arity.MinimumNumberOfValues).ToCamelCase(), option.Arity.MinimumNumberOfValues); + writer.WriteNumber(nameof(option.Arity.MaximumNumberOfValues).ToCamelCase(), option.Arity.MaximumNumberOfValues); + writer.WriteEndObject(); + + writer.WriteBoolean(nameof(option.Required).ToCamelCase(), option.Required); + writer.WriteBoolean(nameof(option.Recursive).ToCamelCase(), option.Recursive); + writer.WriteBoolean(nameof(option.AllowMultipleArgumentsPerToken).ToCamelCase(), option.AllowMultipleArgumentsPerToken); + + writer.WriteEndObject(); + } + writer.WriteEndObject(); + + writer.WriteStartObject(nameof(command.Subcommands).ToCamelCase()); + foreach (var subCommand in command.Subcommands.OrderBy(sc => sc.Name)) + { + writer.WriteStartObject(subCommand.Name); + TraverseCli(subCommand, writer); + writer.WriteEndObject(); + } + writer.WriteEndObject(); + } } diff --git a/src/Cli/dotnet/Extensions/ParseResultExtensions.cs b/src/Cli/dotnet/Extensions/ParseResultExtensions.cs index 31d65f3b8fcf..290f8971b2a2 100644 --- a/src/Cli/dotnet/Extensions/ParseResultExtensions.cs +++ b/src/Cli/dotnet/Extensions/ParseResultExtensions.cs @@ -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) diff --git a/src/Cli/dotnet/Parser.cs b/src/Cli/dotnet/Parser.cs index 3ce2e132ad25..4f540dbf2bf9 100644 --- a/src/Cli/dotnet/Parser.cs +++ b/src/Cli/dotnet/Parser.cs @@ -98,22 +98,28 @@ public static class Parser public static readonly Option VersionOption = new("--version") { - Arity = ArgumentArity.Zero, + Arity = ArgumentArity.Zero }; public static readonly Option InfoOption = new("--info") { - Arity = ArgumentArity.Zero, + Arity = ArgumentArity.Zero }; public static readonly Option ListSdksOption = new("--list-sdks") { - Arity = ArgumentArity.Zero, + Arity = ArgumentArity.Zero }; public static readonly Option ListRuntimesOption = new("--list-runtimes") + { + Arity = ArgumentArity.Zero + }; + + public static readonly Option CliSchemaOption = new("--cli-schema") { Arity = ArgumentArity.Zero, + Recursive = true }; // Argument @@ -152,6 +158,7 @@ private static Command ConfigureCommandLine(Command rootCommand) rootCommand.Options.Add(InfoOption); rootCommand.Options.Add(ListSdksOption); rootCommand.Options.Add(ListRuntimesOption); + rootCommand.Options.Add(CliSchemaOption); // Add argument rootCommand.Arguments.Add(DotnetSubCommand); @@ -175,11 +182,8 @@ private static Command ConfigureCommandLine(Command 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)); /// /// Implements token-per-line response file handling for the CLI. We use this instead of the built-in S.CL handling diff --git a/src/Cli/dotnet/Program.cs b/src/Cli/dotnet/Program.cs index 1616e89b7812..6a5344f37fa7 100644 --- a/src/Cli/dotnet/Program.cs +++ b/src/Cli/dotnet/Program.cs @@ -134,17 +134,18 @@ internal static int ProcessArgs(string[] args, TimeSpan startupTime, ITelemetry } PerformanceLogEventSource.Log.BuiltInCommandParserStop(); - using (IFirstTimeUseNoticeSentinel disposableFirstTimeUseNoticeSentinel = - new FirstTimeUseNoticeSentinel()) + using (IFirstTimeUseNoticeSentinel disposableFirstTimeUseNoticeSentinel = new FirstTimeUseNoticeSentinel()) { IFirstTimeUseNoticeSentinel firstTimeUseNoticeSentinel = disposableFirstTimeUseNoticeSentinel; IAspNetCertificateSentinel aspNetCertificateSentinel = new AspNetCertificateSentinel(); - IFileSentinel toolPathSentinel = new FileSentinel( - new FilePath( - Path.Combine( - CliFolderPathCalculator.DotnetUserProfileFolderPath, - ToolPathSentinelFileName))); - if (parseResult.GetValue(Parser.DiagOption) && parseResult.IsDotnetBuiltInCommand()) + IFileSentinel toolPathSentinel = new FileSentinel(new FilePath(Path.Combine(CliFolderPathCalculator.DotnetUserProfileFolderPath,ToolPathSentinelFileName))); + + if (parseResult.GetValue(Parser.CliSchemaOption)) + { + CommandLineInfo.PrintCliSchema(parseResult.CommandResult.Command); + return 0; + } + else if (parseResult.GetValue(Parser.DiagOption) && parseResult.IsDotnetBuiltInCommand()) { // We found --diagnostic or -d, but we still need to determine whether the option should // be attached to the dotnet command or the subcommand. From 3d72844510f82562670b9e9009c7a375ba1e88cd Mon Sep 17 00:00:00 2001 From: Michael Yanni Date: Fri, 23 May 2025 16:32:02 -0700 Subject: [PATCH 02/16] Adjustments based on feedback. Removed several values from output. Added name for root command. Cleaned up type output. Added default value output. Removed ordering for arguments. Added relaxed JSON encoding. --- .../Extensions/TypeExtensions.cs | 21 +++ src/Cli/dotnet/CommandLineInfo.cs | 136 +++++++++++++----- 2 files changed, 121 insertions(+), 36 deletions(-) create mode 100644 src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/TypeExtensions.cs 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..32ffd917c59e --- /dev/null +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/TypeExtensions.cs @@ -0,0 +1,21 @@ +// 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 +{ + // 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/CommandLineInfo.cs b/src/Cli/dotnet/CommandLineInfo.cs index 6b603cf61732..26b3fbb88df1 100644 --- a/src/Cli/dotnet/CommandLineInfo.cs +++ b/src/Cli/dotnet/CommandLineInfo.cs @@ -3,6 +3,8 @@ #nullable disable +using System.CommandLine; +using System.Text.Encodings.Web; using System.Text.Json; using Microsoft.DotNet.Cli.Commands.Workload; using Microsoft.DotNet.Cli.Utils; @@ -61,24 +63,28 @@ private static string GetDisplayRid(DotnetVersionFile versionFile) public static void PrintCliSchema(Command command) { - var options = new JsonWriterOptions { Indented = true }; - using var stream = new MemoryStream(); - using var writer = new Utf8JsonWriter(stream, options); + // 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 + var options = new JsonWriterOptions { Indented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + //using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(Console.OpenStandardOutput(), options); writer.WriteStartObject(); - TraverseCli(command, writer); + // Explicitly write "name" into the root JSON object as the name for any sub-commands are used as the key to the sub-command object. + writer.WriteString("name", command.Name); + WriteCommand(command, writer); writer.WriteEndObject(); writer.Flush(); - string json = Encoding.UTF8.GetString(stream.ToArray()); - Console.WriteLine(json); + //string json = Encoding.UTF8.GetString(stream.ToArray()); + //Console.WriteLine(json); } - private static void TraverseCli(Command command, Utf8JsonWriter writer) + private static void WriteCommand(Command command, Utf8JsonWriter writer) { writer.WriteString(nameof(command.Description).ToCamelCase(), command.Description); writer.WriteBoolean(nameof(command.Hidden).ToCamelCase(), command.Hidden); - writer.WriteBoolean("hasValidators", command.GetHasValidators() ?? false); + //writer.WriteBoolean("hasValidators", command.GetHasValidators() ?? false); writer.WriteStartArray(nameof(command.Aliases).ToCamelCase()); foreach (var alias in command.Aliases.Order()) @@ -87,39 +93,64 @@ private static void TraverseCli(Command command, Utf8JsonWriter writer) } writer.WriteEndArray(); - writer.WriteBoolean(nameof(command.TreatUnmatchedTokensAsErrors).ToCamelCase(), command.TreatUnmatchedTokensAsErrors); + //writer.WriteBoolean(nameof(command.TreatUnmatchedTokensAsErrors).ToCamelCase(), command.TreatUnmatchedTokensAsErrors); writer.WriteStartObject(nameof(command.Arguments).ToCamelCase()); - foreach (var argument in command.Arguments.OrderBy(a => a.Name)) + // Leave default ordering for arguments. Do not order by name. + foreach (var argument in command.Arguments) + { + WriteArgument(argument, writer); + } + writer.WriteEndObject(); + + writer.WriteStartObject(nameof(command.Options).ToCamelCase()); + foreach (var option in command.Options.OrderBy(o => o.Name)) + { + WriteOption(option, writer); + } + writer.WriteEndObject(); + + writer.WriteStartObject(nameof(command.Subcommands).ToCamelCase()); + foreach (var subCommand in command.Subcommands.OrderBy(sc => sc.Name)) + { + writer.WriteStartObject(subCommand.Name); + WriteCommand(subCommand, writer); + writer.WriteEndObject(); + } + writer.WriteEndObject(); + + static void WriteArgument(Argument argument, Utf8JsonWriter writer) { - // TODO: Check these writer.WriteStartObject(argument.Name); writer.WriteString(nameof(argument.Description).ToCamelCase(), argument.Description); writer.WriteBoolean(nameof(argument.Hidden).ToCamelCase(), argument.Hidden); - writer.WriteBoolean("hasValidators", argument.GetHasValidators() ?? false); + //writer.WriteBoolean("hasValidators", argument.GetHasValidators() ?? false); writer.WriteString(nameof(argument.HelpName).ToCamelCase(), argument.HelpName); - writer.WriteString(nameof(argument.ValueType).ToCamelCase(), argument.ValueType.FullName); - writer.WriteBoolean(nameof(argument.HasDefaultValue).ToCamelCase(), argument.HasDefaultValue); + //writer.WriteString(nameof(argument.ValueType).ToCamelCase(), argument.ValueType.FullName); + writer.WriteString(nameof(argument.ValueType).ToCamelCase(), argument.ValueType.ToCliTypeString()); + //writer.WriteBoolean(nameof(argument.HasDefaultValue).ToCamelCase(), argument.HasDefaultValue); + //// TODO: Can only write the string representation of the default value currently. + //writer.WriteString("defaultValue", argument.GetDefaultValue()?.ToString()); + WriteDefaultValue(argument, writer); - writer.WriteStartObject(nameof(argument.Arity).ToCamelCase()); - writer.WriteNumber(nameof(argument.Arity.MinimumNumberOfValues).ToCamelCase(), argument.Arity.MinimumNumberOfValues); - writer.WriteNumber(nameof(argument.Arity.MaximumNumberOfValues).ToCamelCase(), argument.Arity.MaximumNumberOfValues); - writer.WriteEndObject(); + WriteArity(argument.Arity, writer); + + //writer.WriteStartObject(nameof(argument.Arity).ToCamelCase()); + //writer.WriteNumber(nameof(argument.Arity.MinimumNumberOfValues).ToCamelCase(), argument.Arity.MinimumNumberOfValues); + //writer.WriteNumber(nameof(argument.Arity.MaximumNumberOfValues).ToCamelCase(), argument.Arity.MaximumNumberOfValues); + //writer.WriteEndObject(); writer.WriteEndObject(); } - writer.WriteEndObject(); - writer.WriteStartObject(nameof(command.Options).ToCamelCase()); - foreach (var option in command.Options.OrderBy(o => o.Name)) + static void WriteOption(Option option, Utf8JsonWriter writer) { - // TODO: Check these writer.WriteStartObject(option.Name); writer.WriteString(nameof(option.Description).ToCamelCase(), option.Description); writer.WriteBoolean(nameof(option.Hidden).ToCamelCase(), option.Hidden); - writer.WriteBoolean("hasValidators", option.GetHasValidators() ?? false); + //writer.WriteBoolean("hasValidators", option.GetHasValidators() ?? false); writer.WriteStartArray(nameof(option.Aliases).ToCamelCase()); foreach (var alias in option.Aliases.Order()) @@ -129,30 +160,63 @@ private static void TraverseCli(Command command, Utf8JsonWriter writer) writer.WriteEndArray(); writer.WriteString(nameof(option.HelpName).ToCamelCase(), option.HelpName); + //writer.WriteString(nameof(option.ValueType).ToCamelCase(), option.ValueType.FullName); + writer.WriteString(nameof(option.ValueType).ToCamelCase(), option.ValueType.ToCliTypeString()); + //writer.WriteBoolean(nameof(option.HasDefaultValue).ToCamelCase(), option.HasDefaultValue); + //var internalArgument = option.GetArgument(); + //// TODO: Can only write the string representation of the default value currently. + //writer.WriteString("defaultValue", internalArgument.GetDefaultValue()?.ToString()); var internalArgument = option.GetArgument(); - writer.WriteString(nameof(internalArgument.ValueType).ToCamelCase(), internalArgument.ValueType.FullName); - writer.WriteBoolean(nameof(option.HasDefaultValue).ToCamelCase(), option.HasDefaultValue); + WriteDefaultValue(internalArgument, writer); - writer.WriteStartObject(nameof(option.Arity).ToCamelCase()); - writer.WriteNumber(nameof(option.Arity.MinimumNumberOfValues).ToCamelCase(), option.Arity.MinimumNumberOfValues); - writer.WriteNumber(nameof(option.Arity.MaximumNumberOfValues).ToCamelCase(), option.Arity.MaximumNumberOfValues); - writer.WriteEndObject(); + WriteArity(option.Arity, writer); + + //writer.WriteStartObject(nameof(option.Arity).ToCamelCase()); + //writer.WriteNumber(nameof(option.Arity.MinimumNumberOfValues).ToCamelCase(), option.Arity.MinimumNumberOfValues); + //writer.WriteNumber(nameof(option.Arity.MaximumNumberOfValues).ToCamelCase(), option.Arity.MaximumNumberOfValues); + //writer.WriteEndObject(); writer.WriteBoolean(nameof(option.Required).ToCamelCase(), option.Required); writer.WriteBoolean(nameof(option.Recursive).ToCamelCase(), option.Recursive); - writer.WriteBoolean(nameof(option.AllowMultipleArgumentsPerToken).ToCamelCase(), option.AllowMultipleArgumentsPerToken); + //writer.WriteBoolean(nameof(option.AllowMultipleArgumentsPerToken).ToCamelCase(), option.AllowMultipleArgumentsPerToken); writer.WriteEndObject(); } - writer.WriteEndObject(); - writer.WriteStartObject(nameof(command.Subcommands).ToCamelCase()); - foreach (var subCommand in command.Subcommands.OrderBy(sc => sc.Name)) + static void WriteDefaultValue(Argument argument, Utf8JsonWriter writer) { - writer.WriteStartObject(subCommand.Name); - TraverseCli(subCommand, writer); + writer.WriteBoolean(nameof(argument.HasDefaultValue).ToCamelCase(), argument.HasDefaultValue); + writer.WritePropertyName("defaultValue"); + if (!argument.HasDefaultValue) + { + writer.WriteNullValue(); + return; + } + + // Encode the value automatically based on the System.Type of the argument. + JsonSerializer.Serialize(writer, argument.GetDefaultValue(), argument.ValueType, JsonSerializerOptions); + return; + } + + // When the "OrMore" arity is present, write the maximum as null (thus unbounded). + // The literal max integer value is set to an arbitrary amount (ATTOW 100000), which is not necessary to know for an external consumer. + static void WriteArity(ArgumentArity arity, Utf8JsonWriter writer) + { + writer.WriteStartObject(nameof(arity)); + + writer.WriteNumber("minimum", arity.MinimumNumberOfValues); + writer.WritePropertyName("maximum"); + // ArgumentArity.ZeroOrMore.MaximumNumberOfValues is required as MaximumArity in ArgumentArity is a private field. + if (arity.MaximumNumberOfValues == ArgumentArity.ZeroOrMore.MaximumNumberOfValues) + { + writer.WriteNullValue(); + } + else + { + writer.WriteNumberValue(arity.MaximumNumberOfValues); + } + writer.WriteEndObject(); } - writer.WriteEndObject(); } } From 4615c6209ceb554b7663089df35c3e2b7e129e5c Mon Sep 17 00:00:00 2001 From: Michael Yanni Date: Wed, 28 May 2025 18:12:23 -0700 Subject: [PATCH 03/16] Moved the code for --cli-schema to CliSchema. Updated some nits after in-person discussion. Added version to the root. Shared json serialization options. Created strings for the --cli-schema description. --- src/Cli/dotnet/CliSchema.cs | 149 ++++++++++++++++++++ src/Cli/dotnet/CliStrings.resx | 5 +- src/Cli/dotnet/CliUsage.cs | 1 + src/Cli/dotnet/CommandLineInfo.cs | 164 ---------------------- src/Cli/dotnet/Parser.cs | 1 + src/Cli/dotnet/Program.cs | 2 +- src/Cli/dotnet/xlf/CliStrings.cs.xlf | 5 + src/Cli/dotnet/xlf/CliStrings.de.xlf | 5 + src/Cli/dotnet/xlf/CliStrings.es.xlf | 5 + src/Cli/dotnet/xlf/CliStrings.fr.xlf | 5 + src/Cli/dotnet/xlf/CliStrings.it.xlf | 5 + src/Cli/dotnet/xlf/CliStrings.ja.xlf | 5 + src/Cli/dotnet/xlf/CliStrings.ko.xlf | 5 + src/Cli/dotnet/xlf/CliStrings.pl.xlf | 5 + src/Cli/dotnet/xlf/CliStrings.pt-BR.xlf | 5 + src/Cli/dotnet/xlf/CliStrings.ru.xlf | 5 + src/Cli/dotnet/xlf/CliStrings.tr.xlf | 5 + src/Cli/dotnet/xlf/CliStrings.zh-Hans.xlf | 5 + src/Cli/dotnet/xlf/CliStrings.zh-Hant.xlf | 5 + 19 files changed, 221 insertions(+), 166 deletions(-) create mode 100644 src/Cli/dotnet/CliSchema.cs diff --git a/src/Cli/dotnet/CliSchema.cs b/src/Cli/dotnet/CliSchema.cs new file mode 100644 index 000000000000..0fd5149f17d1 --- /dev/null +++ b/src/Cli/dotnet/CliSchema.cs @@ -0,0 +1,149 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.Text.Encodings.Web; +using System.Text.Json; +using Microsoft.DotNet.Cli.Utils; +using Microsoft.DotNet.Cli.Utils.Extensions; +using Command = System.CommandLine.Command; + +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 + private static readonly JsonWriterOptions s_jsonWriterOptions = new() { Indented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + private static readonly JsonSerializerOptions s_jsonSerializerOptions = new() { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + + public static void PrintCliSchema(Command command) + { + using var writer = new Utf8JsonWriter(Console.OpenStandardOutput(), s_jsonWriterOptions); + writer.WriteStartObject(); + + // Explicitly write "name" into the root JSON object as the name for any sub-commands are used as the key to the sub-command object. + writer.WriteString("name", command.Name); + writer.WriteString("version", Product.Version); + WriteCommand(command, writer); + + writer.WriteEndObject(); + writer.Flush(); + } + + private static void WriteCommand(Command command, Utf8JsonWriter writer) + { + writer.WriteString(nameof(command.Description).ToCamelCase(), command.Description); + writer.WriteBoolean(nameof(command.Hidden).ToCamelCase(), command.Hidden); + + writer.WriteStartArray(nameof(command.Aliases).ToCamelCase()); + foreach (var alias in command.Aliases.Order()) + { + writer.WriteStringValue(alias); + } + writer.WriteEndArray(); + + writer.WriteStartObject(nameof(command.Arguments).ToCamelCase()); + // Leave default ordering for arguments. Do not order by name. + foreach (var argument in command.Arguments) + { + WriteArgument(argument, writer); + } + writer.WriteEndObject(); + + writer.WriteStartObject(nameof(command.Options).ToCamelCase()); + foreach (var option in command.Options.OrderBy(o => o.Name)) + { + WriteOption(option, writer); + } + writer.WriteEndObject(); + + writer.WriteStartObject(nameof(command.Subcommands).ToCamelCase()); + foreach (var subCommand in command.Subcommands.OrderBy(sc => sc.Name)) + { + writer.WriteStartObject(subCommand.Name); + WriteCommand(subCommand, writer); + writer.WriteEndObject(); + } + writer.WriteEndObject(); + } + + private static void WriteArgument(Argument argument, Utf8JsonWriter writer) + { + writer.WriteStartObject(argument.Name); + + writer.WriteString(nameof(argument.Description).ToCamelCase(), argument.Description); + writer.WriteBoolean(nameof(argument.Hidden).ToCamelCase(), argument.Hidden); + writer.WriteString(nameof(argument.HelpName).ToCamelCase(), argument.HelpName); + writer.WriteString(nameof(argument.ValueType).ToCamelCase(), argument.ValueType.ToCliTypeString()); + + WriteDefaultValue(argument, writer); + WriteArity(argument.Arity, writer); + + writer.WriteEndObject(); + } + + private static void WriteOption(Option option, Utf8JsonWriter writer) + { + writer.WriteStartObject(option.Name); + + writer.WriteString(nameof(option.Description).ToCamelCase(), option.Description); + writer.WriteBoolean(nameof(option.Hidden).ToCamelCase(), option.Hidden); + + writer.WriteStartArray(nameof(option.Aliases).ToCamelCase()); + foreach (var alias in option.Aliases.Order()) + { + writer.WriteStringValue(alias); + } + writer.WriteEndArray(); + + writer.WriteString(nameof(option.HelpName).ToCamelCase(), option.HelpName); + writer.WriteString(nameof(option.ValueType).ToCamelCase(), option.ValueType.ToCliTypeString()); + + // GetArgument will only return null if System.CommandLine is changed to no longer contain an Argument property within Option. + var internalArgument = option.GetArgument() ?? new DynamicArgument(string.Empty); + WriteDefaultValue(internalArgument, writer); + WriteArity(option.Arity, writer); + + writer.WriteBoolean(nameof(option.Required).ToCamelCase(), option.Required); + writer.WriteBoolean(nameof(option.Recursive).ToCamelCase(), option.Recursive); + + writer.WriteEndObject(); + } + + private static void WriteDefaultValue(Argument argument, Utf8JsonWriter writer) + { + writer.WriteBoolean(nameof(argument.HasDefaultValue).ToCamelCase(), argument.HasDefaultValue); + writer.WritePropertyName("defaultValue"); + if (!argument.HasDefaultValue) + { + writer.WriteNullValue(); + return; + } + + // Encode the value automatically based on the System.Type of the argument. + JsonSerializer.Serialize(writer, argument.GetDefaultValue(), argument.ValueType, s_jsonSerializerOptions); + return; + } + + private static void WriteArity(ArgumentArity arity, Utf8JsonWriter writer) + { + writer.WriteStartObject(nameof(arity)); + + writer.WriteNumber("minimum", arity.MinimumNumberOfValues); + writer.WritePropertyName("maximum"); + // ArgumentArity.ZeroOrMore.MaximumNumberOfValues is required as MaximumArity in ArgumentArity is a private field. + if (arity.MaximumNumberOfValues == ArgumentArity.ZeroOrMore.MaximumNumberOfValues) + { + // When the "OrMore" arity is present, write the maximum as null (thus unbounded). + // The literal max integer value is set to an arbitrary amount (ATTOW 100000), which is not necessary to know for an external consumer. + writer.WriteNullValue(); + } + else + { + writer.WriteNumberValue(arity.MaximumNumberOfValues); + } + + writer.WriteEndObject(); + } +} diff --git a/src/Cli/dotnet/CliStrings.resx b/src/Cli/dotnet/CliStrings.resx index cf4b6b130d54..697c34af0c87 100644 --- a/src/Cli/dotnet/CliStrings.resx +++ b/src/Cli/dotnet/CliStrings.resx @@ -803,4 +803,7 @@ For a list of locations searched, specify the "-d" option before the tool name.< Cannot specify --version when the package argument already contains a version. {Locked="--version"} - + + Display the command schema as JSON. + + \ No newline at end of file diff --git a/src/Cli/dotnet/CliUsage.cs b/src/Cli/dotnet/CliUsage.cs index 8e0870a86e84..f6e0ec55a4d3 100644 --- a/src/Cli/dotnet/CliUsage.cs +++ b/src/Cli/dotnet/CliUsage.cs @@ -36,6 +36,7 @@ internal static class CliUsage --list-runtimes {CliCommandStrings.SDKListRuntimesCommandDefinition} --list-sdks {CliCommandStrings.SDKListSdksCommandDefinition} --version {CliCommandStrings.SDKVersionCommandDefinition} + --cli-schema {CliStrings.SDKSchemaCommandDefinition} {CliCommandStrings.Commands}: build {CliCommandStrings.BuildDefinition} diff --git a/src/Cli/dotnet/CommandLineInfo.cs b/src/Cli/dotnet/CommandLineInfo.cs index 26b3fbb88df1..84f0b4ba1eff 100644 --- a/src/Cli/dotnet/CommandLineInfo.cs +++ b/src/Cli/dotnet/CommandLineInfo.cs @@ -3,13 +3,8 @@ #nullable disable -using System.CommandLine; -using System.Text.Encodings.Web; -using System.Text.Json; using Microsoft.DotNet.Cli.Commands.Workload; using Microsoft.DotNet.Cli.Utils; -using Microsoft.DotNet.Cli.Utils.Extensions; -using Command = System.CommandLine.Command; using LocalizableStrings = Microsoft.DotNet.Cli.Utils.LocalizableStrings; using RuntimeEnvironment = Microsoft.DotNet.Cli.Utils.RuntimeEnvironment; @@ -60,163 +55,4 @@ private static string GetDisplayRid(DotnetVersionFile versionFile) currentRid : versionFile.BuildRid; } - - public static void PrintCliSchema(Command command) - { - // 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 - var options = new JsonWriterOptions { Indented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; - //using var stream = new MemoryStream(); - using var writer = new Utf8JsonWriter(Console.OpenStandardOutput(), options); - - writer.WriteStartObject(); - // Explicitly write "name" into the root JSON object as the name for any sub-commands are used as the key to the sub-command object. - writer.WriteString("name", command.Name); - WriteCommand(command, writer); - writer.WriteEndObject(); - writer.Flush(); - - //string json = Encoding.UTF8.GetString(stream.ToArray()); - //Console.WriteLine(json); - } - - private static void WriteCommand(Command command, Utf8JsonWriter writer) - { - writer.WriteString(nameof(command.Description).ToCamelCase(), command.Description); - writer.WriteBoolean(nameof(command.Hidden).ToCamelCase(), command.Hidden); - //writer.WriteBoolean("hasValidators", command.GetHasValidators() ?? false); - - writer.WriteStartArray(nameof(command.Aliases).ToCamelCase()); - foreach (var alias in command.Aliases.Order()) - { - writer.WriteStringValue(alias); - } - writer.WriteEndArray(); - - //writer.WriteBoolean(nameof(command.TreatUnmatchedTokensAsErrors).ToCamelCase(), command.TreatUnmatchedTokensAsErrors); - - writer.WriteStartObject(nameof(command.Arguments).ToCamelCase()); - // Leave default ordering for arguments. Do not order by name. - foreach (var argument in command.Arguments) - { - WriteArgument(argument, writer); - } - writer.WriteEndObject(); - - writer.WriteStartObject(nameof(command.Options).ToCamelCase()); - foreach (var option in command.Options.OrderBy(o => o.Name)) - { - WriteOption(option, writer); - } - writer.WriteEndObject(); - - writer.WriteStartObject(nameof(command.Subcommands).ToCamelCase()); - foreach (var subCommand in command.Subcommands.OrderBy(sc => sc.Name)) - { - writer.WriteStartObject(subCommand.Name); - WriteCommand(subCommand, writer); - writer.WriteEndObject(); - } - writer.WriteEndObject(); - - static void WriteArgument(Argument argument, Utf8JsonWriter writer) - { - writer.WriteStartObject(argument.Name); - - writer.WriteString(nameof(argument.Description).ToCamelCase(), argument.Description); - writer.WriteBoolean(nameof(argument.Hidden).ToCamelCase(), argument.Hidden); - //writer.WriteBoolean("hasValidators", argument.GetHasValidators() ?? false); - writer.WriteString(nameof(argument.HelpName).ToCamelCase(), argument.HelpName); - //writer.WriteString(nameof(argument.ValueType).ToCamelCase(), argument.ValueType.FullName); - writer.WriteString(nameof(argument.ValueType).ToCamelCase(), argument.ValueType.ToCliTypeString()); - //writer.WriteBoolean(nameof(argument.HasDefaultValue).ToCamelCase(), argument.HasDefaultValue); - //// TODO: Can only write the string representation of the default value currently. - //writer.WriteString("defaultValue", argument.GetDefaultValue()?.ToString()); - WriteDefaultValue(argument, writer); - - WriteArity(argument.Arity, writer); - - //writer.WriteStartObject(nameof(argument.Arity).ToCamelCase()); - //writer.WriteNumber(nameof(argument.Arity.MinimumNumberOfValues).ToCamelCase(), argument.Arity.MinimumNumberOfValues); - //writer.WriteNumber(nameof(argument.Arity.MaximumNumberOfValues).ToCamelCase(), argument.Arity.MaximumNumberOfValues); - //writer.WriteEndObject(); - - writer.WriteEndObject(); - } - - static void WriteOption(Option option, Utf8JsonWriter writer) - { - writer.WriteStartObject(option.Name); - - writer.WriteString(nameof(option.Description).ToCamelCase(), option.Description); - writer.WriteBoolean(nameof(option.Hidden).ToCamelCase(), option.Hidden); - //writer.WriteBoolean("hasValidators", option.GetHasValidators() ?? false); - - writer.WriteStartArray(nameof(option.Aliases).ToCamelCase()); - foreach (var alias in option.Aliases.Order()) - { - writer.WriteStringValue(alias); - } - writer.WriteEndArray(); - - writer.WriteString(nameof(option.HelpName).ToCamelCase(), option.HelpName); - //writer.WriteString(nameof(option.ValueType).ToCamelCase(), option.ValueType.FullName); - writer.WriteString(nameof(option.ValueType).ToCamelCase(), option.ValueType.ToCliTypeString()); - //writer.WriteBoolean(nameof(option.HasDefaultValue).ToCamelCase(), option.HasDefaultValue); - //var internalArgument = option.GetArgument(); - //// TODO: Can only write the string representation of the default value currently. - //writer.WriteString("defaultValue", internalArgument.GetDefaultValue()?.ToString()); - var internalArgument = option.GetArgument(); - WriteDefaultValue(internalArgument, writer); - - WriteArity(option.Arity, writer); - - //writer.WriteStartObject(nameof(option.Arity).ToCamelCase()); - //writer.WriteNumber(nameof(option.Arity.MinimumNumberOfValues).ToCamelCase(), option.Arity.MinimumNumberOfValues); - //writer.WriteNumber(nameof(option.Arity.MaximumNumberOfValues).ToCamelCase(), option.Arity.MaximumNumberOfValues); - //writer.WriteEndObject(); - - writer.WriteBoolean(nameof(option.Required).ToCamelCase(), option.Required); - writer.WriteBoolean(nameof(option.Recursive).ToCamelCase(), option.Recursive); - //writer.WriteBoolean(nameof(option.AllowMultipleArgumentsPerToken).ToCamelCase(), option.AllowMultipleArgumentsPerToken); - - writer.WriteEndObject(); - } - - static void WriteDefaultValue(Argument argument, Utf8JsonWriter writer) - { - writer.WriteBoolean(nameof(argument.HasDefaultValue).ToCamelCase(), argument.HasDefaultValue); - writer.WritePropertyName("defaultValue"); - if (!argument.HasDefaultValue) - { - writer.WriteNullValue(); - return; - } - - // Encode the value automatically based on the System.Type of the argument. - JsonSerializer.Serialize(writer, argument.GetDefaultValue(), argument.ValueType, JsonSerializerOptions); - return; - } - - // When the "OrMore" arity is present, write the maximum as null (thus unbounded). - // The literal max integer value is set to an arbitrary amount (ATTOW 100000), which is not necessary to know for an external consumer. - static void WriteArity(ArgumentArity arity, Utf8JsonWriter writer) - { - writer.WriteStartObject(nameof(arity)); - - writer.WriteNumber("minimum", arity.MinimumNumberOfValues); - writer.WritePropertyName("maximum"); - // ArgumentArity.ZeroOrMore.MaximumNumberOfValues is required as MaximumArity in ArgumentArity is a private field. - if (arity.MaximumNumberOfValues == ArgumentArity.ZeroOrMore.MaximumNumberOfValues) - { - writer.WriteNullValue(); - } - else - { - writer.WriteNumberValue(arity.MaximumNumberOfValues); - } - - writer.WriteEndObject(); - } - } } diff --git a/src/Cli/dotnet/Parser.cs b/src/Cli/dotnet/Parser.cs index 4f540dbf2bf9..a176dbbd7708 100644 --- a/src/Cli/dotnet/Parser.cs +++ b/src/Cli/dotnet/Parser.cs @@ -118,6 +118,7 @@ public static class Parser public static readonly Option CliSchemaOption = new("--cli-schema") { + Description = CliStrings.SDKSchemaCommandDefinition, Arity = ArgumentArity.Zero, Recursive = true }; diff --git a/src/Cli/dotnet/Program.cs b/src/Cli/dotnet/Program.cs index 6a5344f37fa7..89f569f90b80 100644 --- a/src/Cli/dotnet/Program.cs +++ b/src/Cli/dotnet/Program.cs @@ -142,7 +142,7 @@ internal static int ProcessArgs(string[] args, TimeSpan startupTime, ITelemetry if (parseResult.GetValue(Parser.CliSchemaOption)) { - CommandLineInfo.PrintCliSchema(parseResult.CommandResult.Command); + CliSchema.PrintCliSchema(parseResult.CommandResult.Command); return 0; } else if (parseResult.GetValue(Parser.DiagOption) && parseResult.IsDotnetBuiltInCommand()) diff --git a/src/Cli/dotnet/xlf/CliStrings.cs.xlf b/src/Cli/dotnet/xlf/CliStrings.cs.xlf index 271dc12ceb98..1ceec29971f8 100644 --- a/src/Cli/dotnet/xlf/CliStrings.cs.xlf +++ b/src/Cli/dotnet/xlf/CliStrings.cs.xlf @@ -930,6 +930,11 @@ setx PATH "%PATH%;{0}" Enable diagnostic output. + + Display the command schema as JSON. + Display the command schema as JSON. + + The '--self-contained' and '--no-self-contained' options cannot be used together. The '--self-contained' and '--no-self-contained' options cannot be used together. diff --git a/src/Cli/dotnet/xlf/CliStrings.de.xlf b/src/Cli/dotnet/xlf/CliStrings.de.xlf index f7192562bbdd..ac58488cb2d6 100644 --- a/src/Cli/dotnet/xlf/CliStrings.de.xlf +++ b/src/Cli/dotnet/xlf/CliStrings.de.xlf @@ -930,6 +930,11 @@ setx PATH "%PATH%;{0}" Enable diagnostic output. + + Display the command schema as JSON. + Display the command schema as JSON. + + The '--self-contained' and '--no-self-contained' options cannot be used together. The '--self-contained' and '--no-self-contained' options cannot be used together. diff --git a/src/Cli/dotnet/xlf/CliStrings.es.xlf b/src/Cli/dotnet/xlf/CliStrings.es.xlf index f9d18989e2f4..fcfe4ae7232d 100644 --- a/src/Cli/dotnet/xlf/CliStrings.es.xlf +++ b/src/Cli/dotnet/xlf/CliStrings.es.xlf @@ -930,6 +930,11 @@ setx PATH "%PATH%;{0}" Enable diagnostic output. + + Display the command schema as JSON. + Display the command schema as JSON. + + The '--self-contained' and '--no-self-contained' options cannot be used together. The '--self-contained' and '--no-self-contained' options cannot be used together. diff --git a/src/Cli/dotnet/xlf/CliStrings.fr.xlf b/src/Cli/dotnet/xlf/CliStrings.fr.xlf index 4de69d570b01..1e54d577d7be 100644 --- a/src/Cli/dotnet/xlf/CliStrings.fr.xlf +++ b/src/Cli/dotnet/xlf/CliStrings.fr.xlf @@ -930,6 +930,11 @@ setx PATH "%PATH%;{0}" Enable diagnostic output. + + Display the command schema as JSON. + Display the command schema as JSON. + + The '--self-contained' and '--no-self-contained' options cannot be used together. The '--self-contained' and '--no-self-contained' options cannot be used together. diff --git a/src/Cli/dotnet/xlf/CliStrings.it.xlf b/src/Cli/dotnet/xlf/CliStrings.it.xlf index a566d521423f..0f171b8405b6 100644 --- a/src/Cli/dotnet/xlf/CliStrings.it.xlf +++ b/src/Cli/dotnet/xlf/CliStrings.it.xlf @@ -930,6 +930,11 @@ setx PATH "%PATH%;{0}" Enable diagnostic output. + + Display the command schema as JSON. + Display the command schema as JSON. + + The '--self-contained' and '--no-self-contained' options cannot be used together. The '--self-contained' and '--no-self-contained' options cannot be used together. diff --git a/src/Cli/dotnet/xlf/CliStrings.ja.xlf b/src/Cli/dotnet/xlf/CliStrings.ja.xlf index 528dcc1e8296..52f1a18bef0c 100644 --- a/src/Cli/dotnet/xlf/CliStrings.ja.xlf +++ b/src/Cli/dotnet/xlf/CliStrings.ja.xlf @@ -930,6 +930,11 @@ setx PATH "%PATH%;{0}" Enable diagnostic output. + + Display the command schema as JSON. + Display the command schema as JSON. + + The '--self-contained' and '--no-self-contained' options cannot be used together. The '--self-contained' and '--no-self-contained' options cannot be used together. diff --git a/src/Cli/dotnet/xlf/CliStrings.ko.xlf b/src/Cli/dotnet/xlf/CliStrings.ko.xlf index 242ce343de45..428e38312a54 100644 --- a/src/Cli/dotnet/xlf/CliStrings.ko.xlf +++ b/src/Cli/dotnet/xlf/CliStrings.ko.xlf @@ -930,6 +930,11 @@ setx PATH "%PATH%;{0}" Enable diagnostic output. + + Display the command schema as JSON. + Display the command schema as JSON. + + The '--self-contained' and '--no-self-contained' options cannot be used together. The '--self-contained' and '--no-self-contained' options cannot be used together. diff --git a/src/Cli/dotnet/xlf/CliStrings.pl.xlf b/src/Cli/dotnet/xlf/CliStrings.pl.xlf index fe204fdd12a6..8644c6b55dfd 100644 --- a/src/Cli/dotnet/xlf/CliStrings.pl.xlf +++ b/src/Cli/dotnet/xlf/CliStrings.pl.xlf @@ -930,6 +930,11 @@ setx PATH "%PATH%;{0}" Enable diagnostic output. + + Display the command schema as JSON. + Display the command schema as JSON. + + The '--self-contained' and '--no-self-contained' options cannot be used together. The '--self-contained' and '--no-self-contained' options cannot be used together. diff --git a/src/Cli/dotnet/xlf/CliStrings.pt-BR.xlf b/src/Cli/dotnet/xlf/CliStrings.pt-BR.xlf index 42ee8b4062c3..49fc9918f168 100644 --- a/src/Cli/dotnet/xlf/CliStrings.pt-BR.xlf +++ b/src/Cli/dotnet/xlf/CliStrings.pt-BR.xlf @@ -930,6 +930,11 @@ setx PATH "%PATH%;{0}" Enable diagnostic output. + + Display the command schema as JSON. + Display the command schema as JSON. + + The '--self-contained' and '--no-self-contained' options cannot be used together. The '--self-contained' and '--no-self-contained' options cannot be used together. diff --git a/src/Cli/dotnet/xlf/CliStrings.ru.xlf b/src/Cli/dotnet/xlf/CliStrings.ru.xlf index 5748a0d29601..764b73d6f85d 100644 --- a/src/Cli/dotnet/xlf/CliStrings.ru.xlf +++ b/src/Cli/dotnet/xlf/CliStrings.ru.xlf @@ -930,6 +930,11 @@ setx PATH "%PATH%;{0}" Enable diagnostic output. + + Display the command schema as JSON. + Display the command schema as JSON. + + The '--self-contained' and '--no-self-contained' options cannot be used together. The '--self-contained' and '--no-self-contained' options cannot be used together. diff --git a/src/Cli/dotnet/xlf/CliStrings.tr.xlf b/src/Cli/dotnet/xlf/CliStrings.tr.xlf index c694a19b6cb9..b2b933d14724 100644 --- a/src/Cli/dotnet/xlf/CliStrings.tr.xlf +++ b/src/Cli/dotnet/xlf/CliStrings.tr.xlf @@ -930,6 +930,11 @@ setx PATH "%PATH%;{0}" Enable diagnostic output. + + Display the command schema as JSON. + Display the command schema as JSON. + + The '--self-contained' and '--no-self-contained' options cannot be used together. The '--self-contained' and '--no-self-contained' options cannot be used together. diff --git a/src/Cli/dotnet/xlf/CliStrings.zh-Hans.xlf b/src/Cli/dotnet/xlf/CliStrings.zh-Hans.xlf index 02551c8c7e27..44c09a54af9d 100644 --- a/src/Cli/dotnet/xlf/CliStrings.zh-Hans.xlf +++ b/src/Cli/dotnet/xlf/CliStrings.zh-Hans.xlf @@ -930,6 +930,11 @@ setx PATH "%PATH%;{0}" Enable diagnostic output. + + Display the command schema as JSON. + Display the command schema as JSON. + + The '--self-contained' and '--no-self-contained' options cannot be used together. The '--self-contained' and '--no-self-contained' options cannot be used together. diff --git a/src/Cli/dotnet/xlf/CliStrings.zh-Hant.xlf b/src/Cli/dotnet/xlf/CliStrings.zh-Hant.xlf index 6bd11b5a149f..ece54b83b171 100644 --- a/src/Cli/dotnet/xlf/CliStrings.zh-Hant.xlf +++ b/src/Cli/dotnet/xlf/CliStrings.zh-Hant.xlf @@ -930,6 +930,11 @@ setx PATH "%PATH%;{0}" Enable diagnostic output. + + Display the command schema as JSON. + Display the command schema as JSON. + + The '--self-contained' and '--no-self-contained' options cannot be used together. The '--self-contained' and '--no-self-contained' options cannot be used together. From 22b354afd9f38d357a7311a2ff29417cf0dbd32c Mon Sep 17 00:00:00 2001 From: Michael Yanni Date: Wed, 11 Jun 2025 16:43:00 -0700 Subject: [PATCH 04/16] Added 5 commands with JSON output as tests. --- test/dotnet.Tests/CliSchemaTests.cs | 1133 +++++++++++++++++++++++++++ 1 file changed, 1133 insertions(+) create mode 100644 test/dotnet.Tests/CliSchemaTests.cs diff --git a/test/dotnet.Tests/CliSchemaTests.cs b/test/dotnet.Tests/CliSchemaTests.cs new file mode 100644 index 000000000000..8334118ac6f2 --- /dev/null +++ b/test/dotnet.Tests/CliSchemaTests.cs @@ -0,0 +1,1133 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.Cli.Utils; + +namespace Microsoft.DotNet.Tests; + +public class CliSchemaTests: SdkTest +{ + public CliSchemaTests(ITestOutputHelper log) : base(log) + { + } + + private static readonly string SolutionListJson = $$""" +{ + "name": "list", + "version": "{{Product.Version}}", + "description": "List all projects in a solution file.", + "hidden": false, + "aliases": [], + "arguments": {}, + "options": { + "--solution-folders": { + "description": "Display solution folder paths.", + "hidden": false, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": 0 + }, + "required": false, + "recursive": false + } + }, + "subcommands": {} +} +"""; + + private static readonly string CleanJson = $$""" +{ + "name": "clean", + "version": "{{Product.Version}}", + "description": ".NET Clean Command", + "hidden": false, + "aliases": [], + "arguments": { + "PROJECT | SOLUTION": { + "description": "The project or solution file to operate on. If a file is not specified, the command will search the current directory for one.", + "hidden": false, + "helpName": null, + "valueType": "System.Collections.Generic.IEnumerable", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": null + } + } + }, + "options": { + "--artifacts-path": { + "description": "The artifacts path. All output from the project, including build, publish, and pack output, will go in subfolders under the specified path.", + "hidden": false, + "aliases": [], + "helpName": "ARTIFACTS_DIR", + "valueType": "System.String", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--configuration": { + "description": "The configuration to clean for. The default for most projects is 'Debug'.", + "hidden": false, + "aliases": [ + "-c" + ], + "helpName": "CONFIGURATION", + "valueType": "System.String", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--disable-build-servers": { + "description": "Force the command to ignore any persistent build servers.", + "hidden": false, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": 0 + }, + "required": false, + "recursive": false + }, + "--framework": { + "description": "The target framework to clean for. The target framework must also be specified in the project file.", + "hidden": false, + "aliases": [ + "-f" + ], + "helpName": "FRAMEWORK", + "valueType": "System.String", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--interactive": { + "description": "Allows the command to stop and wait for user input or action (for example to complete authentication).", + "hidden": false, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--nologo": { + "description": "Do not display the startup banner or the copyright message.", + "hidden": false, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": 0 + }, + "required": false, + "recursive": false + }, + "--output": { + "description": "The directory containing the build artifacts to clean.", + "hidden": false, + "aliases": [ + "-o" + ], + "helpName": "OUTPUT_DIR", + "valueType": "System.String", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--runtime": { + "description": null, + "hidden": false, + "aliases": [ + "-r" + ], + "helpName": "RUNTIME_IDENTIFIER", + "valueType": "System.String", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--verbosity": { + "description": "Set the MSBuild verbosity level. Allowed values are q[uiet], m[inimal], n[ormal], d[etailed], and diag[nostic].", + "hidden": false, + "aliases": [ + "-v" + ], + "helpName": "LEVEL", + "valueType": "Microsoft.DotNet.Cli.VerbosityOptions", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + } + }, + "subcommands": {} +} +"""; + + private static readonly string ReferenceJson = $$""" +{ + "name": "reference", + "version": "{{Product.Version}}", + "description": ".NET Remove Command", + "hidden": false, + "aliases": [], + "arguments": {}, + "options": { + "--project": { + "description": "The project file to operate on. If a file is not specified, the command will search the current directory for one.", + "hidden": false, + "aliases": [], + "helpName": null, + "valueType": "System.String", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": true + } + }, + "subcommands": { + "add": { + "description": "Add a project-to-project reference to the project.", + "hidden": false, + "aliases": [], + "arguments": { + "PROJECT_PATH": { + "description": "The paths to the projects to add as references.", + "hidden": false, + "helpName": null, + "valueType": "System.Collections.Generic.IEnumerable", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": null + } + } + }, + "options": { + "--framework": { + "description": "Add the reference only when targeting a specific framework.", + "hidden": false, + "aliases": [ + "-f" + ], + "helpName": "FRAMEWORK", + "valueType": "System.String", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--interactive": { + "description": "Allows the command to stop and wait for user input or action (for example to complete authentication).", + "hidden": false, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 0 + }, + "required": false, + "recursive": false + } + }, + "subcommands": {} + }, + "list": { + "description": "List all project-to-project references of the project.", + "hidden": false, + "aliases": [], + "arguments": {}, + "options": {}, + "subcommands": {} + }, + "remove": { + "description": "Remove a project-to-project reference from the project.", + "hidden": false, + "aliases": [], + "arguments": { + "PROJECT_PATH": { + "description": "The paths to the referenced projects to remove.", + "hidden": false, + "helpName": null, + "valueType": "System.Collections.Generic.IEnumerable", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": null + } + } + }, + "options": { + "--framework": { + "description": "Remove the reference only when targeting a specific framework.", + "hidden": false, + "aliases": [ + "-f" + ], + "helpName": "FRAMEWORK", + "valueType": "System.String", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + } + }, + "subcommands": {} + } + } +} +"""; + + private static readonly string WorkloadInstallJson = $$""" +{ + "name": "install", + "version": "{{Product.Version}}", + "description": "Install one or more workloads.", + "hidden": false, + "aliases": [], + "arguments": { + "workloadId": { + "description": "The NuGet package ID of the workload to install.", + "hidden": false, + "helpName": "WORKLOAD_ID", + "valueType": "System.Collections.Generic.IEnumerable", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": null + } + } + }, + "options": { + "--configfile": { + "description": "The NuGet configuration file to use.", + "hidden": false, + "aliases": [], + "helpName": "FILE", + "valueType": "System.String", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--disable-parallel": { + "description": "Prevent restoring multiple projects in parallel.", + "hidden": false, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": 0 + }, + "required": false, + "recursive": false + }, + "--download-to-cache": { + "description": "Download packages needed to install a workload to a folder that can be used for offline installation.", + "hidden": true, + "aliases": [], + "helpName": "DIRECTORY", + "valueType": "System.String", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--from-cache": { + "description": "Complete the operation from cache (offline).", + "hidden": true, + "aliases": [], + "helpName": "DIRECTORY", + "valueType": "System.String", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--from-rollback-file": { + "description": "Update workloads based on specified rollback definition file.", + "hidden": true, + "aliases": [], + "helpName": null, + "valueType": "System.String", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--ignore-failed-sources": { + "description": "Treat package source failures as warnings.", + "hidden": false, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": 0 + }, + "required": false, + "recursive": false + }, + "--include-previews": { + "description": "Allow prerelease workload manifests.", + "hidden": false, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--interactive": { + "description": "Allows the command to stop and wait for user input or action (for example to complete authentication).", + "hidden": false, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 0 + }, + "required": false, + "recursive": false + }, + "--no-cache": { + "description": "Do not cache packages and http requests.", + "hidden": true, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": 0 + }, + "required": false, + "recursive": false + }, + "--no-http-cache": { + "description": "Do not cache packages and http requests.", + "hidden": false, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": 0 + }, + "required": false, + "recursive": false + }, + "--print-download-link-only": { + "description": "Only print the list of links to download without downloading.", + "hidden": true, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--sdk-version": { + "description": "The version of the SDK.", + "hidden": true, + "aliases": [], + "helpName": "VERSION", + "valueType": "System.String", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--skip-manifest-update": { + "description": "Skip updating the workload manifests.", + "hidden": false, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": 0 + }, + "required": false, + "recursive": false + }, + "--skip-sign-check": { + "description": "Skip signature verification of workload packages and installers.", + "hidden": true, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": 0 + }, + "required": false, + "recursive": false + }, + "--source": { + "description": "The NuGet package source to use during the restore. To specify multiple sources, repeat the option.", + "hidden": false, + "aliases": [ + "-s" + ], + "helpName": "SOURCE", + "valueType": "System.String[]", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": null + }, + "required": false, + "recursive": false + }, + "--temp-dir": { + "description": "Specify a temporary directory for this command to download and extract NuGet packages (must be secure).", + "hidden": false, + "aliases": [], + "helpName": null, + "valueType": "System.String", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--verbosity": { + "description": "Set the MSBuild verbosity level. Allowed values are q[uiet], m[inimal], n[ormal], d[etailed], and diag[nostic].", + "hidden": false, + "aliases": [ + "-v" + ], + "helpName": "LEVEL", + "valueType": "Microsoft.DotNet.Cli.VerbosityOptions", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--version": { + "description": "A workload version to display or one or more workloads and their versions joined by the '@' character.", + "hidden": false, + "aliases": [], + "helpName": null, + "valueType": "System.Collections.Generic.IEnumerable", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": null + }, + "required": false, + "recursive": false + } + }, + "subcommands": {} +} +"""; + + private static readonly string BuildJson = $$""" +{ + "name": "build", + "version": "{{Product.Version}}", + "description": ".NET Builder", + "hidden": false, + "aliases": [], + "arguments": { + "PROJECT | SOLUTION | FILE": { + "description": "The project or solution or C# (file-based program) file to operate on. If a file is not specified, the command will search the current directory for a project or solution.", + "hidden": false, + "helpName": null, + "valueType": "System.String[]", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": null + } + } + }, + "options": { + "--arch": { + "description": "The target architecture.", + "hidden": false, + "aliases": [ + "-a" + ], + "helpName": "ARCH", + "valueType": "System.String", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--artifacts-path": { + "description": "The artifacts path. All output from the project, including build, publish, and pack output, will go in subfolders under the specified path.", + "hidden": false, + "aliases": [], + "helpName": "ARTIFACTS_DIR", + "valueType": "System.String", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--configfile": { + "description": "", + "hidden": true, + "aliases": [], + "helpName": "FILE", + "valueType": "System.String", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--configuration": { + "description": "The configuration to use for building the project. The default for most projects is 'Debug'.", + "hidden": false, + "aliases": [ + "-c" + ], + "helpName": "CONFIGURATION", + "valueType": "System.String", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--debug": { + "description": null, + "hidden": false, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": 0 + }, + "required": false, + "recursive": false + }, + "--disable-build-servers": { + "description": "Force the command to ignore any persistent build servers.", + "hidden": false, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": 0 + }, + "required": false, + "recursive": false + }, + "--disable-parallel": { + "description": "", + "hidden": true, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": 0 + }, + "required": false, + "recursive": false + }, + "--force": { + "description": "Force all dependencies to be resolved even if the last restore was successful.\r\nThis is equivalent to deleting project.assets.json.", + "hidden": true, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": 0 + }, + "required": false, + "recursive": false + }, + "--framework": { + "description": "The target framework to build for. The target framework must also be specified in the project file.", + "hidden": false, + "aliases": [ + "-f" + ], + "helpName": "FRAMEWORK", + "valueType": "System.String", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--ignore-failed-sources": { + "description": "", + "hidden": true, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": 0 + }, + "required": false, + "recursive": false + }, + "--interactive": { + "description": "Allows the command to stop and wait for user input or action (for example to complete authentication).", + "hidden": false, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--no-cache": { + "description": "", + "hidden": true, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": 0 + }, + "required": false, + "recursive": false + }, + "--no-dependencies": { + "description": "Do not build project-to-project references and only build the specified project.", + "hidden": false, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": 0 + }, + "required": false, + "recursive": false + }, + "--no-http-cache": { + "description": "", + "hidden": true, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": 0 + }, + "required": false, + "recursive": false + }, + "--no-incremental": { + "description": "Do not use incremental building.", + "hidden": false, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": 0 + }, + "required": false, + "recursive": false + }, + "--no-restore": { + "description": "Do not restore the project before building.", + "hidden": false, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": 0 + }, + "required": false, + "recursive": false + }, + "--no-self-contained": { + "description": "Publish your application as a framework dependent application. A compatible .NET runtime must be installed on the target machine to run your application.", + "hidden": false, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": 0 + }, + "required": false, + "recursive": false + }, + "--nologo": { + "description": "Do not display the startup banner or the copyright message.", + "hidden": false, + "aliases": [], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": 0 + }, + "required": false, + "recursive": false + }, + "--os": { + "description": "The target operating system.", + "hidden": false, + "aliases": [], + "helpName": "OS", + "valueType": "System.String", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--output": { + "description": "The output directory to place built artifacts in.", + "hidden": false, + "aliases": [ + "-o" + ], + "helpName": "OUTPUT_DIR", + "valueType": "System.String", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--packages": { + "description": "", + "hidden": true, + "aliases": [], + "helpName": "PACKAGES_DIR", + "valueType": "System.String", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--property": { + "description": null, + "hidden": true, + "aliases": [ + "--p", + "-p", + "-property", + "/p", + "/property" + ], + "helpName": null, + "valueType": "System.String[]", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": null + }, + "required": false, + "recursive": false + }, + "--runtime": { + "description": null, + "hidden": false, + "aliases": [ + "-r" + ], + "helpName": "RUNTIME_IDENTIFIER", + "valueType": "System.String", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--self-contained": { + "description": "Publish the .NET runtime with your application so the runtime doesn't need to be installed on the target machine.\r\nThe default is 'false.' However, when targeting .NET 7 or lower, the default is 'true' if a runtime identifier is specified.", + "hidden": false, + "aliases": [ + "--sc" + ], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--source": { + "description": "", + "hidden": true, + "aliases": [], + "helpName": "SOURCE", + "valueType": "System.Collections.Generic.IEnumerable", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": null + }, + "required": false, + "recursive": false + }, + "--use-current-runtime": { + "description": "Use current runtime as the target runtime.", + "hidden": false, + "aliases": [ + "--ucr" + ], + "helpName": null, + "valueType": "System.Boolean", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 0, + "maximum": 0 + }, + "required": false, + "recursive": false + }, + "--verbosity": { + "description": "Set the MSBuild verbosity level. Allowed values are q[uiet], m[inimal], n[ormal], d[etailed], and diag[nostic].", + "hidden": false, + "aliases": [ + "-v" + ], + "helpName": "LEVEL", + "valueType": "Microsoft.DotNet.Cli.VerbosityOptions", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--version-suffix": { + "description": "Set the value of the $(VersionSuffix) property to use when building the project.", + "hidden": false, + "aliases": [], + "helpName": "VERSION_SUFFIX", + "valueType": "System.String", + "hasDefaultValue": false, + "defaultValue": null, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + } + }, + "subcommands": {} +} +"""; + + public static TheoryData CommandsJson => new() + { + { new[] { "solution", "list", "--cli-schema" }, SolutionListJson }, + { new[] { "clean", "--cli-schema" }, CleanJson }, + { new[] { "reference", "--cli-schema" }, ReferenceJson }, + { new[] { "workload", "install", "--cli-schema" }, WorkloadInstallJson }, + { new[] { "build", "--cli-schema" }, BuildJson } + }; + + [Theory] + [MemberData(nameof(CommandsJson))] + public void PrintCliSchema_WritesExpectedJson(string[] commandArgs, string json) + { + var commandResult = new DotnetCommand(Log).Execute(commandArgs); + commandResult.Should().Pass(); + commandResult.Should().HaveStdOut(json); + } +} From bd813bac46feee2b68a5ecdd13452b55991ad24c Mon Sep 17 00:00:00 2001 From: Michael Yanni Date: Thu, 12 Jun 2025 19:13:30 -0700 Subject: [PATCH 05/16] In-progress adding telemetry to the cli schema option. --- src/Cli/dotnet/CliSchema.cs | 24 +++++++++++++++++++++++- src/Cli/dotnet/Program.cs | 18 +++++++++--------- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/Cli/dotnet/CliSchema.cs b/src/Cli/dotnet/CliSchema.cs index 0fd5149f17d1..bd01ff8244f9 100644 --- a/src/Cli/dotnet/CliSchema.cs +++ b/src/Cli/dotnet/CliSchema.cs @@ -4,9 +4,11 @@ using System.CommandLine; using System.Text.Encodings.Web; using System.Text.Json; +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; @@ -17,11 +19,15 @@ internal static class CliSchema private static readonly JsonWriterOptions s_jsonWriterOptions = new() { Indented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; private static readonly JsonSerializerOptions s_jsonSerializerOptions = new() { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; - public static void PrintCliSchema(Command command) + public static void PrintCliSchema(CommandResult commandResult, ITelemetry telemetryClient) { + var commandString = CommandHierarchyAsString(commandResult); + Console.WriteLine($"Schema for command '{commandString}' written to standard output."); + using var writer = new Utf8JsonWriter(Console.OpenStandardOutput(), s_jsonWriterOptions); writer.WriteStartObject(); + var command = commandResult.Command; // Explicitly write "name" into the root JSON object as the name for any sub-commands are used as the key to the sub-command object. writer.WriteString("name", command.Name); writer.WriteString("version", Product.Version); @@ -29,6 +35,9 @@ public static void PrintCliSchema(Command command) writer.WriteEndObject(); writer.Flush(); + + var telemetryProperties = new Dictionary { { "command", commandString } }; + telemetryClient.TrackEvent("schema", telemetryProperties, null); } private static void WriteCommand(Command command, Utf8JsonWriter writer) @@ -146,4 +155,17 @@ private static void WriteArity(ArgumentArity arity, Utf8JsonWriter writer) writer.WriteEndObject(); } + + private static string CommandHierarchyAsString(CommandResult commandResult) + { + var commands = new List(); + var currentResult = commandResult; + while (currentResult is not null) + { + commands.Add(currentResult.Command.Name); + currentResult = currentResult.Parent as CommandResult; + } + + return string.Join(" ", commands.AsEnumerable().Reverse()); + } } diff --git a/src/Cli/dotnet/Program.cs b/src/Cli/dotnet/Program.cs index 89f569f90b80..1700f0dcc50c 100644 --- a/src/Cli/dotnet/Program.cs +++ b/src/Cli/dotnet/Program.cs @@ -140,9 +140,17 @@ internal static int ProcessArgs(string[] args, TimeSpan startupTime, ITelemetry IAspNetCertificateSentinel aspNetCertificateSentinel = new AspNetCertificateSentinel(); IFileSentinel toolPathSentinel = new FileSentinel(new FilePath(Path.Combine(CliFolderPathCalculator.DotnetUserProfileFolderPath,ToolPathSentinelFileName))); + PerformanceLogEventSource.Log.TelemetryRegistrationStart(); + + telemetryClient ??= new Telemetry.Telemetry(firstTimeUseNoticeSentinel); + TelemetryEventEntry.Subscribe(telemetryClient.TrackEvent); + TelemetryEventEntry.TelemetryFilter = new TelemetryFilter(Sha256Hasher.HashWithNormalizedCasing); + + PerformanceLogEventSource.Log.TelemetryRegistrationStop(); + if (parseResult.GetValue(Parser.CliSchemaOption)) { - CliSchema.PrintCliSchema(parseResult.CommandResult.Command); + CliSchema.PrintCliSchema(parseResult.CommandResult, telemetryClient); return 0; } else if (parseResult.GetValue(Parser.DiagOption) && parseResult.IsDotnetBuiltInCommand()) @@ -215,14 +223,6 @@ internal static int ProcessArgs(string[] args, TimeSpan startupTime, ITelemetry skipFirstTimeUseCheck: getStarOptionPassed); PerformanceLogEventSource.Log.FirstTimeConfigurationStop(); } - - PerformanceLogEventSource.Log.TelemetryRegistrationStart(); - - telemetryClient ??= new Telemetry.Telemetry(firstTimeUseNoticeSentinel); - TelemetryEventEntry.Subscribe(telemetryClient.TrackEvent); - TelemetryEventEntry.TelemetryFilter = new TelemetryFilter(Sha256Hasher.HashWithNormalizedCasing); - - PerformanceLogEventSource.Log.TelemetryRegistrationStop(); } if (CommandLoggingContext.IsVerbose) From 1bf27ecb3eb725eaaac9e6e7e23b90fc23d97f89 Mon Sep 17 00:00:00 2001 From: Michael Yanni Date: Fri, 13 Jun 2025 16:55:14 -0700 Subject: [PATCH 06/16] Made --cli-schema hidden. Removed telemetry debug statement. --- src/Cli/dotnet/CliSchema.cs | 6 +++--- src/Cli/dotnet/Parser.cs | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Cli/dotnet/CliSchema.cs b/src/Cli/dotnet/CliSchema.cs index bd01ff8244f9..ee6ba4014052 100644 --- a/src/Cli/dotnet/CliSchema.cs +++ b/src/Cli/dotnet/CliSchema.cs @@ -21,9 +21,6 @@ internal static class CliSchema public static void PrintCliSchema(CommandResult commandResult, ITelemetry telemetryClient) { - var commandString = CommandHierarchyAsString(commandResult); - Console.WriteLine($"Schema for command '{commandString}' written to standard output."); - using var writer = new Utf8JsonWriter(Console.OpenStandardOutput(), s_jsonWriterOptions); writer.WriteStartObject(); @@ -36,6 +33,7 @@ public static void PrintCliSchema(CommandResult commandResult, ITelemetry teleme writer.WriteEndObject(); writer.Flush(); + var commandString = CommandHierarchyAsString(commandResult); var telemetryProperties = new Dictionary { { "command", commandString } }; telemetryClient.TrackEvent("schema", telemetryProperties, null); } @@ -156,6 +154,8 @@ private static void WriteArity(ArgumentArity arity, Utf8JsonWriter writer) writer.WriteEndObject(); } + // 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(); diff --git a/src/Cli/dotnet/Parser.cs b/src/Cli/dotnet/Parser.cs index a176dbbd7708..5f959808ceb5 100644 --- a/src/Cli/dotnet/Parser.cs +++ b/src/Cli/dotnet/Parser.cs @@ -120,7 +120,8 @@ public static class Parser { Description = CliStrings.SDKSchemaCommandDefinition, Arity = ArgumentArity.Zero, - Recursive = true + Recursive = true, + Hidden = true }; // Argument From 9f31a216b2118f45f43cf0a76b043f70df2b3558 Mon Sep 17 00:00:00 2001 From: Michael Yanni Date: Fri, 13 Jun 2025 18:01:32 -0700 Subject: [PATCH 07/16] Adjustments based on review. --- src/Cli/dotnet/CliUsage.cs | 1 - src/Cli/dotnet/Program.cs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Cli/dotnet/CliUsage.cs b/src/Cli/dotnet/CliUsage.cs index f6e0ec55a4d3..8e0870a86e84 100644 --- a/src/Cli/dotnet/CliUsage.cs +++ b/src/Cli/dotnet/CliUsage.cs @@ -36,7 +36,6 @@ internal static class CliUsage --list-runtimes {CliCommandStrings.SDKListRuntimesCommandDefinition} --list-sdks {CliCommandStrings.SDKListSdksCommandDefinition} --version {CliCommandStrings.SDKVersionCommandDefinition} - --cli-schema {CliStrings.SDKSchemaCommandDefinition} {CliCommandStrings.Commands}: build {CliCommandStrings.BuildDefinition} diff --git a/src/Cli/dotnet/Program.cs b/src/Cli/dotnet/Program.cs index 1700f0dcc50c..63ddef0ee3c9 100644 --- a/src/Cli/dotnet/Program.cs +++ b/src/Cli/dotnet/Program.cs @@ -138,7 +138,7 @@ internal static int ProcessArgs(string[] args, TimeSpan startupTime, ITelemetry { IFirstTimeUseNoticeSentinel firstTimeUseNoticeSentinel = disposableFirstTimeUseNoticeSentinel; IAspNetCertificateSentinel aspNetCertificateSentinel = new AspNetCertificateSentinel(); - IFileSentinel toolPathSentinel = new FileSentinel(new FilePath(Path.Combine(CliFolderPathCalculator.DotnetUserProfileFolderPath,ToolPathSentinelFileName))); + IFileSentinel toolPathSentinel = new FileSentinel(new FilePath(Path.Combine(CliFolderPathCalculator.DotnetUserProfileFolderPath, ToolPathSentinelFileName))); PerformanceLogEventSource.Log.TelemetryRegistrationStart(); From 97ad1c68a8531dc9ebb2cf130eac41baa2916074 Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Sat, 14 Jun 2025 10:30:54 -0500 Subject: [PATCH 08/16] use the S.CL Action pattern to implement the feature --- .../Extensions/StringExtensions.cs | 11 ++++++++- .../Extensions/TypeExtensions.cs | 8 ++++++- src/Cli/dotnet/CliSchema.cs | 7 +++--- src/Cli/dotnet/Parser.cs | 17 ++++++++++++- src/Cli/dotnet/Program.cs | 24 ++++++++----------- 5 files changed, 47 insertions(+), 20 deletions(-) diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/StringExtensions.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/StringExtensions.cs index 0f2c5d24d233..63f2b953ecfe 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/StringExtensions.cs +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/StringExtensions.cs @@ -7,6 +7,11 @@ 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); @@ -29,6 +34,10 @@ static int GetPrefixLength(string name) } } - // https://stackoverflow.com/a/66342091/294804 + /// + /// 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 index 32ffd917c59e..4c78dac7e82f 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/TypeExtensions.cs +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/TypeExtensions.cs @@ -5,7 +5,13 @@ namespace Microsoft.DotNet.Cli.Utils.Extensions; public static class TypeExtensions { - // This is used when outputting the Type information for the CLI schema JSON. + /// + /// 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; diff --git a/src/Cli/dotnet/CliSchema.cs b/src/Cli/dotnet/CliSchema.cs index ee6ba4014052..e05e851f48b8 100644 --- a/src/Cli/dotnet/CliSchema.cs +++ b/src/Cli/dotnet/CliSchema.cs @@ -52,9 +52,9 @@ private static void WriteCommand(Command command, Utf8JsonWriter writer) writer.WriteStartObject(nameof(command.Arguments).ToCamelCase()); // Leave default ordering for arguments. Do not order by name. - foreach (var argument in command.Arguments) + foreach ((var index, var argument) in command.Arguments.Index()) { - WriteArgument(argument, writer); + WriteArgument(index, argument, writer); } writer.WriteEndObject(); @@ -75,11 +75,12 @@ private static void WriteCommand(Command command, Utf8JsonWriter writer) writer.WriteEndObject(); } - private static void WriteArgument(Argument argument, Utf8JsonWriter writer) + private static void WriteArgument(int index, Argument argument, Utf8JsonWriter writer) { writer.WriteStartObject(argument.Name); writer.WriteString(nameof(argument.Description).ToCamelCase(), argument.Description); + writer.WriteNumber("order", index); writer.WriteBoolean(nameof(argument.Hidden).ToCamelCase(), argument.Hidden); writer.WriteString(nameof(argument.HelpName).ToCamelCase(), argument.HelpName); writer.WriteString(nameof(argument.ValueType).ToCamelCase(), argument.ValueType.ToCliTypeString()); diff --git a/src/Cli/dotnet/Parser.cs b/src/Cli/dotnet/Parser.cs index 3e125dafab5c..3c0c8b884489 100644 --- a/src/Cli/dotnet/Parser.cs +++ b/src/Cli/dotnet/Parser.cs @@ -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; @@ -121,7 +122,8 @@ public static class Parser Description = CliStrings.SDKSchemaCommandDefinition, Arity = ArgumentArity.Zero, Recursive = true, - Hidden = true + Hidden = true, + Action = new PrintCliSchemaAction() }; // Argument @@ -391,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, Program.TelemetryClient); + return 1; + } + } } diff --git a/src/Cli/dotnet/Program.cs b/src/Cli/dotnet/Program.cs index 8f82198e8a44..e589eade68f4 100644 --- a/src/Cli/dotnet/Program.cs +++ b/src/Cli/dotnet/Program.cs @@ -24,6 +24,7 @@ public class Program { private static readonly string ToolPathSentinelFileName = $"{Product.Version}.toolpath.sentinel"; + public static ITelemetry TelemetryClient; public static int Main(string[] args) { using AutomaticEncodingRestorer _ = new(); @@ -113,12 +114,12 @@ public static int Main(string[] args) } } - internal static int ProcessArgs(string[] args, ITelemetry telemetryClient = null) + internal static int ProcessArgs(string[] args) { - return ProcessArgs(args, new TimeSpan(0), telemetryClient); + return ProcessArgs(args, new TimeSpan(0)); } - internal static int ProcessArgs(string[] args, TimeSpan startupTime, ITelemetry telemetryClient = null) + internal static int ProcessArgs(string[] args, TimeSpan startupTime) { Dictionary performanceData = []; @@ -146,18 +147,13 @@ internal static int ProcessArgs(string[] args, TimeSpan startupTime, ITelemetry PerformanceLogEventSource.Log.TelemetryRegistrationStart(); - telemetryClient ??= new Telemetry.Telemetry(firstTimeUseNoticeSentinel); - TelemetryEventEntry.Subscribe(telemetryClient.TrackEvent); + TelemetryClient ??= new Telemetry.Telemetry(firstTimeUseNoticeSentinel); + TelemetryEventEntry.Subscribe(TelemetryClient.TrackEvent); TelemetryEventEntry.TelemetryFilter = new TelemetryFilter(Sha256Hasher.HashWithNormalizedCasing); PerformanceLogEventSource.Log.TelemetryRegistrationStop(); - if (parseResult.GetValue(Parser.CliSchemaOption)) - { - CliSchema.PrintCliSchema(parseResult.CommandResult, telemetryClient); - return 0; - } - else if (parseResult.GetValue(Parser.DiagOption) && parseResult.IsDotnetBuiltInCommand()) + if (parseResult.GetValue(Parser.DiagOption) && parseResult.IsDotnetBuiltInCommand()) { // We found --diagnostic or -d, but we still need to determine whether the option should // be attached to the dotnet command or the subcommand. @@ -231,7 +227,7 @@ internal static int ProcessArgs(string[] args, TimeSpan startupTime, ITelemetry if (CommandLoggingContext.IsVerbose) { - Console.WriteLine($"Telemetry is: {(telemetryClient.Enabled ? "Enabled" : "Disabled")}"); + Console.WriteLine($"Telemetry is: {(TelemetryClient.Enabled ? "Enabled" : "Disabled")}"); } PerformanceLogEventSource.Log.TelemetrySaveIfEnabledStart(); performanceData.Add("Startup Time", startupTime.TotalMilliseconds); @@ -281,10 +277,10 @@ internal static int ProcessArgs(string[] args, TimeSpan startupTime, ITelemetry } PerformanceLogEventSource.Log.TelemetryClientFlushStart(); - telemetryClient.Flush(); + TelemetryClient.Flush(); PerformanceLogEventSource.Log.TelemetryClientFlushStop(); - telemetryClient.Dispose(); + TelemetryClient.Dispose(); return exitCode; } From 3a02313903a07e214cfe58d1d9e89471453720a5 Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Sat, 14 Jun 2025 10:38:49 -0500 Subject: [PATCH 09/16] remove unused members --- .../Extensions/ArgumentExtensions.cs | 16 ----------- .../Extensions/CommandExtensions.cs | 27 ------------------- .../Extensions/OptionExtensions.cs | 7 +++-- 3 files changed, 3 insertions(+), 47 deletions(-) delete mode 100644 src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/ArgumentExtensions.cs delete mode 100644 src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/CommandExtensions.cs diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/ArgumentExtensions.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/ArgumentExtensions.cs deleted file mode 100644 index 2f3144e4a9f3..000000000000 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/ArgumentExtensions.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.CommandLine; -using System.Reflection; - -namespace Microsoft.DotNet.Cli.Utils.Extensions; - -public static class ArgumentExtensions -{ - private static readonly PropertyInfo[] s_nonPublicProperties = typeof(Argument).GetProperties(BindingFlags.Instance | BindingFlags.NonPublic); - - public static bool? GetHasValidators(this Argument argument) => - s_nonPublicProperties.First(pi => pi.Name == "HasValidators").GetValue(argument) as bool?; -} - diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/CommandExtensions.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/CommandExtensions.cs deleted file mode 100644 index eef56a09a491..000000000000 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/CommandExtensions.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Reflection; - -namespace Microsoft.DotNet.Cli.Utils.Extensions; - -#pragma warning disable IDE0065 // Misplaced using directive -using Command = System.CommandLine.Command; -#pragma warning restore IDE0065 // Misplaced using directive - -public static class CommandExtensions -{ - private static readonly PropertyInfo[] s_nonPublicProperties = typeof(Command).GetProperties(BindingFlags.Instance | BindingFlags.NonPublic); - - public static bool? GetHasArguments(this Command command) => - s_nonPublicProperties.First(pi => pi.Name == "HasArguments").GetValue(command) as bool?; - - public static bool? GetHasOptions(this Command command) => - s_nonPublicProperties.First(pi => pi.Name == "HasOptions").GetValue(command) as bool?; - - public static bool? GetHasSubcommands(this Command command) => - s_nonPublicProperties.First(pi => pi.Name == "HasSubcommands").GetValue(command) as bool?; - - public static bool? GetHasValidators(this Command command) => - s_nonPublicProperties.First(pi => pi.Name == "HasValidators").GetValue(command) as bool?; -} diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/OptionExtensions.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/OptionExtensions.cs index bce98b90476d..95e18902bdb0 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/OptionExtensions.cs +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/OptionExtensions.cs @@ -8,11 +8,10 @@ namespace Microsoft.DotNet.Cli.Utils.Extensions; public static class OptionExtensions { - private static readonly PropertyInfo[] s_nonPublicProperties = typeof(Option).GetProperties(BindingFlags.Instance | BindingFlags.NonPublic); + private static readonly PropertyInfo s_argumentProperty = + typeof(Option).GetProperties(BindingFlags.Instance | BindingFlags.NonPublic).First(pi => pi.Name == "Argument"); public static Argument? GetArgument(this Option option) => - s_nonPublicProperties.First(pi => pi.Name == "Argument").GetValue(option) as Argument; + s_argumentProperty.GetValue(option) as Argument; - public static bool? GetHasValidators(this Option option) => - s_nonPublicProperties.First(pi => pi.Name == "HasValidators").GetValue(option) as bool?; } From 361761b093554306ce4ff6ed37856f1e4d31d666 Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Sat, 14 Jun 2025 10:42:24 -0500 Subject: [PATCH 10/16] remove reflection in favor of asking the option for default values directly --- .../Extensions/OptionExtensions.cs | 17 ----------------- src/Cli/dotnet/CliSchema.cs | 17 +++++++++++++++-- 2 files changed, 15 insertions(+), 19 deletions(-) delete mode 100644 src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/OptionExtensions.cs diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/OptionExtensions.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/OptionExtensions.cs deleted file mode 100644 index 95e18902bdb0..000000000000 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/Extensions/OptionExtensions.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.CommandLine; -using System.Reflection; - -namespace Microsoft.DotNet.Cli.Utils.Extensions; - -public static class OptionExtensions -{ - private static readonly PropertyInfo s_argumentProperty = - typeof(Option).GetProperties(BindingFlags.Instance | BindingFlags.NonPublic).First(pi => pi.Name == "Argument"); - - public static Argument? GetArgument(this Option option) => - s_argumentProperty.GetValue(option) as Argument; - -} diff --git a/src/Cli/dotnet/CliSchema.cs b/src/Cli/dotnet/CliSchema.cs index e05e851f48b8..830a2e2de2d2 100644 --- a/src/Cli/dotnet/CliSchema.cs +++ b/src/Cli/dotnet/CliSchema.cs @@ -109,8 +109,7 @@ private static void WriteOption(Option option, Utf8JsonWriter writer) writer.WriteString(nameof(option.ValueType).ToCamelCase(), option.ValueType.ToCliTypeString()); // GetArgument will only return null if System.CommandLine is changed to no longer contain an Argument property within Option. - var internalArgument = option.GetArgument() ?? new DynamicArgument(string.Empty); - WriteDefaultValue(internalArgument, writer); + WriteDefaultValue(option, writer); WriteArity(option.Arity, writer); writer.WriteBoolean(nameof(option.Required).ToCamelCase(), option.Required); @@ -133,6 +132,20 @@ private static void WriteDefaultValue(Argument argument, Utf8JsonWriter writer) JsonSerializer.Serialize(writer, argument.GetDefaultValue(), argument.ValueType, s_jsonSerializerOptions); return; } + private static void WriteDefaultValue(Option option, Utf8JsonWriter writer) + { + writer.WriteBoolean(nameof(option.HasDefaultValue).ToCamelCase(), option.HasDefaultValue); + writer.WritePropertyName("defaultValue"); + if (!option.HasDefaultValue) + { + writer.WriteNullValue(); + return; + } + + // Encode the value automatically based on the System.Type of the argument. + JsonSerializer.Serialize(writer, option.GetDefaultValue(), option.ValueType, s_jsonSerializerOptions); + return; + } private static void WriteArity(ArgumentArity arity, Utf8JsonWriter writer) { From 98e4ab7749c38992d1f357dfa79dfc8ee79acbf3 Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Sat, 14 Jun 2025 10:51:39 -0500 Subject: [PATCH 11/16] ensure consistent newlines to make output equivalent --- src/Cli/dotnet/CliSchema.cs | 3 ++- test/dotnet.Tests/CliSchemaTests.cs | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Cli/dotnet/CliSchema.cs b/src/Cli/dotnet/CliSchema.cs index 830a2e2de2d2..2bcf05cc0eed 100644 --- a/src/Cli/dotnet/CliSchema.cs +++ b/src/Cli/dotnet/CliSchema.cs @@ -16,7 +16,8 @@ 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 - private static readonly JsonWriterOptions s_jsonWriterOptions = new() { Indented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + // Force the newline to be "\n" instead of the default "\r\n" for consistency across platforms (and for testing) + private static readonly JsonWriterOptions s_jsonWriterOptions = new() { Indented = true, NewLine = "\n", Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; private static readonly JsonSerializerOptions s_jsonSerializerOptions = new() { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; public static void PrintCliSchema(CommandResult commandResult, ITelemetry telemetryClient) diff --git a/test/dotnet.Tests/CliSchemaTests.cs b/test/dotnet.Tests/CliSchemaTests.cs index 8334118ac6f2..1744f0d2170c 100644 --- a/test/dotnet.Tests/CliSchemaTests.cs +++ b/test/dotnet.Tests/CliSchemaTests.cs @@ -5,7 +5,7 @@ namespace Microsoft.DotNet.Tests; -public class CliSchemaTests: SdkTest +public class CliSchemaTests : SdkTest { public CliSchemaTests(ITestOutputHelper log) : base(log) { @@ -1128,6 +1128,6 @@ public void PrintCliSchema_WritesExpectedJson(string[] commandArgs, string json) { var commandResult = new DotnetCommand(Log).Execute(commandArgs); commandResult.Should().Pass(); - commandResult.Should().HaveStdOut(json); + commandResult.Should().HaveStdOut(json.ReplaceLineEndings("\n")); } } From b1ae0c5ee6df49f08488d6ef64af6508fc914ffa Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Sat, 14 Jun 2025 13:22:58 -0500 Subject: [PATCH 12/16] fix really silly error --- src/Cli/dotnet/Parser.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cli/dotnet/Parser.cs b/src/Cli/dotnet/Parser.cs index 3c0c8b884489..e8a3ea91c3c0 100644 --- a/src/Cli/dotnet/Parser.cs +++ b/src/Cli/dotnet/Parser.cs @@ -403,7 +403,7 @@ internal PrintCliSchemaAction() public override int Invoke(ParseResult parseResult) { CliSchema.PrintCliSchema(parseResult.CommandResult, Program.TelemetryClient); - return 1; + return 0; } } } From 24d14f678bb8f01bbeca7a5cf3f3670df02e38bb Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Sat, 14 Jun 2025 18:39:40 -0500 Subject: [PATCH 13/16] big rework: use record structures to 'capture' the json output so we can easily emit a schema later on. stylistic changes: * don't emit null values * don't emit empty nesting objects/maps * add an order property to arguments --- src/Cli/dotnet/CliSchema.cs | 254 +++++++++++++++------------- src/Cli/dotnet/Parser.cs | 2 +- test/dotnet.Tests/CliSchemaTests.cs | 224 ++++-------------------- 3 files changed, 168 insertions(+), 312 deletions(-) diff --git a/src/Cli/dotnet/CliSchema.cs b/src/Cli/dotnet/CliSchema.cs index 2bcf05cc0eed..3d3e9b20611d 100644 --- a/src/Cli/dotnet/CliSchema.cs +++ b/src/Cli/dotnet/CliSchema.cs @@ -1,9 +1,11 @@ // 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.Serialization; using Microsoft.DotNet.Cli.Telemetry; using Microsoft.DotNet.Cli.Utils; using Microsoft.DotNet.Cli.Utils.Extensions; @@ -17,158 +19,172 @@ 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 JsonWriterOptions s_jsonWriterOptions = new() { Indented = true, NewLine = "\n", Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; - private static readonly JsonSerializerOptions s_jsonSerializerOptions = new() { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; - - public static void PrintCliSchema(CommandResult commandResult, ITelemetry telemetryClient) + private static readonly JsonSerializerOptions s_jsonSerializerOptions = new() + { + 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? 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) { - using var writer = new Utf8JsonWriter(Console.OpenStandardOutput(), s_jsonWriterOptions); - writer.WriteStartObject(); - var command = commandResult.Command; - // Explicitly write "name" into the root JSON object as the name for any sub-commands are used as the key to the sub-command object. - writer.WriteString("name", command.Name); - writer.WriteString("version", Product.Version); - WriteCommand(command, writer); - - writer.WriteEndObject(); - writer.Flush(); - + 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); + telemetryClient?.TrackEvent("schema", telemetryProperties, null); } - private static void WriteCommand(Command command, Utf8JsonWriter writer) + private static ArityDetails CreateArityDetails(ArgumentArity arity) { - writer.WriteString(nameof(command.Description).ToCamelCase(), command.Description); - writer.WriteBoolean(nameof(command.Hidden).ToCamelCase(), command.Hidden); - - writer.WriteStartArray(nameof(command.Aliases).ToCamelCase()); - foreach (var alias in command.Aliases.Order()) - { - writer.WriteStringValue(alias); - } - writer.WriteEndArray(); + return new ArityDetails( + minimum: arity.MinimumNumberOfValues, + maximum: arity.MaximumNumberOfValues == ArgumentArity.ZeroOrMore.MaximumNumberOfValues ? null : arity.MaximumNumberOfValues + ); + } - writer.WriteStartObject(nameof(command.Arguments).ToCamelCase()); - // Leave default ordering for arguments. Do not order by name. - foreach ((var index, var argument) in command.Arguments.Index()) - { - WriteArgument(index, argument, writer); - } - writer.WriteEndObject(); + 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, + hidden: command.Hidden, + aliases: DetermineAliases(command.Aliases), + arguments: arguments, + options: options, + subcommands: subcommands + ); + } - writer.WriteStartObject(nameof(command.Options).ToCamelCase()); - foreach (var option in command.Options.OrderBy(o => o.Name)) + private static Dictionary? CreateArgumentsDictionary(IList arguments) + { + if (arguments.Count == 0) { - WriteOption(option, writer); + return null; } - writer.WriteEndObject(); - - writer.WriteStartObject(nameof(command.Subcommands).ToCamelCase()); - foreach (var subCommand in command.Subcommands.OrderBy(sc => sc.Name)) + var dict = new Dictionary(); + foreach ((var index, var argument) in arguments.Index()) { - writer.WriteStartObject(subCommand.Name); - WriteCommand(subCommand, writer); - writer.WriteEndObject(); + dict[argument.Name] = CreateArgumentDetails(index, argument); } - writer.WriteEndObject(); + return dict; } - private static void WriteArgument(int index, Argument argument, Utf8JsonWriter writer) + private static Dictionary? CreateOptionsDictionary(IList