-
Notifications
You must be signed in to change notification settings - Fork 1.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
WIP: Add name argument for dotnet new #33810
base: main
Are you sure you want to change the base?
Conversation
… A bunch of adjustments were necessary to make help and flow the name properly to the template command. Other adjustments made to satisfy testing situations. In-progress of updating tests to run properly.
…on making tests run properly.
# Conflicts: # src/Cli/Microsoft.TemplateEngine.Cli/Commands/NewCommand.Legacy.cs # src/Cli/Microsoft.TemplateEngine.Cli/Commands/NewCommand.cs # src/Cli/Microsoft.TemplateEngine.Cli/Commands/SharedOptions.cs # src/Cli/Microsoft.TemplateEngine.Cli/Commands/create/InstantiateCommand.Help.cs # src/Cli/Microsoft.TemplateEngine.Cli/Commands/create/InstantiateCommand.cs # src/Cli/Microsoft.TemplateEngine.Cli/Commands/create/TemplateCommand.cs # src/Containers/containerize/ContainerizeCommand.cs # src/Tests/Microsoft.TemplateEngine.Cli.UnitTests/ParserTests/InstantiateTests.cs
internal static CliArgument<string> NameArgument { get; } = new CliArgument<string>("name") | ||
{ | ||
Description = SymbolStrings.TemplateCommand_Option_Name, | ||
Arity = new ArgumentArity(0, 1) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Even though this is a shared name 'argument', it is used in the code the same way as the rest of the shared options.
|
||
#pragma warning disable CA1826 | ||
var firstTemplateCommandToken = parseResult.CommandResult.Tokens.FirstOrDefault()?.Value ?? string.Empty; | ||
#pragma warning restore CA1826 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure why I was getting a CA1826 here as the logic requires FirstOrDefault
since I'm not sure if there even is a token available.
CliConfiguration parser = ParserFactory.CreateParser(command); | ||
ParseResult parseResult = parser.Parse(args.RemainingArguments ?? Array.Empty<string>()); | ||
ParseResult parseResult = Reparse(command, args.RemainingArguments ?? Array.Empty<string>()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You no longer need to create a parser out of the factory and sent the command to it. The Parse
method on the command (which may or may not be an extension method) does this already.
// This is only used to let the help generation know there is a name argument. | ||
// It doesn't contribute to parsing the arguments because the arity is set to 0. | ||
// InstantiateCommand reparses the arguments in the context of TemplateCommand to properly parse the name. | ||
internal static CliArgument<string> NameArgument { get; } = new CliArgument<string>("name") | ||
{ | ||
Description = SymbolStrings.TemplateCommand_Option_Name, | ||
Arity = new ArgumentArity(0, 0), | ||
Hidden = true | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Workaround to make help generate properly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
probably we can just adapt the help generation instead, as it is already custom.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
see here:
sdk/src/Cli/Microsoft.TemplateEngine.Cli/Commands/create/InstantiateCommand.Help.cs
Line 462 in 0ba5815
private static void CustomUsageSection(HelpContext context, CliCommand command) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did some investigation with this. It looks like both dotnet new --help
and dotnet new create --help
both call this CustomUsageSection
method. Without my code above, new
is missing the name
argument information, but create
is not. This is because InstantiateCommand
(aka create
) has the real name argument, but we don't want a real name argument for NewCommand
. We only want to show it in the help for new
.
The CustomUsageSection
method is for a custom Usage section but doesn't cover the Arguments section, which is currently not custom. That's why I opted to create this 'fake' name argument since it would "just work" for both sections. If I was to touch this help output, I'd likely refactor more than just working around this situation. But I didn't want to do all that for this PR.
if (firstTemplateCommandToken.StartsWith('-') && (tokenIndex = argsList.IndexOf(firstTemplateCommandToken)) >= 0) | ||
{ | ||
argsList.Insert(tokenIndex, TemplateCommandArgs.NameDefaultSentinel); | ||
parseResult = command.Parse(argsList, configuration); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This solution does require this second parse as you need to have the tokens individually first; then use the sentinel if the first token isn't supposed to be name
.
var tokensToInvoke = args.TokensToInvoke ?? Array.Empty<string>(); | ||
if (tokensToInvoke.Last() == "foo") | ||
{ | ||
tokensToInvoke = tokensToInvoke.SkipLast(1).ToArray(); | ||
} | ||
ParseResult templateParseResult = parser.Parse(tokensToInvoke); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is fun as foo
would previously be 'unparsed' and the test would run properly. Now, it was getting picked up as the name argument and thus having 2 assigned names, which we've made into an error.
var nameOption = parseResult.GetValueForOptionOrNull(SharedOptions.NameOption); | ||
if (nameArgument is not null && nameOption is not null) | ||
{ | ||
throw new InvalidOperationException($"Name argument '{nameArgument}' and name option (--name) '{nameOption}' have both been provided. Only one may be provided at a time."); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this the right way to error here? I've seen some throws like this, but it would also mean that these situations are unlocalized.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it is better to do it via validation delegate instead.
See as example:
internal void AddNoLegacyUsageValidators(CliCommand command, params CliSymbol[] except) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've added a validator for this situation. When are the validators executed?
@@ -172,6 +173,26 @@ async Task<string> GetTemplatePackagesList(TemplateGroup templateGroup) | |||
} | |||
} | |||
|
|||
// Reparses the CLI parameters in the context of the provided command (usually TemplateCommand). | |||
// If the first token after reparsing starts with a dash, we reparse again and inject a name sentinel to allow it to be handled properly (using empty name logic). | |||
internal static ParseResult Reparse(CliCommand command, IReadOnlyList<string> args, CliConfiguration? configuration = null) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The reason this solution works at all is because when we go to call a template, we reparse in the context of the template command. Otherwise, the name argument would always be lumped into the template options. If you debug here, you'll see the name argument value come out of the template options bucket and go into the appropriate name argument in TemplateCommand.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would expect parser should be capable to do a bit of magic for us already on the first pass.
I would expect it should be possible to achieve same via parseArgument
delegates set on the Name
and RemainingArgs
.
The logic for name:
- check if argument starts with
-
: yes - this is an arg, parse asnull
, no - this is a name.
The logic for remaining args (if needed)
- if name was an arg, prepend it as the first arg
Reparsing is costly operation and in this case it might be avoided, also it streamlines the logic.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Example of custom parseArgument
delegate is here:
sdk/src/Containers/containerize/ContainerizeCommand.cs
Lines 94 to 152 in 0ba5815
internal CliOption<Port[]> PortsOption { get; } = new("--ports") | |
{ | |
Description = "Ports that the application declares that it will use. Note that this means nothing to container hosts, by default - it's mostly documentation. Ports should be of the form {number}/{type}, where {type} is tcp or udp", | |
AllowMultipleArgumentsPerToken = true, | |
CustomParser = result => | |
{ | |
string[] ports = result.Tokens.Select(x => x.Value).ToArray(); | |
var goodPorts = new List<Port>(); | |
var badPorts = new List<(string, ContainerHelpers.ParsePortError)>(); | |
foreach (string port in ports) | |
{ | |
string[] split = port.Split('/'); | |
if (split.Length == 2) | |
{ | |
if (ContainerHelpers.TryParsePort(split[0], split[1], out var portInfo, out var portError)) | |
{ | |
goodPorts.Add(portInfo.Value); | |
} | |
else | |
{ | |
var pe = (ContainerHelpers.ParsePortError)portError!; | |
badPorts.Add((port, pe)); | |
} | |
} | |
else if (split.Length == 1) | |
{ | |
if (ContainerHelpers.TryParsePort(split[0], out var portInfo, out var portError)) | |
{ | |
goodPorts.Add(portInfo.Value); | |
} | |
else | |
{ | |
var pe = (ContainerHelpers.ParsePortError)portError!; | |
badPorts.Add((port, pe)); | |
} | |
} | |
else | |
{ | |
badPorts.Add((port, ContainerHelpers.ParsePortError.UnknownPortFormat)); | |
continue; | |
} | |
} | |
if (badPorts.Count != 0) | |
{ | |
var builder = new StringBuilder(); | |
builder.AppendLine("Incorrectly formatted ports:"); | |
foreach (var (badPort, error) in badPorts) | |
{ | |
var errors = Enum.GetValues<ContainerHelpers.ParsePortError>().Where(e => error.HasFlag(e)); | |
builder.AppendLine($"\t{badPort}:\t({string.Join(", ", errors)})"); | |
} | |
result.AddError(builder.ToString()); | |
return Array.Empty<Port>(); | |
} | |
return goodPorts.ToArray(); | |
} | |
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some clarification/comments:
- Reparsing was already taking place here. I ended up extracting the reparsing logic out into this method and calling it in 2 places.
- Changing out how
RemainingArgs
works changes the entirety ofdotnet new <TemplateName>
parsing, including the arguments specific to a template. I consider that out-of-scope for this PR. That should get easier to do the more familiar with S.CL I become. - I'll give the custom parser a try specifically for
Name
. As long as it is being activated during reparsing, it should work. But I need to validate that first.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@vlada-shubina I did some testing here and was unsuccessful with using CustomParser
. The situation is quite complex and I took a deeper dive into it. I found that currently, the dotnet new <TEMPLATE>
command is reparsed many times. In a simple dotnet new console
scenario (using the code from main
, not from this PR), it ends up reparsing 6 times. I recorded a video for you showing what happens (only viewable by MSFT).
I need to double check if the condition I put below to reparse for the name situation is called more than once. If it is, then I need to change it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I left some comments inline.
I hope the suggested approach helps with tests too, if not, let's discuss it separately.
src/Cli/Microsoft.TemplateEngine.Cli/Commands/NewCommand.Legacy.cs
Outdated
Show resolved
Hide resolved
// This is only used to let the help generation know there is a name argument. | ||
// It doesn't contribute to parsing the arguments because the arity is set to 0. | ||
// InstantiateCommand reparses the arguments in the context of TemplateCommand to properly parse the name. | ||
internal static CliArgument<string> NameArgument { get; } = new CliArgument<string>("name") | ||
{ | ||
Description = SymbolStrings.TemplateCommand_Option_Name, | ||
Arity = new ArgumentArity(0, 0), | ||
Hidden = true | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
probably we can just adapt the help generation instead, as it is already custom.
// This is only used to let the help generation know there is a name argument. | ||
// It doesn't contribute to parsing the arguments because the arity is set to 0. | ||
// InstantiateCommand reparses the arguments in the context of TemplateCommand to properly parse the name. | ||
internal static CliArgument<string> NameArgument { get; } = new CliArgument<string>("name") | ||
{ | ||
Description = SymbolStrings.TemplateCommand_Option_Name, | ||
Arity = new ArgumentArity(0, 0), | ||
Hidden = true | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
see here:
sdk/src/Cli/Microsoft.TemplateEngine.Cli/Commands/create/InstantiateCommand.Help.cs
Line 462 in 0ba5815
private static void CustomUsageSection(HelpContext context, CliCommand command) |
var nameOption = parseResult.GetValueForOptionOrNull(SharedOptions.NameOption); | ||
if (nameArgument is not null && nameOption is not null) | ||
{ | ||
throw new InvalidOperationException($"Name argument '{nameArgument}' and name option (--name) '{nameOption}' have both been provided. Only one may be provided at a time."); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it is better to do it via validation delegate instead.
See as example:
internal void AddNoLegacyUsageValidators(CliCommand command, params CliSymbol[] except) |
@@ -172,6 +173,26 @@ async Task<string> GetTemplatePackagesList(TemplateGroup templateGroup) | |||
} | |||
} | |||
|
|||
// Reparses the CLI parameters in the context of the provided command (usually TemplateCommand). | |||
// If the first token after reparsing starts with a dash, we reparse again and inject a name sentinel to allow it to be handled properly (using empty name logic). | |||
internal static ParseResult Reparse(CliCommand command, IReadOnlyList<string> args, CliConfiguration? configuration = null) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would expect parser should be capable to do a bit of magic for us already on the first pass.
I would expect it should be possible to achieve same via parseArgument
delegates set on the Name
and RemainingArgs
.
The logic for name:
- check if argument starts with
-
: yes - this is an arg, parse asnull
, no - this is a name.
The logic for remaining args (if needed)
- if name was an arg, prepend it as the first arg
Reparsing is costly operation and in this case it might be avoided, also it streamlines the logic.
…tion. Resourcized this error message.
…ed the 3 arguments from NewCommand.Legacy to NewCommand. Rebuilt so the missing localization strings are present.
@@ -297,7 +297,7 @@ dotnet_diagnostic.CA1823.severity = warning | |||
# Avoid zero-length array allocations. | |||
dotnet_diagnostic.CA1825.severity = warning | |||
# Do not use Enumerable methods on indexable collections. Instead use the collection directly | |||
dotnet_diagnostic.CA1826.severity = warning | |||
dotnet_diagnostic.CA1826.severity = none |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Disabled this warning as there are specific situations in which you want to use .FirstOrDefault()
since you cannot guarantee the collection has any elements to access directly without manual checks/extra boilerplate code.
Related: dotnet/templating#3009
This is attempting to add a name argument for
dotnet new
when creating a template. There is already an existing--name
option, but there was a suggestion to use a name argument (value with no--{something}
identifier). It should only be positional and exist after the template name.I ran into several issues with this as the tests make assumptions about the code flow and parsing of the commands. The current tests I was looking at,
Create_CanDetectParseErrorsTemplateOptions
, essentially recreates the code flow of activating the command (like when a user invokes it) but doesn't run down that code path. It has manual parse calls which end up not realistically representing the code flow.After making these changes, 70+ tests were broken and I've fixed several in this code so far. Not sure if I should keep going down this rabbit hole with the implementation presented here.