diff --git a/CSharpRepl.Services/Completion/AutoCompleteService.cs b/CSharpRepl.Services/Completion/AutoCompleteService.cs
index ccef8f4..41b4df3 100644
--- a/CSharpRepl.Services/Completion/AutoCompleteService.cs
+++ b/CSharpRepl.Services/Completion/AutoCompleteService.cs
@@ -9,13 +9,13 @@
using System.Threading.Tasks;
using CSharpRepl.Services.Extensions;
using CSharpRepl.Services.SyntaxHighlighting;
-using CSharpRepl.Services.Theming;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Completion;
using Microsoft.Extensions.Caching.Memory;
using PrettyPrompt.Highlighting;
-
+using CompletionEdit = PrettyPrompt.Completion.CompletionEdit;
using PrettyPromptCompletionItem = PrettyPrompt.Completion.CompletionItem;
+using PrettyPromptTextSpan = PrettyPrompt.Documents.TextSpan;
namespace CSharpRepl.Services.Completion;
@@ -85,6 +85,31 @@ FormattedString GetDisplayText(CompletionItem item)
}
}
+ ///
+ /// Resolves the actual document edit Roslyn wants to apply when is committed. Most items are simple insertions but
+ /// "complex" items, like a conversion/cast completion that rewrites `i.` into `((byte)i)`, replace a larger region than the typed word and
+ /// reposition the caret.
+ ///
+ public async Task GetChangeAsync(Document document, int caret, CompletionItem item, CancellationToken cancellationToken)
+ {
+ var completionService = CompletionService.GetService(document);
+ if (completionService is null)
+ {
+ return new CompletionEdit(new PrettyPromptTextSpan(0, 0), item.DisplayText);
+ }
+
+ var change = await completionService.GetChangeAsync(document, item, cancellationToken: cancellationToken).ConfigureAwait(false);
+ var changeSpan = change.TextChange.Span;
+
+ // The item can be stale: the completion menu opens at `i.`, but the user then types filter characters (`i.by` to narrow to `(byte)`) before committing.
+ var sourceText = await document.GetTextAsync(cancellationToken).ConfigureAwait(false);
+ var wordSpan = completionService.GetDefaultCompletionListSpan(sourceText, caret);
+ var start = Math.Min(changeSpan.Start, wordSpan.Start);
+ var end = Math.Max(changeSpan.End, wordSpan.End);
+
+ return new CompletionEdit(PrettyPromptTextSpan.FromBounds(start, end), change.TextChange.NewText ?? string.Empty, change.NewPosition);
+ }
+
private static async Task GetExtendedDescriptionAsync(CompletionService completionService, Document document, CompletionItem item, SyntaxHighlighter highlighter)
{
var description = await completionService.GetDescriptionAsync(document, item);
diff --git a/CSharpRepl.Services/Roslyn/RoslynServices.cs b/CSharpRepl.Services/Roslyn/RoslynServices.cs
index b101942..6ac95bf 100644
--- a/CSharpRepl.Services/Roslyn/RoslynServices.cs
+++ b/CSharpRepl.Services/Roslyn/RoslynServices.cs
@@ -36,6 +36,7 @@
using PrettyPrompt.Highlighting;
using Spectre.Console;
using Spectre.Console.Rendering;
+using CompletionEdit = PrettyPrompt.Completion.CompletionEdit;
using PrettyPromptTextSpan = PrettyPrompt.Documents.TextSpan;
namespace CSharpRepl.Services.Roslyn;
@@ -369,6 +370,14 @@ public async Task GetSpanToReplaceByCompletionAsync(string
return new PrettyPromptTextSpan(span.Start, span.Length);
}
+ public async Task GetCompletionChangeAsync(string text, int caret, CompletionItem item, CancellationToken cancellationToken)
+ {
+ await Initialization.ConfigureAwait(false);
+
+ var document = workspaceManager.CurrentDocument.WithText(SourceText.From(text));
+ return await autocompleteService.GetChangeAsync(document, caret, item, cancellationToken).ConfigureAwait(false);
+ }
+
public Task ShouldOpenCompletionWindowAsync(string text, int caret, KeyPress keyPress, CancellationToken cancellationToken)
{
var keyChar = keyPress.ConsoleKeyInfo.KeyChar;
diff --git a/CSharpRepl/CSharpReplPromptCallbacks.cs b/CSharpRepl/CSharpReplPromptCallbacks.cs
index e68b92c..2867b89 100644
--- a/CSharpRepl/CSharpReplPromptCallbacks.cs
+++ b/CSharpRepl/CSharpReplPromptCallbacks.cs
@@ -156,7 +156,10 @@ internal CompletionItem CreatePrettyPromptCompletionItem(CompletionItemWithDescr
displayText: r.DisplayText,
getExtendedDescription: r.GetDescriptionAsync,
filterText: r.Item.FilterText,
- commitCharacterRules: MergeCommitRules(r.Item.Rules.CommitCharacterRules, commitKeybinding));
+ commitCharacterRules: MergeCommitRules(r.Item.Rules.CommitCharacterRules, commitKeybinding),
+ getComplexTextEdit: r.Item.IsComplexTextEdit
+ ? (text, caret, cancellationToken) => roslyn.GetCompletionChangeAsync(text, caret, r.Item, cancellationToken)
+ : null);
}
private static CharacterSetModificationRule CreateCommitRuleForUserKeybinding(in KeyPressPatterns commitCompletion)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 20bc124..041cdbb 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -37,7 +37,7 @@
-
+
diff --git a/Tests/CSharpRepl.Tests/CompletionTests.cs b/Tests/CSharpRepl.Tests/CompletionTests.cs
index 67482b3..68a050a 100644
--- a/Tests/CSharpRepl.Tests/CompletionTests.cs
+++ b/Tests/CSharpRepl.Tests/CompletionTests.cs
@@ -115,6 +115,65 @@ public async Task Complete_SyntaxHighlight_CachesAreIsolated()
Assert.NotEmpty(highlights);
}
+ ///
+ /// https://github.com/waf/CSharpRepl/issues/97
+ /// A conversion ("cast") completion is a complex text edit: committing (byte) on i. must
+ /// rewrite the expression to ((byte)i), not append the display text after the dot (i.byte).
+ /// We assert the Roslyn-computed edit, applied to the source, produces the cast.
+ ///
+ [Fact]
+ public async Task Complete_CastCompletion_RewritesExpressionWithCast()
+ {
+ const string code = "int i;\ni.";
+ var caret = code.Length;
+ var completions = await this.services.CompleteAsync(code, caret, TestContext.Current.CancellationToken);
+
+ var castItem = completions.SingleOrDefault(c =>
+ c.Item.IsComplexTextEdit &&
+ c.Item.DisplayTextPrefix + c.Item.DisplayText + c.Item.DisplayTextSuffix == "(byte)");
+ Assert.NotNull(castItem);
+
+ // The PrettyPrompt completion item we hand to the prompt must carry the complex edit (this is what
+ // CompletionPane.InsertCompletion uses on commit), and that edit must produce the cast.
+ var promptItem = promptCallbacks.CreatePrettyPromptCompletionItem(castItem);
+ Assert.True(promptItem.HasComplexTextEdit);
+ var edit = await promptItem.GetComplexTextEditAsync(code, caret, TestContext.Current.CancellationToken);
+
+ var rewritten = code
+ .Remove(edit.SpanToReplace.Start, edit.SpanToReplace.Length)
+ .Insert(edit.SpanToReplace.Start, edit.NewText);
+
+ Assert.Equal("int i;\n((byte)i)", rewritten);
+ Assert.DoesNotContain("i.byte", rewritten);
+ }
+
+ ///
+ /// https://github.com/waf/CSharpRepl/issues/97
+ /// Faithful reproduction of the live flow: the completion menu opens at i. (so the Roslyn item, and
+ /// the span its change replaces, are computed there), but the user then types filter characters (i.by
+ /// to narrow to (byte)) before committing. PrettyPrompt filters client-side without re-querying, so
+ /// the edit must still consume the typed by and produce ((byte)i), not ((byte)i)by.
+ ///
+ [Fact]
+ public async Task Complete_CastCompletion_CommittedAfterTypingFilter_ConsumesTypedText()
+ {
+ const string opened = "int i;\ni.";
+ var items = await this.services.CompleteAsync(opened, opened.Length, TestContext.Current.CancellationToken);
+ var castItem = items.Single(c =>
+ c.Item.IsComplexTextEdit &&
+ c.Item.DisplayTextPrefix + c.Item.DisplayText + c.Item.DisplayTextSuffix == "(byte)");
+
+ // The stale item is committed against the longer (filtered) commit-time document.
+ const string atCommit = "int i;\ni.by";
+ var edit = await this.services.GetCompletionChangeAsync(atCommit, atCommit.Length, castItem.Item, TestContext.Current.CancellationToken);
+
+ var rewritten = atCommit
+ .Remove(edit.SpanToReplace.Start, edit.SpanToReplace.Length)
+ .Insert(edit.SpanToReplace.Start, edit.NewText);
+
+ Assert.Equal("int i;\n((byte)i)", rewritten);
+ }
+
///
/// https://github.com/waf/CSharpRepl/issues/65
///