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 ///