|
3 | 3 |
|
4 | 4 | namespace Microsoft.DotNet.Cli.Completions.Shells; |
5 | 5 |
|
| 6 | +using System.CodeDom.Compiler; |
| 7 | +using System.CommandLine; |
| 8 | +using System.CommandLine.Completions; |
| 9 | +using System.IO; |
| 10 | + |
6 | 11 | public class BashShellProvider : IShellProvider |
7 | 12 | { |
8 | 13 | public string ArgumentName => "bash"; |
9 | 14 |
|
10 | | - private static readonly string _dynamicCompletionScript = |
11 | | - """ |
12 | | - # bash parameter completion for the dotnet CLI |
13 | | - # add this to your .bashrc, .bash_profile, .bash_login, or .profile to enable completion |
| 15 | + public string GenerateCompletions(System.CommandLine.CliCommand command) |
| 16 | + { |
| 17 | + var initialFunctionName = new[] { command }.FunctionName().MakeSafeFunctionName(); |
| 18 | + return |
| 19 | + $""" |
| 20 | + #! /bin/bash |
| 21 | + {GenerateCommandsCompletions([command])} |
| 22 | +
|
| 23 | + complete -F {initialFunctionName} {command.Name} |
| 24 | + """; |
| 25 | + } |
| 26 | + |
| 27 | + private string GenerateCommandsCompletions(CliCommand[] commands) |
| 28 | + { |
| 29 | + var command = commands.Last(); |
| 30 | + var functionName = commands.FunctionName().MakeSafeFunctionName(); |
| 31 | + |
| 32 | + var isRootCommand = commands.Length == 1; |
| 33 | + var dollarOne = isRootCommand ? "1" : "$1"; |
| 34 | + var subcommandArgument = isRootCommand ? "2" : "$(($1+1))"; |
| 35 | + |
| 36 | + // generate the words for options and subcommands |
| 37 | + var visibleSubcommands = command.Subcommands.Where(c => !c.Hidden).ToArray(); |
| 38 | + var completionOptions = command.Options.Where(o => !o.Hidden).SelectMany(o => o.Names()).ToArray(); |
| 39 | + var completionSubcommands = visibleSubcommands.Select(x => x.Name).ToArray(); |
| 40 | + string[] completionWords = [.. completionSubcommands, .. completionOptions]; |
| 41 | + |
| 42 | + // for positional arguments this can be pretty dynamic |
| 43 | + var positionalArgumentCompletions = PositionalArgumentTerms(command.Arguments.Where(a => !a.Hidden).ToArray()); |
| 44 | + |
| 45 | + using var textWriter = new StringWriter { NewLine = "\n" }; |
| 46 | + using var writer = new IndentedTextWriter(textWriter); |
| 47 | + |
| 48 | + // write the overall completion function shell |
| 49 | + writer.WriteLine($"{functionName}() {{"); |
| 50 | + writer.WriteLine(); |
| 51 | + writer.Indent++; |
| 52 | + |
| 53 | + // set up state |
| 54 | + writer.WriteLine("""cur="${COMP_WORDS[COMP_CWORD]}" """); |
| 55 | + writer.WriteLine("""prev="${COMP_WORDS[COMP_CWORD-1]}" """); |
| 56 | + writer.WriteLine("COMPREPLY=()"); |
| 57 | + writer.WriteLine(); |
| 58 | + |
| 59 | + // fill in a set of completions for all of the subcommands and flag options for the top-level command |
| 60 | + writer.WriteLine($"""opts="{String.Join(' ', completionWords)}" """); |
| 61 | + foreach (var positionalArgumentCompletion in positionalArgumentCompletions) |
| 62 | + { |
| 63 | + writer.WriteLine($"""opts="$opts {positionalArgumentCompletion}" """); |
| 64 | + } |
| 65 | + writer.WriteLine(); |
| 66 | + |
| 67 | + // emit a short-circuit for when the first argument index (COMP_CWORD) is 1 (aka "dotnet") |
| 68 | + // in this short-circuit we'll just use the choices we set up above in $opts |
| 69 | + writer.WriteLine($"""if [[ $COMP_CWORD == {dollarOne} ]]; then"""); |
| 70 | + writer.Indent++; |
| 71 | + writer.WriteLine("""COMPREPLY=( $(compgen -W "$opts" -- "$cur") )"""); |
| 72 | + writer.WriteLine("return"); |
| 73 | + writer.Indent--; |
| 74 | + writer.WriteLine("fi"); |
| 75 | + writer.WriteLine(); |
| 76 | + |
| 77 | + // generate how to handle completions for options or flags |
| 78 | + var optionHandlers = GenerateOptionHandlers(command); |
| 79 | + if (optionHandlers is not null) |
| 80 | + { |
| 81 | + writer.WriteLine("case $prev in"); |
| 82 | + writer.Indent++; |
| 83 | + foreach (var line in optionHandlers.Split('\n')) |
| 84 | + { |
| 85 | + writer.WriteLine(line); |
| 86 | + } |
| 87 | + writer.Indent--; |
| 88 | + writer.WriteLine("esac"); |
| 89 | + writer.WriteLine(); |
| 90 | + } |
14 | 91 |
|
15 | | - function _dotnet_bash_complete() |
| 92 | + // finally subcommand completions - these are going to emit calls to subcommand completion functions that we'll emit at the end of this method |
| 93 | + if (visibleSubcommands.Length > 0) |
16 | 94 | { |
17 | | - local cur="${COMP_WORDS[COMP_CWORD]}" IFS=$'\n' # On Windows you may need to use use IFS=$'\r\n' |
18 | | - local candidates |
| 95 | + writer.WriteLine($"case ${{COMP_WORDS[{dollarOne}]}} in"); |
| 96 | + writer.Indent++; |
| 97 | + foreach (var subcommand in visibleSubcommands) |
| 98 | + { |
| 99 | + writer.WriteLine($"({subcommand.Name})"); |
| 100 | + writer.Indent++; |
| 101 | + writer.WriteLine($"{functionName}_{subcommand.Name} {subcommandArgument}"); |
| 102 | + writer.WriteLine("return"); |
| 103 | + writer.WriteLine(";;"); |
| 104 | + writer.WriteLine(); |
| 105 | + writer.Indent--; |
| 106 | + } |
| 107 | + writer.Indent--; |
| 108 | + writer.WriteLine("esac"); |
| 109 | + writer.WriteLine(); |
| 110 | + } |
| 111 | + |
| 112 | + // write the final trailer for the overall completion script |
| 113 | + writer.WriteLine("""COMPREPLY=( $(compgen -W "$opts" -- "$cur") )"""); |
| 114 | + writer.Indent--; |
| 115 | + writer.WriteLine("}"); |
| 116 | + writer.WriteLine(); |
19 | 117 |
|
20 | | - read -d '' -ra candidates < <(dotnet complete --position "${COMP_POINT}" "${COMP_LINE}" 2>/dev/null) |
| 118 | + // annnnd flush! |
| 119 | + writer.Flush(); |
| 120 | + return textWriter.ToString() + String.Join('\n', visibleSubcommands.Select(c => GenerateCommandsCompletions([.. commands, c]))); |
| 121 | + } |
21 | 122 |
|
22 | | - read -d '' -ra COMPREPLY < <(compgen -W "${candidates[*]:-}" -- "$cur") |
| 123 | + private static string[] PositionalArgumentTerms(CliArgument[] arguments) |
| 124 | + { |
| 125 | + var completions = new List<string>(); |
| 126 | + foreach (var argument in arguments) |
| 127 | + { |
| 128 | + if (argument.GetType().GetGenericTypeDefinition() == typeof(DynamicArgument<int>).GetGenericTypeDefinition()) |
| 129 | + { |
| 130 | + // if the argument is a not-static-friendly argument, we need to call into the app for completions |
| 131 | + completions.Add($"$({GenerateDynamicCall()})"); |
| 132 | + continue; |
| 133 | + } |
| 134 | + var argCompletions = argument.GetCompletions(CompletionContext.Empty).Select(c => c.Label).ToArray(); |
| 135 | + if (argCompletions.Length != 0) |
| 136 | + { |
| 137 | + // otherwise emit a direct list of choices |
| 138 | + completions.Add($"""({String.Join(' ', argCompletions)})"""); |
| 139 | + } |
23 | 140 | } |
24 | 141 |
|
25 | | - complete -f -F _dotnet_bash_complete dotnet |
26 | | - """; |
| 142 | + return completions.ToArray(); |
| 143 | + } |
| 144 | + |
| 145 | + /// <summary> |
| 146 | + /// Generates a call to `dotnet complete <string> --position <int>` for dynamic completions where necessary, but in a more generic way |
| 147 | + /// </summary> |
| 148 | + /// <returns></returns> |
| 149 | + private static string GenerateDynamicCall() |
| 150 | + { |
| 151 | + return $$"""${COMP_WORDS[0]} complete --position "${COMP_POINT}" "${COMP_LINE}" 2>/dev/null | tr '\n' ' '"""; |
| 152 | + } |
| 153 | + |
| 154 | + private static string GenerateOptionHandlers(CliCommand command) |
| 155 | + { |
| 156 | + var optionHandlers = command.Options.Where(o => !o.Hidden).Select(GenerateOptionHandler); |
| 157 | + return String.Join("\n", optionHandlers); |
| 158 | + } |
| 159 | + |
| 160 | + private static string GenerateOptionHandler(CliOption option) |
| 161 | + { |
| 162 | + var optionNames = String.Join('|', option.Names()); |
| 163 | + string completionCommand; |
| 164 | + if (option.GetType().IsGenericType && |
| 165 | + (option.GetType().GetGenericTypeDefinition() == typeof(DynamicOption<int>).GetGenericTypeDefinition() |
| 166 | + || option.GetType().GetGenericTypeDefinition() == typeof(DynamicForwardedOption<string>).GetGenericTypeDefinition())) |
| 167 | + { |
| 168 | + // dynamic options require a call into the app for completions |
| 169 | + completionCommand = $$"""COMPREPLY=( $(compgen -W "$({{GenerateDynamicCall()}})" -- "$cur") )"""; |
| 170 | + } |
| 171 | + else |
| 172 | + { |
| 173 | + var completions = option.GetCompletions(CompletionContext.Empty).Select(c => c.Label); |
| 174 | + if (completions.Count() == 0) |
| 175 | + { |
| 176 | + // if no completions, assume that we need to call into the app for completions |
| 177 | + completionCommand = ""; |
| 178 | + } |
| 179 | + else |
| 180 | + { |
| 181 | + // otherwise emit a direct list of choices |
| 182 | + completionCommand = $"""COMPREPLY=( $(compgen -W "{String.Join(' ', completions)}" -- "$cur") )"""; |
| 183 | + } |
| 184 | + } |
| 185 | + |
| 186 | + return $""" |
| 187 | + {optionNames}) |
| 188 | + {completionCommand} |
| 189 | + return |
| 190 | + ;; |
| 191 | + """; |
| 192 | + } |
| 193 | +} |
| 194 | + |
| 195 | + |
| 196 | +public static class HelpExtensions |
| 197 | +{ |
| 198 | + public static string FunctionName(this CliCommand[] commands) => "_" + String.Join('_', commands.Select(c => c.Name)); |
| 199 | + public static string MakeSafeFunctionName(this string functionName) => functionName.Replace('-', '_'); |
| 200 | + public static string[] Names(this CliOption option) |
| 201 | + { |
| 202 | + if (option.Aliases.Count == 0) |
| 203 | + { |
| 204 | + return [option.Name]; |
| 205 | + } |
| 206 | + else |
| 207 | + { |
| 208 | + return [option.Name, .. option.Aliases]; |
| 209 | + } |
| 210 | + } |
| 211 | + public static string[] Names(this CliCommand command) |
| 212 | + { |
| 213 | + if (command.Aliases.Count == 0) |
| 214 | + { |
| 215 | + return [command.Name]; |
| 216 | + } |
| 217 | + else |
| 218 | + { |
| 219 | + return [command.Name, .. command.Aliases]; |
| 220 | + } |
| 221 | + } |
27 | 222 |
|
28 | | - public string GenerateCompletions(System.CommandLine.CliCommand command) => _dynamicCompletionScript; |
29 | 223 | } |
0 commit comments