Skip to content
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

RFC for an argument completer base class #269

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
348 changes: 348 additions & 0 deletions 1-Draft/RFC-ArgumentCompleter-BaseClass.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,348 @@
---
RFC: RFC<four digit unique incrementing number assigned by Committee, this shall be left blank by the author>
Author: Staffan Gustafsson
Status: Draft
SupercededBy:
Version: <Major>.<Minor>
Area: Command Completion
Comments Due: 1 month
Plan to implement: Yes
---

Provide a base class, ArgumentCompleterBase, to simplify writing custom argument completers

Some common tasks needs to be done almost every time a completer is written.

Things like quoting arguments if they contain spaces, and matching against `WordToComplete`.
Ensuring that the results are sorted is another such task.

## Motivation

As a Developer,
I can use the base class when writing custom argument completers,
so that results are achieved faster, with high quality, and
so that users get a consistent completion experience.

## User Experience

```csharp
using namespace System.Management.Automation;

public class GetCommitCompleter : ArgumentCompleter
{
protected override CompletionResultSortKind AddCompletionsFor(string commandName, string parameterName, IDictionary fakeBoundParameters)
{
switch (parameterName)
{
case nameof(GitRebaseCommand.CommitHash):
CompleteGitCommitHash();
break;
}

return CompletionResultSortKind.Sorted;
}

private void CompleteGitCommitHash()
{
var user = GetFakeBoundParameter<string>("User");
foreach(var commit in GitExe.Log())
{
if (user is { } u && commit.User != u){
continue;
}
CompleteMatching(text: commit.Hash, tooltip: $"{commit.User}\r\n{commit.Description}}": CompletionMatch.AnyContainsWithWordToComplete);
}
}
}
```


## Specification

```CSharp
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Management.Automation.Language;

namespace System.Management.Automation
{

public enum CompletionMatch
{
TextStartsWithWordToComplete,
TextContainsWordToComplete,
AnyStartsWithWordToComplete,
AnyContainsWordToComplete,
}

public enum CompletionResultSortKind
{
None,
PreferStartsWithWordToComplete,
Sorted
}

/// <summary>
/// Base class for writing custom Argument Completers
/// </summary>
/// <para>Derived classes should override <see cref="AddCompletionsFor" /> </para>
public abstract class ArgumentCompleter : IArgumentCompleter
{
private readonly StringComparison _stringComparison;
private List<CompletionResult>? _results;
private IDictionary? _fakeBoundParameters;
private string? _wordToComplete;
private CommandAst? _commandAst;

protected ArgumentCompleter(StringComparison stringComparison = StringComparison.CurrentCultureIgnoreCase) : this(01, stringComparison)
{
}

protected ArgumentCompleter(int capacity, StringComparison stringComparison = StringComparison.CurrentCultureIgnoreCase)
{
_stringComparison = stringComparison;
if (capacity > 0)
{
_results = new List<CompletionResult>(capacity: capacity);
}
}
private List<CompletionResult> Results => _results ??= new List<CompletionResult>();

#pragma warning disable CA1033 // Interface methods should be callable by child types
IEnumerable<CompletionResult>? IArgumentCompleter.CompleteArgument(string commandName, string parameterName,
#pragma warning restore CA1033 // Interface methods should be callable by child types
string wordToComplete, CommandAst commandAst, IDictionary fakeBoundParameters)
{
_fakeBoundParameters = fakeBoundParameters;
WordToComplete = wordToComplete;
CommandAst = commandAst;
var sortKind = AddCompletionsFor(commandName: commandName, parameterName: parameterName, fakeBoundParameters: fakeBoundParameters);
SortResults(sortKind);
return _results;
}

[return: NotNullIfNotNull("defaultValue")]
public T? GetBoundParameterOrDefault<T>(string parameterName, T? defaultValue) where T : class => _fakeBoundParameters?[key: parameterName] is T value ? value : defaultValue;

/// <summary>
/// Override in child class to add completions by calling <see ref="CompleteWith" />
/// </summary>
/// <param name="commandName">the command to complete parameters for</param>
/// <param name="parameterName">the parameter to complete</param>
/// <param name="fakeBoundParameters">previously specified command parameters</param>
protected abstract CompletionResultSortKind AddCompletionsFor(string commandName, string parameterName, IDictionary fakeBoundParameters);

/// <summary>
/// Adds a completion result to the result set
/// </summary>
/// <param name="completionResult">the completion result to add</param>
public void Complete(CompletionResult completionResult)
{
Results.Add(item: completionResult);
}

/// <summary>
/// Adds a completion result to the result set with the specified parameters
/// </summary>
/// <param name="text">the text to be used as the auto completion result</param>
/// <param name="listItemText">the text to be displayed in a list</param>
/// <param name="toolTip">the text for the tooltip with details to be displayed about the object</param>
/// <param name="resultType">the type of completion result</param>
public void Complete(string text, string? listItemText = null, string? toolTip = null, CompletionResultType resultType = CompletionResultType.ParameterValue)
{
if (text == null) throw new ArgumentNullException(nameof(text));

var quotedText = QuoteCompletionText(text: text);
var completionResult = new CompletionResult(completionText: quotedText, listItemText ?? text,
resultType: resultType, toolTip ?? text);
Results.Add(item: completionResult);
}

/// <summary>
/// Adds a completion result to the result set if the text starts with <see cref="WordToComplete" />
/// </summary>
/// <para>The comparison is case insensitive</para>
/// <param name="text">the text to be used as the auto completion result</param>
/// <param name="listItemText">the text to be displayed in a list</param>
/// <param name="toolTip">the text for the tooltip with details to be displayed about the object</param>
/// <param name="resultType">the type of completion result</param>
/// <param name="completionMatch">specifies how item(s) are match against <see cref="WordToComplete"/>.</param>
public void CompleteMatching(string text, string? listItemText = null, string? toolTip = null, CompletionResultType resultType = CompletionResultType.ParameterValue, CompletionMatch completionMatch = CompletionMatch.TextStartsWithWordToComplete)
{
switch (completionMatch)
{
case CompletionMatch.TextStartsWithWordToComplete:
CompleteIfTextStartsWithWordToComplete(text, listItemText, toolTip, resultType);
break;
case CompletionMatch.TextContainsWordToComplete:
CompleteIfTextContainsWordToComplete(text, listItemText, toolTip, resultType);
break;
case CompletionMatch.AnyStartsWithWordToComplete:
CompleteIfAnyStartsWithWordToComplete(text, listItemText, toolTip, resultType);
break;
case CompletionMatch.AnyContainsWordToComplete:
CompleteIfAnyContainsWordToComplete(text, listItemText, toolTip, resultType);
break;
default:
throw new ArgumentOutOfRangeException(nameof(completionMatch), completionMatch, null);
}
}

/// <summary>
/// Adds a completion result to the result set if the text starts with <see cref="WordToComplete" />
/// </summary>
/// <para>The comparison is case insensitive</para>
/// <param name="text">the text to be used as the auto completion result</param>
/// <param name="listItemText">the text to be displayed in a list</param>
/// <param name="toolTip">the text for the tooltip with details to be displayed about the object</param>
/// <param name="resultType">the type of completion result</param>
private void CompleteIfTextStartsWithWordToComplete(string text, string? listItemText = null, string? toolTip = null, CompletionResultType resultType = CompletionResultType.ParameterValue)
{
if (StartWithWordToComplete(text: text))
Complete(text: text, listItemText ?? text, toolTip ?? text,
resultType: resultType);
}

/// <summary>
/// Adds a completion result to the result set if the any string argument starts with <see cref="WordToComplete" />
/// </summary>
/// <para>The comparison is case insensitive</para>
/// <param name="text">the text to be used as the auto completion result</param>
/// <param name="listItemText">the text to be displayed in a list</param>
/// <param name="toolTip">the text for the tooltip with details to be displayed about the object</param>
/// <param name="resultType">the type of completion result</param>
private void CompleteIfAnyStartsWithWordToComplete(string text, string? listItemText = null,
string? toolTip = null, CompletionResultType resultType = CompletionResultType.ParameterValue)
{
if (StartWithWordToComplete(text: text) || StartWithWordToComplete(text: listItemText) || StartWithWordToComplete(text: toolTip))
Complete(text: text, listItemText ?? text, toolTip ?? text,
resultType: resultType);
}

/// <summary>
/// Adds a completion result to the result set if the any string argument starts with <see cref="WordToComplete" />
/// </summary>
/// <para>The comparison is case insensitive</para>
/// <param name="text">the text to be used as the auto completion result</param>
/// <param name="listItemText">the text to be displayed in a list</param>
/// <param name="toolTip">the text for the tooltip with details to be displayed about the object</param>
/// <param name="resultType">the type of completion result</param>
private void CompleteIfAnyContainsWordToComplete(string text, string? listItemText = null,
string? toolTip = null, CompletionResultType resultType = CompletionResultType.ParameterValue)
{
if (ContainsWordToComplete(text: text) || ContainsWordToComplete(text: listItemText) || ContainsWordToComplete(text: toolTip))
Complete(text: text, listItemText ?? text, toolTip ?? text,
resultType: resultType);
}

/// <summary>
/// Adds a completion result to the result set if the text contains <see cref="WordToComplete" />
/// </summary>
/// <para>The comparison is case insensitive</para>
/// <param name="text">the text to be used as the auto completion result</param>
/// <param name="listItemText">the text to be displayed in a list</param>
/// <param name="toolTip">the text for the tooltip with details to be displayed about the object</param>
/// <param name="resultType">the type of completion result</param>
private void CompleteIfTextContainsWordToComplete(string text, string? listItemText = null,
string? toolTip = null, CompletionResultType resultType = CompletionResultType.ParameterValue)
{
if (ContainsWordToComplete(text: text))
Complete(text: text, listItemText ?? text, toolTip ?? text,
resultType: resultType);
}

/// <summary>
/// If necessary, puts quotation marks around the completion text
/// </summary>
/// <param name="text">The text to complete</param>
/// <returns></returns>
protected virtual string QuoteCompletionText(string text) => text.Contains(" ") ? $@"""{text}""" : text;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if the user is using a single quote?

Copy link
Contributor

@vexx32 vexx32 May 19, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh gosh, had some flashbacks of working on argument completion for PSWordCloud. There's a bit more you need to handle for this kind of thing as well, depending on the colorful variety of things you may be expecting to handle as the completion results themselves. Let me find some code I wrote a while back... here:

https://github.com/vexx32/PSWordCloud/blob/main/Module/src/PSWordCloud/Completers/FontFamilyCompleter.cs

This one covers escaping things that need escaping, spaces, # characters in the completion results (I happened to end up having fonts with that character on my computer and it breaks completions terribly 😀) and probably a few other things. I haven't managed to break it yet.

The only thing I don't think it bothers to handle is whether the user used single quotes, it currently replaces them with double quotes instead I think. So it could be expanded on to detect that and act accordingly (less things need escaping there, etc).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I expect we have to turn this into a much more complete implementation. Maybe we can make use of the parser to determine what needs to be escaped?


/// <summary>
/// Predicate to test if a string starts with <see cref="WordToComplete" />
/// </summary>
/// <param name="text">text to compare to <see cref="WordToComplete" />.</param>
/// <returns><see langword="true"/> if the text contains <see cref="WordToComplete" />, otherwise <see langword="false"/></returns>
public bool StartWithWordToComplete(string? text) => StartWithWordToComplete(text, _stringComparison);

/// <summary>
/// Predicate to test if a string starts with <see cref="WordToComplete" />
/// </summary>
/// <param name="text">text to compare to <see cref="WordToComplete" />.</param>
/// <param name="stringComparison">Determines whether the beginning of this string instance matches the specified string when compared using the specified comparison option.</param>
/// <returns><see langword="true"/> if the text contains <see cref="WordToComplete" />, otherwise <see langword="false"/></returns>
public bool StartWithWordToComplete(string? text, StringComparison stringComparison) =>
!string.IsNullOrEmpty(value: text) && text.StartsWith(value: WordToComplete, comparisonType: stringComparison);

/// <summary>
/// Predicate to test if a string starts with <see cref="WordToComplete" />
/// </summary>
/// <param name="text">the text to test</param>
/// <returns><see langword="true"/> if the text contains <see cref="WordToComplete" />, otherwise <see langword="false"/></returns>
public bool ContainsWordToComplete(string? text) => ContainsWordToComplete(text, _stringComparison);

/// <summary>
/// Predicate to test if a string starts with <see cref="WordToComplete" />
/// </summary>
/// <param name="text">the text to test</param>
/// <param name="stringComparison">Determines whether the beginning of this string instance matches the specified string when compared using the specified comparison option.</param>
/// <returns><see langword="true"/> if the text contains <see cref="WordToComplete" />, otherwise <see langword="false"/></returns>
public bool ContainsWordToComplete(string? text, StringComparison stringComparison) =>
!string.IsNullOrEmpty(value: text) && text.IndexOf(value: WordToComplete, comparisonType: stringComparison) != -1;

/// <summary>
/// Sort the completion results as specified by the <paramref name="completionResultSortKind"/> parameter.
/// </summary>
/// <param name="completionResultSortKind">Specifies of the completion results should be returned as is or sorted in some way.</param>
protected virtual void SortResults(CompletionResultSortKind completionResultSortKind)
{
int CompareStartWithWordToCompleteCompletionResult(CompletionResult x, CompletionResult y)
{
var startsWith = x.CompletionText.StartsWith(WordToComplete, _stringComparison).CompareTo(y.CompletionText.StartsWith(WordToComplete, _stringComparison));
return startsWith != 0 ? startsWith : string.Compare(strA: x.CompletionText, strB: y.CompletionText, comparisonType: StringComparison.Ordinal);
}

int CompareCompletionResult(CompletionResult x, CompletionResult y) => string.Compare(strA: x.CompletionText, strB: y.CompletionText, _stringComparison);

switch (completionResultSortKind)
{
case CompletionResultSortKind.None:
break;
case CompletionResultSortKind.PreferStartsWithWordToComplete:
Results.Sort(comparison: CompareStartWithWordToCompleteCompletionResult);
break;
case CompletionResultSortKind.Sorted:
Results.Sort(comparison: CompareCompletionResult);
break;
default:
throw new ArgumentOutOfRangeException(nameof(completionResultSortKind), completionResultSortKind, null);
}
}

public bool Empty => _results?.Count == 0;

/// <summary>
/// The word to complete
/// </summary>
public string WordToComplete
{
get => _wordToComplete ?? throw new InvalidOperationException();
private set => _wordToComplete = value;
}

/// <summary>
/// The Command Ast for the command to complete
/// </summary>
public CommandAst CommandAst
{
get => _commandAst ?? throw new InvalidOperationException();
private set => _commandAst = value;
}
}
}

```

## Alternate Proposals and Considerations