diff --git a/build/Packages.props b/build/Packages.props index 1763397560..724e628df0 100644 --- a/build/Packages.props +++ b/build/Packages.props @@ -6,7 +6,7 @@ 3.1.12 16.9.0 5.2.0 - 3.10.0-1.21125.6 + 3.10.0-3.21222.20 2.4.1 diff --git a/src/OmniSharp.Abstractions/IsExternalInit.cs b/src/OmniSharp.Abstractions/IsExternalInit.cs new file mode 100644 index 0000000000..4594436de5 --- /dev/null +++ b/src/OmniSharp.Abstractions/IsExternalInit.cs @@ -0,0 +1,4 @@ +namespace System.Runtime.CompilerServices +{ + internal sealed class IsExternalInit { } +} diff --git a/src/OmniSharp.Abstractions/Models/v1/Completion/CompletionAfterInsertRequest.cs b/src/OmniSharp.Abstractions/Models/v1/Completion/CompletionAfterInsertRequest.cs new file mode 100644 index 0000000000..5afcb3f4d6 --- /dev/null +++ b/src/OmniSharp.Abstractions/Models/v1/Completion/CompletionAfterInsertRequest.cs @@ -0,0 +1,12 @@ +#nullable enable + +using OmniSharp.Mef; + +namespace OmniSharp.Models.v1.Completion +{ + [OmniSharpEndpoint(OmniSharpEndpoints.CompletionAfterInsert, typeof(CompletionAfterInsertRequest), typeof(CompletionAfterInsertResponse))] + public class CompletionAfterInsertRequest : IRequest + { + public CompletionItem Item { get; set; } = null!; + } +} diff --git a/src/OmniSharp.Abstractions/Models/v1/Completion/CompletionAfterInsertResponse.cs b/src/OmniSharp.Abstractions/Models/v1/Completion/CompletionAfterInsertResponse.cs new file mode 100644 index 0000000000..a8e4b65487 --- /dev/null +++ b/src/OmniSharp.Abstractions/Models/v1/Completion/CompletionAfterInsertResponse.cs @@ -0,0 +1,26 @@ +#nullable enable + +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace OmniSharp.Models.v1.Completion +{ + public class CompletionAfterInsertResponse + { + /// + /// Text changes to be applied to the document. These need to be applied in batch, all with reference to + /// the same original document. + /// + public IReadOnlyList? Changes { get; set; } + /// + /// Line to position the cursor on after applying . + /// + [JsonConverter(typeof(ZeroBasedIndexConverter))] + public int? Line { get; set; } + /// + /// Column to position the cursor on after applying . + /// + [JsonConverter(typeof(ZeroBasedIndexConverter))] + public int? Column { get; set; } + } +} diff --git a/src/OmniSharp.Abstractions/Models/v1/Completion/CompletionItem.cs b/src/OmniSharp.Abstractions/Models/v1/Completion/CompletionItem.cs index 7e7acd64a0..4544e5f6ae 100644 --- a/src/OmniSharp.Abstractions/Models/v1/Completion/CompletionItem.cs +++ b/src/OmniSharp.Abstractions/Models/v1/Completion/CompletionItem.cs @@ -87,7 +87,12 @@ public class CompletionItem /// /// Index in the completions list that this completion occurred. /// - public int Data { get; set; } + public (long CacheId, int Index) Data { get; set; } + + /// + /// True if there is a post-insert step for this completion item for asynchronous completion support. + /// + public bool HasAfterInsertStep { get; set; } public override string ToString() { diff --git a/src/OmniSharp.Abstractions/OmniSharpEndpoints.cs b/src/OmniSharp.Abstractions/OmniSharpEndpoints.cs index f8b8c98d45..fd292c4c84 100644 --- a/src/OmniSharp.Abstractions/OmniSharpEndpoints.cs +++ b/src/OmniSharp.Abstractions/OmniSharpEndpoints.cs @@ -49,6 +49,7 @@ public static class OmniSharpEndpoints public const string Completion = "/completion"; public const string CompletionResolve = "/completion/resolve"; + public const string CompletionAfterInsert = "/completion/afterinsert"; public static class V2 { diff --git a/src/OmniSharp.LanguageServerProtocol/Handlers/OmniSharpCompletionHandler.cs b/src/OmniSharp.LanguageServerProtocol/Handlers/OmniSharpCompletionHandler.cs index ed312dec51..e2e58ea28f 100644 --- a/src/OmniSharp.LanguageServerProtocol/Handlers/OmniSharpCompletionHandler.cs +++ b/src/OmniSharp.LanguageServerProtocol/Handlers/OmniSharpCompletionHandler.cs @@ -28,6 +28,8 @@ namespace OmniSharp.LanguageServerProtocol.Handlers { class OmniSharpCompletionHandler : CompletionHandlerBase { + const string AfterInsertCommandName = "csharp.completion.afterInsert"; + public static IEnumerable Enumerate(RequestHandlers handlers) { foreach (var (selector, completionHandler, completionResolveHandler) in handlers @@ -139,7 +141,10 @@ private CompletionItem ToLSPCompletionItem(OmnisharpCompletionItem omnisharpComp AdditionalTextEdits = omnisharpCompletionItem.AdditionalTextEdits is { } edits ? TextEditContainer.From(edits.Select(e => Helpers.ToTextEdit(e))) : null, - Data = JToken.FromObject(omnisharpCompletionItem.Data) + Data = JToken.FromObject(omnisharpCompletionItem.Data), + Command = omnisharpCompletionItem.HasAfterInsertStep + ? Command.Create(AfterInsertCommandName) + : null, }; private OmnisharpCompletionItem ToOmnisharpCompletionItem(CompletionItem completionItem) @@ -157,7 +162,7 @@ private OmnisharpCompletionItem ToOmnisharpCompletionItem(CompletionItem complet TextEdit = Helpers.FromTextEdit(completionItem.TextEdit!.TextEdit), CommitCharacters = completionItem.CommitCharacters?.Select(i => i[0]).ToList(), AdditionalTextEdits = completionItem.AdditionalTextEdits?.Select(e => Helpers.FromTextEdit(e)).ToList(), - Data = completionItem.Data!.ToObject() + Data = completionItem.Data!.ToObject<(long, int)>() }; } } diff --git a/src/OmniSharp.Roslyn.CSharp/Services/Completion/CompletionListBuilder.cs b/src/OmniSharp.Roslyn.CSharp/Services/Completion/CompletionListBuilder.cs index 693fa10b5c..8fb3742769 100644 --- a/src/OmniSharp.Roslyn.CSharp/Services/Completion/CompletionListBuilder.cs +++ b/src/OmniSharp.Roslyn.CSharp/Services/Completion/CompletionListBuilder.cs @@ -7,6 +7,7 @@ using OmniSharp.Models; using OmniSharp.Models.v1.Completion; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using System.Threading.Tasks; using CompletionItem = OmniSharp.Models.v1.Completion.CompletionItem; @@ -63,15 +64,16 @@ internal static partial class CompletionListBuilder internal static async Task<(IReadOnlyList, bool)> BuildCompletionItems( Document document, SourceText sourceText, + long cacheId, int position, CSharpCompletionService completionService, CSharpCompletionList completions, TextSpan typedSpan, bool expectingImportedItems, - bool isSuggestionMode) - { - return await BuildCompletionItemsSync(document, sourceText, position, completionService, completions, typedSpan, expectingImportedItems, isSuggestionMode); - } + bool isSuggestionMode, bool enableAsyncCompletion) + => enableAsyncCompletion + ? await BuildCompletionItemsAsync(document, sourceText, cacheId, position, completionService, completions, typedSpan, expectingImportedItems, isSuggestionMode) + : await BuildCompletionItemsSync(document, sourceText, cacheId, position, completionService, completions, typedSpan, expectingImportedItems, isSuggestionMode); internal static LinePositionSpanTextChange GetChangeForTextAndSpan(string? insertText, TextSpan changeSpan, SourceText sourceText) { @@ -85,5 +87,76 @@ internal static LinePositionSpanTextChange GetChangeForTextAndSpan(string? inser EndColumn = changeLinePositionSpan.End.Character }; } + + private static IReadOnlyList? BuildCommitCharacters(ImmutableArray characterRules, bool isSuggestionMode, Dictionary, IReadOnlyList> commitCharacterRulesCache, HashSet commitCharactersBuilder) + { + if (characterRules.IsEmpty) + { + // Use defaults + return isSuggestionMode ? DefaultRulesWithoutSpace : CompletionRules.Default.DefaultCommitCharacters; + } + + if (commitCharacterRulesCache.TryGetValue(characterRules, out var cachedRules)) + { + return cachedRules; + } + + addAllCharacters(CompletionRules.Default.DefaultCommitCharacters); + + foreach (var modifiedRule in characterRules) + { + switch (modifiedRule.Kind) + { + case CharacterSetModificationKind.Add: + commitCharactersBuilder.UnionWith(modifiedRule.Characters); + break; + + case CharacterSetModificationKind.Remove: + commitCharactersBuilder.ExceptWith(modifiedRule.Characters); + break; + + case CharacterSetModificationKind.Replace: + commitCharactersBuilder.Clear(); + addAllCharacters(modifiedRule.Characters); + break; + } + } + + // VS has a more complex concept of a commit mode vs suggestion mode for intellisense. + // LSP doesn't have this, so mock it as best we can by removing space ` ` from the list + // of commit characters if we're in suggestion mode. + if (isSuggestionMode) + { + commitCharactersBuilder.Remove(' '); + } + + var finalCharacters = commitCharactersBuilder.ToList(); + commitCharactersBuilder.Clear(); + + commitCharacterRulesCache.Add(characterRules, finalCharacters); + + return finalCharacters; + + void addAllCharacters(ImmutableArray characters) + { + foreach (var @char in characters) + { + commitCharactersBuilder.Add(@char); + } + } + } + + private static CompletionItemKind GetCompletionItemKind(ImmutableArray tags) + { + foreach (var tag in tags) + { + if (s_roslynTagToCompletionItemKind.TryGetValue(tag, out var itemKind)) + { + return itemKind; + } + } + + return CompletionItemKind.Text; + } } } diff --git a/src/OmniSharp.Roslyn.CSharp/Services/Completion/CompletionListBuilder_Async.cs b/src/OmniSharp.Roslyn.CSharp/Services/Completion/CompletionListBuilder_Async.cs new file mode 100644 index 0000000000..d761515d03 --- /dev/null +++ b/src/OmniSharp.Roslyn.CSharp/Services/Completion/CompletionListBuilder_Async.cs @@ -0,0 +1,114 @@ +#nullable enable + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Completion; +using Microsoft.CodeAnalysis.Text; +using OmniSharp.Models; +using OmniSharp.Models.v1.Completion; +using OmniSharp.Roslyn.CSharp.Services.Intellisense; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Threading.Tasks; +using CompletionItem = OmniSharp.Models.v1.Completion.CompletionItem; +using CSharpCompletionList = Microsoft.CodeAnalysis.Completion.CompletionList; +using CSharpCompletionService = Microsoft.CodeAnalysis.Completion.CompletionService; + +namespace OmniSharp.Roslyn.CSharp.Services.Completion +{ + internal static partial class CompletionListBuilder + { + internal static async Task<(IReadOnlyList, bool)> BuildCompletionItemsAsync( + Document document, + SourceText sourceText, + long cacheId, + int position, + CSharpCompletionService completionService, + CSharpCompletionList completions, + TextSpan typedSpan, + bool expectingImportedItems, bool isSuggestionMode) + { + var completionsBuilder = new List(completions.Items.Length); + var seenUnimportedCompletions = false; + var commitCharacterRuleCache = new Dictionary, IReadOnlyList>(); + var commitCharacterRuleBuilder = new HashSet(); + var isOverrideOrPartialCompletion = completions.Items.Length > 0 + && completions.Items[0].GetProviderName() is CompletionItemExtensions.OverrideCompletionProvider or CompletionItemExtensions.PartialMethodCompletionProvider; + + for (int i = 0; i < completions.Items.Length; i++) + { + var completion = completions.Items[i]; + string labelText = completion.DisplayTextPrefix + completion.DisplayText + completion.DisplayTextSuffix; + string? insertText; + string? filterText = null; + List? additionalTextEdits = null; + InsertTextFormat insertTextFormat = InsertTextFormat.PlainText; + TextSpan changeSpan; + string? sortText; + bool hasAfterInsertStep = false; + if (completion.IsComplexTextEdit) + { + // The completion is somehow expensive. Currently, this one of two categories: import completion, or override/partial + // completion. + Debug.Assert(completion.GetProviderName() is CompletionItemExtensions.OverrideCompletionProvider or CompletionItemExtensions.PartialMethodCompletionProvider + or CompletionItemExtensions.TypeImportCompletionProvider or CompletionItemExtensions.ExtensionMethodImportCompletionProvider); + + changeSpan = typedSpan; + + if (isOverrideOrPartialCompletion) + { + // For override and partial completion, we don't want to use the DisplayText as the insert text because they contain + // characters that will affect our ability to asynchronously resolve the change later. + insertText = completion.FilterText; + sortText = GetSortText(completion, labelText, expectingImportedItems); + hasAfterInsertStep = true; + } + else + { + insertText = completion.DisplayText; + sortText = '1' + completion.SortText; + seenUnimportedCompletions = true; + } + } + else + { + // For non-complex completions, just await the text edit. It's cheap enough that it doesn't impact our ability + // to pop completions quickly + + // If the completion item is the misc project name, skip it. + if (completion.DisplayText == Configuration.OmniSharpMiscProjectName) continue; + + GetCompletionInfo( + sourceText, + position, + completion, + await completionService.GetChangeAsync(document, completion), + typedSpan, + labelText, + expectingImportedItems, + out insertText, out filterText, out sortText, out insertTextFormat, out changeSpan, out additionalTextEdits); + } + + var commitCharacters = BuildCommitCharacters(completion.Rules.CommitCharacterRules, isSuggestionMode, commitCharacterRuleCache, commitCharacterRuleBuilder); + + completionsBuilder.Add(new CompletionItem + { + Label = labelText, + TextEdit = GetChangeForTextAndSpan(insertText!, changeSpan, sourceText), + InsertTextFormat = insertTextFormat, + AdditionalTextEdits = additionalTextEdits, + SortText = sortText, + FilterText = filterText, + Kind = GetCompletionItemKind(completion.Tags), + Detail = completion.InlineDescription, + Data = (cacheId, i), + Preselect = completion.Rules.SelectionBehavior == CompletionItemSelectionBehavior.HardSelection, + CommitCharacters = commitCharacters, + HasAfterInsertStep = hasAfterInsertStep, + }); + } + + return (completionsBuilder, seenUnimportedCompletions); + } + } +} diff --git a/src/OmniSharp.Roslyn.CSharp/Services/Completion/CompletionListBuilder_Sync.cs b/src/OmniSharp.Roslyn.CSharp/Services/Completion/CompletionListBuilder_Sync.cs index 80f5e41926..f4d8cbf26f 100644 --- a/src/OmniSharp.Roslyn.CSharp/Services/Completion/CompletionListBuilder_Sync.cs +++ b/src/OmniSharp.Roslyn.CSharp/Services/Completion/CompletionListBuilder_Sync.cs @@ -2,22 +2,19 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Completion; -using Microsoft.CodeAnalysis.Tags; using Microsoft.CodeAnalysis.Text; using OmniSharp.Models; using OmniSharp.Models.v1.Completion; using OmniSharp.Roslyn.CSharp.Helpers; using OmniSharp.Roslyn.CSharp.Services.Intellisense; using OmniSharp.Utilities; -using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; using System.Linq; -using System.Text; using System.Threading.Tasks; using CompletionItem = OmniSharp.Models.v1.Completion.CompletionItem; -using CompletionTriggerKind = OmniSharp.Models.v1.Completion.CompletionTriggerKind; +using CSharpCompletionItem = Microsoft.CodeAnalysis.Completion.CompletionItem; using CSharpCompletionList = Microsoft.CodeAnalysis.Completion.CompletionList; using CSharpCompletionService = Microsoft.CodeAnalysis.Completion.CompletionService; @@ -28,6 +25,7 @@ internal static partial class CompletionListBuilder internal static async Task<(IReadOnlyList, bool)> BuildCompletionItemsSync( Document document, SourceText sourceText, + long cacheId, int position, CSharpCompletionService completionService, CSharpCompletionList completions, @@ -53,6 +51,7 @@ internal static partial class CompletionListBuilder return ((Task?)arg.completionService.GetChangeAsync(arg.document, completion), providerName); } }); + for (int i = 0; i < completions.Items.Length; i++) { TextSpan changeSpan = typedSpan; @@ -84,94 +83,22 @@ internal static partial class CompletionListBuilder // Except for import completion, we just resolve the change up front in the sync version. It's only expensive // for override completion, but there's not a heck of a lot we can do about that for the sync scenario Debug.Assert(changeTask is not null); - var change = await changeTask!; - - // Roslyn will give us the position to move the cursor after the completion is entered. - // However, this is in the _new_ document, after changes have been applied. In order to - // snippetize the insertion text, we need to calculate the offset as we move along the - // edits, subtracting or adding the difference for edits that do not insersect the current - // span. - var adjustedNewPosition = change!.NewPosition; - - // There must be at least one change that affects the current location, or something is seriously wrong - Debug.Assert(change.TextChanges.Any(change => change.Span.IntersectsWith(position))); - - foreach (var textChange in change.TextChanges) - { - if (!textChange.Span.IntersectsWith(position)) - { - additionalTextEdits ??= new(); - additionalTextEdits.Add(GetChangeForTextAndSpan(textChange.NewText!, textChange.Span, sourceText)); - - if (adjustedNewPosition is int newPosition) - { - // Find the diff between the original text length and the new text length. - var diff = (textChange.NewText?.Length ?? 0) - textChange.Span.Length; - // If the new text is longer than the replaced text, we want to subtract that - // length from the current new position to find the adjusted position in the old - // document. If the new text was shorter, diff will be negative, and subtracting - // will result in increasing the adjusted position as expected - adjustedNewPosition = newPosition - diff; - } - } - else - { - // Either there should be no new position, or it should be within the text that is being added - // by this change. - Debug.Assert(adjustedNewPosition is null || - (adjustedNewPosition.Value <= textChange.Span.Start + textChange.NewText!.Length) && - (adjustedNewPosition.Value >= textChange.Span.Start)); - - changeSpan = textChange.Span; - (insertText, insertTextFormat) = getPossiblySnippitizedInsertText(textChange, adjustedNewPosition); - - // If we're expecting there to be unimported types, put in an explicit sort text to put things already in scope first. - // Otherwise, omit the sort text if it's the same as the label to save on space. - sortText = expectingImportedItems - ? '0' + completion.SortText - : labelText == completion.SortText ? null : completion.SortText; - - // If the completion is replacing a bigger range than the previously-typed word, we need to have the filter - // text compensate. Clients will use the range of the text edit to determine the thing that is being filtered - // against. For example, override completion: - // - // override $$ - // |--------| Range that is being changed by the completion - // - // That means vscode will consider "override " when looking to see whether the item - // still matches. To compensate, we add the start of the replacing range, up to the start of the current word, - // to ensure the item isn't silently filtered out. - - if (changeSpan != typedSpan) - { - if (typedSpan.Start < changeSpan.Start) - { - // This means that some part of the currently-typed text is an exact match for the start of the - // change, so chop off changeSpan.Start - typedSpan.Start from the filter text to get it to match - // up with the range - int prefixMatchElement = changeSpan.Start - typedSpan.Start; - Debug.Assert(completion.FilterText.StartsWith(sourceText.GetSubText(new TextSpan(typedSpan.Start, prefixMatchElement)).ToString())); - filterText = completion.FilterText.Substring(prefixMatchElement); - } - else - { - var prefix = sourceText.GetSubText(TextSpan.FromBounds(changeSpan.Start, typedSpan.Start)).ToString(); - filterText = prefix + completion.FilterText; - } - } - else - { - filterText = labelText == completion.FilterText ? null : completion.FilterText; - } - } - } + GetCompletionInfo( + sourceText, + position, + completion, + await changeTask!, + typedSpan, + labelText, + expectingImportedItems, + out insertText, out filterText, out sortText, out insertTextFormat, out changeSpan, out additionalTextEdits); break; } } - var commitCharacters = buildCommitCharacters(completion.Rules.CommitCharacterRules, isSuggestionMode, commitCharacterRuleCache, commitCharacterRuleBuilder); + var commitCharacters = BuildCommitCharacters(completion.Rules.CommitCharacterRules, isSuggestionMode, commitCharacterRuleCache, commitCharacterRuleBuilder); completionsBuilder.Add(new CompletionItem { @@ -181,15 +108,117 @@ internal static partial class CompletionListBuilder AdditionalTextEdits = additionalTextEdits, SortText = sortText, FilterText = filterText, - Kind = getCompletionItemKind(completion.Tags), + Kind = GetCompletionItemKind(completion.Tags), Detail = completion.InlineDescription, - Data = i, + Data = (cacheId, i), Preselect = completion.Rules.SelectionBehavior == CompletionItemSelectionBehavior.HardSelection, CommitCharacters = commitCharacters, }); } return (completionsBuilder, seenUnimportedCompletions); + } + + private static void GetCompletionInfo( + SourceText sourceText, + int position, + CSharpCompletionItem completion, + CompletionChange change, + TextSpan typedSpan, + string labelText, + bool expectingImportedItems, + out string? insertText, + out string? filterText, + out string? sortText, + out InsertTextFormat insertTextFormat, + out TextSpan changeSpan, + out List? additionalTextEdits) + { + insertTextFormat = InsertTextFormat.PlainText; + changeSpan = typedSpan; + insertText = null; + filterText = null; + sortText = null; + additionalTextEdits = null; + + // Roslyn will give us the position to move the cursor after the completion is entered. + // However, this is in the _new_ document, after changes have been applied. In order to + // snippetize the insertion text, we need to calculate the offset as we move along the + // edits, subtracting or adding the difference for edits that do not insersect the current + // span. + var adjustedNewPosition = change!.NewPosition; + + // There must be at least one change that affects the current location, or something is seriously wrong + Debug.Assert(change.TextChanges.Any(change => change.Span.IntersectsWith(position))); + + foreach (var textChange in change.TextChanges) + { + if (!textChange.Span.IntersectsWith(position)) + { + additionalTextEdits ??= new(); + additionalTextEdits.Add(GetChangeForTextAndSpan(textChange.NewText!, textChange.Span, sourceText)); + + if (adjustedNewPosition is int newPosition) + { + // Find the diff between the original text length and the new text length. + var diff = (textChange.NewText?.Length ?? 0) - textChange.Span.Length; + + // If the new text is longer than the replaced text, we want to subtract that + // length from the current new position to find the adjusted position in the old + // document. If the new text was shorter, diff will be negative, and subtracting + // will result in increasing the adjusted position as expected + adjustedNewPosition = newPosition - diff; + } + } + else + { + // Either there should be no new position, or it should be within the text that is being added + // by this change. + Debug.Assert(adjustedNewPosition is null || + (adjustedNewPosition.Value <= textChange.Span.Start + textChange.NewText!.Length) && + (adjustedNewPosition.Value >= textChange.Span.Start)); + + changeSpan = textChange.Span; + (insertText, insertTextFormat) = getPossiblySnippitizedInsertText(textChange, adjustedNewPosition); + + // If we're expecting there to be unimported types, put in an explicit sort text to put things already in scope first. + // Otherwise, omit the sort text if it's the same as the label to save on space. + sortText = GetSortText(completion, labelText, expectingImportedItems); + + // If the completion is replacing a bigger range than the previously-typed word, we need to have the filter + // text compensate. Clients will use the range of the text edit to determine the thing that is being filtered + // against. For example, override completion: + // + // override $$ + // |--------| Range that is being changed by the completion + // + // That means vscode will consider "override " when looking to see whether the item + // still matches. To compensate, we add the start of the replacing range, up to the start of the current word, + // to ensure the item isn't silently filtered out. + + if (changeSpan != typedSpan) + { + if (typedSpan.Start < changeSpan.Start) + { + // This means that some part of the currently-typed text is an exact match for the start of the + // change, so chop off changeSpan.Start - typedSpan.Start from the filter text to get it to match + // up with the range + int prefixMatchElement = changeSpan.Start - typedSpan.Start; + Debug.Assert(completion.FilterText!.StartsWith(sourceText.GetSubText(new TextSpan(typedSpan.Start, prefixMatchElement)).ToString())); + filterText = completion.FilterText.Substring(prefixMatchElement); + } + else + { + var prefix = sourceText.GetSubText(TextSpan.FromBounds(changeSpan.Start, typedSpan.Start)).ToString(); + filterText = prefix + completion.FilterText; + } + } + else + { + filterText = labelText == completion.FilterText ? null : completion.FilterText; + } + } + } static (string?, InsertTextFormat) getPossiblySnippitizedInsertText(TextChange change, int? adjustedNewPosition) { @@ -207,81 +236,13 @@ internal static partial class CompletionListBuilder return ($"{beforeText}$0{afterText}", InsertTextFormat.Snippet); } + } - static CompletionItemKind getCompletionItemKind(ImmutableArray tags) - { - foreach (var tag in tags) - { - if (s_roslynTagToCompletionItemKind.TryGetValue(tag, out var itemKind)) - { - return itemKind; - } - } - - return CompletionItemKind.Text; - } - - static IReadOnlyList? buildCommitCharacters( - ImmutableArray characterRules, - bool isSuggestionMode, - Dictionary, IReadOnlyList> commitCharacterRulesCache, - HashSet commitCharactersBuilder) - { - if (characterRules.IsEmpty) - { - // Use defaults - return isSuggestionMode ? DefaultRulesWithoutSpace : CompletionRules.Default.DefaultCommitCharacters; - } - - if (commitCharacterRulesCache.TryGetValue(characterRules, out var cachedRules)) - { - return cachedRules; - } - - addAllCharacters(CompletionRules.Default.DefaultCommitCharacters); - - foreach (var modifiedRule in characterRules) - { - switch (modifiedRule.Kind) - { - case CharacterSetModificationKind.Add: - commitCharactersBuilder.UnionWith(modifiedRule.Characters); - break; - - case CharacterSetModificationKind.Remove: - commitCharactersBuilder.ExceptWith(modifiedRule.Characters); - break; - - case CharacterSetModificationKind.Replace: - commitCharactersBuilder.Clear(); - addAllCharacters(modifiedRule.Characters); - break; - } - } - - // VS has a more complex concept of a commit mode vs suggestion mode for intellisense. - // LSP doesn't have this, so mock it as best we can by removing space ` ` from the list - // of commit characters if we're in suggestion mode. - if (isSuggestionMode) - { - commitCharactersBuilder.Remove(' '); - } - - var finalCharacters = commitCharactersBuilder.ToList(); - commitCharactersBuilder.Clear(); - - commitCharacterRulesCache.Add(characterRules, finalCharacters); - - return finalCharacters; - - void addAllCharacters(ImmutableArray characters) - { - foreach (var @char in characters) - { - commitCharactersBuilder.Add(@char); - } - } - } + private static string? GetSortText(CSharpCompletionItem completion, string labelText, bool expectingImportedItems) + { + return expectingImportedItems + ? '0' + completion.SortText + : labelText == completion.SortText ? null : completion.SortText; } } } diff --git a/src/OmniSharp.Roslyn.CSharp/Services/Completion/CompletionListCache.cs b/src/OmniSharp.Roslyn.CSharp/Services/Completion/CompletionListCache.cs new file mode 100644 index 0000000000..a1ff6f0c7a --- /dev/null +++ b/src/OmniSharp.Roslyn.CSharp/Services/Completion/CompletionListCache.cs @@ -0,0 +1,97 @@ +// Based on dotnet/roslyn:src/Features/LanguageServer/Protocol/Handler/Completion/CompletionListCache.cs +// CommitID: 62528a6843dc5b21b1a7d399bae814868456e823 +// Original license is MIT + +#nullable enable + +using System.Collections.Generic; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Completion; + +namespace OmniSharp.Roslyn.CSharp.Services.Completion +{ + /// + /// Caches completion lists in between calls to CompletionHandler and + /// CompletionResolveHandler. Used to avoid unnecessary recomputation. + /// + internal class CompletionListCache + { + /// + /// Maximum number of completion lists allowed in cache. Must be >= 1. + /// + private const int MaxCacheSize = 2; + + /// + /// Multiple cache requests or updates may be received concurrently. + /// We need this lock to ensure that we aren't making concurrent + /// modifications to _nextResultId or _resultIdToCompletionList. + /// + private readonly object _accessLock = new object(); + + #region protected by _accessLock + /// + /// The next resultId available to use. + /// + private long _nextResultId; + + /// + /// Keeps track of the resultIds in the cache and their associated + /// completion list. + /// + private readonly List _resultIdToCompletionList = new(); + #endregion + + /// + /// Adds a completion list to the cache. If the cache reaches its maximum size, the oldest completion + /// list in the cache is removed. + /// + /// + /// The generated resultId associated with the passed in completion list. + /// + public long UpdateCache(Document document, int position, CompletionList completionList) + { + lock (_accessLock) + { + // If cache exceeds maximum size, remove the oldest list in the cache + if (_resultIdToCompletionList.Count >= MaxCacheSize) + { + _resultIdToCompletionList.RemoveAt(0); + } + + // Getting the generated unique resultId + var resultId = _nextResultId++; + + // Add passed in completion list to cache + var cacheEntry = new CacheEntry(resultId, document, position, completionList); + _resultIdToCompletionList.Add(cacheEntry); + + // Return generated resultId so completion list can later be retrieved from cache + return resultId; + } + } + + /// + /// Attempts to return the completion list in the cache associated with the given resultId. + /// Returns null if no match is found. + /// + public CacheEntry? GetCachedCompletionList(long resultId) + { + lock (_accessLock) + { + foreach (var cacheEntry in _resultIdToCompletionList) + { + if (cacheEntry.ResultId == resultId) + { + // We found a match - return completion list + return cacheEntry; + } + } + + // A completion list associated with the given resultId was not found + return null; + } + } + + public record CacheEntry(long ResultId, Document Document, int Position, CompletionList CompletionList); + } +} diff --git a/src/OmniSharp.Roslyn.CSharp/Services/Completion/CompletionService.cs b/src/OmniSharp.Roslyn.CSharp/Services/Completion/CompletionService.cs index 541e4c948d..cdb3807390 100644 --- a/src/OmniSharp.Roslyn.CSharp/Services/Completion/CompletionService.cs +++ b/src/OmniSharp.Roslyn.CSharp/Services/Completion/CompletionService.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Completion; +using Microsoft.CodeAnalysis.Text; using Microsoft.Extensions.Logging; using OmniSharp.Extensions; using OmniSharp.Mef; @@ -17,9 +18,9 @@ using OmniSharp.Options; using OmniSharp.Roslyn.CSharp.Helpers; using OmniSharp.Roslyn.CSharp.Services.Intellisense; +using OmniSharp.Utilities; using CompletionItem = OmniSharp.Models.v1.Completion.CompletionItem; using CompletionTriggerKind = OmniSharp.Models.v1.Completion.CompletionTriggerKind; -using CSharpCompletionList = Microsoft.CodeAnalysis.Completion.CompletionList; using CSharpCompletionService = Microsoft.CodeAnalysis.Completion.CompletionService; namespace OmniSharp.Roslyn.CSharp.Services.Completion @@ -27,33 +28,31 @@ namespace OmniSharp.Roslyn.CSharp.Services.Completion [Shared] [OmniSharpHandler(OmniSharpEndpoints.Completion, LanguageNames.CSharp)] [OmniSharpHandler(OmniSharpEndpoints.CompletionResolve, LanguageNames.CSharp)] + [OmniSharpHandler(OmniSharpEndpoints.CompletionAfterInsert, LanguageNames.CSharp)] public class CompletionService : IRequestHandler, - IRequestHandler + IRequestHandler, + IRequestHandler { - private readonly OmniSharpWorkspace _workspace; + private readonly OmniSharpOptions _omniSharpOptions; private readonly FormattingOptions _formattingOptions; private readonly ILogger _logger; - private readonly object _lock = new(); - private (CSharpCompletionList Completions, string FileName, int position)? _lastCompletion = null; + private readonly CompletionListCache _cache = new(); [ImportingConstructor] - public CompletionService(OmniSharpWorkspace workspace, FormattingOptions formattingOptions, ILoggerFactory loggerFactory) + public CompletionService(OmniSharpWorkspace workspace, FormattingOptions formattingOptions, ILoggerFactory loggerFactory, OmniSharpOptions omniSharpOptions) { _workspace = workspace; _formattingOptions = formattingOptions; _logger = loggerFactory.CreateLogger(); + _omniSharpOptions = omniSharpOptions; } public async Task Handle(CompletionRequest request) { _logger.LogTrace("Completions requested"); - lock (_lock) - { - _lastCompletion = null; - } var document = _workspace.GetDocument(request.FileName); if (document is null) @@ -70,7 +69,6 @@ public async Task Handle(CompletionRequest request) CompletionTrigger trigger = request.CompletionTrigger switch { - CompletionTriggerKind.Invoked => CompletionTrigger.Invoke, CompletionTriggerKind.TriggerCharacter when request.TriggerCharacter is char c => CompletionTrigger.CreateInsertionTrigger(c), _ => CompletionTrigger.Invoke, }; @@ -109,11 +107,7 @@ CompletionItemExtensions.PartialMethodCompletionProvider or var typedSpan = completionService.GetDefaultCompletionListSpan(sourceText, position); string typedText = sourceText.GetSubText(typedSpan).ToString(); _logger.LogTrace("Completions filled in"); - - lock (_lock) - { - _lastCompletion = (completions, request.FileName, position); - } + var cacheId = _cache.UpdateCache(document, position, completions); // If we don't encounter any unimported types, and the completion context thinks that some would be available, then @@ -128,7 +122,17 @@ CompletionItemExtensions.PartialMethodCompletionProvider or var isSuggestionMode = completions.SuggestionModeItem is not null; - var (completionsList, seenUnimportedCompletions) = await CompletionListBuilder.BuildCompletionItems(document, sourceText, position, completionService, completions, typedSpan, expectingImportedItems, isSuggestionMode); + var (completionsList, seenUnimportedCompletions) = await CompletionListBuilder.BuildCompletionItems( + document, + sourceText, + cacheId, + position, + completionService, + completions, + typedSpan, + expectingImportedItems, + isSuggestionMode, + _omniSharpOptions.RoslynExtensionsOptions.EnableAsyncCompletion); return new CompletionResponse { @@ -139,42 +143,35 @@ CompletionItemExtensions.PartialMethodCompletionProvider or public async Task Handle(CompletionResolveRequest request) { - if (_lastCompletion is null) + var cachedList = _cache.GetCachedCompletionList(request.Item.Data.CacheId); + if (cachedList is null) { _logger.LogError("Cannot call completion/resolve before calling completion!"); return new CompletionResolveResponse { Item = request.Item }; } - var (completions, fileName, position) = _lastCompletion.Value; + var (_, document, position, completions) = cachedList; + var index = request.Item.Data.Index; if (request.Item is null - || request.Item.Data >= completions.Items.Length - || request.Item.Data < 0) + || index >= completions.Items.Length + || index < 0) { _logger.LogError("Received invalid completion resolve!"); return new CompletionResolveResponse { Item = request.Item }; } - var lastCompletionItem = completions.Items[request.Item.Data]; + var lastCompletionItem = completions.Items[index]; if (lastCompletionItem.DisplayTextPrefix + lastCompletionItem.DisplayText + lastCompletionItem.DisplayTextSuffix != request.Item.Label) { _logger.LogError("Inconsistent completion data. Requested data on {0}, but found completion item {1}", request.Item.Label, lastCompletionItem.DisplayText); return new CompletionResolveResponse { Item = request.Item }; } - - var document = _workspace.GetDocument(fileName); - if (document is null) - { - _logger.LogInformation("Could not find document for file {0}", fileName); - return new CompletionResolveResponse { Item = request.Item }; - } - var completionService = CSharpCompletionService.GetService(document); - var description = await completionService.GetDescriptionAsync(document, lastCompletionItem); - StringBuilder textBuilder = new StringBuilder(); + var textBuilder = new StringBuilder(); MarkdownHelpers.TaggedTextToMarkdown(description.TaggedParts, textBuilder, _formattingOptions, MarkdownFormat.FirstLineAsCSharp, out _); request.Item.Documentation = textBuilder.ToString(); @@ -209,5 +206,62 @@ public async Task Handle(CompletionResolveRequest req Item = request.Item }; } + + public async Task Handle(CompletionAfterInsertRequest request) + { + var cachedList = _cache.GetCachedCompletionList(request.Item.Data.CacheId); + if (cachedList is null) + { + _logger.LogError("Cannot call completion/afterInsert before calling completion!"); + return new CompletionAfterInsertResponse(); + } + + var (_, document, _, completions) = cachedList; + var index = request.Item.Data.Index; + + if (request.Item is null + || index >= completions.Items.Length + || index < 0 + || request.Item.TextEdit is null) + { + _logger.LogError("Received invalid completion afterInsert!"); + return new CompletionAfterInsertResponse(); + } + + var lastCompletionItem = completions.Items[index]; + if (lastCompletionItem.DisplayTextPrefix + lastCompletionItem.DisplayText + lastCompletionItem.DisplayTextSuffix != request.Item.Label) + { + _logger.LogError("Inconsistent completion data. Requested data on {0}, but found completion item {1}", request.Item.Label, lastCompletionItem.DisplayText); + return new CompletionAfterInsertResponse(); + } + + if (lastCompletionItem.GetProviderName() is not (CompletionItemExtensions.OverrideCompletionProvider or + CompletionItemExtensions.PartialMethodCompletionProvider) + and var name) + { + _logger.LogWarning("Received unsupported afterInsert completion request for provider {0}", name); + return new CompletionAfterInsertResponse(); + } + + var completionService = CSharpCompletionService.GetService(document); + + // Get a document with change from the completion inserted, so that we can resolve the completion and get the + // final full change. + var sourceText = await document.GetTextAsync(); + var insertedSpan = sourceText.GetSpanFromLinePositionSpanTextChange(request.Item.TextEdit); + var changedText = sourceText.WithChanges(new TextChange(insertedSpan, request.Item.TextEdit.NewText)); + var changedDocument = document.WithText(changedText); + + var finalChange = await completionService.GetChangeAsync(changedDocument, lastCompletionItem, new TextSpan(insertedSpan.Start, request.Item.TextEdit.NewText.Length)); + var finalText = changedText.WithChanges(finalChange.TextChange); + var finalPosition = finalText.GetPointFromPosition(finalChange.NewPosition!.Value); + + return new CompletionAfterInsertResponse + { + Changes = finalChange.TextChanges.SelectAsArray(changedText, static (c, changedText) => CompletionListBuilder.GetChangeForTextAndSpan(c.NewText, c.Span, changedText)), + Line = finalPosition.Line, + Column = finalPosition.Column, + }; + } } } diff --git a/src/OmniSharp.Roslyn.CSharp/Services/Intellisense/CompletionItemExtensions.cs b/src/OmniSharp.Roslyn.CSharp/Services/Intellisense/CompletionItemExtensions.cs index 148125cd40..2980abf9bd 100644 --- a/src/OmniSharp.Roslyn.CSharp/Services/Intellisense/CompletionItemExtensions.cs +++ b/src/OmniSharp.Roslyn.CSharp/Services/Intellisense/CompletionItemExtensions.cs @@ -92,9 +92,11 @@ public static async Task> GetCompletionSymbolsAsync(this Co && properties.TryGetValue(SymbolKind, out string symbolKindValue) && int.Parse(symbolKindValue) is int symbolKindInt) { +#pragma warning disable RS1024 // Compare symbols correctly: service is deprecated, not going to change behavior now. return recommendedSymbols .Where(x => (int)x.Kind == symbolKindInt && x.Name.Equals(symbolNameValue, StringComparison.OrdinalIgnoreCase)) .Distinct(); +#pragma warning restore RS1024 // Compare symbols correctly } return Enumerable.Empty(); diff --git a/src/OmniSharp.Roslyn/Extensions/TextExtensions.cs b/src/OmniSharp.Roslyn/Extensions/TextExtensions.cs index b6b8844747..1f22f1fffe 100644 --- a/src/OmniSharp.Roslyn/Extensions/TextExtensions.cs +++ b/src/OmniSharp.Roslyn/Extensions/TextExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.CodeAnalysis.Text; +using OmniSharp.Models; using OmniSharp.Models.V2; namespace OmniSharp.Extensions @@ -48,5 +49,13 @@ public static TextSpan GetSpanFromRange(this SourceText text, Range range) => TextSpan.FromBounds( start: text.GetPositionFromPoint(range.Start), end: text.GetPositionFromPoint(range.End)); + + /// + /// Converts an OmniSharp to a within a . + /// + public static TextSpan GetSpanFromLinePositionSpanTextChange(this SourceText text, LinePositionSpanTextChange change) + => TextSpan.FromBounds( + start: text.GetPositionFromLineAndOffset(change.StartLine, change.StartColumn), + end: text.GetPositionFromLineAndOffset(change.EndLine, change.EndColumn)); } } diff --git a/tests/OmniSharp.Roslyn.CSharp.Tests/CompletionFacts.cs b/tests/OmniSharp.Roslyn.CSharp.Tests/CompletionFacts.cs index a859d6e7eb..ddfebf4f1d 100644 --- a/tests/OmniSharp.Roslyn.CSharp.Tests/CompletionFacts.cs +++ b/tests/OmniSharp.Roslyn.CSharp.Tests/CompletionFacts.cs @@ -146,9 +146,11 @@ public Class1() } [Theory] - [InlineData("dummy.cs")] - [InlineData("dummy.csx")] - public async Task ImportCompletionResolvesOnSubsequentQueries(string filename) + [InlineData("dummy.cs", true)] + [InlineData("dummy.cs", false)] + [InlineData("dummy.csx", true)] + [InlineData("dummy.csx", false)] + public async Task ImportCompletionResolvesOnSubsequentQueries(string filename, bool useAsyncCompletion) { const string input = @"public class Class1 { @@ -158,12 +160,13 @@ public Class1() } }"; - using var host = GetImportCompletionHost(); + using var host = useAsyncCompletion ? GetAsyncCompletionAndImportCompletionHost() : GetImportCompletionHost(); // First completion request should kick off the task to update the completion cache. var completions = await FindCompletionsAsync(filename, input, host); Assert.True(completions.IsIncomplete); Assert.DoesNotContain("Guid", completions.Items.Select(c => c.TextEdit.NewText)); + Assert.All(completions.Items, c => Assert.False(c.HasAfterInsertStep)); // Populating the completion cache should take no more than a few ms, don't let it take too // long @@ -182,9 +185,11 @@ await Task.Run(async () => } [Theory] - [InlineData("dummy.cs")] - [InlineData("dummy.csx")] - public async Task ImportCompletion_LocalsPrioritizedOverImports(string filename) + [InlineData("dummy.cs", true)] + [InlineData("dummy.cs", false)] + [InlineData("dummy.csx", true)] + [InlineData("dummy.csx", false)] + public async Task ImportCompletion_LocalsPrioritizedOverImports(string filename, bool useAsyncCompletion) { const string input = @@ -196,20 +201,22 @@ public Class1() } }"; - using var host = GetImportCompletionHost(); + using var host = useAsyncCompletion ? GetAsyncCompletionAndImportCompletionHost() : GetImportCompletionHost(); var completions = await FindCompletionsWithImportedAsync(filename, input, host); CompletionItem localCompletion = completions.Items.First(c => c.TextEdit.NewText == "guid"); CompletionItem typeCompletion = completions.Items.First(c => c.TextEdit.NewText == "Guid"); - Assert.True(localCompletion.Data < typeCompletion.Data); + Assert.True(localCompletion.Data.Index < typeCompletion.Data.Index); Assert.StartsWith("0", localCompletion.SortText); Assert.StartsWith("1", typeCompletion.SortText); VerifySortOrders(completions.Items); } [Theory] - [InlineData("dummy.cs")] - [InlineData("dummy.csx")] - public async Task ImportCompletions_IncludesExtensionMethods(string filename) + [InlineData("dummy.cs", true)] + [InlineData("dummy.cs", false)] + [InlineData("dummy.csx", true)] + [InlineData("dummy.csx", false)] + public async Task ImportCompletions_IncludesExtensionMethods(string filename, bool useAsyncCompletion) { const string input = @"namespace N1 @@ -232,16 +239,18 @@ public static void Test(this object o) } }"; - using var host = GetImportCompletionHost(); + using var host = useAsyncCompletion ? GetAsyncCompletionAndImportCompletionHost() : GetImportCompletionHost(); var completions = await FindCompletionsWithImportedAsync(filename, input, host); Assert.Contains("Test", completions.Items.Select(c => c.TextEdit.NewText)); VerifySortOrders(completions.Items); } [Theory] - [InlineData("dummy.cs")] - [InlineData("dummy.csx")] - public async Task ImportCompletion_ResolveAddsImportEdit(string filename) + [InlineData("dummy.cs", true)] + [InlineData("dummy.cs", false)] + [InlineData("dummy.csx", true)] + [InlineData("dummy.csx", false)] + public async Task ImportCompletion_ResolveAddsImportEdit(string filename, bool useAsyncCompletion) { const string input = @"namespace N1 @@ -264,7 +273,7 @@ public static void Test(this object o) } }"; - using var host = GetImportCompletionHost(); + using var host = useAsyncCompletion ? GetAsyncCompletionAndImportCompletionHost() : GetImportCompletionHost(); var completions = await FindCompletionsWithImportedAsync(filename, input, host); var resolved = await ResolveCompletionAsync(completions.Items.First(c => c.TextEdit.NewText == "Test"), host); @@ -280,13 +289,15 @@ public static void Test(this object o) } [Theory] - [InlineData("dummy.cs")] - [InlineData("dummy.csx")] - public async Task ImportCompletion_OnLine0(string filename) + [InlineData("dummy.cs", true)] + [InlineData("dummy.cs", false)] + [InlineData("dummy.csx", true)] + [InlineData("dummy.csx", false)] + public async Task ImportCompletion_OnLine0(string filename, bool useAsyncCompletion) { const string input = @"$$"; - using var host = GetImportCompletionHost(); + using var host = useAsyncCompletion ? GetAsyncCompletionAndImportCompletionHost() : GetImportCompletionHost(); var completions = await FindCompletionsWithImportedAsync(filename, input, host); var resolved = await ResolveCompletionAsync(completions.Items.First(c => c.TextEdit.NewText == "Console"), host); @@ -302,9 +313,11 @@ public async Task ImportCompletion_OnLine0(string filename) } [Theory] - [InlineData("dummy.cs")] - [InlineData("dummy.csx")] - public async Task SelectsLastInstanceOfCompletion(string filename) + [InlineData("dummy.cs", true)] + [InlineData("dummy.cs", false)] + [InlineData("dummy.csx", true)] + [InlineData("dummy.csx", false)] + public async Task SelectsLastInstanceOfCompletion(string filename, bool useAsyncCompletion) { const string input = @"namespace N1 @@ -327,7 +340,7 @@ public static void Test(this object o) } }"; - using var host = GetImportCompletionHost(); + using var host = useAsyncCompletion ? GetAsyncCompletionAndImportCompletionHost() : GetImportCompletionHost(); var completions = await FindCompletionsWithImportedAsync(filename, input, host); var resolved = await ResolveCompletionAsync(completions.Items.First(c => c.TextEdit.NewText == "Guid"), host); @@ -343,9 +356,11 @@ public static void Test(this object o) } [Theory] - [InlineData("dummy.cs")] - [InlineData("dummy.csx")] - public async Task UsingsAddedInOrder(string filename) + [InlineData("dummy.cs", true)] + [InlineData("dummy.cs", false)] + [InlineData("dummy.csx", true)] + [InlineData("dummy.csx", false)] + public async Task UsingsAddedInOrder(string filename, bool useAsyncCompletion) { const string input = @@ -374,7 +389,7 @@ public class C3 } }"; - using var host = GetImportCompletionHost(); + using var host = useAsyncCompletion ? GetAsyncCompletionAndImportCompletionHost() : GetImportCompletionHost(); var completions = await FindCompletionsWithImportedAsync(filename, input, host); var resolved = await ResolveCompletionAsync(completions.Items.First(c => c.TextEdit.NewText == "C2"), host); @@ -603,7 +618,45 @@ MyClass m$$ [Theory] [InlineData("dummy.cs")] [InlineData("dummy.csx")] - public async Task OverrideSignatures_Publics(string filename) + public async Task OverrideSignatures_Publics_Async(string filename) + { + const string source = @" +class Foo +{ + public virtual void Test(string text) {} + public virtual void Test(string text, string moreText) {} +} +class FooChild : Foo +{ + override $$ +} +"; + + using var host = GetAsyncCompletionAndImportCompletionHost(); + var completions = await FindCompletionsAsync(filename, source, host); + Assert.Equal(new[] { "Equals(object obj)", "GetHashCode()", "Test(string text)", "Test(string text, string moreText)", "ToString()" }, + completions.Items.Select(c => c.Label)); + Assert.Equal(new[] { "Equals", "GetHashCode", "Test", "Test", "ToString" }, + completions.Items.Select(c => c.TextEdit.NewText)); + + Assert.All(completions.Items.Select(c => c.AdditionalTextEdits), edit => Assert.Null(edit)); + Assert.All(completions.Items, c => Assert.Equal(InsertTextFormat.PlainText, c.InsertTextFormat)); + + var afterInsert = await AfterInsertResponse(completions.Items[0], host); + var change = afterInsert.Changes.Single(); + Assert.Equal("public override bool Equals(object obj)\n {\n return base.Equals(obj);\n }", change.NewText); + Assert.Equal(8, change.StartLine); + Assert.Equal(4, change.StartColumn); + Assert.Equal(8, change.EndLine); + Assert.Equal(19, change.EndColumn); + Assert.Equal(10, afterInsert.Line); + Assert.Equal(32, afterInsert.Column); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task OverrideSignatures_Publics_Sync(string filename) { const string source = @" class Foo @@ -655,7 +708,54 @@ class FooChild : Foo [Theory] [InlineData("dummy.cs")] [InlineData("dummy.csx")] - public async Task OverrideSignatures_UnimportedTypesFullyQualified(string filename) + public async Task OverrideSignatures_UnimportedTypesFullyQualified_Async(string filename) + { + const string source = @" +using N2; +namespace N1 +{ + public class CN1 {} +} +namespace N2 +{ + using N1; + public abstract class IN2 { protected abstract CN1 GetN1(); } +} +namespace N3 +{ + class CN3 : IN2 + { + override $$ + } +}"; + + using var host = GetAsyncCompletionAndImportCompletionHost(); + var completions = await FindCompletionsAsync(filename, source, host); + Assert.Equal(new[] { "Equals(object obj)", "GetHashCode()", "GetN1()", "ToString()" }, + completions.Items.Select(c => c.Label)); + + Assert.Equal(new[] { "Equals", "GetHashCode", "GetN1", "ToString" }, + completions.Items.Select(c => c.TextEdit.NewText)); + + Assert.All(completions.Items.Select(c => c.AdditionalTextEdits), edit => Assert.Null(edit)); + Assert.All(completions.Items, c => Assert.Equal(InsertTextFormat.PlainText, c.InsertTextFormat)); + + var afterInsert = await AfterInsertResponse(completions.Items.First(i => i.Label == "GetN1()"), host); + var change = afterInsert.Changes.Single(); + Assert.Equal("protected override N1.CN1 GetN1()\n {\n throw new System.NotImplementedException();\n }", + change.NewText); + Assert.Equal(15, change.StartLine); + Assert.Equal(8, change.StartColumn); + Assert.Equal(15, change.EndLine); + Assert.Equal(22, change.EndColumn); + Assert.Equal(17, afterInsert.Line); + Assert.Equal(55, afterInsert.Column); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task OverrideSignatures_UnimportedTypesFullyQualified_Sync(string filename) { const string source = @" using N2; @@ -711,7 +811,40 @@ class CN3 : IN2 [Theory] [InlineData("dummy.cs")] [InlineData("dummy.csx")] - public async Task OverrideSignatures_ModifierInFront(string filename) + public async Task OverrideSignatures_ModifierInFront_Async(string filename) + { + const string source = @" +class C +{ + public override $$ +}"; + + using var host = GetAsyncCompletionAndImportCompletionHost(); + var completions = await FindCompletionsAsync(filename, source, host); + Assert.Equal(new[] { "Equals(object obj)", "GetHashCode()", "ToString()" }, + completions.Items.Select(c => c.Label)); + + Assert.Equal(new[] { "Equals", "GetHashCode", "ToString" }, + completions.Items.Select(c => c.TextEdit.NewText)); + + Assert.All(completions.Items.Select(c => c.AdditionalTextEdits), a => Assert.Null(a)); + Assert.All(completions.Items, c => Assert.Equal(InsertTextFormat.PlainText, c.InsertTextFormat)); + + var afterInsert = await AfterInsertResponse(completions.Items[0], host); + var change = afterInsert.Changes.Single(); + Assert.Equal("bool Equals(object obj)\n {\n return base.Equals(obj);\n }", change.NewText); + Assert.Equal(3, change.StartLine); + Assert.Equal(20, change.StartColumn); + Assert.Equal(3, change.EndLine); + Assert.Equal(26, change.EndColumn); + Assert.Equal(5, afterInsert.Line); + Assert.Equal(32, afterInsert.Column); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task OverrideSignatures_ModifierInFront_Sync(string filename) { const string source = @" class C @@ -742,7 +875,96 @@ public override $$ [Theory] [InlineData("dummy.cs")] [InlineData("dummy.csx")] - public async Task OverrideSignatures_ModifierAndReturnTypeInFront(string filename) + public async Task OverrideSignatures_PartiallyTypedMethod_Async(string filename) + { + const string source = @" +class C +{ + override Eq$$ +}"; + + using var host = GetAsyncCompletionAndImportCompletionHost(); + var completions = await FindCompletionsAsync(filename, source, host); + Assert.Equal(new[] { "Equals(object obj)", "GetHashCode()", "ToString()" }, + completions.Items.Select(c => c.Label)); + + Assert.Equal(new[] { "Equals", "GetHashCode", "ToString" }, + completions.Items.Select(c => c.TextEdit.NewText)); + Assert.All(completions.Items.Select(c => c.AdditionalTextEdits), a => Assert.Null(a)); + Assert.All(completions.Items, c => Assert.Equal(InsertTextFormat.PlainText, c.InsertTextFormat)); + + var afterInsert = await AfterInsertResponse(completions.Items[0], host); + var change = afterInsert.Changes.Single(); + Assert.Equal("public override bool Equals(object obj)\n {\n return base.Equals(obj);\n }", change.NewText); + Assert.Equal(3, change.StartLine); + Assert.Equal(4, change.StartColumn); + Assert.Equal(3, change.EndLine); + Assert.Equal(19, change.EndColumn); + Assert.Equal(5, afterInsert.Line); + Assert.Equal(32, afterInsert.Column); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task OverrideSignatures_PartiallyTypedMethod_Sync(string filename) + { + const string source = @" +class C +{ + override Eq$$ +}"; + + var completions = await FindCompletionsAsync(filename, source, SharedOmniSharpTestHost); + Assert.Equal(new[] { "Equals(object obj)", "GetHashCode()", "ToString()" }, + completions.Items.Select(c => c.Label)); + + Assert.Equal(new[] { "public override bool Equals(object obj)\n {\n return base.Equals(obj);$0\n \\}", + "public override int GetHashCode()\n {\n return base.GetHashCode();$0\n \\}", + "public override string ToString()\n {\n return base.ToString();$0\n \\}" + }, + completions.Items.Select(c => c.TextEdit.NewText)); + Assert.All(completions.Items.Select(c => c.AdditionalTextEdits), a => Assert.Null(a)); + Assert.All(completions.Items, c => Assert.Equal(InsertTextFormat.Snippet, c.InsertTextFormat)); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task OverrideSignatures_ModifierAndReturnTypeInFront_Async(string filename) + { + const string source = @" +class C +{ + public override bool $$ +}"; + + using var host = GetAsyncCompletionAndImportCompletionHost(); + var completions = await FindCompletionsAsync(filename, source, host); + Assert.Equal(new[] { "Equals(object obj)" }, + completions.Items.Select(c => c.Label)); + + Assert.Equal(new[] { "Equals" }, + completions.Items.Select(c => c.TextEdit.NewText)); + + Assert.All(completions.Items.Select(c => c.AdditionalTextEdits), edit => Assert.Null(edit)); + Assert.All(completions.Items, c => Assert.Equal(InsertTextFormat.PlainText, c.InsertTextFormat)); + + var afterInsert = await AfterInsertResponse(completions.Items[0], host); + var change = afterInsert.Changes.Single(); + Assert.Equal("(object obj)\n {\n return base.Equals(obj);\n }", change.NewText); + Assert.Equal(3, change.StartLine); + Assert.Equal(31, change.StartColumn); + Assert.Equal(3, change.EndLine); + Assert.Equal(31, change.EndColumn); + Assert.Equal(5, afterInsert.Line); + Assert.Equal(32, afterInsert.Column); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task OverrideSignatures_ModifierAndReturnTypeInFront_Sync(string filename) { const string source = @" class C @@ -767,7 +989,45 @@ public override bool $$ [Theory] [InlineData("dummy.cs")] [InlineData("dummy.csx")] - public async Task OverrideSignatures_TestTest(string filename) + public async Task OverrideSignatures_TestTest_Async(string filename) + { + const string source = @" +class Test {} +abstract class Base +{ + protected abstract Test Test(); +} +class Derived : Base +{ + override $$ +}"; + + using var host = GetAsyncCompletionAndImportCompletionHost(); + var completions = await FindCompletionsAsync(filename, source, host); + Assert.Equal(new[] { "Equals(object obj)", "GetHashCode()", "Test()", "ToString()" }, + completions.Items.Select(c => c.Label)); + + Assert.Equal(new[] { "Equals", "GetHashCode", "Test", "ToString" }, + completions.Items.Select(c => c.TextEdit.NewText)); + + Assert.All(completions.Items.Select(c => c.AdditionalTextEdits), edit => Assert.Null(edit)); + Assert.All(completions.Items, c => Assert.Equal(InsertTextFormat.PlainText, c.InsertTextFormat)); + + var afterInsert = await AfterInsertResponse(completions.Items[0], host); + var change = afterInsert.Changes.Single(); + Assert.Equal("public override bool Equals(object obj)\n {\n return base.Equals(obj);\n }", change.NewText); + Assert.Equal(8, change.StartLine); + Assert.Equal(4, change.StartColumn); + Assert.Equal(8, change.EndLine); + Assert.Equal(19, change.EndColumn); + Assert.Equal(10, afterInsert.Line); + Assert.Equal(32, afterInsert.Column); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task OverrideSignatures_TestTest_Sync(string filename) { const string source = @" class Test {} @@ -812,7 +1072,53 @@ class Derived : Base } [Fact] - public async Task OverrideCompletion_TypesNeedImport() + public async Task OverrideCompletion_TypesNeedImport_Async() + { + const string baseText = @" +using System; +public class Base +{ + public virtual Action GetAction(Action a) => null; +} +"; + + const string derivedText = @" +public class Derived : Base +{ + override $$ +}"; + + using var host = GetAsyncCompletionAndImportCompletionHost(); + var completions = await FindCompletionsAsync("derived.cs", derivedText, host, additionalFiles: new[] { new TestFile("base.cs", baseText) }); + var item = completions.Items.Single(c => c.Label.StartsWith("GetAction")); + Assert.Equal("GetAction(System.Action a)", item.Label); + Assert.Equal("GetAction", item.TextEdit.NewText); + + Assert.Null(item.AdditionalTextEdits); + + var afterInsert = await AfterInsertResponse(item, host); + Assert.Equal(2, afterInsert.Changes.Count); + + var firstChange = afterInsert.Changes[0]; + Assert.Equal(NormalizeNewlines("using System;\n\n"), firstChange.NewText); + Assert.Equal(1, firstChange.StartLine); + Assert.Equal(0, firstChange.StartColumn); + Assert.Equal(1, firstChange.EndLine); + Assert.Equal(0, firstChange.EndColumn); + + var secondChange = afterInsert.Changes[1]; + Assert.Equal(NormalizeNewlines("public override Action GetAction(Action a)\n {\n return base.GetAction(a);\n }"), secondChange.NewText); + Assert.Equal(3, secondChange.StartLine); + Assert.Equal(4, secondChange.StartColumn); + Assert.Equal(3, secondChange.EndLine); + Assert.Equal(22, secondChange.EndColumn); + + Assert.Equal(7, afterInsert.Line); + Assert.Equal(33, afterInsert.Column); + } + + [Fact] + public async Task OverrideCompletion_TypesNeedImport_Sync() { const string baseText = @" using System; @@ -838,7 +1144,7 @@ public class Derived : Base Assert.Equal(0, item.AdditionalTextEdits[0].StartColumn); Assert.Equal(1, item.AdditionalTextEdits[0].EndLine); Assert.Equal(0, item.AdditionalTextEdits[0].EndColumn); - Assert.Equal("public override Action GetAction(Action a)\n {\n return base.GetAction(a);$0\n \\}", item.TextEdit.NewText); + Assert.Equal(NormalizeNewlines("public override Action GetAction(Action a)\n {\n return base.GetAction(a);$0\n \\}"), item.TextEdit.NewText); Assert.Equal(3, item.TextEdit.StartLine); Assert.Equal(4, item.TextEdit.StartColumn); Assert.Equal(3, item.TextEdit.EndLine); @@ -847,7 +1153,41 @@ public class Derived : Base } [Fact] - public async Task OverrideCompletion_FromNullableToNonNullableContext() + public async Task OverrideCompletion_FromNullableToNonNullableContext_Async() + { + const string text = @" +#nullable enable +public class Base +{ + public virtual object? M1(object? param) => throw null; +} +#nullable disable +public class Derived : Base +{ + override $$ +}"; + + using var host = GetAsyncCompletionAndImportCompletionHost(); + var completions = await FindCompletionsAsync("derived.cs", text, host); + var item = completions.Items.Single(c => c.Label.StartsWith("M1")); + Assert.Equal("M1(object? param)", item.Label); + Assert.Equal("M1", item.TextEdit.NewText); + + Assert.Null(item.AdditionalTextEdits); + + var afterInsert = await AfterInsertResponse(completions.Items[0], host); + var change = afterInsert.Changes.Single(); + Assert.Equal("public override bool Equals(object obj)\n {\n return base.Equals(obj);\n }", change.NewText); + Assert.Equal(9, change.StartLine); + Assert.Equal(4, change.StartColumn); + Assert.Equal(9, change.EndLine); + Assert.Equal(19, change.EndColumn); + Assert.Equal(11, afterInsert.Line); + Assert.Equal(32, afterInsert.Column); + } + + [Fact] + public async Task OverrideCompletion_FromNullableToNonNullableContext_Sync() { const string text = @" #nullable enable @@ -877,7 +1217,7 @@ public class Derived : Base [Theory] [InlineData("dummy.cs")] [InlineData("dummy.csx")] - public async Task OverrideCompletion_PropertyGetSet(string filename) + public async Task OverrideCompletion_PropertyGetSet_Sync(string filename) { const string source = @" using System; @@ -907,7 +1247,43 @@ public class Derived : Base [Theory] [InlineData("dummy.cs")] [InlineData("dummy.csx")] - public async Task OverrideCompletion_PropertyGet(string filename) + public async Task OverrideCompletion_PropertyGetSet_Async(string filename) + { + const string source = @" +using System; +public class Base +{ + public abstract string Prop { get; set; } +} +public class Derived : Base +{ + override $$ +} +"; + + using var host = GetAsyncCompletionAndImportCompletionHost(); + var completions = await FindCompletionsAsync(filename, source, host); + var item = completions.Items.Single(c => c.Label.StartsWith("Prop")); + Assert.Equal("Prop", item.Label); + Assert.Equal("Prop", item.TextEdit.NewText); + + Assert.Null(item.AdditionalTextEdits); + + var afterInsert = await AfterInsertResponse(item, host); + var change = afterInsert.Changes.Single(); + Assert.Equal("public override string Prop { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }", change.NewText); + Assert.Equal(8, change.StartLine); + Assert.Equal(4, change.StartColumn); + Assert.Equal(8, change.EndLine); + Assert.Equal(17, change.EndColumn); + Assert.Equal(8, afterInsert.Line); + Assert.Equal(76, afterInsert.Column); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task OverrideCompletion_PropertyGet_Sync(string filename) { const string source = @" using System; @@ -938,7 +1314,45 @@ public class Derived : Base [Theory] [InlineData("dummy.cs")] [InlineData("dummy.csx")] - public async Task PartialCompletion(string filename) + public async Task PartialCompletion_Async(string filename) + { + const string source = @" +partial class C +{ + partial void M1(string param); +} +partial class C +{ + partial $$ +} +"; + + using var host = GetAsyncCompletionAndImportCompletionHost(); + var completions = await FindCompletionsAsync(filename, source, host); + Assert.Equal(new[] { "M1(string param)" }, + completions.Items.Select(c => c.Label)); + + Assert.Equal(new[] { "M1" }, + completions.Items.Select(c => c.TextEdit.NewText)); + + Assert.All(completions.Items.Select(c => c.AdditionalTextEdits), edit => Assert.Null(edit)); + Assert.All(completions.Items, c => Assert.Equal(InsertTextFormat.PlainText, c.InsertTextFormat)); + + var afterInsert = await AfterInsertResponse(completions.Items[0], host); + var change = afterInsert.Changes.Single(); + Assert.Equal("void M1(string param)\n {\n throw new System.NotImplementedException();\n }", change.NewText); + Assert.Equal(7, change.StartLine); + Assert.Equal(12, change.StartColumn); + Assert.Equal(7, change.EndLine); + Assert.Equal(14, change.EndColumn); + Assert.Equal(9, afterInsert.Line); + Assert.Equal(51, afterInsert.Column); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task PartialCompletion_Sync(string filename) { const string source = @" partial class C @@ -963,7 +1377,52 @@ partial class C } [Fact] - public async Task PartialCompletion_TypesNeedImport() + public async Task PartialCompletion_TypesNeedImport_Async() + { + const string file1 = @" +using System; +public partial class C +{ + partial void M(Action a); +} +"; + + const string file2 = @" +public partial class C +{ + partial $$ +}"; + + using var host = GetAsyncCompletionAndImportCompletionHost(); + var completions = await FindCompletionsAsync("derived.cs", file2, host, additionalFiles: new[] { new TestFile("base.cs", file1) }); + var item = completions.Items.Single(c => c.Label.StartsWith("M")); + + Assert.Null(item.AdditionalTextEdits); + Assert.Equal("M", item.TextEdit.NewText); + + var afterInsert = await AfterInsertResponse(item, host); + Assert.Equal(2, afterInsert.Changes.Count); + + var firstChange = afterInsert.Changes[0]; + Assert.Equal(NormalizeNewlines("using System;\n\n"), firstChange.NewText); + Assert.Equal(1, firstChange.StartLine); + Assert.Equal(0, firstChange.StartColumn); + Assert.Equal(1, firstChange.EndLine); + Assert.Equal(0, firstChange.EndColumn); + + var secondChange = afterInsert.Changes[1]; + Assert.Equal(NormalizeNewlines("void M(Action a)\n {\n throw new NotImplementedException();\n }"), secondChange.NewText); + Assert.Equal(3, secondChange.StartLine); + Assert.Equal(12, secondChange.StartColumn); + Assert.Equal(3, secondChange.EndLine); + Assert.Equal(13, secondChange.EndColumn); + + Assert.Equal(7, afterInsert.Line); + Assert.Equal(44, afterInsert.Column); + } + + [Fact] + public async Task PartialCompletion_TypesNeedImport_Sync() { const string file1 = @" using System; @@ -998,7 +1457,40 @@ public partial class C } [Fact] - public async Task PartialCompletion_FromNullableToNonNullableContext() + public async Task PartialCompletion_FromNullableToNonNullableContext_Async() + { + const string text = @" +#nullable enable +public partial class C +{ + partial void M1(object? param); +} +#nullable disable +public partial class C +{ + partial $$ +}"; + + using var host = GetAsyncCompletionAndImportCompletionHost(); + var completions = await FindCompletionsAsync("derived.cs", text, host); + var item = completions.Items.Single(c => c.Label.StartsWith("M1")); + Assert.Equal("M1(object param)", item.Label); + Assert.Null(item.AdditionalTextEdits); + Assert.Equal("M1", item.TextEdit.NewText); + + var afterInsert = await AfterInsertResponse(item, host); + var change = afterInsert.Changes.Single(); + Assert.Equal("void M1(object param)\n {\n throw new System.NotImplementedException();\n }", change.NewText); + Assert.Equal(9, change.StartLine); + Assert.Equal(12, change.StartColumn); + Assert.Equal(9, change.EndLine); + Assert.Equal(14, change.EndColumn); + Assert.Equal(11, afterInsert.Line); + Assert.Equal(51, afterInsert.Column); + } + + [Fact] + public async Task PartialCompletion_FromNullableToNonNullableContext_Sync() { const string text = @" #nullable enable @@ -1022,7 +1514,40 @@ public partial class C [Theory] [InlineData("dummy.cs")] [InlineData("dummy.csx")] - public async Task OverrideSignatures_PartiallyTypedIdentifier(string filename) + public async Task OverrideSignatures_PartiallyTypedIdentifier_Async(string filename) + { + const string source = @" +class C +{ + override Ge$$ +}"; + + using var host = GetAsyncCompletionAndImportCompletionHost(); + var completions = await FindCompletionsAsync(filename, source, host); + Assert.Equal(new[] { "Equals(object obj)", "GetHashCode()", "ToString()" }, + completions.Items.Select(c => c.Label)); + + Assert.Equal(new[] { "Equals", "GetHashCode", "ToString" }, + completions.Items.Select(c => c.TextEdit.NewText)); + + Assert.All(completions.Items.Select(c => c.AdditionalTextEdits), edit => Assert.Null(edit)); + Assert.All(completions.Items, c => Assert.Equal(InsertTextFormat.PlainText, c.InsertTextFormat)); + + var afterInsert = await AfterInsertResponse(completions.Items[0], host); + var change = afterInsert.Changes.Single(); + Assert.Equal("public override bool Equals(object obj)\n {\n return base.Equals(obj);\n }", change.NewText); + Assert.Equal(3, change.StartLine); + Assert.Equal(4, change.StartColumn); + Assert.Equal(3, change.EndLine); + Assert.Equal(19, change.EndColumn); + Assert.Equal(5, afterInsert.Line); + Assert.Equal(32, afterInsert.Column); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task OverrideSignatures_PartiallyTypedIdentifier_Sync_Sync(string filename) { const string source = @" class C @@ -1398,6 +1923,38 @@ public async Task InternalsVisibleToCompletionSkipsMiscProject() Assert.Equal("AssemblyNameVal", completions.Items[0].TextEdit.NewText); } + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task PrefixHeaderIsFullyCorrect_Async(string filename) + { + const string input = +@"public class Base +{ + protected virtual void OnEnable() {} +} +public class Derived : Base +{ + protected override void On$$ +}"; + + using var host = GetAsyncCompletionAndImportCompletionHost(); + var completions = await FindCompletionsAsync(filename, input, host); + var onEnable = completions.Items.Single(c => c.TextEdit.NewText.Contains("OnEnable")); + + Assert.Equal("OnEnable", onEnable.TextEdit.NewText); + + var afterInsert = await AfterInsertResponse(onEnable, host); + var change = afterInsert.Changes.Single(); + Assert.Equal("()\n {\n base.OnEnable();\n }", change.NewText); + Assert.Equal(6, change.StartLine); + Assert.Equal(36, change.StartColumn); + Assert.Equal(6, change.EndLine); + Assert.Equal(36, change.EndColumn); + Assert.Equal(8, afterInsert.Line); + Assert.Equal(24, afterInsert.Column); + } + [Theory] [InlineData("dummy.cs")] [InlineData("dummy.csx")] @@ -1423,7 +1980,40 @@ protected override void On$$ [Theory] [InlineData("dummy.cs")] [InlineData("dummy.csx")] - public async Task PrefixHeaderIsPartiallyCorrect_1(string filename) + public async Task PrefixHeaderIsPartiallyCorrect_1_Async(string filename) + { + const string input = +@"public class Base +{ + protected virtual void OnEnable() {} +} +public class Derived : Base +{ + protected override void ON$$ +}"; + + using var host = GetAsyncCompletionAndImportCompletionHost(); + var completions = await FindCompletionsAsync(filename, input, host); + var onEnable = completions.Items.Single(c => c.TextEdit.NewText.Contains("OnEnable")); + Assert.Equal(onEnable.TextEdit.StartLine, onEnable.TextEdit.EndLine); + Assert.Equal(2, onEnable.TextEdit.EndColumn - onEnable.TextEdit.StartColumn); + Assert.Equal("OnEnable", onEnable.TextEdit.NewText); + + var afterInsert = await AfterInsertResponse(onEnable, host); + var change = afterInsert.Changes.Single(); + Assert.Equal("()\n {\n base.OnEnable();\n }", change.NewText); + Assert.Equal(6, change.StartLine); + Assert.Equal(36, change.StartColumn); + Assert.Equal(6, change.EndLine); + Assert.Equal(36, change.EndColumn); + Assert.Equal(8, afterInsert.Line); + Assert.Equal(24, afterInsert.Column); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task PrefixHeaderIsPartiallyCorrect_1_Sync(string filename) { const string input = @"public class Base @@ -1445,7 +2035,40 @@ protected override void ON$$ [Theory] [InlineData("dummy.cs")] [InlineData("dummy.csx")] - public async Task PrefixHeaderIsPartiallyCorrect_2(string filename) + public async Task PrefixHeaderIsPartiallyCorrect_2_Async(string filename) + { + const string input = +@"public class Base +{ + protected virtual void OnEnable() {} +} +public class Derived : Base +{ + protected override void on$$ +}"; + + using var host = GetAsyncCompletionAndImportCompletionHost(); + var completions = await FindCompletionsAsync(filename, input, host); + var onEnable = completions.Items.Single(c => c.TextEdit.NewText.Contains("OnEnable")); + Assert.Equal(onEnable.TextEdit.StartLine, onEnable.TextEdit.EndLine); + Assert.Equal(2, onEnable.TextEdit.EndColumn - onEnable.TextEdit.StartColumn); + Assert.Equal("OnEnable", onEnable.TextEdit.NewText); + + var afterInsert = await AfterInsertResponse(onEnable, host); + var change = afterInsert.Changes.Single(); + Assert.Equal("()\n {\n base.OnEnable();\n }", change.NewText); + Assert.Equal(6, change.StartLine); + Assert.Equal(36, change.StartColumn); + Assert.Equal(6, change.EndLine); + Assert.Equal(36, change.EndColumn); + Assert.Equal(8, afterInsert.Line); + Assert.Equal(24, afterInsert.Column); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task PrefixHeaderIsPartiallyCorrect_2_Sync(string filename) { const string input = @"public class Base @@ -1571,6 +2194,24 @@ private OmniSharpTestHost GetImportCompletionHost() return testHost; } + private OmniSharpTestHost GetAsyncCompletionAndImportCompletionHost() + { + var testHost = CreateOmniSharpHost(configurationData: new[] { + new KeyValuePair("RoslynExtensionsOptions:EnableImportCompletion", "true"), + new KeyValuePair("RoslynExtensionsOptions:EnableAsyncCompletion", "true"), + }); + testHost.AddFilesToWorkspace(); + return testHost; + } + + private Task AfterInsertResponse(CompletionItem completionItem, OmniSharpTestHost testHost) + { + return GetCompletionService(testHost).Handle(new CompletionAfterInsertRequest + { + Item = completionItem + }); + } + private static string NormalizeNewlines(string str) => str.Replace("\r\n", Environment.NewLine);