Skip to content

Commit

Permalink
Complete rewrite to support dynamic completions
Browse files Browse the repository at this point in the history
  • Loading branch information
baronfel committed Jul 28, 2024
1 parent bf80e3f commit 4fa8920
Show file tree
Hide file tree
Showing 8 changed files with 268 additions and 23 deletions.
48 changes: 41 additions & 7 deletions src/Cli/dotnet/CommonOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,28 @@

namespace Microsoft.DotNet.Cli
{
/// <summary>
/// Represents an Option whose completions are dynamically generated and so should not be emitted in static completion scripts.
/// </summary>
/// <typeparam name="T"></typeparam>
internal class DynamicOption<T> : CliOption<T>
{
public DynamicOption(string name, params string[] aliases) : base(name, aliases)
{
}
}

/// <summary>
/// Represents an Argument whose completions are dynamically generated and so should not be emitted in static completion scripts.
/// </summary>
/// <typeparam name="T"></typeparam>
internal class DynamicArgument<T> : CliArgument<T>
{
public DynamicArgument(string name) : base(name)
{
}
}

internal static class CommonOptions
{
public static CliOption<string[]> PropertiesOption =
Expand All @@ -35,13 +57,13 @@ internal static class CommonOptions
}.ForwardAsSingle(o => $"-verbosity:{o}");

public static CliOption<string> FrameworkOption(string description) =>
new ForwardedOption<string>("--framework", "-f")
new DynamicForwardedOption<string>("--framework", "-f")
{
Description = description,
HelpName = CommonLocalizableStrings.FrameworkArgumentName

}.ForwardAsSingle(o => $"-property:TargetFramework={o}")
.AddCompletions(Complete.TargetFrameworksFromProjectFile);
}
.AddCompletions(Complete.TargetFrameworksFromProjectFile)
.ForwardAsSingle(o => $"-property:TargetFramework={o}");

public static CliOption<string> ArtifactsPathOption =
new ForwardedOption<string>(
Expand All @@ -63,14 +85,14 @@ public static IEnumerable<string> RuntimeArgFunc(string rid)
}

public static CliOption<string> RuntimeOption =
new ForwardedOption<string>("--runtime", "-r")
new DynamicForwardedOption<string>("--runtime", "-r")
{
HelpName = RuntimeArgName
}.ForwardAsMany(RuntimeArgFunc)
.AddCompletions(Complete.RunTimesFromProjectFile);

public static CliOption<string> LongFormRuntimeOption =
new ForwardedOption<string>("--runtime")
new DynamicForwardedOption<string>("--runtime")
{
HelpName = RuntimeArgName
}.ForwardAsMany(RuntimeArgFunc)
Expand All @@ -83,7 +105,7 @@ public static CliOption<bool> CurrentRuntimeOption(string description) =>
}.ForwardAs("-property:UseCurrentRuntimeIdentifier=True");

public static CliOption<string> ConfigurationOption(string description) =>
new ForwardedOption<string>("--configuration", "-c")
new DynamicForwardedOption<string>("--configuration", "-c")
{
Description = description,
HelpName = CommonLocalizableStrings.ConfigurationArgumentName
Expand Down Expand Up @@ -277,6 +299,18 @@ internal static CliArgument<T> AddCompletions<T>(this CliArgument<T> argument, F
argument.CompletionSources.Add(completionSource);
return argument;
}

internal static DynamicOption<T> AddCompletions<T>(this DynamicOption<T> option, Func<CompletionContext, IEnumerable<CompletionItem>> completionSource)
{
option.CompletionSources.Add(completionSource);
return option;
}

internal static DynamicForwardedOption<T> AddCompletions<T>(this DynamicForwardedOption<T> option, Func<CompletionContext, IEnumerable<CompletionItem>> completionSource)
{
option.CompletionSources.Add(completionSource);
return option;
}
}

public enum VerbosityOptions
Expand Down
10 changes: 10 additions & 0 deletions src/Cli/dotnet/OptionForwardingExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -147,4 +147,14 @@ public Func<ParseResult, IEnumerable<string>> GetForwardingFunction()
return ForwardingFunction;
}
}

public class DynamicForwardedOption<T> : ForwardedOption<T>
{
public DynamicForwardedOption(string name, Func<ArgumentResult, T> parseArgument, string description = null)
: base(name, parseArgument, description)
{
}

public DynamicForwardedOption(string name, params string[] aliases) : base(name, aliases) { }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ namespace Microsoft.DotNet.Cli
{
internal static class AddPackageParser
{
public static readonly CliArgument<string> CmdPackageArgument = new CliArgument<string>(LocalizableStrings.CmdPackage)
public static readonly CliArgument<string> CmdPackageArgument = new DynamicArgument<string>(LocalizableStrings.CmdPackage)
{
Description = LocalizableStrings.CmdPackageDescription
}.AddCompletions((context) => QueryNuGet(context.WordToComplete).Select(match => new CompletionItem(match)));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ internal static class AddProjectToProjectReferenceParser
Arity = ArgumentArity.OneOrMore
};

public static readonly CliOption<string> FrameworkOption = new CliOption<string>("--framework", "-f")
public static readonly CliOption<string> FrameworkOption = new DynamicOption<string>("--framework", "-f")
{
Description = LocalizableStrings.CmdFrameworkDescription,
HelpName = Tools.Add.PackageReference.LocalizableStrings.CmdFramework
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ internal static class CompleteCommandParser
HelpName = "command"
};

public static readonly CliOption<bool> Detailed = new("--detailed")
{
Hidden = true,
Description = "Show detailed completion information"
};

private static readonly CliCommand Command = ConstructCommand();

public static CliCommand GetCommand()
Expand Down
219 changes: 207 additions & 12 deletions src/Cli/dotnet/commands/dotnet-completions/shells/Bash.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,222 @@

namespace Microsoft.DotNet.Cli.Completions.Shells;

using System.CodeDom.Compiler;
using System.CommandLine;
using System.CommandLine.Completions;
using System.IO;
using Windows.Win32.Security.Cryptography;

public class BashShellProvider : IShellProvider
{
public string ArgumentName => "bash";

private static readonly string _dynamicCompletionScript =
"""
# bash parameter completion for the dotnet CLI
# add this to your .bashrc, .bash_profile, .bash_login, or .profile to enable completion
public string GenerateCompletions(System.CommandLine.CliCommand command)
{
var initialFunctionName = new[] { command }.FunctionName().MakeSafeFunctionName();
return
$"""
#! /bin/bash
{GenerateCommandsCompletions([command])}
complete -F {initialFunctionName} {command.Name}
""";
}

private string GenerateCommandsCompletions(CliCommand[] commands)
{
var command = commands.Last();
var functionName = commands.FunctionName().MakeSafeFunctionName();

var isRootCommand = commands.Length == 1;
var dollarOne = isRootCommand ? "1" : "$1";
var subcommandArgument = isRootCommand ? "2" : "$(($1+1))";

// generate the words for options and subcommands
var visibleSubcommands = command.Subcommands.Where(c => !c.Hidden).ToArray();
var completionOptions = command.Options.Where(o => !o.Hidden).SelectMany(o => o.Names()).ToArray();
var completionSubcommands = visibleSubcommands.Select(x => x.Name).ToArray();
string[] completionWords = [.. completionSubcommands, .. completionOptions];

// for positional arguments this can be pretty dynamic
var positionalArgumentCompletions = PositionalArgumentTerms(command.Arguments.Where(a => !a.Hidden).ToArray());

using var textWriter = new StringWriter { NewLine = "\n" };
using var writer = new IndentedTextWriter(textWriter);

// write the overall completion function shell
writer.WriteLine($"{functionName}() {{");
writer.WriteLine();
writer.Indent++;

// set up state
writer.WriteLine("""cur="${COMP_WORDS[COMP_CWORD]}" """);
writer.WriteLine("""prev="${COMP_WORDS[COMP_CWORD-1]}" """);
writer.WriteLine("COMPREPLY=()");
writer.WriteLine();

// fill in a set of completions for all of the subcommands and flag options for the top-level command
writer.WriteLine($"""opts="{String.Join(' ', completionWords)}" """);
foreach (var positionalArgumentCompletion in positionalArgumentCompletions)
{
writer.WriteLine($"""opts="$opts {positionalArgumentCompletion}" """);
}
writer.WriteLine();

// emit a short-circuit for when the first argument index (COMP_CWORD) is 1 (aka "dotnet")
// in this short-circuit we'll just use the choices we set up above in $opts
writer.WriteLine($"""if [[ $COMP_CWORD == {dollarOne} ]]; then""");
writer.Indent++;
writer.WriteLine("""COMPREPLY=( $(compgen -W "$opts" -- "$cur") )""");
writer.WriteLine("return");
writer.Indent--;
writer.WriteLine("fi");
writer.WriteLine();

// generate how to handle completions for options or flags
var optionHandlers = GenerateOptionHandlers(command);
if (optionHandlers is not null)
{
writer.WriteLine("case $prev in");
writer.Indent++;
foreach (var line in optionHandlers.Split('\n'))
{
writer.WriteLine(line);
}
writer.Indent--;
writer.WriteLine("esac");
writer.WriteLine();
}

function _dotnet_bash_complete()
// finally subcommand completions - these are going to emit calls to subcommand completion functions that we'll emit at the end of this method
if (visibleSubcommands.Length > 0)
{
local cur="${COMP_WORDS[COMP_CWORD]}" IFS=$'\n' # On Windows you may need to use use IFS=$'\r\n'
local candidates
writer.WriteLine($"case ${{COMP_WORDS[{dollarOne}]}} in");
writer.Indent++;
foreach (var subcommand in visibleSubcommands)
{
writer.WriteLine($"({subcommand.Name})");
writer.Indent++;
writer.WriteLine($"{functionName}_{subcommand.Name} {subcommandArgument}");
writer.WriteLine("return");
writer.WriteLine(";;");
writer.WriteLine();
writer.Indent--;
}
writer.Indent--;
writer.WriteLine("esac");
writer.WriteLine();
}

// write the final trailer for the overall completion script
writer.WriteLine("""COMPREPLY=( $(compgen -W "$opts" -- "$cur") )""");
writer.Indent--;
writer.WriteLine("}");
writer.WriteLine();

read -d '' -ra candidates < <(dotnet complete --position "${COMP_POINT}" "${COMP_LINE}" 2>/dev/null)
// annnnd flush!
writer.Flush();
return textWriter.ToString() + String.Join('\n', visibleSubcommands.Select(c => GenerateCommandsCompletions([.. commands, c])));
}

read -d '' -ra COMPREPLY < <(compgen -W "${candidates[*]:-}" -- "$cur")
private static string[] PositionalArgumentTerms(CliArgument[] arguments)
{
var completions = new List<string>();
foreach (var argument in arguments)
{
if (argument.GetType().GetGenericTypeDefinition() == typeof(DynamicArgument<int>).GetGenericTypeDefinition())
{
// if the argument is a not-static-friendly argument, we need to call into the app for completions
completions.Add($"$({GenerateDynamicCall()})");
continue;
}
var argCompletions = argument.GetCompletions(CompletionContext.Empty).Select(c => c.Label).ToArray();
if (argCompletions.Length != 0)
{
// otherwise emit a direct list of choices
completions.Add($"""({String.Join(' ', argCompletions)})""");
}
}

complete -f -F _dotnet_bash_complete dotnet
""";
return completions.ToArray();
}

/// <summary>
/// Generates a call to `dotnet complete <string> --position <int>` for dynamic completions where necessary, but in a more generic way
/// </summary>
/// <returns></returns>
private static string GenerateDynamicCall()
{
return $$"""${COMP_WORDS[0]} complete --position "${COMP_POINT}" "${COMP_LINE}" 2>/dev/null | tr '\n' ' '""";
}

private static string GenerateOptionHandlers(CliCommand command)
{
var optionHandlers = command.Options.Where(o => !o.Hidden).Select(GenerateOptionHandler);
return String.Join("\n", optionHandlers);
}

private static string GenerateOptionHandler(CliOption option)
{
var optionNames = String.Join('|', option.Names());
string completionCommand;
if (option.GetType().IsGenericType &&
(option.GetType().GetGenericTypeDefinition() == typeof(DynamicOption<int>).GetGenericTypeDefinition()
|| option.GetType().GetGenericTypeDefinition() == typeof(DynamicForwardedOption<string>).GetGenericTypeDefinition()))
{
// dynamic options require a call into the app for completions
completionCommand = $$"""COMPREPLY=( $(compgen -W "$({{GenerateDynamicCall()}})" -- "$cur") )""";
}
else
{
var completions = option.GetCompletions(CompletionContext.Empty).Select(c => c.Label);
if (completions.Count() == 0)
{
// if no completions, assume that we need to call into the app for completions
completionCommand = "";
}
else
{
// otherwise emit a direct list of choices
completionCommand = $"""COMPREPLY=( $(compgen -W "{String.Join(' ', completions)}" -- "$cur") )""";
}
}

return $"""
{optionNames})
{completionCommand}
return
;;
""";
}
}


public static class HelpExtensions
{
public static string FunctionName(this CliCommand[] commands) => "_" + String.Join('_', commands.Select(c => c.Name));
public static string MakeSafeFunctionName(this string functionName) => functionName.Replace('-', '_');
public static string[] Names(this CliOption option)
{
if (option.Aliases.Count == 0)
{
return [option.Name];
}
else
{
return [option.Name, .. option.Aliases];
}
}
public static string[] Names(this CliCommand command)
{
if (command.Aliases.Count == 0)
{
return [command.Name];
}
else
{
return [command.Name, .. command.Aliases];
}
}

public string GenerateCompletions(System.CommandLine.CliCommand command) => _dynamicCompletionScript;
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ namespace Microsoft.DotNet.Cli
{
internal static class RemoveProjectToProjectReferenceParser
{
public static readonly CliArgument<IEnumerable<string>> ProjectPathArgument = new CliArgument<IEnumerable<string>>(LocalizableStrings.ProjectPathArgumentName)
public static readonly CliArgument<IEnumerable<string>> ProjectPathArgument = new DynamicArgument<IEnumerable<string>>(LocalizableStrings.ProjectPathArgumentName)
{
Description = LocalizableStrings.ProjectPathArgumentDescription,
Arity = ArgumentArity.OneOrMore,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ private static IEnumerable<CliOption> ImplicitRestoreOptions(bool showHelp, bool

if (includeRuntimeOption)
{
CliOption<IEnumerable<string>> runtimeOption = new ForwardedOption<IEnumerable<string>>("--runtime")
CliOption<IEnumerable<string>> runtimeOption = new DynamicForwardedOption<IEnumerable<string>>("--runtime")
{
Description = LocalizableStrings.CmdRuntimeOptionDescription,
HelpName = LocalizableStrings.CmdRuntimeOption,
Expand Down

0 comments on commit 4fa8920

Please sign in to comment.