diff --git a/src/OmniSharp.Abstractions/Models/FileBasedRequest.cs b/src/OmniSharp.Abstractions/Models/FileBasedRequest.cs new file mode 100644 index 0000000000..0173d0bea9 --- /dev/null +++ b/src/OmniSharp.Abstractions/Models/FileBasedRequest.cs @@ -0,0 +1,18 @@ +using System.IO; + +namespace OmniSharp.Models +{ + public class FileBasedRequest : IRequest + { + private string _fileName; + + /// + /// The name of the file this request is based on. + /// + public string FileName + { + get => _fileName?.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + set => _fileName = value; + } + } +} diff --git a/src/OmniSharp.Abstractions/Models/Request.cs b/src/OmniSharp.Abstractions/Models/Request.cs index 96663c5626..cd3e8e0fcd 100644 --- a/src/OmniSharp.Abstractions/Models/Request.cs +++ b/src/OmniSharp.Abstractions/Models/Request.cs @@ -1,29 +1,15 @@ using System.Collections.Generic; -using System.IO; using Newtonsoft.Json; namespace OmniSharp.Models { - public class Request : IRequest + public class Request : FileBasedRequest { - private string _fileName; - [JsonConverter(typeof(ZeroBasedIndexConverter))] public int Line { get; set; } [JsonConverter(typeof(ZeroBasedIndexConverter))] public int Column { get; set; } public string Buffer { get; set; } public IEnumerable Changes { get; set; } - public string FileName - { - get - { - return _fileName?.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); - } - set - { - _fileName = value; - } - } } } diff --git a/src/OmniSharp.Abstractions/Models/v2/Completion/CharacterSetModificationRule.cs b/src/OmniSharp.Abstractions/Models/v2/Completion/CharacterSetModificationRule.cs new file mode 100644 index 0000000000..7922191e88 --- /dev/null +++ b/src/OmniSharp.Abstractions/Models/v2/Completion/CharacterSetModificationRule.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace OmniSharp.Models.V2.Completion +{ + public class CharacterSetModificationRule + { + /// + /// The kind of modification. + /// + [JsonConverter(typeof(StringEnumConverter), /*camelCaseText*/ true)] + public CharacterSetModificationRuleKind Kind { get; set; } + + /// + /// One or more characters. + /// + public char[] Characters { get; set; } + } +} diff --git a/src/OmniSharp.Abstractions/Models/v2/Completion/CharacterSetModificationRuleKind.cs b/src/OmniSharp.Abstractions/Models/v2/Completion/CharacterSetModificationRuleKind.cs new file mode 100644 index 0000000000..f283943267 --- /dev/null +++ b/src/OmniSharp.Abstractions/Models/v2/Completion/CharacterSetModificationRuleKind.cs @@ -0,0 +1,9 @@ +namespace OmniSharp.Models.V2.Completion +{ + public enum CharacterSetModificationRuleKind + { + Add, + Remove, + Replace + } +} diff --git a/src/OmniSharp.Abstractions/Models/v2/Completion/CompletionItem.cs b/src/OmniSharp.Abstractions/Models/v2/Completion/CompletionItem.cs new file mode 100644 index 0000000000..65a2529b03 --- /dev/null +++ b/src/OmniSharp.Abstractions/Models/v2/Completion/CompletionItem.cs @@ -0,0 +1,22 @@ +namespace OmniSharp.Models.V2.Completion +{ + public class CompletionItem + { + public string DisplayText { get; set; } + public string Kind { get; set; } + public string FilterText { get; set; } + public string SortText { get; set; } + + /// + /// Rules that modify the set of characters that can be typed to cause the + /// selected item to be commited. + /// + public CharacterSetModificationRule[] CommitCharacterRules { get; set; } + + // These properties must be resolved via the '/v2/completionItem/resolve' + // end point before they are available. + public string Description { get; set; } + public TextEdit TextEdit { get; set; } + public TextEdit[] AdditionalTextEdits { get; set; } + } +} diff --git a/src/OmniSharp.Abstractions/Models/v2/Completion/CompletionItemResolveRequest.cs b/src/OmniSharp.Abstractions/Models/v2/Completion/CompletionItemResolveRequest.cs new file mode 100644 index 0000000000..06966ccc13 --- /dev/null +++ b/src/OmniSharp.Abstractions/Models/v2/Completion/CompletionItemResolveRequest.cs @@ -0,0 +1,20 @@ +using OmniSharp.Mef; + +namespace OmniSharp.Models.V2.Completion +{ + [OmniSharpEndpoint(OmniSharpEndpoints.V2.CompletionItemResolve, typeof(CompletionItemResolveRequest), typeof(CompletionItemResolveResponse))] + public class CompletionItemResolveRequest : FileBasedRequest + { + /// + /// Zero-based index of the completion item to resolve within the list of items returned + /// by the last . + /// + public int ItemIndex { get; set; } + + /// + /// The display text of the completion item to resolve. If set, this is used to help verify + /// that the correct completion item is resolved. + /// + public string DisplayText { get; set; } + } +} diff --git a/src/OmniSharp.Abstractions/Models/v2/Completion/CompletionItemResolveResponse.cs b/src/OmniSharp.Abstractions/Models/v2/Completion/CompletionItemResolveResponse.cs new file mode 100644 index 0000000000..f52e45e3c0 --- /dev/null +++ b/src/OmniSharp.Abstractions/Models/v2/Completion/CompletionItemResolveResponse.cs @@ -0,0 +1,7 @@ +namespace OmniSharp.Models.V2.Completion +{ + public class CompletionItemResolveResponse + { + public CompletionItem Item { get; set; } + } +} diff --git a/src/OmniSharp.Abstractions/Models/v2/Completion/CompletionRequest.cs b/src/OmniSharp.Abstractions/Models/v2/Completion/CompletionRequest.cs new file mode 100644 index 0000000000..1c72d8ec9a --- /dev/null +++ b/src/OmniSharp.Abstractions/Models/v2/Completion/CompletionRequest.cs @@ -0,0 +1,18 @@ +using OmniSharp.Mef; + +namespace OmniSharp.Models.V2.Completion +{ + [OmniSharpEndpoint(OmniSharpEndpoints.V2.Completion, typeof(CompletionRequest), typeof(CompletionResponse))] + public class CompletionRequest : FileBasedRequest + { + /// + /// The zero-based position in the file where completion is requested. + /// + public int Position { get; set; } + + /// + /// The action that started completion. + /// + public CompletionTrigger Trigger { get; set; } + } +} diff --git a/src/OmniSharp.Abstractions/Models/v2/Completion/CompletionResponse.cs b/src/OmniSharp.Abstractions/Models/v2/Completion/CompletionResponse.cs new file mode 100644 index 0000000000..bb282c65ef --- /dev/null +++ b/src/OmniSharp.Abstractions/Models/v2/Completion/CompletionResponse.cs @@ -0,0 +1,25 @@ +namespace OmniSharp.Models.V2.Completion +{ + public class CompletionResponse + { + // Note: We use an expression-bodied property rather than a getter-only auto-property + // to ensure that a new instance is created every time, since this class is mutable. + public static CompletionResponse Empty => new CompletionResponse(); + + /// + /// The default set of typed characters that cause a completion item to be committed. + /// + public char[] DefaultCommitCharacters { get; set; } + + /// + /// Returns true if the completion list is "suggestion mode", meaning that it should not + /// commit aggressively on characters like ' '. + /// + public bool IsSuggestionMode { get; set; } + + /// + /// The completion items to present to the user. + /// + public CompletionItem[] Items { get; set; } + } +} diff --git a/src/OmniSharp.Abstractions/Models/v2/Completion/CompletionTrigger.cs b/src/OmniSharp.Abstractions/Models/v2/Completion/CompletionTrigger.cs new file mode 100644 index 0000000000..fab82a4aa6 --- /dev/null +++ b/src/OmniSharp.Abstractions/Models/v2/Completion/CompletionTrigger.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace OmniSharp.Models.V2.Completion +{ + public class CompletionTrigger + { + /// + /// The action that started completion. + /// + [JsonConverter(typeof(StringEnumConverter), /*camelCaseText*/ true)] + public CompletionTriggerKind Kind { get; set; } + + /// + /// The character associated with the triggering action. + /// + public string Character { get; set; } + } +} diff --git a/src/OmniSharp.Abstractions/Models/v2/Completion/CompletionTriggerKind.cs b/src/OmniSharp.Abstractions/Models/v2/Completion/CompletionTriggerKind.cs new file mode 100644 index 0000000000..51027cd27f --- /dev/null +++ b/src/OmniSharp.Abstractions/Models/v2/Completion/CompletionTriggerKind.cs @@ -0,0 +1,9 @@ +namespace OmniSharp.Models.V2.Completion +{ + public enum CompletionTriggerKind + { + Invoke, + Insertion, + Deletion + } +} diff --git a/src/OmniSharp.Abstractions/Models/v2/TextEdit.cs b/src/OmniSharp.Abstractions/Models/v2/TextEdit.cs new file mode 100644 index 0000000000..ed6010b744 --- /dev/null +++ b/src/OmniSharp.Abstractions/Models/v2/TextEdit.cs @@ -0,0 +1,8 @@ +namespace OmniSharp.Models.V2 +{ + public class TextEdit + { + public Range Range { get; set; } + public string NewText { get; set; } + } +} diff --git a/src/OmniSharp.Abstractions/OmniSharpEndpoints.cs b/src/OmniSharp.Abstractions/OmniSharpEndpoints.cs index 84f7e8633c..167f251d83 100644 --- a/src/OmniSharp.Abstractions/OmniSharpEndpoints.cs +++ b/src/OmniSharp.Abstractions/OmniSharpEndpoints.cs @@ -45,6 +45,9 @@ public static class OmniSharpEndpoints public static class V2 { + public const string Completion = "/v2/completion"; + public const string CompletionItemResolve = "/v2/completionItem/resolve"; + public const string GetCodeActions = "/v2/getcodeactions"; public const string RunCodeAction = "/v2/runcodeaction"; diff --git a/src/OmniSharp.Roslyn.CSharp/Services/Intellisense/IntellisenseService.cs b/src/OmniSharp.Roslyn.CSharp/Services/IntelliSense/AutoCompleteService.cs similarity index 98% rename from src/OmniSharp.Roslyn.CSharp/Services/Intellisense/IntellisenseService.cs rename to src/OmniSharp.Roslyn.CSharp/Services/IntelliSense/AutoCompleteService.cs index 2214c8172e..37f702151e 100644 --- a/src/OmniSharp.Roslyn.CSharp/Services/Intellisense/IntellisenseService.cs +++ b/src/OmniSharp.Roslyn.CSharp/Services/IntelliSense/AutoCompleteService.cs @@ -13,16 +13,16 @@ using OmniSharp.Options; using OmniSharp.Roslyn.CSharp.Services.Documentation; -namespace OmniSharp.Roslyn.CSharp.Services.Intellisense +namespace OmniSharp.Roslyn.CSharp.Services.IntelliSense { [OmniSharpHandler(OmniSharpEndpoints.AutoComplete, LanguageNames.CSharp)] - public class IntellisenseService : IRequestHandler> + public class AutoCompleteService : IRequestHandler> { private readonly OmniSharpWorkspace _workspace; private readonly FormattingOptions _formattingOptions; [ImportingConstructor] - public IntellisenseService(OmniSharpWorkspace workspace, FormattingOptions formattingOptions) + public AutoCompleteService(OmniSharpWorkspace workspace, FormattingOptions formattingOptions) { _workspace = workspace; _formattingOptions = formattingOptions; diff --git a/src/OmniSharp.Roslyn.CSharp/Services/Intellisense/CompletionItemExtensions.cs b/src/OmniSharp.Roslyn.CSharp/Services/IntelliSense/CompletionItemExtensions.cs similarity index 98% rename from src/OmniSharp.Roslyn.CSharp/Services/Intellisense/CompletionItemExtensions.cs rename to src/OmniSharp.Roslyn.CSharp/Services/IntelliSense/CompletionItemExtensions.cs index 8067cbf2a2..fcdefab7ef 100644 --- a/src/OmniSharp.Roslyn.CSharp/Services/Intellisense/CompletionItemExtensions.cs +++ b/src/OmniSharp.Roslyn.CSharp/Services/IntelliSense/CompletionItemExtensions.cs @@ -8,7 +8,7 @@ using Microsoft.CodeAnalysis.Completion; using OmniSharp.Utilities; -namespace OmniSharp.Roslyn.CSharp.Services.Intellisense +namespace OmniSharp.Roslyn.CSharp.Services.IntelliSense { internal static class CompletionItemExtensions { diff --git a/src/OmniSharp.Roslyn.CSharp/Services/IntelliSense/V2/CompletionService.cs b/src/OmniSharp.Roslyn.CSharp/Services/IntelliSense/V2/CompletionService.cs new file mode 100644 index 0000000000..c26bcba353 --- /dev/null +++ b/src/OmniSharp.Roslyn.CSharp/Services/IntelliSense/V2/CompletionService.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Completion; +using Microsoft.Extensions.Logging; +using OmniSharp.Mef; +using CSharpCompletionService = Microsoft.CodeAnalysis.Completion.CompletionService; + +namespace OmniSharp.Roslyn.CSharp.Services.IntelliSense.V2 +{ + using Models = OmniSharp.Models.V2; + using CompletionModels = OmniSharp.Models.V2.Completion; + + [Shared] + [OmniSharpHandler(OmniSharpEndpoints.V2.Completion, LanguageNames.CSharp)] + [OmniSharpHandler(OmniSharpEndpoints.V2.CompletionItemResolve, LanguageNames.CSharp)] + public class CompletionService : + IRequestHandler, + IRequestHandler + { + private readonly OmniSharpWorkspace _workspace; + private readonly ILogger _logger; + + private (string fileName, CompletionList completionList) _lastResponse; + + [ImportingConstructor] + public CompletionService(OmniSharpWorkspace workspace, ILoggerFactory loggerFactory) + { + _workspace = workspace; + _logger = loggerFactory.CreateLogger(); + } + + public async Task Handle(CompletionModels.CompletionRequest request) + { + var fileName = request.FileName; + var position = request.Position; + var trigger = request.Trigger.ToRoslynCompletionTrigger(); + + var document = GetDocument(fileName); + + var text = await document.GetTextAsync(); + if (position < 0 || position > text.Length) + { + throw new ArgumentOutOfRangeException($"Invalid position: {position}. Should be within range 0 to {text.Length}"); + } + + var service = GetService(document); + + var completionList = await service.GetCompletionsAsync(document, position, trigger); + if (completionList == null) + { + return CompletionModels.CompletionResponse.Empty; + } + + var isSuggestionMode = completionList.SuggestionModeItem != null; + + int itemCount = completionList.Items.Length; + var items = new CompletionModels.CompletionItem[itemCount]; + + for (int i = 0; i < itemCount; i++) + { + var item = completionList.Items[i]; + + items[i] = new CompletionModels.CompletionItem + { + DisplayText = item.DisplayText, + Kind = item.GetKind(), + FilterText = item.FilterText, + SortText = item.SortText, + CommitCharacterRules = GetCommitCharacterRulesModels(item.Rules.CommitCharacterRules) + }; + } + + var response = new CompletionModels.CompletionResponse + { + DefaultCommitCharacters = completionList.Rules.DefaultCommitCharacters.ToArray(), + IsSuggestionMode = isSuggestionMode, + Items = items + }; + + _lastResponse = (fileName, completionList); + + return response; + } + + private static CompletionModels.CharacterSetModificationRule[] GetCommitCharacterRulesModels(ImmutableArray commitCharacterRules) + { + var result = commitCharacterRules.Length > 0 + ? new CompletionModels.CharacterSetModificationRule[commitCharacterRules.Length] + : Array.Empty(); + + for (int i = 0; i < commitCharacterRules.Length; i++) + { + var rule = commitCharacterRules[i]; + result[i] = new CompletionModels.CharacterSetModificationRule + { + Characters = rule.Characters.ToArray(), + Kind = (CompletionModels.CharacterSetModificationRuleKind)rule.Kind + }; + } + + return result; + } + + private static CSharpCompletionService GetService(Document document) + { + var service = CSharpCompletionService.GetService(document); + if (service == null) + { + throw new InvalidOperationException("Could not retrieve Roslyn CompletionService."); + } + + return service; + } + + private Document GetDocument(string fileName) + { + var document = _workspace.GetDocument(fileName); + if (document == null) + { + throw new ArgumentException($"Could not find document for {fileName}."); + } + + return document; + } + + public async Task Handle(CompletionModels.CompletionItemResolveRequest request) + { + if (_lastResponse.fileName == null || _lastResponse.completionList == null) + { + throw new InvalidOperationException($"{OmniSharpEndpoints.V2.CompletionItemResolve} end point cannot be called before {OmniSharpEndpoints.V2.Completion}"); + } + + var previousFileName = _lastResponse.fileName; + var previousCompletionList = _lastResponse.completionList; + + var fileName = request.FileName; + var itemIndex = request.ItemIndex; + var displayText = request.DisplayText; + + if (!string.Equals(_lastResponse.fileName, fileName, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException($"Cannot resolve completion item from '{fileName}' because the last {OmniSharpEndpoints.V2.Completion} request was for '{previousFileName}'"); + } + + if (itemIndex < 0 || itemIndex >= previousCompletionList.Items.Length) + { + throw new ArgumentOutOfRangeException($"Invalid item index: {itemIndex}. Should be within range 0 to {previousCompletionList.Items.Length}"); + } + + var previousItem = previousCompletionList.Items[itemIndex]; + if (!string.Equals(previousItem.DisplayText, displayText)) + { + throw new ArgumentException($"Cannot resolve completion item. Display text does not match. Expected '{previousItem.DisplayText}' but was '{displayText}'"); + } + + var document = GetDocument(fileName); + var service = GetService(document); + + var description = await service.GetDescriptionAsync(document, previousItem); + + // CompletionService.GetChangeAsync(...) can optionally take the commit character which might be + // used to produce a slightly different change. Unfortunately, most editors don't provide this. + var change = await service.GetChangeAsync(document, previousItem); + + var text = await document.GetTextAsync(); + + var startTextLine = text.Lines.GetLineFromPosition(change.TextChange.Span.Start); + var startPoint = new Models.Point + { + Line = startTextLine.LineNumber, + Column = change.TextChange.Span.Start - startTextLine.Start + }; + + var endTextLine = text.Lines.GetLineFromPosition(change.TextChange.Span.End); + var endPoint = new Models.Point + { + Line = endTextLine.LineNumber, + Column = change.TextChange.Span.End - endTextLine.Start + }; + + return new CompletionModels.CompletionItemResolveResponse + { + Item = new CompletionModels.CompletionItem + { + DisplayText = previousItem.DisplayText, + Kind = previousItem.GetKind(), + FilterText = previousItem.FilterText, + SortText = previousItem.SortText, + CommitCharacterRules = GetCommitCharacterRulesModels(previousItem.Rules.CommitCharacterRules), + Description = description.Text, + TextEdit = new Models.TextEdit + { + NewText = change.TextChange.NewText, + Range = new Models.Range + { + Start = startPoint, + End = endPoint + } + } + } + }; + } + } +} diff --git a/src/OmniSharp.Roslyn.CSharp/Services/IntelliSense/V2/Extensions.cs b/src/OmniSharp.Roslyn.CSharp/Services/IntelliSense/V2/Extensions.cs new file mode 100644 index 0000000000..5f7aaa6c6d --- /dev/null +++ b/src/OmniSharp.Roslyn.CSharp/Services/IntelliSense/V2/Extensions.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis.Completion; + +namespace OmniSharp.Roslyn.CSharp.Services.IntelliSense.V2 +{ + using Models = OmniSharp.Models.V2.Completion; + + internal static class Extensions + { + public static CompletionTrigger ToRoslynCompletionTrigger(this Models.CompletionTrigger trigger) + { + if (trigger.Kind == Models.CompletionTriggerKind.Deletion || + trigger.Kind == Models.CompletionTriggerKind.Insertion && + trigger.Character == null) + { + throw new ArgumentException($"'{trigger.Kind}' completion triggers must provide a Character value."); + } + + if (trigger.Character != null && + trigger.Character.Length != 1) + { + throw new ArgumentException($"Invalid trigger character: {trigger.Character}. Should have a length of 1."); + } + + switch (trigger.Kind) + { + case Models.CompletionTriggerKind.Invoke: + return CompletionTrigger.Invoke; + case Models.CompletionTriggerKind.Insertion: + return CompletionTrigger.CreateInsertionTrigger(trigger.Character[0]); + case Models.CompletionTriggerKind.Deletion: + return CompletionTrigger.CreateDeletionTrigger(trigger.Character[0]); + + default: + throw new ArgumentException($"Invalid completion trigger kind encountered: {trigger.Kind}"); + } + } + + private static readonly ImmutableArray s_kindTags = ImmutableArray.Create( + CompletionTags.Class, + CompletionTags.Constant, + CompletionTags.Delegate, + CompletionTags.Enum, + CompletionTags.EnumMember, + CompletionTags.Event, + CompletionTags.ExtensionMethod, + CompletionTags.Field, + CompletionTags.Interface, + CompletionTags.Intrinsic, + CompletionTags.Keyword, + CompletionTags.Label, + CompletionTags.Local, + CompletionTags.Method, + CompletionTags.Module, + CompletionTags.Namespace, + CompletionTags.Operator, + CompletionTags.Parameter, + CompletionTags.Property, + CompletionTags.RangeVariable, + CompletionTags.Reference, + CompletionTags.Structure, + CompletionTags.TypeParameter); + + public static string GetKind(this CompletionItem completionItem) + { + foreach (var tag in s_kindTags) + { + if (completionItem.Tags.Contains(tag)) + { + return tag; + } + } + + return null; + } + } +} diff --git a/tests/OmniSharp.Roslyn.CSharp.Tests/AbstractAutoCompleteTestFixture.cs b/tests/OmniSharp.Roslyn.CSharp.Tests/AbstractAutoCompleteTestFixture.cs index 9910a5435f..75c63ee500 100644 --- a/tests/OmniSharp.Roslyn.CSharp.Tests/AbstractAutoCompleteTestFixture.cs +++ b/tests/OmniSharp.Roslyn.CSharp.Tests/AbstractAutoCompleteTestFixture.cs @@ -1,13 +1,13 @@ using System.Collections.Generic; using System.Threading.Tasks; using OmniSharp.Models.AutoComplete; -using OmniSharp.Roslyn.CSharp.Services.Intellisense; +using OmniSharp.Roslyn.CSharp.Services.IntelliSense; using TestUtility; using Xunit.Abstractions; namespace OmniSharp.Roslyn.CSharp.Tests { - public class AbstractAutoCompleteTestFixture : AbstractSingleRequestHandlerTestFixture + public class AbstractAutoCompleteTestFixture : AbstractSingleRequestHandlerTestFixture { protected AbstractAutoCompleteTestFixture(ITestOutputHelper output, SharedOmniSharpHostFixture sharedOmniSharpHostFixture) : base(output, sharedOmniSharpHostFixture) diff --git a/tests/OmniSharp.Roslyn.CSharp.Tests/IntellisenseFacts.cs b/tests/OmniSharp.Roslyn.CSharp.Tests/AutoCompleteFacts.cs similarity index 98% rename from tests/OmniSharp.Roslyn.CSharp.Tests/IntellisenseFacts.cs rename to tests/OmniSharp.Roslyn.CSharp.Tests/AutoCompleteFacts.cs index d7af3ef07a..7849b96051 100644 --- a/tests/OmniSharp.Roslyn.CSharp.Tests/IntellisenseFacts.cs +++ b/tests/OmniSharp.Roslyn.CSharp.Tests/AutoCompleteFacts.cs @@ -10,14 +10,14 @@ namespace OmniSharp.Roslyn.CSharp.Tests { - public class IntellisenseFacts : AbstractAutoCompleteTestFixture + public class AutoCompleteFacts : AbstractAutoCompleteTestFixture { private readonly ILogger _logger; - public IntellisenseFacts(ITestOutputHelper output, SharedOmniSharpHostFixture sharedOmniSharpHostFixture) + public AutoCompleteFacts(ITestOutputHelper output, SharedOmniSharpHostFixture sharedOmniSharpHostFixture) : base(output, sharedOmniSharpHostFixture) { - this._logger = this.LoggerFactory.CreateLogger(); + this._logger = this.LoggerFactory.CreateLogger(); } [Theory] diff --git a/tests/OmniSharp.Roslyn.CSharp.Tests/CompletionFacts.cs b/tests/OmniSharp.Roslyn.CSharp.Tests/CompletionFacts.cs new file mode 100644 index 0000000000..c334a4858f --- /dev/null +++ b/tests/OmniSharp.Roslyn.CSharp.Tests/CompletionFacts.cs @@ -0,0 +1,375 @@ +using System.Linq; +using System.Threading.Tasks; +using OmniSharp.Models.V2.Completion; +using OmniSharp.Roslyn.CSharp.Services.IntelliSense.V2; +using TestUtility; +using Xunit; +using Xunit.Abstractions; + +namespace OmniSharp.Roslyn.CSharp.Tests +{ + public class CompletionFacts : AbstractSingleRequestHandlerTestFixture + { + public CompletionFacts(ITestOutputHelper output) + : base(output) + { + } + + protected override string EndpointName => OmniSharpEndpoints.V2.Completion; + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async void Empty_file_contains_keywords(string fileName) + { + const string markup = @"$$"; + + var (items, _) = await RequestCompletionAsync(fileName, markup); + + Assert.Contains(items, item => + { + return item.DisplayText == "class" + && item.Kind == "Keyword"; + }); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async void Attributes_do_not_have_attribute_suffix(string fileName) + { + const string markup = @" +class SuperAttribute : System.Attribute { } + +[S$$] +class C +{ +}"; + + var (items, _) = await RequestCompletionAsync(fileName, markup); + + Assert.Contains(items, item => item.DisplayText == "Super"); + Assert.DoesNotContain(items, item => item.DisplayText == "SuperAttribute"); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task Returns_members_in_object_initializer_context(string fileName) + { + const string markup = @" +class B +{ + public string Foo { get; set; } +} + +class C +{ + void M() + { + var c = new B + { + $$ + } + } +}"; + + var (items, _) = await RequestCompletionAsync(fileName, markup); + Assert.Contains(items, item => item.DisplayText == "Foo"); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task Returns_named_parameter_inside_a_method_call(string fileName) + { + const string markup = @" +class Greeter +{ + public void SayHi(string text) { } +} + +class C +{ + void M() + { + var greeter = new Greeter(); + greeter.SayHi($$ + } +}"; + + var (items, _) = await RequestCompletionAsync(fileName, markup); + Assert.Contains(items, item => item.DisplayText == "text:"); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task Returns_override_signatures(string fileName) + { + const string markup = @" +class B +{ + public virtual void Test(string text) { } + public virtual void Test(string text, string moreText) { } +} + +class C : B +{ + override $$ +}"; + + var (items, _) = await RequestCompletionAsync(fileName, markup); + + Assert.Contains(items, item => item.DisplayText == "Equals(object obj)"); + Assert.Contains(items, item => item.DisplayText == "GetHashCode()"); + Assert.Contains(items, item => item.DisplayText == "Test(string text)"); + Assert.Contains(items, item => item.DisplayText == "Test(string text, string moreText)"); + Assert.Contains(items, item => item.DisplayText == "ToString()"); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task Returns_cref_completion(string fileName) + { + const string markup = @" +/// +/// A comment. for more details +/// +public class MyClass +{ +} +"; + + var (items, _) = await RequestCompletionAsync(fileName, markup); + Assert.Contains(items, item => item.DisplayText == "MyClass"); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task Returns_cref_completion_for_generic(string fileName) + { + const string markup = @" +/// +/// A comment. for more details +/// +public class MyClass +{ +} +"; + + var (items, _) = await RequestCompletionAsync(fileName, markup); + Assert.Contains(items, item => item.DisplayText == "List{T}"); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async void Description_has_minimally_qualified_crefs(string fileName) + { + const string markup = @" +using System.Text; + +/// My Crefs are and . +class B { } + +class C +{ + void M() + { + new B$$ + } +}"; + + await AssertItemDescriptionAsync(fileName, markup, + displayText: "B", expectedDescription: + "class B\r\nMy Crefs are StringBuilder and System.IO.Path."); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task Is_suggestion_mode_true_for_lambda_expression_position1(string fileName) + { + const string markup = @" +using System; +class C +{ + int CallMe(int i) => 42; + + void M(Func a) { } + + void M() + { + M(c$$ + } +}"; + + var (_, isSuggestionMode) = await RequestCompletionAsync(fileName, markup); + Assert.True(isSuggestionMode); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task Is_suggestion_mode_true_for_lambda_expression_position2(string fileName) + { + const string markup = @" +using System; +class C +{ + int CallMe(int i) => 42; + + void M() + { + Func a = c$$ + } +}"; + + var (_, isSuggestionMode) = await RequestCompletionAsync(fileName, markup); + Assert.True(isSuggestionMode); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task Is_suggestion_mode_false_for_normal_position1(string fileName) + { + const string markup = @" +using System; +class C +{ + int CallMe(int i) => 42; + + void M(int a) { } + + void M() + { + M(c$$ + } +}"; + + var (_, isSuggestionMode) = await RequestCompletionAsync(fileName, markup); + Assert.False(isSuggestionMode); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task Is_suggestion_mode_false_for_normal_position2(string fileName) + { + const string markup = @" +using System; +class C +{ + int CallMe(int i) => 42; + + void M() + { + int a = c$$ + } +}"; + + var (_, isSuggestionMode) = await RequestCompletionAsync(fileName, markup); + Assert.False(isSuggestionMode); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task Special_commit_characters_for_using_directives(string fileName) + { + const string markup = @"using $$"; + + var (items, isSuggestionMode) = await RequestCompletionAsync(fileName, markup); + Assert.False(isSuggestionMode); + + // In this position, only the '.' and ';' characters commit to make it easier to type + // namespace aliases, such as: 'using S = System;' + + var item = items.Single(i => i.DisplayText == "System"); + var rule = Assert.Single(item.CommitCharacterRules); + + Assert.Equal(CharacterSetModificationRuleKind.Replace, rule.Kind); + Assert.Equal(2, rule.Characters.Length); + Assert.Contains('.', rule.Characters); + Assert.Contains(';', rule.Characters); + } + + private async Task AssertItemDescriptionAsync(string fileName, string markup, string displayText, string expectedDescription) + { + var testFile = new TestFile(fileName, markup); + + using (var host = CreateOmniSharpHost(testFile)) + { + var (items, _) = await RequestCompletionAsync(testFile, host); + var item = await ResolveCompletionItemAsync(displayText, items, testFile, host); + + Assert.Equal(expectedDescription, item.Description); + } + } + + private Task<(CompletionItem[] items, bool isSuggestionMode)> RequestCompletionAsync(string fileName, string markup) + { + var testFile = new TestFile(fileName, markup); + + using (var host = CreateOmniSharpHost(testFile)) + { + return RequestCompletionAsync(testFile, host); + } + } + + private async Task<(CompletionItem[] items, bool isSuggestionMode)> RequestCompletionAsync(TestFile testFile, OmniSharpTestHost host) + { + var handler = GetRequestHandler(host); + + var request = new CompletionRequest + { + FileName = testFile.FileName, + Position = testFile.Content.Position, + Trigger = new CompletionTrigger + { + Kind = CompletionTriggerKind.Invoke + } + }; + + var response = await handler.Handle(request); + + return (response.Items, response.IsSuggestionMode); + } + + private async Task ResolveCompletionItemAsync(string displayText, CompletionItem[] items, TestFile testFile, OmniSharpTestHost host) + { + var itemIndex = GetItemIndex(displayText, items); + Assert.True(itemIndex >= 0); + + var handler = GetRequestHandler(host); + + var request = new CompletionItemResolveRequest + { + DisplayText = displayText, + FileName = testFile.FileName, + ItemIndex = itemIndex + }; + + var response = await handler.Handle(request); + + return response.Item; + } + + private int GetItemIndex(string displayText, CompletionItem[] items) + { + for (int i = 0; i < items.Length; i++) + { + if (items[i].DisplayText == displayText) + { + return i; + } + } + + return -1; + } + } +}