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
29 changes: 27 additions & 2 deletions CSharpRepl.Services/Completion/AutoCompleteService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -85,6 +85,31 @@ FormattedString GetDisplayText(CompletionItem item)
}
}

/// <summary>
/// Resolves the actual document edit Roslyn wants to apply when <paramref name="item"/> 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.
/// </summary>
public async Task<CompletionEdit> 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<FormattedString> GetExtendedDescriptionAsync(CompletionService completionService, Document document, CompletionItem item, SyntaxHighlighter highlighter)
{
var description = await completionService.GetDescriptionAsync(document, item);
Expand Down
9 changes: 9 additions & 0 deletions CSharpRepl.Services/Roslyn/RoslynServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -369,6 +370,14 @@ public async Task<PrettyPromptTextSpan> GetSpanToReplaceByCompletionAsync(string
return new PrettyPromptTextSpan(span.Start, span.Length);
}

public async Task<CompletionEdit> 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<bool> ShouldOpenCompletionWindowAsync(string text, int caret, KeyPress keyPress, CancellationToken cancellationToken)
{
var keyChar = keyPress.ConsoleKeyInfo.KeyChar;
Expand Down
5 changes: 4 additions & 1 deletion CSharpRepl/CSharpReplPromptCallbacks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
<PackageVersion Include="NuGet.ProjectModel" Version="7.6.0" />
<PackageVersion Include="NuGet.Protocol" Version="7.6.0" />
<PackageVersion Include="NuGet.Versioning" Version="7.6.0" />
<PackageVersion Include="PrettyPrompt" Version="6.0.3" />
<PackageVersion Include="PrettyPrompt" Version="6.0.4" />
<PackageVersion Include="Spectre.Console.Ansi" Version="0.57.1" />
<PackageVersion Include="Spectre.Console.Cli" Version="0.55.0" />
<PackageVersion Include="Spectre.Console.Testing" Version="0.57.1" />
Expand Down
59 changes: 59 additions & 0 deletions Tests/CSharpRepl.Tests/CompletionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,65 @@ public async Task Complete_SyntaxHighlight_CachesAreIsolated()
Assert.NotEmpty(highlights);
}

/// <summary>
/// https://github.com/waf/CSharpRepl/issues/97
/// A conversion ("cast") completion is a complex text edit: committing <c>(byte)</c> on <c>i.</c> must
/// rewrite the expression to <c>((byte)i)</c>, not append the display text after the dot (<c>i.byte</c>).
/// We assert the Roslyn-computed edit, applied to the source, produces the cast.
/// </summary>
[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);
}

/// <summary>
/// https://github.com/waf/CSharpRepl/issues/97
/// Faithful reproduction of the live flow: the completion menu opens at <c>i.</c> (so the Roslyn item, and
/// the span its change replaces, are computed there), but the user then types filter characters (<c>i.by</c>
/// to narrow to <c>(byte)</c>) before committing. PrettyPrompt filters client-side without re-querying, so
/// the edit must still consume the typed <c>by</c> and produce <c>((byte)i)</c>, not <c>((byte)i)by</c>.
/// </summary>
[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);
}

/// <summary>
/// https://github.com/waf/CSharpRepl/issues/65
/// </summary>
Expand Down
Loading