Skip to content
This repository was archived by the owner on Dec 8, 2021. It is now read-only.

Commit a82fd29

Browse files
rjmholtiSazonov
andauthored
Add Microsoft.PowerShell.UnixCompleters module (#44)
Co-authored-by: Ilya <[email protected]>
1 parent 30f0477 commit a82fd29

18 files changed

+1246
-0
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
out/
2+
bin/
3+
obj/
4+
*.dll
5+
*.pdb
6+
.vs/
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
@{
2+
3+
# Script module or binary module file associated with this manifest.
4+
RootModule = 'Microsoft.PowerShell.UnixCompleters.dll'
5+
6+
# Version number of this module.
7+
ModuleVersion = '0.1.1'
8+
9+
# Supported PSEditions
10+
CompatiblePSEditions = 'Core'
11+
12+
# ID used to uniquely identify this module
13+
GUID = '042bff5f-9644-43ef-8f4e-d8b8ed5a1f97'
14+
15+
# Author of this module
16+
Author = 'Microsoft Corporation'
17+
18+
# Company or vendor of this module
19+
CompanyName = 'Microsoft Corporation'
20+
21+
# Copyright statement for this module
22+
Copyright = '(c) Microsoft Corporation'
23+
24+
# Description of the functionality provided by this module
25+
Description = 'Get parameter completion for native Unix utilities. Requires zsh or bash.'
26+
27+
# Minimum version of the PowerShell engine required by this module
28+
PowerShellVersion = '6.0'
29+
30+
# Script files (.ps1) that are run in the caller's environment prior to importing this module.
31+
ScriptsToProcess = @("OnStart.ps1")
32+
33+
# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export.
34+
FunctionsToExport = @()
35+
36+
# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export.
37+
CmdletsToExport = @(
38+
'Import-UnixCompleters',
39+
'Remove-UnixCompleters',
40+
'Set-UnixCompleter'
41+
)
42+
43+
# Variables to export from this module
44+
VariablesToExport = @()
45+
46+
# Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export.
47+
AliasesToExport = @()
48+
49+
# Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell.
50+
PrivateData = @{
51+
52+
PSData = @{
53+
54+
# A URL to the license for this module.
55+
LicenseUri = 'https://raw.githubusercontent.com/PowerShell/Modules/master/LICENSE'
56+
57+
# A URL to the main website for this project.
58+
ProjectUri = 'https://github.com/PowerShell/Modules/tree/master/Modules/Microsoft.PowerShell.UnixCompleters'
59+
60+
# Flag to indicate whether the module requires explicit user acceptance for install/update/save
61+
RequireLicenseAcceptance = $false
62+
63+
Tags = @(
64+
'PSEdition_Core',
65+
'Linux',
66+
'Mac'
67+
)
68+
69+
} # End of PSData hashtable
70+
71+
} # End of PrivateData hashtable
72+
}
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
using System.Collections.Concurrent;
6+
using System.Collections.Generic;
7+
using System.Diagnostics;
8+
using System.IO;
9+
using System.Linq;
10+
using System.Management.Automation;
11+
using System.Management.Automation.Language;
12+
using System.Runtime.InteropServices;
13+
using System.Text;
14+
15+
namespace Microsoft.PowerShell.UnixCompleters
16+
{
17+
public class BashUtilCompleter : IUnixUtilCompleter
18+
{
19+
20+
private static readonly string s_resolveCompleterCommandTemplate = string.Join("; ", new []
21+
{
22+
"-lic \". /usr/share/bash-completion 2>/dev/null",
23+
"__load_completion {0} 2>/dev/null",
24+
"complete -p {0} 2>/dev/null | sed -E 's/^complete.*-F ([^ ]+).*$/\\1/'\""
25+
});
26+
27+
private readonly Dictionary<string, string> _commandCompletionFunctions;
28+
29+
private readonly string _bashPath;
30+
31+
public BashUtilCompleter(string bashPath)
32+
{
33+
_bashPath = bashPath;
34+
_commandCompletionFunctions = new Dictionary<string, string>();
35+
}
36+
37+
public IEnumerable<string> FindCompletableCommands()
38+
{
39+
return UnixHelpers.NativeUtilNames;
40+
}
41+
42+
public IEnumerable<CompletionResult> CompleteCommand(
43+
string command,
44+
string wordToComplete,
45+
CommandAst commandAst,
46+
int cursorPosition)
47+
{
48+
string completerFunction = ResolveCommandCompleterFunction(command);
49+
50+
int cursorWordIndex = 0;
51+
string previousWord = commandAst.CommandElements[0].Extent.Text;
52+
for (int i = 1; i < commandAst.CommandElements.Count; i++)
53+
{
54+
IScriptExtent elementExtent = commandAst.CommandElements[i].Extent;
55+
56+
if (cursorPosition < elementExtent.EndColumnNumber)
57+
{
58+
previousWord = commandAst.CommandElements[i - 1].Extent.Text;
59+
cursorWordIndex = i;
60+
break;
61+
}
62+
63+
if (cursorPosition == elementExtent.EndColumnNumber)
64+
{
65+
previousWord = elementExtent.Text;
66+
cursorWordIndex = i + 1;
67+
break;
68+
}
69+
70+
if (cursorPosition < elementExtent.StartColumnNumber)
71+
{
72+
previousWord = commandAst.CommandElements[i - 1].Extent.Text;
73+
cursorWordIndex = i;
74+
break;
75+
}
76+
77+
if (i == commandAst.CommandElements.Count - 1 && cursorPosition > elementExtent.EndColumnNumber)
78+
{
79+
previousWord = elementExtent.Text;
80+
cursorWordIndex = i + 1;
81+
break;
82+
}
83+
}
84+
85+
string commandLine;
86+
string bashWordArray;
87+
88+
if (cursorWordIndex > 0)
89+
{
90+
commandLine = "'" + commandAst.Extent.Text + "'";
91+
92+
// Handle a case like '/mnt/c/Program Files'/<TAB> where the slash is outside the string
93+
94+
// The presumed slash-prefixed string
95+
IScriptExtent currentExtent = commandAst.CommandElements[cursorWordIndex].Extent;
96+
97+
// The string argument
98+
IScriptExtent previousExtent = commandAst.CommandElements[cursorWordIndex - 1].Extent;
99+
100+
if (currentExtent.Text.StartsWith("/") && currentExtent.StartColumnNumber == previousExtent.EndColumnNumber)
101+
{
102+
commandLine = commandLine.Replace(previousExtent.Text + currentExtent.Text, wordToComplete);
103+
bashWordArray = BuildCompWordsBashArrayString(commandAst.Extent.Text, replaceAt: cursorPosition, replacementWord: wordToComplete);
104+
}
105+
else
106+
{
107+
bashWordArray = BuildCompWordsBashArrayString(commandAst.Extent.Text);
108+
}
109+
}
110+
else if (cursorPosition > commandAst.Extent.Text.Length)
111+
{
112+
cursorWordIndex++;
113+
commandLine = "'" + commandAst.Extent.Text + " '";
114+
bashWordArray = new StringBuilder(64)
115+
.Append("('").Append(commandAst.Extent.Text).Append("' '')")
116+
.ToString();
117+
}
118+
else
119+
{
120+
commandLine = "'" + commandAst.Extent.Text + "'";
121+
bashWordArray = new StringBuilder(32)
122+
.Append("('").Append(wordToComplete).Append("')")
123+
.ToString();
124+
}
125+
126+
string completionCommand = BuildCompletionCommand(
127+
command,
128+
COMP_LINE: commandLine,
129+
COMP_WORDS: bashWordArray,
130+
COMP_CWORD: cursorWordIndex,
131+
COMP_POINT: cursorPosition,
132+
completerFunction,
133+
wordToComplete,
134+
previousWord);
135+
136+
List<string> completionResults = InvokeBashWithArguments(completionCommand)
137+
.Split('\n')
138+
.Distinct(StringComparer.Ordinal)
139+
.ToList();
140+
141+
completionResults.Sort(StringComparer.Ordinal);
142+
143+
string previousCompletion = null;
144+
foreach (string completionResult in completionResults)
145+
{
146+
if (string.IsNullOrEmpty(completionResult))
147+
{
148+
continue;
149+
}
150+
151+
int equalsIndex = wordToComplete.IndexOf('=');
152+
153+
string completionText;
154+
string listItemText;
155+
if (equalsIndex >= 0)
156+
{
157+
completionText = wordToComplete.Substring(0, equalsIndex) + completionResult;
158+
listItemText = completionResult;
159+
}
160+
else
161+
{
162+
completionText = completionResult;
163+
listItemText = completionText;
164+
}
165+
166+
if (completionText.Equals(previousCompletion))
167+
{
168+
listItemText += " ";
169+
}
170+
171+
previousCompletion = completionText;
172+
173+
yield return new CompletionResult(
174+
completionText,
175+
listItemText,
176+
CompletionResultType.ParameterName,
177+
completionText);
178+
}
179+
}
180+
181+
private string ResolveCommandCompleterFunction(string commandName)
182+
{
183+
if (string.IsNullOrEmpty(commandName))
184+
{
185+
throw new ArgumentException(nameof(commandName));
186+
}
187+
188+
string completerFunction;
189+
if (_commandCompletionFunctions.TryGetValue(commandName, out completerFunction))
190+
{
191+
return completerFunction;
192+
}
193+
194+
string resolveCompleterInvocation = string.Format(s_resolveCompleterCommandTemplate, commandName);
195+
completerFunction = InvokeBashWithArguments(resolveCompleterInvocation).Trim();
196+
_commandCompletionFunctions[commandName] = completerFunction;
197+
198+
return completerFunction;
199+
}
200+
201+
private string InvokeBashWithArguments(string argumentString)
202+
{
203+
using (var bashProc = new Process())
204+
{
205+
bashProc.StartInfo.FileName = this._bashPath;
206+
bashProc.StartInfo.Arguments = argumentString;
207+
bashProc.StartInfo.UseShellExecute = false;
208+
bashProc.StartInfo.RedirectStandardOutput = true;
209+
bashProc.Start();
210+
211+
return bashProc.StandardOutput.ReadToEnd();
212+
}
213+
}
214+
215+
private static string EscapeCompletionResult(string completionResult)
216+
{
217+
completionResult = completionResult.Trim();
218+
219+
if (!completionResult.Contains(' '))
220+
{
221+
return completionResult;
222+
}
223+
224+
return "'" + completionResult.Replace("'", "''") + "'";
225+
}
226+
227+
228+
private static string BuildCompWordsBashArrayString(
229+
string line,
230+
int replaceAt = -1,
231+
string replacementWord = null)
232+
{
233+
// Build a bash array of line components, like "('ls' '-a')"
234+
string[] lineElements = line.Split();
235+
236+
int approximateLength = 0;
237+
foreach (string element in lineElements)
238+
{
239+
approximateLength += lineElements.Length + 2;
240+
}
241+
242+
var sb = new StringBuilder(approximateLength);
243+
244+
sb.Append('(')
245+
.Append('\'')
246+
.Append(lineElements[0].Replace("'", "\\'"))
247+
.Append('\'');
248+
249+
if (replaceAt < 1)
250+
{
251+
for (int i = 1; i < lineElements.Length; i++)
252+
{
253+
sb.Append(' ')
254+
.Append('\'')
255+
.Append(lineElements[i].Replace("'", "\\'"))
256+
.Append('\'');
257+
}
258+
}
259+
else
260+
{
261+
for (int i = 1; i < lineElements.Length; i++)
262+
{
263+
if (i == replaceAt - 1)
264+
{
265+
continue;
266+
}
267+
268+
if (i == replaceAt)
269+
{
270+
sb.Append(' ').Append(replacementWord);
271+
continue;
272+
}
273+
274+
sb.Append(' ')
275+
.Append('\'')
276+
.Append(lineElements[i].Replace("'", "\\'"))
277+
.Append('\'');
278+
}
279+
}
280+
281+
sb.Append(')');
282+
283+
return sb.ToString();
284+
}
285+
286+
private static string BuildCompletionCommand(
287+
string command,
288+
string COMP_LINE,
289+
string COMP_WORDS,
290+
int COMP_CWORD,
291+
int COMP_POINT,
292+
string completionFunction,
293+
string wordToComplete,
294+
string previousWord)
295+
{
296+
return new StringBuilder(512)
297+
.Append("-lic \". /usr/share/bash-completion/bash_completion 2>/dev/null; ")
298+
.Append("__load_completion ").Append(command).Append(" 2>/dev/null; ")
299+
.Append("COMP_LINE=").Append(COMP_LINE).Append("; ")
300+
.Append("COMP_WORDS=").Append(COMP_WORDS).Append("; ")
301+
.Append("COMP_CWORD=").Append(COMP_CWORD).Append("; ")
302+
.Append("COMP_POINT=").Append(COMP_POINT).Append("; ")
303+
.Append("bind 'set completion-ignore-case on' 2>/dev/null; ")
304+
.Append(completionFunction)
305+
.Append(" '").Append(command).Append("'")
306+
.Append(" '").Append(wordToComplete).Append("'")
307+
.Append(" '").Append(previousWord).Append("' 2>/dev/null; ")
308+
.Append("IFS=$'\\n'; ")
309+
.Append("echo \"\"\"${COMPREPLY[*]}\"\"\"\"")
310+
.ToString();
311+
}
312+
}
313+
}

0 commit comments

Comments
 (0)