From 9efaba4e8a75944c88a63462cdee71b7a17ad616 Mon Sep 17 00:00:00 2001 From: Will Fuqua Date: Sun, 28 Jun 2026 22:04:16 +0700 Subject: [PATCH] Support complex completions that rewrite the source document --- CHANGELOG.md | 4 ++ src/PrettyPrompt/Completion/CompletionEdit.cs | 34 ++++++++++ src/PrettyPrompt/Completion/CompletionItem.cs | 28 ++++++++- src/PrettyPrompt/Panes/CompletionPane.cs | 13 ++++ tests/PrettyPrompt.Tests/CompletionTests.cs | 62 +++++++++++++++++++ 5 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 src/PrettyPrompt/Completion/CompletionEdit.cs 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. ///