diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7b5abae..cd670f5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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)).
diff --git a/src/PrettyPrompt/Completion/CompletionEdit.cs b/src/PrettyPrompt/Completion/CompletionEdit.cs
new file mode 100644
index 0000000..9658dc1
--- /dev/null
+++ b/src/PrettyPrompt/Completion/CompletionEdit.cs
@@ -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;
+
+///
+/// Describes the document change applied when a "complex" is committed.
+///
+public readonly struct CompletionEdit
+{
+ /// The span of existing document text to replace with .
+ public TextSpan SpanToReplace { get; }
+
+ /// The text inserted in place of .
+ public string NewText { get; }
+
+ ///
+ /// 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.
+ ///
+ public int? NewCaret { get; }
+
+ public CompletionEdit(TextSpan spanToReplace, string newText, int? newCaret = null)
+ {
+ SpanToReplace = spanToReplace;
+ NewText = newText ?? string.Empty;
+ NewCaret = newCaret;
+ }
+}
diff --git a/src/PrettyPrompt/Completion/CompletionItem.cs b/src/PrettyPrompt/Completion/CompletionItem.cs
index dd25adb..a559561 100644
--- a/src/PrettyPrompt/Completion/CompletionItem.cs
+++ b/src/PrettyPrompt/Completion/CompletionItem.cs
@@ -22,6 +22,12 @@ public class CompletionItem
{
public delegate Task GetExtendedDescriptionHandler(CancellationToken cancellationToken);
+ ///
+ /// 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)`
+ ///
+ public delegate Task GetComplexTextEditHandler(string text, int caret, CancellationToken cancellationToken);
+
///
/// When the completion item is selected, this text will be inserted into the document at the specified start index.
///
@@ -54,22 +60,42 @@ public class CompletionItem
private readonly GetExtendedDescriptionHandler getExtendedDescription;
+ private readonly GetComplexTextEditHandler? getComplexTextEdit;
+
+ ///
+ /// When true, committing this item applies the returned by
+ ///
+ public bool HasComplexTextEdit => getComplexTextEdit is not null;
+
+ ///
+ /// Computes the complex text edit to apply when this item is committed. Only valid when is true.
+ ///
+ public Task 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.");
+
/// When the completion item is selected, this text will be inserted into the document at the specified start index.
/// This text will be displayed in the completion menu. If not specified, the value will be used.
/// This lazy task will be executed when the item is selected, to display the extended "tool tip" description to the right of the menu.
/// The text used to determine if the item matches the filter in the list. If not specified the value is used.
/// Rules that modify the set of characters that can be typed to cause the selected item to be committed.
+ ///
+ /// When supplied, committing this item applies the returned instead of inserting into the completion span.
+ ///
public CompletionItem(
string replacementText,
FormattedString displayText = default,
string? filterText = null,
GetExtendedDescriptionHandler? getExtendedDescription = null,
- ImmutableArray commitCharacterRules = default)
+ ImmutableArray commitCharacterRules = default,
+ GetComplexTextEditHandler? getComplexTextEdit = null)
{
ReplacementText = replacementText;
DisplayTextFormatted = displayText.IsEmpty ? replacementText : displayText;
FilterText = filterText ?? replacementText;
CommitCharacterRules = commitCharacterRules.IsDefault ? ImmutableArray.Empty : commitCharacterRules;
+ this.getComplexTextEdit = getComplexTextEdit;
Task? extendedDescriptionTask = null; //will be stored in closure of getExtendedDescription
this.getExtendedDescription = ct => extendedDescriptionTask ??= getExtendedDescription?.Invoke(ct) ?? Task.FromResult(FormattedString.Empty);
diff --git a/src/PrettyPrompt/Panes/CompletionPane.cs b/src/PrettyPrompt/Panes/CompletionPane.cs
index b61816a..e7aceb2 100644
--- a/src/PrettyPrompt/Panes/CompletionPane.cs
+++ b/src/PrettyPrompt/Panes/CompletionPane.cs
@@ -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);
diff --git a/tests/PrettyPrompt.Tests/CompletionTests.cs b/tests/PrettyPrompt.Tests/CompletionTests.cs
index afcd578..d57837f 100644
--- a/tests/PrettyPrompt.Tests/CompletionTests.cs
+++ b/tests/PrettyPrompt.Tests/CompletionTests.cs
@@ -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;
@@ -317,6 +319,66 @@ public async Task ReadLine_CaretMovingInsideWord_WhileCompletionListIsOpen_WontC
}
}
+ ///
+ /// A "complex" completion item supplies its own , which can rewrite a larger
+ /// region of the document than the completion word. This mirrors a C# cast completion turning i.
+ /// into ((byte)i) (see CSharpRepl issue #97), which a plain insertion of the replacement text
+ /// (byte) into the completion span could never produce.
+ ///
+ [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>(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);
+ }
+
+ ///
+ /// A complex edit can place the caret anywhere via . Here the caret is
+ /// requested just before the trailing ); the subsequently typed X must land there.
+ ///
+ [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>(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);
+ }
+
///
/// Tests bug from https://github.com/waf/PrettyPrompt/issues/96.
///