Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# Release 6.0.4 (UNRELEASED)

- Support "complex" completion items that rewrite a larger region of the document than the typed word (and reposition the caret).

# Release 6.0.3

- Fix reformat-on-submit: when a `FormatInput` callback reformats the document on the same keystroke that submits the prompt, the returned `PromptResult` (and the persisted history entry) now contains the formatted text instead of the pre-format snapshot, and the on-screen line is repainted to match ([#301](https://github.com/waf/PrettyPrompt/pull/301), [CSharpRepl #356](https://github.com/waf/CSharpRepl/issues/356)).
Expand Down
34 changes: 34 additions & 0 deletions src/PrettyPrompt/Completion/CompletionEdit.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#region License Header
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
#endregion

using PrettyPrompt.Documents;

namespace PrettyPrompt.Completion;

/// <summary>
/// Describes the document change applied when a "complex" <see cref="CompletionItem"/> is committed.
/// </summary>
public readonly struct CompletionEdit
{
/// <summary>The span of existing document text to replace with <see cref="NewText"/>.</summary>
public TextSpan SpanToReplace { get; }

/// <summary>The text inserted in place of <see cref="SpanToReplace"/>.</summary>
public string NewText { get; }

/// <summary>
/// The absolute caret index (in the edited document) to move the caret to after applying the edit.
/// When null, the caret is placed at the end of the inserted text.
/// </summary>
public int? NewCaret { get; }

public CompletionEdit(TextSpan spanToReplace, string newText, int? newCaret = null)
{
SpanToReplace = spanToReplace;
NewText = newText ?? string.Empty;
NewCaret = newCaret;
}
}
28 changes: 27 additions & 1 deletion src/PrettyPrompt/Completion/CompletionItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ public class CompletionItem
{
public delegate Task<FormattedString> GetExtendedDescriptionHandler(CancellationToken cancellationToken);

/// <summary>
/// Computes the document change to apply when a "complex" completion item is committed. Used for completions
/// that can't be expressed as a simple insertion into the completion span like a C# cast completion that rewrites `i.` into `((byte)i)`
/// </summary>
public delegate Task<CompletionEdit> GetComplexTextEditHandler(string text, int caret, CancellationToken cancellationToken);

/// <summary>
/// When the completion item is selected, this text will be inserted into the document at the specified start index.
/// </summary>
Expand Down Expand Up @@ -54,22 +60,42 @@ public class CompletionItem

private readonly GetExtendedDescriptionHandler getExtendedDescription;

private readonly GetComplexTextEditHandler? getComplexTextEdit;

/// <summary>
/// When true, committing this item applies the <see cref="CompletionEdit"/> returned by <see cref="GetComplexTextEditAsync"/>
/// </summary>
public bool HasComplexTextEdit => getComplexTextEdit is not null;

/// <summary>
/// Computes the complex text edit to apply when this item is committed. Only valid when <see cref="HasComplexTextEdit"/> is true.
/// </summary>
public Task<CompletionEdit> GetComplexTextEditAsync(string text, int caret, CancellationToken cancellationToken)
=> getComplexTextEdit is not null
? getComplexTextEdit(text, caret, cancellationToken)
: throw new InvalidOperationException($"{nameof(GetComplexTextEditAsync)} called on a completion item without a complex text edit.");

/// <param name="replacementText">When the completion item is selected, this text will be inserted into the document at the specified start index.</param>
/// <param name="displayText">This text will be displayed in the completion menu. If not specified, the <paramref name="replacementText"/> value will be used.</param>
/// <param name="getExtendedDescription">This lazy task will be executed when the item is selected, to display the extended "tool tip" description to the right of the menu.</param>
/// <param name="filterText">The text used to determine if the item matches the filter in the list. If not specified the <paramref name="replacementText"/> value is used.</param>
/// <param name="commitCharacterRules">Rules that modify the set of characters that can be typed to cause the selected item to be committed.</param>
/// <param name="getComplexTextEdit">
/// When supplied, committing this item applies the returned <see cref="CompletionEdit"/> instead of inserting <paramref name="replacementText"/> into the completion span.
/// </param>
public CompletionItem(
string replacementText,
FormattedString displayText = default,
string? filterText = null,
GetExtendedDescriptionHandler? getExtendedDescription = null,
ImmutableArray<CharacterSetModificationRule> commitCharacterRules = default)
ImmutableArray<CharacterSetModificationRule> commitCharacterRules = default,
GetComplexTextEditHandler? getComplexTextEdit = null)
{
ReplacementText = replacementText;
DisplayTextFormatted = displayText.IsEmpty ? replacementText : displayText;
FilterText = filterText ?? replacementText;
CommitCharacterRules = commitCharacterRules.IsDefault ? ImmutableArray<CharacterSetModificationRule>.Empty : commitCharacterRules;
this.getComplexTextEdit = getComplexTextEdit;

Task<FormattedString>? extendedDescriptionTask = null; //will be stored in closure of getExtendedDescription
this.getExtendedDescription = ct => extendedDescriptionTask ??= getExtendedDescription?.Invoke(ct) ?? Task.FromResult(FormattedString.Empty);
Expand Down
13 changes: 13 additions & 0 deletions src/PrettyPrompt/Panes/CompletionPane.cs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,19 @@ await promptCallbacks.ShouldOpenCompletionWindowAsync(codePane.Document.GetText(
private async Task InsertCompletion(CodePane codepane, CompletionItem completion, CancellationToken cancellationToken)
{
var document = codepane.Document;

if (completion.HasComplexTextEdit)
{
// The item rewrites the document (e.g. a C# cast completion turning `i.` into `((byte)i)`). These edits can span a larger region than the completion word and set their own caret position, so
var edit = await completion.GetComplexTextEditAsync(document.GetText(), document.Caret, cancellationToken).ConfigureAwait(false);
document.Remove(codepane, edit.SpanToReplace);
document.Caret = Math.Clamp(edit.SpanToReplace.Start, 0, document.Length);
document.InsertAtCaret(codepane, edit.NewText);
document.Caret = Math.Clamp(edit.NewCaret ?? edit.SpanToReplace.Start + edit.NewText.Length, 0, document.Length);
await Close(cancellationToken).ConfigureAwait(false);
return;
}

var spanToReplace = await promptCallbacks.GetSpanToReplaceByCompletionAsync(document.GetText(), document.Caret, cancellationToken).ConfigureAwait(false);
document.Remove(codepane, spanToReplace);
document.InsertAtCaret(codepane, completion.ReplacementText);
Expand Down
62 changes: 62 additions & 0 deletions tests/PrettyPrompt.Tests/CompletionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using PrettyPrompt.Completion;
using PrettyPrompt.Consoles;
using PrettyPrompt.Documents;
using Xunit;
using static System.ConsoleKey;
using static System.ConsoleModifiers;
Expand Down Expand Up @@ -317,6 +319,66 @@ public async Task ReadLine_CaretMovingInsideWord_WhileCompletionListIsOpen_WontC
}
}

/// <summary>
/// A "complex" completion item supplies its own <see cref="CompletionEdit"/>, which can rewrite a larger
/// region of the document than the completion word. This mirrors a C# cast completion turning <c>i.</c>
/// into <c>((byte)i)</c> (see CSharpRepl issue #97), which a plain insertion of the replacement text
/// (<c>byte</c>) into the completion span could never produce.
/// </summary>
[Fact]
public async Task ReadLine_ComplexTextEdit_RewritesRegionBeyondCompletionWord()
{
var console = ConsoleStub.NewConsole();
console.StubInput($"i.{Control}{Spacebar}{Enter}{Enter}"); // type "i.", open menu, commit, submit

var callbacks = new TestPromptCallbacks
{
CompletionCallback = (text, caret, spanToBeReplaced) => Task.FromResult<IReadOnlyList<CompletionItem>>(new[]
{
new CompletionItem(
replacementText: "byte",
displayText: "(byte)",
getComplexTextEdit: (_, _, _) =>
Task.FromResult(new CompletionEdit(new TextSpan(0, "i.".Length), "((byte)i)"))),
}),
};
var prompt = new Prompt(callbacks: callbacks, console: console);

var result = await prompt.ReadLineAsync();

Assert.True(result.IsSuccess);
Assert.Equal("((byte)i)", result.Text);
}

/// <summary>
/// A complex edit can place the caret anywhere via <see cref="CompletionEdit.NewCaret"/>. Here the caret is
/// requested just before the trailing <c>)</c>; the subsequently typed <c>X</c> must land there.
/// </summary>
[Fact]
public async Task ReadLine_ComplexTextEdit_HonorsCaretPosition()
{
var console = ConsoleStub.NewConsole();
console.StubInput($"i.{Control}{Spacebar}{Enter}X{Enter}"); // commit, then type X at the requested caret

var callbacks = new TestPromptCallbacks
{
CompletionCallback = (text, caret, spanToBeReplaced) => Task.FromResult<IReadOnlyList<CompletionItem>>(new[]
{
new CompletionItem(
replacementText: "byte",
displayText: "(byte)",
getComplexTextEdit: (_, _, _) =>
Task.FromResult(new CompletionEdit(new TextSpan(0, "i.".Length), "((byte)i)", newCaret: "((byte)i".Length))),
}),
};
var prompt = new Prompt(callbacks: callbacks, console: console);

var result = await prompt.ReadLineAsync();

Assert.True(result.IsSuccess);
Assert.Equal("((byte)iX)", result.Text);
}

/// <summary>
/// Tests bug from https://github.com/waf/PrettyPrompt/issues/96.
/// </summary>
Expand Down
Loading