diff --git a/src/OmniSharp.Abstractions/Models/v1/Completion/CompletionItem.cs b/src/OmniSharp.Abstractions/Models/v1/Completion/CompletionItem.cs new file mode 100644 index 0000000000..141c3a2575 --- /dev/null +++ b/src/OmniSharp.Abstractions/Models/v1/Completion/CompletionItem.cs @@ -0,0 +1,136 @@ +#nullable enable + +using System.Collections.Immutable; + +namespace OmniSharp.Models.v1.Completion +{ + public class CompletionItem + { + /// + /// The label of this completion item. By default + /// also the text that is inserted when selecting + /// this completion. + /// + public string Label { get; set; } = null!; + + /// + /// The kind of this completion item. Based of the kind + /// an icon is chosen by the editor.The standardized set + /// of available values is defined in + /// + public CompletionItemKind Kind { get; set; } + + /// + /// Tags for this completion item + /// + public ImmutableArray? Tags { get; set; } + + /// + /// A human-readable string with additional information + /// about this item, like type or symbol information + /// + public string? Detail { get; set; } + + /// + /// A human-readable string that represents a doc-comment. This is + /// formatted as markdown. + /// + public string? Documentation { get; set; } + + /// + /// Select this item when showing. + /// + public bool Preselect { get; set; } + + /// + /// A string that should be used when comparing this item + /// with other items. When null or empty the label is used. + /// + public string? SortText { get; set; } + + /// + /// A string that should be used when filtering a set of + /// completion items. When null or empty the label is used. + /// + public string? FilterText { get; set; } + + /// + /// A string that should be inserted into a document when selecting + /// this completion.When null or empty the label is used. + /// + public string? InsertText { get; set; } + + /// + /// The format of . + /// + public InsertTextFormat? InsertTextFormat { get; set; } + + /// + /// An optional set of characters that when pressed while this completion is active will accept it first and + /// then type that character. + /// + public ImmutableArray? CommitCharacters { get; set; } + + /// + /// An optional array of additional text edits that are applied when + /// selecting this completion.Edits must not overlap (including the same insert position) + /// with the main edit nor with themselves. + /// + /// Additional text edits should be used to change text unrelated to the current cursor position + /// (for example adding an import statement at the top of the file if the completion item will + /// insert an unqualified type). + /// + public ImmutableArray? AdditionalTextEdits { get; set; } + + /// + /// Index in the completions list that this completion occurred. + /// + public int Data { get; set; } + + public override string ToString() + { + return $"{{ {nameof(Label)} = {Label}, {nameof(CompletionItemKind)} = {Kind} }}"; + } + } + + public enum CompletionItemKind + { + Text = 1, + Method = 2, + Function = 3, + Constructor = 4, + Field = 5, + Variable = 6, + Class = 7, + Interface = 8, + Module = 9, + Property = 10, + Unit = 11, + Value = 12, + Enum = 13, + Keyword = 14, + Snippet = 15, + Color = 16, + File = 17, + Reference = 18, + Folder = 19, + EnumMember = 20, + Constant = 21, + Struct = 22, + Event = 23, + Operator = 24, + TypeParameter = 25, + } + + public enum CompletionItemTag + { + Deprecated = 1, + } + + public enum InsertTextFormat + { + PlainText = 1, + // TODO: Support snippets + Snippet = 2, + } +} diff --git a/src/OmniSharp.Abstractions/Models/v1/Completion/CompletionRequest.cs b/src/OmniSharp.Abstractions/Models/v1/Completion/CompletionRequest.cs new file mode 100644 index 0000000000..671cfa549c --- /dev/null +++ b/src/OmniSharp.Abstractions/Models/v1/Completion/CompletionRequest.cs @@ -0,0 +1,42 @@ +#nullable enable + +using System.ComponentModel; +using OmniSharp.Mef; + +namespace OmniSharp.Models.v1.Completion +{ + [OmniSharpEndpoint(OmniSharpEndpoints.Completion, typeof(CompletionRequest), typeof(CompletionResponse))] + public class CompletionRequest : Request + { + /// + /// How the completion was triggered + /// + public CompletionTriggerKind CompletionTrigger { get; set; } + + /// + /// The character that triggered completion if + /// is . + /// otherwise. + /// + public char? TriggerCharacter { get; set; } + } + + public enum CompletionTriggerKind + { + /// + /// Completion was triggered by typing an identifier (24x7 code + /// complete), manual invocation (e.g Ctrl+Space) or via API + /// + Invoked = 1, + /// + /// Completion was triggered by a trigger character specified by + /// the `triggerCharacters` properties of the `CompletionRegistrationOptions`. + /// + TriggerCharacter = 2, + + // We don't need to support incomplete completion lists that need to be recomputed + // later, but this is reserving the number to match LSP if we need it later. + [EditorBrowsable(EditorBrowsableState.Never)] + TriggerForIncompleteCompletions = 3 + } +} diff --git a/src/OmniSharp.Abstractions/Models/v1/Completion/CompletionResolveRequest.cs b/src/OmniSharp.Abstractions/Models/v1/Completion/CompletionResolveRequest.cs new file mode 100644 index 0000000000..9ebf78e411 --- /dev/null +++ b/src/OmniSharp.Abstractions/Models/v1/Completion/CompletionResolveRequest.cs @@ -0,0 +1,12 @@ +#nullable enable + +using OmniSharp.Mef; + +namespace OmniSharp.Models.v1.Completion +{ + [OmniSharpEndpoint(OmniSharpEndpoints.CompletionResolve, typeof(CompletionResolveRequest), typeof(CompletionResolveResponse))] + public class CompletionResolveRequest : IRequest + { + public CompletionItem Item { get; set; } = null!; + } +} diff --git a/src/OmniSharp.Abstractions/Models/v1/Completion/CompletionResolveResponse.cs b/src/OmniSharp.Abstractions/Models/v1/Completion/CompletionResolveResponse.cs new file mode 100644 index 0000000000..26837f003d --- /dev/null +++ b/src/OmniSharp.Abstractions/Models/v1/Completion/CompletionResolveResponse.cs @@ -0,0 +1,9 @@ +#nullable enable + +namespace OmniSharp.Models.v1.Completion +{ + public class CompletionResolveResponse + { + public CompletionItem? Item { get; set; } + } +} diff --git a/src/OmniSharp.Abstractions/Models/v1/Completion/CompletionResponse.cs b/src/OmniSharp.Abstractions/Models/v1/Completion/CompletionResponse.cs new file mode 100644 index 0000000000..e624f14158 --- /dev/null +++ b/src/OmniSharp.Abstractions/Models/v1/Completion/CompletionResponse.cs @@ -0,0 +1,19 @@ +#nullable enable + +using System.Collections.Immutable; + +namespace OmniSharp.Models.v1.Completion +{ + public class CompletionResponse + { + /// + /// If true, this list is not complete. Further typing should result in recomputing the list. + /// + public bool IsIncomplete { get; set; } + + /// + /// The completion items. + /// + public ImmutableArray Items { get; set; } + } +} diff --git a/src/OmniSharp.Abstractions/OmniSharpEndpoints.cs b/src/OmniSharp.Abstractions/OmniSharpEndpoints.cs index b20b5c3787..f8b8c98d45 100644 --- a/src/OmniSharp.Abstractions/OmniSharpEndpoints.cs +++ b/src/OmniSharp.Abstractions/OmniSharpEndpoints.cs @@ -47,6 +47,9 @@ public static class OmniSharpEndpoints public const string ReAnalyze = "/reanalyze"; public const string QuickInfo = "/quickinfo"; + public const string Completion = "/completion"; + public const string CompletionResolve = "/completion/resolve"; + public static class V2 { public const string GetCodeActions = "/v2/getcodeactions"; diff --git a/src/OmniSharp.Roslyn.CSharp/Helpers/LspSnippetHelpers.cs b/src/OmniSharp.Roslyn.CSharp/Helpers/LspSnippetHelpers.cs new file mode 100644 index 0000000000..29531b1dfc --- /dev/null +++ b/src/OmniSharp.Roslyn.CSharp/Helpers/LspSnippetHelpers.cs @@ -0,0 +1,19 @@ +using System.Text.RegularExpressions; + +namespace OmniSharp.Roslyn.CSharp.Helpers +{ + public static class LspSnippetHelpers + { + private static Regex EscapeRegex = new Regex(@"([\\\$}])", RegexOptions.Compiled); + + /// + /// Escape the given string for use as an LSP snippet. This escapes '\', '$', and '}'. + /// + public static string Escape(string snippet) + { + if (snippet == null) + return null; + return EscapeRegex.Replace(snippet, @"\$1"); + } + } +} diff --git a/src/OmniSharp.Roslyn.CSharp/Helpers/MarkdownHelpers.cs b/src/OmniSharp.Roslyn.CSharp/Helpers/MarkdownHelpers.cs new file mode 100644 index 0000000000..523929a766 --- /dev/null +++ b/src/OmniSharp.Roslyn.CSharp/Helpers/MarkdownHelpers.cs @@ -0,0 +1,270 @@ +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.CodeAnalysis; +using OmniSharp.Options; + +namespace OmniSharp.Roslyn.CSharp.Helpers +{ + public static class MarkdownHelpers + { + private static Regex EscapeRegex = new Regex(@"([\\`\*_\{\}\[\]\(\)#+\-\.!])", RegexOptions.Compiled); + + public static string Escape(string markdown) + { + if (markdown == null) + return null; + return EscapeRegex.Replace(markdown, @"\$1"); + } + + /// + /// Indicates the start of a text container. The elements after through (but not + /// including) the matching are rendered in a rectangular block which is positioned + /// as an inline element relative to surrounding elements. The text of the element + /// itself precedes the content of the container, and is typically a bullet or number header for an item in a + /// list. + /// + private const string ContainerStart = nameof(ContainerStart); + /// + /// Indicates the end of a text container. See . + /// + private const string ContainerEnd = nameof(ContainerEnd); + + public static void TaggedTextToMarkdown( + ImmutableArray taggedParts, + StringBuilder stringBuilder, + FormattingOptions formattingOptions, + MarkdownFormat markdownFormat) + { + bool isInCodeBlock = false; + bool brokeLine = true; + bool afterFirstLine = false; + + if (markdownFormat == MarkdownFormat.Italicize) + { + stringBuilder.Append("_"); + } + + for (int i = 0; i < taggedParts.Length; i++) + { + var current = taggedParts[i]; + + if (brokeLine && markdownFormat != MarkdownFormat.Italicize) + { + Debug.Assert(!isInCodeBlock); + brokeLine = false; + bool canFormatAsBlock = (afterFirstLine, markdownFormat) switch + { + (false, MarkdownFormat.FirstLineAsCSharp) => true, + (true, MarkdownFormat.FirstLineDefaultRestCSharp) => true, + (_, MarkdownFormat.AllTextAsCSharp) => true, + _ => false + }; + + if (!canFormatAsBlock) + { + // If we're on a new line and there are no text parts in the upcoming line, then we + // can format the whole line as C# code instead of plaintext. Otherwise, we need to + // intermix, and can only use simple ` codefences + for (int j = i; j < taggedParts.Length; j++) + { + switch (taggedParts[j].Tag) + { + case TextTags.Text: + canFormatAsBlock = false; + goto endOfLineOrTextFound; + + case ContainerStart: + case ContainerEnd: + case TextTags.LineBreak: + goto endOfLineOrTextFound; + + default: + // If the block is just newlines, then we don't want to format that as + // C# code. So, we default to false, set it to true if there's actually + // content on the line, then set to false again if Text content is + // encountered. + canFormatAsBlock = true; + continue; + } + } + } + else + { + // If it's just a newline, we're going to default to standard handling which will + // skip the newline. + canFormatAsBlock = !indexIsTag(i, ContainerStart, ContainerEnd, TextTags.LineBreak); + } + + endOfLineOrTextFound: + if (canFormatAsBlock) + { + afterFirstLine = true; + stringBuilder.Append("```csharp"); + stringBuilder.Append(formattingOptions.NewLine); + for (; i < taggedParts.Length; i++) + { + current = taggedParts[i]; + if (current.Tag == ContainerStart + || current.Tag == ContainerEnd + || current.Tag == TextTags.LineBreak) + { + stringBuilder.Append(formattingOptions.NewLine); + + if (markdownFormat != MarkdownFormat.AllTextAsCSharp + && markdownFormat != MarkdownFormat.FirstLineDefaultRestCSharp) + { + // End the codeblock + stringBuilder.Append("```"); + + // We know we're at a line break of some kind, but it could be + // a container start, so let the standard handling take care of it. + goto standardHandling; + } + } + else + { + stringBuilder.Append(current.Text); + } + } + + // If we're here, that means that the last part has been reached, so just + // return. + Debug.Assert(i == taggedParts.Length); + stringBuilder.Append(formattingOptions.NewLine); + stringBuilder.Append("```"); + return; + } + } + + standardHandling: + switch (current.Tag) + { + case TextTags.Text when !isInCodeBlock: + addText(current.Text); + break; + + case TextTags.Text: + endBlock(); + addText(current.Text); + break; + + case TextTags.Space when isInCodeBlock: + if (indexIsTag(i + 1, TextTags.Text)) + { + endBlock(); + } + + addText(current.Text); + break; + + case TextTags.Space: + case TextTags.Punctuation: + addText(current.Text); + break; + + case ContainerStart: + addNewline(); + addText(current.Text); + break; + + case ContainerEnd: + addNewline(); + break; + + case TextTags.LineBreak: + if (stringBuilder.Length != 0 && !indexIsTag(i + 1, ContainerStart, ContainerEnd) && i + 1 != taggedParts.Length) + { + addNewline(); + } + break; + + default: + if (!isInCodeBlock) + { + isInCodeBlock = true; + stringBuilder.Append('`'); + } + stringBuilder.Append(current.Text); + break; + } + } + + if (isInCodeBlock) + { + endBlock(); + } + + return; + + void addText(string text) + { + afterFirstLine = true; + if (!isInCodeBlock) + { + text = Escape(text); + } + stringBuilder.Append(text); + } + + void addNewline() + { + if (isInCodeBlock) + { + endBlock(); + } + + if (markdownFormat == MarkdownFormat.Italicize) + { + stringBuilder.Append("_"); + } + + // Markdown needs 2 linebreaks to make a new paragraph + stringBuilder.Append(formattingOptions.NewLine); + stringBuilder.Append(formattingOptions.NewLine); + brokeLine = true; + + if (markdownFormat == MarkdownFormat.Italicize) + { + stringBuilder.Append("_"); + } + } + + void endBlock() + { + stringBuilder.Append('`'); + isInCodeBlock = false; + } + + bool indexIsTag(int i, params string[] tags) + => i < taggedParts.Length && tags.Contains(taggedParts[i].Tag); + } + } + + public enum MarkdownFormat + { + /// + /// Only format entire lines as C# code if there is no standard text on the line + /// + Default, + /// + /// Italicize the section + /// + Italicize, + /// + /// Format the first line as C#, unconditionally + /// + FirstLineAsCSharp, + /// + /// Format the first line as default text, and format the rest of the lines as C#, unconditionally + /// + FirstLineDefaultRestCSharp, + /// + /// Format the entire set of text as C#, unconditionally + /// + AllTextAsCSharp + } +} diff --git a/src/OmniSharp.Roslyn.CSharp/Services/Completion/CompletionService.cs b/src/OmniSharp.Roslyn.CSharp/Services/Completion/CompletionService.cs new file mode 100644 index 0000000000..7d89f6c69e --- /dev/null +++ b/src/OmniSharp.Roslyn.CSharp/Services/Completion/CompletionService.cs @@ -0,0 +1,444 @@ +#nullable enable + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Composition; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Completion; +using Microsoft.CodeAnalysis.Tags; +using Microsoft.Extensions.Logging; +using OmniSharp.Extensions; +using OmniSharp.Mef; +using OmniSharp.Models; +using OmniSharp.Models.v1.Completion; +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 +{ + [Shared] + [OmniSharpHandler(OmniSharpEndpoints.Completion, LanguageNames.CSharp)] + [OmniSharpHandler(OmniSharpEndpoints.CompletionResolve, LanguageNames.CSharp)] + public class CompletionService : + IRequestHandler, + IRequestHandler + { + private static readonly Dictionary s_roslynTagToCompletionItemKind = new Dictionary() + { + { WellKnownTags.Public, CompletionItemKind.Keyword }, + { WellKnownTags.Protected, CompletionItemKind.Keyword }, + { WellKnownTags.Private, CompletionItemKind.Keyword }, + { WellKnownTags.Internal, CompletionItemKind.Keyword }, + { WellKnownTags.File, CompletionItemKind.File }, + { WellKnownTags.Project, CompletionItemKind.File }, + { WellKnownTags.Folder, CompletionItemKind.Folder }, + { WellKnownTags.Assembly, CompletionItemKind.File }, + { WellKnownTags.Class, CompletionItemKind.Class }, + { WellKnownTags.Constant, CompletionItemKind.Constant }, + { WellKnownTags.Delegate, CompletionItemKind.Function }, + { WellKnownTags.Enum, CompletionItemKind.Enum }, + { WellKnownTags.EnumMember, CompletionItemKind.EnumMember }, + { WellKnownTags.Event, CompletionItemKind.Event }, + { WellKnownTags.ExtensionMethod, CompletionItemKind.Method }, + { WellKnownTags.Field, CompletionItemKind.Field }, + { WellKnownTags.Interface, CompletionItemKind.Interface }, + { WellKnownTags.Intrinsic, CompletionItemKind.Text }, + { WellKnownTags.Keyword, CompletionItemKind.Keyword }, + { WellKnownTags.Label, CompletionItemKind.Text }, + { WellKnownTags.Local, CompletionItemKind.Variable }, + { WellKnownTags.Namespace, CompletionItemKind.Text }, + { WellKnownTags.Method, CompletionItemKind.Method }, + { WellKnownTags.Module, CompletionItemKind.Module }, + { WellKnownTags.Operator, CompletionItemKind.Operator }, + { WellKnownTags.Parameter, CompletionItemKind.Value }, + { WellKnownTags.Property, CompletionItemKind.Property }, + { WellKnownTags.RangeVariable, CompletionItemKind.Variable }, + { WellKnownTags.Reference, CompletionItemKind.Reference }, + { WellKnownTags.Structure, CompletionItemKind.Struct }, + { WellKnownTags.TypeParameter, CompletionItemKind.TypeParameter }, + { WellKnownTags.Snippet, CompletionItemKind.Snippet }, + { WellKnownTags.Error, CompletionItemKind.Text }, + { WellKnownTags.Warning, CompletionItemKind.Text }, + }; + + private readonly OmniSharpWorkspace _workspace; + private readonly FormattingOptions _formattingOptions; + private readonly ILogger _logger; + + private readonly object _lock = new object(); + private (CSharpCompletionList Completions, string FileName)? _lastCompletion = null; + + [ImportingConstructor] + public CompletionService(OmniSharpWorkspace workspace, FormattingOptions formattingOptions, ILoggerFactory loggerFactory) + { + _workspace = workspace; + _formattingOptions = formattingOptions; + _logger = loggerFactory.CreateLogger(); + } + + public async Task Handle(CompletionRequest request) + { + _logger.LogTrace("Completions requested"); + lock (_lock) + { + _lastCompletion = null; + } + + var document = _workspace.GetDocument(request.FileName); + if (document is null) + { + _logger.LogInformation("Could not find document for file {0}", request.FileName); + return new CompletionResponse { Items = ImmutableArray.Empty }; + } + + var sourceText = await document.GetTextAsync(); + var position = sourceText.GetTextPosition(request); + + var completionService = CSharpCompletionService.GetService(document); + Debug.Assert(request.TriggerCharacter != null || request.CompletionTrigger != CompletionTriggerKind.TriggerCharacter); + + if (request.CompletionTrigger == CompletionTriggerKind.TriggerCharacter && + !completionService.ShouldTriggerCompletion(sourceText, position, getCompletionTrigger(includeTriggerCharacter: true))) + { + _logger.LogTrace("Should not insert completions here."); + return new CompletionResponse { Items = ImmutableArray.Empty }; + } + + var completions = await completionService.GetCompletionsAsync(document, position, getCompletionTrigger(includeTriggerCharacter: false)); + _logger.LogTrace("Found {0} completions for {1}:{2},{3}", + completions?.Items.IsDefaultOrEmpty != true ? 0 : completions.Items.Length, + request.FileName, + request.Line, + request.Column); + + if (completions is null || completions.Items.Length == 0) + { + return new CompletionResponse { Items = ImmutableArray.Empty }; + } + + if (request.TriggerCharacter == ' ' && !completions.Items.Any(c => c.IsObjectCreationCompletionItem())) + { + // Only trigger on space if there is an object creation completion + return new CompletionResponse { Items = ImmutableArray.Empty }; + } + + var typedSpan = completionService.GetDefaultCompletionListSpan(sourceText, position); + string typedText = sourceText.GetSubText(typedSpan).ToString(); + + ImmutableArray filteredItems = typedText != string.Empty + ? completionService.FilterItems(document, completions.Items, typedText).SelectAsArray(i => i.DisplayText) + : ImmutableArray.Empty; + _logger.LogTrace("Completions filled in"); + + lock (_lock) + { + _lastCompletion = (completions, request.FileName); + } + + var triggerCharactersBuilder = ImmutableArray.CreateBuilder(completions.Rules.DefaultCommitCharacters.Length); + var completionsBuilder = ImmutableArray.CreateBuilder(completions.Items.Length); + + for (int i = 0; i < completions.Items.Length; i++) + { + var completion = completions.Items[i]; + var commitCharacters = buildCommitCharacters(completions, completion.Rules.CommitCharacterRules, triggerCharactersBuilder); + + var insertTextFormat = InsertTextFormat.PlainText; + ImmutableArray? additionalTextEdits = null; + + if (!completion.TryGetInsertionText(out string insertText)) + { + switch (completion.GetProviderName()) + { + case CompletionItemExtensions.InternalsVisibleToCompletionProvider: + // The IVT completer doesn't add extra things before the completion + // span, only assembly keys at the end if they exist. + { + CompletionChange change = await completionService.GetChangeAsync(document, completion); + Debug.Assert(typedSpan == change.TextChange.Span); + insertText = change.TextChange.NewText!; + } + break; + + case CompletionItemExtensions.XmlDocCommentCompletionProvider: + { + // The doc comment completion might compensate for the < before + // the current word, if one exists. For these cases, if the token + // before the current location is a < and the text it's replacing starts + // with a <, erase the < from the given insertion text. + var change = await completionService.GetChangeAsync(document, completion); + + bool trimFront = change.TextChange.NewText![0] == '<' + && sourceText[change.TextChange.Span.Start] == '<'; + + Debug.Assert(!trimFront || change.TextChange.Span.Start + 1 == typedSpan.Start); + + (insertText, insertTextFormat) = getAdjustedInsertTextWithPosition(change, position, newOffset: trimFront ? 1 : 0); + } + break; + + case CompletionItemExtensions.OverrideCompletionProvider: + case CompletionItemExtensions.PartialMethodCompletionProvider: + { + // For these two, we potentially need to use additionalTextEdits. It's possible + // that override (or C# expanded partials) will cause the word or words before + // the cursor to be adjusted. For example: + // + // public class C { + // override $0 + // } + // + // Invoking completion and selecting, say Equals, wants to cause the line to be + // rewritten as this: + // + // public class C { + // public override bool Equals(object other) + // { + // return base.Equals(other);$0 + // } + // } + // + // In order to handle this, we need to chop off the section of the completion + // before the cursor and bundle that into an additionalTextEdit. Then, we adjust + // the remaining bit of the change to put the cursor in the expected spot via + // snippets. We could leave the additionalTextEdit bit for resolve, but we already + // have the data do the change and we basically have to compute the whole thing now + // anyway, so it doesn't really save us anything. + + var change = await completionService.GetChangeAsync(document, completion); + + // If the span we're using to key the completion off is the same as the replacement + // span, then we don't need to do anything special, just snippitize the text and + // exit + if (typedSpan == change.TextChange.Span) + { + (insertText, insertTextFormat) = getAdjustedInsertTextWithPosition(change, position, newOffset: 0); + break; + } + + // We know the span starts before the text we're keying off of. So, break that + // out into a separate edit. We need to cut out the space before the current word, + // as the additional edit is not allowed to overlap with the insertion point. + var additionalEditStartPosition = sourceText.Lines.GetLinePosition(change.TextChange.Span.Start); + var additionalEditEndPosition = sourceText.Lines.GetLinePosition(typedSpan.Start - 1); + int additionalEditEndOffset = change.TextChange.NewText!.IndexOf(completion.DisplayText); + if (additionalEditEndOffset < 1) + { + // The first index of this was either 0 and the edit span was wrong, + // or it wasn't found at all. In this case, just do the best we can: + // send the whole string wtih no additional edits and log a warning. + _logger.LogWarning("Could not find the first index of the display text.\nDisplay text: {0}.\nCompletion Text: {1}", + completion.DisplayText, change.TextChange.NewText); + (insertText, insertTextFormat) = getAdjustedInsertTextWithPosition(change, position, newOffset: 0); + break; + } + + additionalTextEdits = ImmutableArray.Create(new LinePositionSpanTextChange + { + // Again, we cut off the space at the end of the offset + NewText = change.TextChange.NewText!.Substring(0, additionalEditEndOffset - 1), + StartLine = additionalEditStartPosition.Line, + StartColumn = additionalEditStartPosition.Character, + EndLine = additionalEditEndPosition.Line, + EndColumn = additionalEditEndPosition.Character, + }); + + // Now that we have the additional edit, adjust the rest of the new text + (insertText, insertTextFormat) = getAdjustedInsertTextWithPosition(change, position, additionalEditEndOffset); + } + break; + + default: + insertText = completion.DisplayText; + break; + } + } + + completionsBuilder.Add(new CompletionItem + { + Label = completion.DisplayTextPrefix + completion.DisplayText + completion.DisplayTextSuffix, + InsertText = insertText, + InsertTextFormat = insertTextFormat, + AdditionalTextEdits = additionalTextEdits, + SortText = completion.SortText, + FilterText = completion.FilterText, + Kind = getCompletionItemKind(completion.Tags), + Detail = completion.InlineDescription, + Data = i, + Preselect = completion.Rules.MatchPriority == MatchPriority.Preselect || filteredItems.Contains(completion.DisplayText), + CommitCharacters = commitCharacters, + }); + } + + return new CompletionResponse + { + IsIncomplete = false, + Items = completionsBuilder.MoveToImmutable() + }; + + CompletionTrigger getCompletionTrigger(bool includeTriggerCharacter) + => request.CompletionTrigger switch + { + CompletionTriggerKind.Invoked => CompletionTrigger.Invoke, + // https://github.com/dotnet/roslyn/issues/42982: Passing a trigger character + // to GetCompletionsAsync causes a null ref currently. + CompletionTriggerKind.TriggerCharacter when includeTriggerCharacter => CompletionTrigger.CreateInsertionTrigger((char)request.TriggerCharacter!), + _ => CompletionTrigger.Invoke, + }; + + static CompletionItemKind getCompletionItemKind(ImmutableArray tags) + { + foreach (var tag in tags) + { + if (s_roslynTagToCompletionItemKind.TryGetValue(tag, out var itemKind)) + { + return itemKind; + } + } + + return CompletionItemKind.Text; + } + + static ImmutableArray buildCommitCharacters(CSharpCompletionList completions, ImmutableArray characterRules, ImmutableArray.Builder triggerCharactersBuilder) + { + triggerCharactersBuilder.AddRange(completions.Rules.DefaultCommitCharacters); + + foreach (var modifiedRule in characterRules) + { + switch (modifiedRule.Kind) + { + case CharacterSetModificationKind.Add: + triggerCharactersBuilder.AddRange(modifiedRule.Characters); + break; + + case CharacterSetModificationKind.Remove: + for (int i = 0; i < triggerCharactersBuilder.Count; i++) + { + if (modifiedRule.Characters.Contains(triggerCharactersBuilder[i])) + { + triggerCharactersBuilder.RemoveAt(i); + i--; + } + } + + break; + + case CharacterSetModificationKind.Replace: + triggerCharactersBuilder.Clear(); + triggerCharactersBuilder.AddRange(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 (completions.SuggestionModeItem is object) + { + triggerCharactersBuilder.Remove(' '); + } + + return triggerCharactersBuilder.ToImmutableAndClear(); + } + + static (string, InsertTextFormat) getAdjustedInsertTextWithPosition( + CompletionChange change, + int originalPosition, + int newOffset) + { + // We often have to trim part of the given change off the front, but we + // still want to turn the resulting change into a snippet and control + // the cursor location in the insertion text. We therefore need to compensate + // by cutting off the requested portion of the text, finding the adjusted + // position in the requested string, and snippetizing it. + + // NewText is annotated as nullable, but this is a misannotation that will be fixed. + string newText = change.TextChange.NewText!; + + // Easy-out, either Roslyn doesn't have an opinion on adjustment, or the adjustment is after the + // end of the new text. Just return a substring from the requested offset to the end + if (!(change.NewPosition is int newPosition) + || newPosition >= (change.TextChange.Span.Start + newText.Length)) + { + return (newText.Substring(newOffset), InsertTextFormat.PlainText); + } + + if (newPosition < (originalPosition + newOffset)) + { + Debug.Fail($"Unknown case of attempting to move cursor before the text that needs to be cut off. Requested cutoff: {newOffset}. New Position: {newPosition}"); + // Gracefully handle as best we can in release + return (newText.Substring(newOffset), InsertTextFormat.PlainText); + } + + // Roslyn wants to move the cursor somewhere inside the result. Substring from the + // requested start to the new position, and from the new position to the end of the + // string. + int midpoint = newPosition - change.TextChange.Span.Start; + var beforeText = LspSnippetHelpers.Escape(newText.Substring(newOffset, midpoint - newOffset)); + var afterText = LspSnippetHelpers.Escape(newText.Substring(midpoint)); + + return (beforeText + "$0" + afterText, InsertTextFormat.Snippet); + } + } + + public async Task Handle(CompletionResolveRequest request) + { + if (_lastCompletion is null) + { + _logger.LogError("Cannot call completion/resolve before calling completion!"); + return new CompletionResolveResponse { Item = request.Item }; + } + + var (completions, fileName) = _lastCompletion.Value; + + if (request.Item is null + || request.Item.Data >= completions.Items.Length + || request.Item.Data < 0) + { + _logger.LogError("Received invalid completion resolve!"); + return new CompletionResolveResponse { Item = request.Item }; + } + + var lastCompletionItem = completions.Items[request.Item.Data]; + if (lastCompletionItem.DisplayTextPrefix + lastCompletionItem.DisplayText + lastCompletionItem.DisplayTextSuffix != request.Item.Label) + { + _logger.LogError($"Inconsistent completion data. Requested data on {request.Item.Label}, but found completion item {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(); + MarkdownHelpers.TaggedTextToMarkdown(description.TaggedParts, textBuilder, _formattingOptions, MarkdownFormat.FirstLineAsCSharp); + + request.Item.Documentation = textBuilder.ToString(); + + // TODO: Do import completion diffing here + + return new CompletionResolveResponse + { + Item = request.Item + }; + } + } +} diff --git a/src/OmniSharp.Roslyn.CSharp/Services/Formatting/FormatAfterKeystrokeService.cs b/src/OmniSharp.Roslyn.CSharp/Services/Formatting/FormatAfterKeystrokeService.cs index 11c3af9940..c62949707d 100644 --- a/src/OmniSharp.Roslyn.CSharp/Services/Formatting/FormatAfterKeystrokeService.cs +++ b/src/OmniSharp.Roslyn.CSharp/Services/Formatting/FormatAfterKeystrokeService.cs @@ -4,6 +4,7 @@ using Microsoft.CodeAnalysis.Text; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using OmniSharp.Extensions; using OmniSharp.Mef; using OmniSharp.Models.Format; using OmniSharp.Options; @@ -35,7 +36,7 @@ public async Task Handle(FormatAfterKeystrokeRequest reques } var text = await document.GetTextAsync(); - int position = text.Lines.GetPosition(new LinePosition(request.Line, request.Column)); + int position = text.GetTextPosition(request); var changes = await FormattingWorker.GetFormattingChangesAfterKeystroke(document, position, request.Char, _omnisharpOptions, _loggerFactory); return new FormatRangeResponse() diff --git a/src/OmniSharp.Roslyn.CSharp/Services/Formatting/FormatRangeService.cs b/src/OmniSharp.Roslyn.CSharp/Services/Formatting/FormatRangeService.cs index aa42ae9aec..07493e24dd 100644 --- a/src/OmniSharp.Roslyn.CSharp/Services/Formatting/FormatRangeService.cs +++ b/src/OmniSharp.Roslyn.CSharp/Services/Formatting/FormatRangeService.cs @@ -3,6 +3,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; using Microsoft.Extensions.Logging; +using OmniSharp.Extensions; using OmniSharp.Mef; using OmniSharp.Models.Format; using OmniSharp.Options; @@ -34,7 +35,7 @@ public async Task Handle(FormatRangeRequest request) } var text = await document.GetTextAsync(); - var start = text.Lines.GetPosition(new LinePosition(request.Line, request.Column)); + var start = text.GetTextPosition(request); var end = text.Lines.GetPosition(new LinePosition(request.EndLine, request.EndColumn)); var syntaxTree = await document.GetSyntaxRootAsync(); var tokenStart = syntaxTree.FindToken(start).FullSpan.Start; diff --git a/src/OmniSharp.Roslyn.CSharp/Services/Intellisense/CompletionItemExtensions.cs b/src/OmniSharp.Roslyn.CSharp/Services/Intellisense/CompletionItemExtensions.cs index ed42561e01..f9d51e8876 100644 --- a/src/OmniSharp.Roslyn.CSharp/Services/Intellisense/CompletionItemExtensions.cs +++ b/src/OmniSharp.Roslyn.CSharp/Services/Intellisense/CompletionItemExtensions.cs @@ -18,8 +18,10 @@ internal static class CompletionItemExtensions private const string InsertionText = nameof(InsertionText); private const string ObjectCreationCompletionProvider = "Microsoft.CodeAnalysis.CSharp.Completion.Providers.ObjectCreationCompletionProvider"; private const string NamedParameterCompletionProvider = "Microsoft.CodeAnalysis.CSharp.Completion.Providers.NamedParameterCompletionProvider"; - private const string OverrideCompletionProvider = "Microsoft.CodeAnalysis.CSharp.Completion.Providers.OverrideCompletionProvider"; - private const string ParitalMethodCompletionProvider = "Microsoft.CodeAnalysis.CSharp.Completion.Providers.PartialMethodCompletionProvider"; + internal const string OverrideCompletionProvider = "Microsoft.CodeAnalysis.CSharp.Completion.Providers.OverrideCompletionProvider"; + internal const string PartialMethodCompletionProvider = "Microsoft.CodeAnalysis.CSharp.Completion.Providers.PartialMethodCompletionProvider"; + internal const string InternalsVisibleToCompletionProvider = "Microsoft.CodeAnalysis.CSharp.Completion.Providers.InternalsVisibleToCompletionProvider"; + internal const string XmlDocCommentCompletionProvider = "Microsoft.CodeAnalysis.CSharp.Completion.Providers.XmlDocCommentCompletionProvider"; private const string ProviderName = nameof(ProviderName); private const string SymbolCompletionItem = "Microsoft.CodeAnalysis.Completion.Providers.SymbolCompletionItem"; private const string SymbolKind = nameof(SymbolKind); @@ -37,7 +39,7 @@ static CompletionItemExtensions() _getProviderName = typeof(CompletionItem).GetProperty(ProviderName, BindingFlags.NonPublic | BindingFlags.Instance); } - private static string GetProviderName(CompletionItem item) + internal static string GetProviderName(this CompletionItem item) { return (string)_getProviderName.GetValue(item); } @@ -76,7 +78,7 @@ public static async Task> GetCompletionSymbolsAsync(this Co public static bool UseDisplayTextAsCompletionText(this CompletionItem completionItem) { var provider = GetProviderName(completionItem); - return provider == NamedParameterCompletionProvider || provider == OverrideCompletionProvider || provider == ParitalMethodCompletionProvider; + return provider == NamedParameterCompletionProvider || provider == OverrideCompletionProvider || provider == PartialMethodCompletionProvider; } public static bool TryGetInsertionText(this CompletionItem completionItem, out string insertionText) diff --git a/src/OmniSharp.Roslyn.CSharp/Services/Intellisense/IntellisenseService.cs b/src/OmniSharp.Roslyn.CSharp/Services/Intellisense/IntellisenseService.cs index b92bac7857..b042f092d8 100644 --- a/src/OmniSharp.Roslyn.CSharp/Services/Intellisense/IntellisenseService.cs +++ b/src/OmniSharp.Roslyn.CSharp/Services/Intellisense/IntellisenseService.cs @@ -37,7 +37,7 @@ public async Task> Handle(AutoCompleteRequest foreach (var document in documents) { var sourceText = await document.GetTextAsync(); - var position = sourceText.Lines.GetPosition(new LinePosition(request.Line, request.Column)); + var position = sourceText.GetTextPosition(request); var service = CompletionService.GetService(document); var completionList = await service.GetCompletionsAsync(document, position); diff --git a/src/OmniSharp.Roslyn.CSharp/Services/Navigation/FindImplementationsService.cs b/src/OmniSharp.Roslyn.CSharp/Services/Navigation/FindImplementationsService.cs index dcbb374b72..2d8e198390 100644 --- a/src/OmniSharp.Roslyn.CSharp/Services/Navigation/FindImplementationsService.cs +++ b/src/OmniSharp.Roslyn.CSharp/Services/Navigation/FindImplementationsService.cs @@ -34,7 +34,7 @@ public async Task Handle(FindImplementationsRequest request) { var semanticModel = await document.GetSemanticModelAsync(); var sourceText = await document.GetTextAsync(); - var position = sourceText.Lines.GetPosition(new LinePosition(request.Line, request.Column)); + var position = sourceText.GetTextPosition(request); var quickFixes = new List(); var symbol = await SymbolFinder.FindSymbolAtPositionAsync(semanticModel, position, _workspace); diff --git a/src/OmniSharp.Roslyn.CSharp/Services/Navigation/GotoDefinitionService.cs b/src/OmniSharp.Roslyn.CSharp/Services/Navigation/GotoDefinitionService.cs index 06c84fdd94..18d99416dc 100644 --- a/src/OmniSharp.Roslyn.CSharp/Services/Navigation/GotoDefinitionService.cs +++ b/src/OmniSharp.Roslyn.CSharp/Services/Navigation/GotoDefinitionService.cs @@ -39,7 +39,7 @@ public async Task Handle(GotoDefinitionRequest request) { var semanticModel = await document.GetSemanticModelAsync(); var sourceText = await document.GetTextAsync(); - var position = sourceText.Lines.GetPosition(new LinePosition(request.Line, request.Column)); + var position = sourceText.GetTextPosition(request); var symbol = await SymbolFinder.FindSymbolAtPositionAsync(semanticModel, position, _workspace); // go to definition for namespaces is not supported diff --git a/src/OmniSharp.Roslyn.CSharp/Services/QuickInfoProvider.cs b/src/OmniSharp.Roslyn.CSharp/Services/QuickInfoProvider.cs index 8babf99669..30587d1f39 100644 --- a/src/OmniSharp.Roslyn.CSharp/Services/QuickInfoProvider.cs +++ b/src/OmniSharp.Roslyn.CSharp/Services/QuickInfoProvider.cs @@ -6,10 +6,11 @@ using Microsoft.CodeAnalysis.QuickInfo; using Microsoft.CodeAnalysis.Text; using Microsoft.Extensions.Logging; +using OmniSharp.Extensions; using OmniSharp.Mef; using OmniSharp.Models; using OmniSharp.Options; -using OmniSharp.Utilities; +using OmniSharp.Roslyn.CSharp.Helpers; #nullable enable @@ -24,18 +25,6 @@ public class QuickInfoProvider : IRequestHandler - /// Indicates the start of a text container. The elements after through (but not - /// including) the matching are rendered in a rectangular block which is positioned - /// as an inline element relative to surrounding elements. The text of the element - /// itself precedes the content of the container, and is typically a bullet or number header for an item in a - /// list. - /// - private const string ContainerStart = nameof(ContainerStart); - /// - /// Indicates the end of a text container. See . - /// - private const string ContainerEnd = nameof(ContainerEnd); /// /// Section kind for nullability analysis. /// @@ -71,7 +60,7 @@ public async Task Handle(QuickInfoRequest request) } var sourceText = await document.GetTextAsync(); - var position = sourceText.Lines.GetPosition(new LinePosition(request.Line, request.Column)); + var position = sourceText.GetTextPosition(request); var quickInfo = await quickInfoService.GetQuickInfoAsync(document, position); if (quickInfo is null) @@ -81,19 +70,26 @@ public async Task Handle(QuickInfoRequest request) } var finalTextBuilder = new StringBuilder(); - var sectionTextBuilder = new StringBuilder(); var description = quickInfo.Sections.FirstOrDefault(s => s.Kind == QuickInfoSectionKinds.Description); if (description is object) { - appendSectionAsCsharp(description, finalTextBuilder, _formattingOptions, includeSpaceAtStart: false); + appendSection(description, MarkdownFormat.AllTextAsCSharp); + + // The description doesn't include a set of newlines at the end, regardless + // of whether there are more sections, so if there are more sections we need + // to ensure they're separated. + if (quickInfo.Sections.Length > 1) + { + finalTextBuilder.Append(_formattingOptions.NewLine); + finalTextBuilder.Append(_formattingOptions.NewLine); + } } var summary = quickInfo.Sections.FirstOrDefault(s => s.Kind == QuickInfoSectionKinds.DocumentationComments); if (summary is object) { - buildSectionAsMarkdown(summary, sectionTextBuilder, _formattingOptions, out _); - appendBuiltSection(finalTextBuilder, sectionTextBuilder, _formattingOptions); + appendSection(summary, MarkdownFormat.Default); } foreach (var section in quickInfo.Sections) @@ -105,27 +101,22 @@ public async Task Handle(QuickInfoRequest request) continue; case QuickInfoSectionKinds.TypeParameters: - appendSectionAsCsharp(section, finalTextBuilder, _formattingOptions); + appendSection(section, MarkdownFormat.AllTextAsCSharp); break; case QuickInfoSectionKinds.AnonymousTypes: // The first line is "Anonymous Types:" - buildSectionAsMarkdown(section, sectionTextBuilder, _formattingOptions, out int lastIndex, untilLineBreak: true); - appendBuiltSection(finalTextBuilder, sectionTextBuilder, _formattingOptions); - // Then we want all anonymous types to be C# highlighted - appendSectionAsCsharp(section, finalTextBuilder, _formattingOptions, lastIndex + 1); + appendSection(section, MarkdownFormat.FirstLineDefaultRestCSharp); break; case NullabilityAnalysis: // Italicize the nullable analysis for emphasis. - buildSectionAsMarkdown(section, sectionTextBuilder, _formattingOptions, out _); - appendBuiltSection(finalTextBuilder, sectionTextBuilder, _formattingOptions, italicize: true); + appendSection(section, MarkdownFormat.Italicize); break; default: - buildSectionAsMarkdown(section, sectionTextBuilder, _formattingOptions, out _); - appendBuiltSection(finalTextBuilder, sectionTextBuilder, _formattingOptions); + appendSection(section, MarkdownFormat.Default); break; } } @@ -134,151 +125,9 @@ public async Task Handle(QuickInfoRequest request) return response; - static void appendBuiltSection(StringBuilder finalTextBuilder, StringBuilder stringBuilder, FormattingOptions formattingOptions, bool italicize = false) - { - // Two newlines to trigger a markdown new paragraph - finalTextBuilder.Append(formattingOptions.NewLine); - finalTextBuilder.Append(formattingOptions.NewLine); - if (italicize) - { - finalTextBuilder.Append("_"); - } - finalTextBuilder.Append(stringBuilder); - if (italicize) - { - finalTextBuilder.Append("_"); - } - stringBuilder.Clear(); - } - - static void appendSectionAsCsharp(QuickInfoSection section, StringBuilder builder, FormattingOptions formattingOptions, int startingIndex = 0, bool includeSpaceAtStart = true) + void appendSection(QuickInfoSection section, MarkdownFormat format) { - if (includeSpaceAtStart) - { - builder.Append(formattingOptions.NewLine); - } - builder.Append("```csharp"); - builder.Append(formattingOptions.NewLine); - for (int i = startingIndex; i < section.TaggedParts.Length; i++) - { - TaggedText part = section.TaggedParts[i]; - if (part.Tag == TextTags.LineBreak && i + 1 != section.TaggedParts.Length) - { - builder.Append(formattingOptions.NewLine); - } - else - { - builder.Append(part.Text); - } - } - builder.Append(formattingOptions.NewLine); - builder.Append("```"); - } - - static void buildSectionAsMarkdown(QuickInfoSection section, StringBuilder stringBuilder, FormattingOptions formattingOptions, out int lastIndex, bool untilLineBreak = false) - { - bool isInCodeBlock = false; - lastIndex = 0; - for (int i = 0; i < section.TaggedParts.Length; i++) - { - var current = section.TaggedParts[i]; - lastIndex = i; - - switch (current.Tag) - { - case TextTags.Text when !isInCodeBlock: - addText(current.Text); - break; - - case TextTags.Text: - endBlock(); - addText(current.Text); - break; - - case TextTags.Space when isInCodeBlock: - if (nextIsTag(i, TextTags.Text)) - { - endBlock(); - } - - stringBuilder.Append(current.Text); - break; - - case TextTags.Punctuation when isInCodeBlock && current.Text != "`": - stringBuilder.Append(current.Text); - break; - - case TextTags.Space: - case TextTags.Punctuation: - stringBuilder.Append(current.Text); - break; - - case ContainerStart: - addNewline(); - addText(current.Text); - break; - - case ContainerEnd: - addNewline(); - break; - - case TextTags.LineBreak when untilLineBreak && stringBuilder.Length != 0: - // The section will end and another newline will be appended, no need to add yet another newline. - return; - - case TextTags.LineBreak: - if (stringBuilder.Length != 0 && !nextIsTag(i, ContainerStart, ContainerEnd) && i + 1 != section.TaggedParts.Length) - { - addNewline(); - } - break; - - default: - if (!isInCodeBlock) - { - isInCodeBlock = true; - stringBuilder.Append('`'); - } - stringBuilder.Append(current.Text); - break; - } - } - - if (isInCodeBlock) - { - endBlock(); - } - - return; - - void addText(string text) - { - stringBuilder.Append(MarkdownHelpers.Escape(text)); - } - - void addNewline() - { - if (isInCodeBlock) - { - endBlock(); - } - - // Markdown needs 2 linebreaks to make a new paragraph - stringBuilder.Append(formattingOptions.NewLine); - stringBuilder.Append(formattingOptions.NewLine); - } - - void endBlock() - { - stringBuilder.Append('`'); - isInCodeBlock = false; - } - - bool nextIsTag(int i, params string[] tags) - { - int nextI = i + 1; - return nextI < section.TaggedParts.Length && tags.Contains(section.TaggedParts[nextI].Tag); - } + MarkdownHelpers.TaggedTextToMarkdown(section.TaggedParts, finalTextBuilder, _formattingOptions, format); } } } diff --git a/src/OmniSharp.Roslyn.CSharp/Services/Refactoring/GetCodeActionService.cs b/src/OmniSharp.Roslyn.CSharp/Services/Refactoring/GetCodeActionService.cs index bc84766125..c5ec6528d9 100644 --- a/src/OmniSharp.Roslyn.CSharp/Services/Refactoring/GetCodeActionService.cs +++ b/src/OmniSharp.Roslyn.CSharp/Services/Refactoring/GetCodeActionService.cs @@ -7,6 +7,7 @@ using Microsoft.CodeAnalysis.CodeActions; using Microsoft.CodeAnalysis.CodeRefactorings; using Microsoft.CodeAnalysis.Text; +using OmniSharp.Extensions; using OmniSharp.Mef; using OmniSharp.Models.CodeAction; using OmniSharp.Services; @@ -40,7 +41,7 @@ public async Task Handle(GetCodeActionRequest request) if (document != null) { var sourceText = await document.GetTextAsync(); - var position = sourceText.Lines.GetPosition(new LinePosition(request.Line, request.Column)); + var position = sourceText.GetTextPosition(request); var location = new TextSpan(position, 1); return new CodeRefactoringContext(document, location, (a) => actionsDestination.Add(a), CancellationToken.None); } diff --git a/src/OmniSharp.Roslyn.CSharp/Services/Refactoring/RenameService.cs b/src/OmniSharp.Roslyn.CSharp/Services/Refactoring/RenameService.cs index 8b136b0761..815203cca3 100644 --- a/src/OmniSharp.Roslyn.CSharp/Services/Refactoring/RenameService.cs +++ b/src/OmniSharp.Roslyn.CSharp/Services/Refactoring/RenameService.cs @@ -7,6 +7,7 @@ using Microsoft.CodeAnalysis.FindSymbols; using Microsoft.CodeAnalysis.Rename; using Microsoft.CodeAnalysis.Text; +using OmniSharp.Extensions; using OmniSharp.Mef; using OmniSharp.Models; using OmniSharp.Models.Rename; @@ -33,7 +34,7 @@ public async Task Handle(RenameRequest request) if (document != null) { var sourceText = await document.GetTextAsync(); - var position = sourceText.Lines.GetPosition(new LinePosition(request.Line, request.Column)); + var position = sourceText.GetTextPosition(request); var symbol = await SymbolFinder.FindSymbolAtPositionAsync(document, position); Solution solution = _workspace.CurrentSolution; diff --git a/src/OmniSharp.Roslyn.CSharp/Services/Refactoring/RunCodeActionService.cs b/src/OmniSharp.Roslyn.CSharp/Services/Refactoring/RunCodeActionService.cs index 357fd2a95e..bc6fe92012 100644 --- a/src/OmniSharp.Roslyn.CSharp/Services/Refactoring/RunCodeActionService.cs +++ b/src/OmniSharp.Roslyn.CSharp/Services/Refactoring/RunCodeActionService.cs @@ -7,6 +7,7 @@ using Microsoft.CodeAnalysis.CodeActions; using Microsoft.CodeAnalysis.CodeRefactorings; using Microsoft.CodeAnalysis.Text; +using OmniSharp.Extensions; using OmniSharp.Mef; using OmniSharp.Models.CodeAction; using OmniSharp.Roslyn.Utilities; @@ -73,7 +74,7 @@ public async Task Handle(RunCodeActionRequest request) if (document != null) { var sourceText = await document.GetTextAsync(); - var position = sourceText.Lines.GetPosition(new LinePosition(request.Line, request.Column)); + var position = sourceText.GetTextPosition(request); var location = new TextSpan(position, 1); return new CodeRefactoringContext(document, location, (a) => actionsDestination.Add(a), CancellationToken.None); } diff --git a/src/OmniSharp.Roslyn.CSharp/Services/Signatures/SignatureHelpService.cs b/src/OmniSharp.Roslyn.CSharp/Services/Signatures/SignatureHelpService.cs index 3c5a3a7f7d..ec85681855 100644 --- a/src/OmniSharp.Roslyn.CSharp/Services/Signatures/SignatureHelpService.cs +++ b/src/OmniSharp.Roslyn.CSharp/Services/Signatures/SignatureHelpService.cs @@ -6,6 +6,7 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; +using OmniSharp.Extensions; using OmniSharp.Mef; using OmniSharp.Models; using OmniSharp.Models.SignatureHelp; @@ -104,7 +105,7 @@ throughExpression is LiteralExpressionSyntax || private async Task GetInvocation(Document document, Request request) { var sourceText = await document.GetTextAsync(); - var position = sourceText.Lines.GetPosition(new LinePosition(request.Line, request.Column)); + var position = sourceText.GetTextPosition(request); var tree = await document.GetSyntaxTreeAsync(); var root = await tree.GetRootAsync(); var node = root.FindToken(position).Parent; diff --git a/src/OmniSharp.Roslyn.CSharp/Services/TestCommands/TestCommandService.cs b/src/OmniSharp.Roslyn.CSharp/Services/TestCommands/TestCommandService.cs index 1fc153cc02..63f62386b5 100644 --- a/src/OmniSharp.Roslyn.CSharp/Services/TestCommands/TestCommandService.cs +++ b/src/OmniSharp.Roslyn.CSharp/Services/TestCommands/TestCommandService.cs @@ -7,6 +7,7 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; +using OmniSharp.Extensions; using OmniSharp.Mef; using OmniSharp.Models; using OmniSharp.Models.TestCommand; @@ -38,7 +39,7 @@ public async Task Handle(TestCommandRequest request) var semanticModel = await document.GetSemanticModelAsync(); var syntaxTree = semanticModel.SyntaxTree; var sourceText = await document.GetTextAsync(); - var position = sourceText.Lines.GetPosition(new LinePosition(request.Line, request.Column)); + var position = sourceText.GetTextPosition(request); var node = syntaxTree.GetRoot().FindToken(position).Parent; SyntaxNode method = node.FirstAncestorOrSelf(); diff --git a/src/OmniSharp.Roslyn.CSharp/Services/Types/TypeLookup.cs b/src/OmniSharp.Roslyn.CSharp/Services/Types/TypeLookup.cs index 89fc1a4bd5..a721a7a52b 100644 --- a/src/OmniSharp.Roslyn.CSharp/Services/Types/TypeLookup.cs +++ b/src/OmniSharp.Roslyn.CSharp/Services/Types/TypeLookup.cs @@ -3,6 +3,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.FindSymbols; using Microsoft.CodeAnalysis.Text; +using OmniSharp.Extensions; using OmniSharp.Mef; using OmniSharp.Models.TypeLookup; using OmniSharp.Options; @@ -44,7 +45,7 @@ public async Task Handle(TypeLookupRequest request) { var semanticModel = await document.GetSemanticModelAsync(); var sourceText = await document.GetTextAsync(); - var position = sourceText.Lines.GetPosition(new LinePosition(request.Line, request.Column)); + var position = sourceText.GetTextPosition(request); var symbol = await SymbolFinder.FindSymbolAtPositionAsync(semanticModel, position, _workspace); if (symbol != null) { diff --git a/src/OmniSharp.Roslyn/Extensions/SourceTextExtensions.cs b/src/OmniSharp.Roslyn/Extensions/SourceTextExtensions.cs new file mode 100644 index 0000000000..efefc56f99 --- /dev/null +++ b/src/OmniSharp.Roslyn/Extensions/SourceTextExtensions.cs @@ -0,0 +1,13 @@ +#nullable enable + +using Microsoft.CodeAnalysis.Text; +using OmniSharp.Models; + +namespace OmniSharp.Extensions +{ + internal static class SourceTextExtensions + { + public static int GetTextPosition(this SourceText sourceText, Request request) + => sourceText.Lines.GetPosition(new LinePosition(request.Line, request.Column)); + } +} diff --git a/src/OmniSharp.Shared/Utilities/ImmutableArrayExtensions.cs b/src/OmniSharp.Shared/Utilities/ImmutableArrayExtensions.cs index 6b81b05dda..7481d718db 100644 --- a/src/OmniSharp.Shared/Utilities/ImmutableArrayExtensions.cs +++ b/src/OmniSharp.Shared/Utilities/ImmutableArrayExtensions.cs @@ -1,5 +1,7 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Collections.Immutable; +using System.Threading.Tasks; namespace OmniSharp.Utilities { @@ -17,5 +19,35 @@ public static ImmutableArray AsImmutableOrNull(this IEnumerable items) return ImmutableArray.CreateRange(items); } + + public static ImmutableArray SelectAsArray(this ImmutableArray array, Func mapper) + { + if (array.IsDefaultOrEmpty) + { + return ImmutableArray.Empty; + } + + var builder = ImmutableArray.CreateBuilder(array.Length); + foreach (var e in array) + { + builder.Add(mapper(e)); + } + + return builder.MoveToImmutable(); + } + + public static ImmutableArray ToImmutableAndClear(this ImmutableArray.Builder builder) + { + if (builder.Capacity == builder.Count) + { + return builder.MoveToImmutable(); + } + else + { + var result = builder.ToImmutable(); + builder.Clear(); + return result; + } + } } } diff --git a/src/OmniSharp.Shared/Utilities/MarkdownHelpers.cs b/src/OmniSharp.Shared/Utilities/MarkdownHelpers.cs deleted file mode 100644 index 9a2bc5a4cc..0000000000 --- a/src/OmniSharp.Shared/Utilities/MarkdownHelpers.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Text.RegularExpressions; - -namespace OmniSharp.Utilities -{ - public static class MarkdownHelpers - { - private static Regex EscapeRegex = new Regex(@"([\\`\*_\{\}\[\]\(\)#+\-\.!])", RegexOptions.Compiled); - - public static string Escape(string markdown) - { - if (markdown == null) - return null; - return EscapeRegex.Replace(markdown, @"\$1"); - } - } -} diff --git a/tests/OmniSharp.Roslyn.CSharp.Tests/CompletionFacts.cs b/tests/OmniSharp.Roslyn.CSharp.Tests/CompletionFacts.cs new file mode 100644 index 0000000000..fc60da18ec --- /dev/null +++ b/tests/OmniSharp.Roslyn.CSharp.Tests/CompletionFacts.cs @@ -0,0 +1,947 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.Extensions.Logging; +using OmniSharp.Models.v1.Completion; +using OmniSharp.Roslyn.CSharp.Services.Completion; +using TestUtility; +using Xunit; +using Xunit.Abstractions; + +namespace OmniSharp.Roslyn.CSharp.Tests +{ + public class CompletionFacts : AbstractTestFixture + { + private readonly ILogger _logger; + + private string EndpointName => OmniSharpEndpoints.Completion; + + public CompletionFacts(ITestOutputHelper output, SharedOmniSharpHostFixture sharedOmniSharpHostFixture) + : base(output, sharedOmniSharpHostFixture) + { + this._logger = this.LoggerFactory.CreateLogger(); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task PropertyCompletion(string filename) + { + const string input = + @"public class Class1 { + public int Foo { get; set; } + public Class1() + { + Foo$$ + } + }"; + + var completions = await FindCompletionsAsync(filename, input); + Assert.Contains("Foo", completions.Items.Select(c => c.Label)); + Assert.Contains("Foo", completions.Items.Select(c => c.InsertText)); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task VariableCompletion(string filename) + { + const string input = + @"public class Class1 { + public Class1() + { + var foo = 1; + foo$$ + } + }"; + + var completions = await FindCompletionsAsync(filename, input); + Assert.Contains("foo", completions.Items.Select(c => c.Label)); + Assert.Contains("foo", completions.Items.Select(c => c.InsertText)); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task DocumentationIsResolved(string filename) + { + const string input = + @"public class Class1 { + public Class1() + { + Foo$$ + } + /// Some Text + public void Foo(int bar = 1) + { + } + }"; + + var completions = await FindCompletionsAsync(filename, input); + Assert.All(completions.Items, c => Assert.Null(c.Documentation)); + + var fooCompletion = completions.Items.Single(c => c.Label == "Foo"); + var resolvedCompletion = await ResolveCompletionAsync(fooCompletion); + Assert.Equal("```csharp\nvoid Class1.Foo([int bar = 1])\n```\n\nSome Text", resolvedCompletion.Item.Documentation); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task ReturnsCamelCasedCompletions(string filename) + { + const string input = + @"public class Class1 { + public Class1() + { + System.Guid.tp$$ + } + }"; + + var completions = await FindCompletionsAsync(filename, input); + Assert.Contains("TryParse", completions.Items.Select(c => c.InsertText)); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task ReturnsSubsequences(string filename) + { + const string input = + @"public class Class1 { + public Class1() + { + System.Guid.ng$$ + } + }"; + + var completions = await FindCompletionsAsync(filename, input); + Assert.Contains("NewGuid", completions.Items.Select(c => c.Label)); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task ReturnsSubsequencesWithoutFirstLetter(string filename) + { + const string input = + @"public class Class1 { + public Class1() + { + System.Guid.gu$$ + } + }"; + + var completions = await FindCompletionsAsync(filename, input); + Assert.Contains("NewGuid", completions.Items.Select(c => c.Label)); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task MethodHeaderDocumentation(string filename) + { + const string input = + @"public class Class1 { + public Class1() + { + System.Guid.ng$$ + } + }"; + + var completions = await FindCompletionsAsync(filename, input); + Assert.All(completions.Items, c => Assert.Null(c.Documentation)); + + var fooCompletion = completions.Items.Single(c => c.Label == "NewGuid"); + var resolvedCompletion = await ResolveCompletionAsync(fooCompletion); + Assert.Equal("```csharp\nSystem.Guid System.Guid.NewGuid()\n```", resolvedCompletion.Item.Documentation); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task PreselectsCorrectCasing_Lowercase(string filename) + { + const string input = + @"public class MyClass1 { + + public MyClass1() + { + var myvar = 1; + my$$ + } + }"; + + var completions = await FindCompletionsAsync(filename, input); + Assert.Contains(completions.Items, c => c.Label == "myvar"); + Assert.Contains(completions.Items, c => c.Label == "MyClass1"); + Assert.All(completions.Items, c => + { + switch (c.Label) + { + case "myvar": + Assert.True(c.Preselect); + break; + default: + Assert.False(c.Preselect); + break; + } + }); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task PreselectsCorrectCasing_Uppercase(string filename) + { + const string input = + @"public class MyClass1 { + + public MyClass1() + { + var myvar = 1; + My$$ + } + }"; + + var completions = await FindCompletionsAsync(filename, input); + Assert.Contains(completions.Items, c => c.Label == "myvar"); + Assert.Contains(completions.Items, c => c.Label == "MyClass1"); + Assert.All(completions.Items, c => + { + switch (c.Label) + { + case "MyClass1": + Assert.True(c.Preselect); + break; + default: + Assert.False(c.Preselect); + break; + } + }); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task NoCompletionsInInvalid(string filename) + { + const string source = + @"public class MyClass1 { + + public MyClass1() + { + var x$$ + } + }"; + + var completions = await FindCompletionsAsync(filename, source); + Assert.Empty(completions.Items); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task AttributeDoesNotHaveAttributeSuffix(string filename) + { + const string source = + @"using System; + + public class BarAttribute : Attribute {} + + [B$$ + public class Foo {}"; + + var completions = await FindCompletionsAsync(filename, source); + Assert.Contains(completions.Items, c => c.Label == "Bar"); + Assert.Contains(completions.Items, c => c.InsertText == "Bar"); + Assert.All(completions.Items, c => + { + switch (c.Label) + { + case "Bar": + Assert.True(c.Preselect); + break; + default: + Assert.False(c.Preselect); + break; + } + }); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task ReturnsObjectInitalizerMembers(string filename) + { + const string source = + @"public class MyClass1 { + public string Foo {get; set;} + } + + public class MyClass2 { + + public MyClass2() + { + var c = new MyClass1 { + F$$ + } + } + "; + + var completions = await FindCompletionsAsync(filename, source); + Assert.Single(completions.Items); + Assert.Equal("Foo", completions.Items[0].Label); + Assert.Equal("Foo", completions.Items[0].InsertText); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task IncludesParameterNames(string filename) + { + const string source = + @"public class MyClass1 { + public void SayHi(string text) {} + } + + public class MyClass2 { + + public MyClass2() + { + var c = new MyClass1(); + c.SayHi(te$$ + } + } + "; + + var completions = await FindCompletionsAsync(filename, source); + var item = completions.Items.First(c => c.Label == "text:"); + Assert.NotNull(item); + Assert.Equal("text", item.InsertText); + Assert.All(completions.Items, c => + { + switch (c.Label) + { + case "text:": + Assert.True(c.Preselect); + break; + default: + Assert.False(c.Preselect); + break; + } + }); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task ReturnsNameSuggestions(string filename) + { + const string source = + @" +public class MyClass +{ + MyClass m$$ +} + "; + + var completions = await FindCompletionsAsync(filename, source); + Assert.Equal(new[] { "myClass", "my", "@class", "MyClass", "My", "Class", "GetMyClass", "GetMy", "GetClass" }, + completions.Items.Select(c => c.Label)); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task OverrideSignatures_Publics(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 $$ +} +"; + + var completions = await FindCompletionsAsync(filename, source); + 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(object obj)\n {\n return base.Equals(obj);$0\n \\}", + "GetHashCode()\n {\n return base.GetHashCode();$0\n \\}", + "Test(string text)\n {\n base.Test(text);$0\n \\}", + "Test(string text, string moreText)\n {\n base.Test(text, moreText);$0\n \\}", + "ToString()\n {\n return base.ToString();$0\n \\}" + }, + completions.Items.Select(c => c.InsertText)); + + Assert.Equal(new[] { "public override bool", + "public override int", + "public override void", + "public override void", + "public override string"}, + completions.Items.Select(c => c.AdditionalTextEdits.Value.Single().NewText)); + + Assert.All(completions.Items.Select(c => c.AdditionalTextEdits.Value.Single()), + r => + { + Assert.Equal(9, r.StartLine); + Assert.Equal(4, r.StartColumn); + Assert.Equal(9, r.EndLine); + Assert.Equal(12, r.EndColumn); + }); + + Assert.All(completions.Items, c => Assert.Equal(InsertTextFormat.Snippet, c.InsertTextFormat)); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task OverrideSignatures_UnimportedTypesFullyQualified(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 $$ + } +}"; + + var completions = await FindCompletionsAsync(filename, source); + Assert.Equal(new[] { "Equals(object obj)", "GetHashCode()", "GetN1()", "ToString()" }, + completions.Items.Select(c => c.Label)); + + Assert.Equal(new[] { "Equals(object obj)\n {\n return base.Equals(obj);$0\n \\}", + "GetHashCode()\n {\n return base.GetHashCode();$0\n \\}", + "GetN1()\n {\n throw new System.NotImplementedException();$0\n \\}", + "ToString()\n {\n return base.ToString();$0\n \\}" + }, + completions.Items.Select(c => c.InsertText)); + + Assert.Equal(new[] { "public override bool", + "public override int", + "protected override N1.CN1", + "public override string"}, + completions.Items.Select(c => c.AdditionalTextEdits.Value.Single().NewText)); + + Assert.All(completions.Items.Select(c => c.AdditionalTextEdits.Value.Single()), + r => + { + Assert.Equal(15, r.StartLine); + Assert.Equal(8, r.StartColumn); + Assert.Equal(15, r.EndLine); + Assert.Equal(16, r.EndColumn); + }); + + Assert.All(completions.Items, c => Assert.Equal(InsertTextFormat.Snippet, c.InsertTextFormat)); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task OverrideSignatures_ModifierInFront(string filename) + { + const string source = @" +class C +{ + public override $$ +}"; + + var completions = await FindCompletionsAsync(filename, source); + Assert.Equal(new[] { "Equals(object obj)", "GetHashCode()", "ToString()" }, + completions.Items.Select(c => c.Label)); + + Assert.Equal(new[] { "bool Equals(object obj)\n {\n return base.Equals(obj);$0\n \\}", + "int GetHashCode()\n {\n return base.GetHashCode();$0\n \\}", + "string ToString()\n {\n return base.ToString();$0\n \\}" + }, + completions.Items.Select(c => c.InsertText)); + + 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(string filename) + { + const string source = @" +class C +{ + public override bool $$ +}"; + + var completions = await FindCompletionsAsync(filename, source); + Assert.Equal(new[] { "Equals(object obj)" }, + completions.Items.Select(c => c.Label)); + + Assert.Equal(new[] { "Equals(object obj)\n {\n return base.Equals(obj);$0\n \\}" }, + completions.Items.Select(c => c.InsertText)); + + 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 PartialCompletion(string filename) + { + const string source = @" +partial class C +{ + partial void M1(string param); +} +partial class C +{ + partial $$ +} +"; + + var completions = await FindCompletionsAsync(filename, source); + Assert.Equal(new[] { "M1(string param)" }, + completions.Items.Select(c => c.Label)); + + Assert.Equal(new[] { "void M1(string param)\n {\n throw new System.NotImplementedException();$0\n \\}" }, + completions.Items.Select(c => c.InsertText)); + + 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_PartiallyTypedIdentifier(string filename) + { + const string source = @" +class C +{ + override Ge$$ +}"; + + var completions = await FindCompletionsAsync(filename, source); + Assert.Equal(new[] { "Equals(object obj)", "GetHashCode()", "ToString()" }, + completions.Items.Select(c => c.Label)); + + Assert.Equal(new[] { "Equals(object obj)\n {\n return base.Equals(obj);$0\n \\}", + "GetHashCode()\n {\n return base.GetHashCode();$0\n \\}", + "ToString()\n {\n return base.ToString();$0\n \\}" + }, + completions.Items.Select(c => c.InsertText)); + + Assert.Equal(new[] { "public override bool", + "public override int", + "public override string"}, + completions.Items.Select(c => c.AdditionalTextEdits.Value.Single().NewText)); + + Assert.All(completions.Items.Select(c => c.AdditionalTextEdits.Value.Single()), + r => + { + Assert.Equal(3, r.StartLine); + Assert.Equal(4, r.StartColumn); + Assert.Equal(3, r.EndLine); + Assert.Equal(12, r.EndColumn); + }); + + Assert.All(completions.Items, c => + { + switch (c.Label) + { + case "GetHashCode()": + Assert.True(c.Preselect); + break; + default: + Assert.False(c.Preselect); + break; + } + }); + + Assert.All(completions.Items, c => Assert.Equal(InsertTextFormat.Snippet, c.InsertTextFormat)); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task CrefCompletion(string filename) + { + const string source = + @" /// + /// A comment. for more details + /// + public class MyClass1 { + } + "; + + var completions = await FindCompletionsAsync(filename, source); + Assert.Contains(completions.Items, c => c.Label == "MyClass1"); + Assert.All(completions.Items, c => + { + switch (c.Label) + { + case "MyClass1": + Assert.True(c.Preselect); + break; + default: + Assert.False(c.Preselect); + break; + } + }); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task DocCommentTagCompletions(string filename) + { + const string source = + @" /// + /// A comment. <$$ + /// + public class MyClass1 { + } + "; + + var completions = await FindCompletionsAsync(filename, source); + Assert.Equal(new[] { "!--$0-->", + "![CDATA[$0]]>", + "c", + "code", + "inheritdoc$0/>", + "list type=\"$0\"", + "para", + "see cref=\"$0\"/>", + "seealso cref=\"$0\"/>" + }, + completions.Items.Select(c => c.InsertText)); + Assert.All(completions.Items, c => Assert.Equal(c.InsertText.Contains("$0"), c.InsertTextFormat == InsertTextFormat.Snippet)); + } + + [Fact] + public async Task HostObjectCompletionInScripts() + { + const string source = + "Prin$$"; + + var completions = await FindCompletionsAsync("dummy.csx", source); + Assert.Contains(completions.Items, c => c.Label == "Print"); + Assert.Contains(completions.Items, c => c.Label == "PrintOptions"); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task NoCommitOnSpaceInLambdaParameter_MethodArgument(string filename) + { + const string source = @" +using System; +class C +{ + int CallMe(int i) => 42; + + void M(Func a) { } + void M(string unrelated) { } + + void M() + { + M(c$$ + } +} +"; + + var completions = await FindCompletionsAsync(filename, source); + + Assert.True(completions.Items.All(c => c.IsSuggestionMode())); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task NoCommitOnSpaceInLambdaParameter_Initializer(string filename) + { + const string source = @" +using System; +class C +{ + int CallMe(int i) => 42; + + void M() + { + Func a = c$$ + } +} +"; + + var completions = await FindCompletionsAsync(filename, source); + + Assert.True(completions.Items.All(c => c.IsSuggestionMode())); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task CommitOnSpaceWithoutLambda_InArgument(string filename) + { + const string source = @" +using System; +class C +{ + int CallMe(int i) => 42; + + void M(int a) { } + + void M() + { + M(c$$ + } +} +"; + + var completions = await FindCompletionsAsync(filename, source); + + Assert.True(completions.Items.All(c => !c.IsSuggestionMode())); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task CommitOnSpaceWithoutLambda_InInitializer(string filename) + { + const string source = @" +using System; +class C +{ + int CallMe(int i) => 42; + + void M() + { + int a = c$$ + } +} +"; + + var completions = await FindCompletionsAsync(filename, source); + + Assert.True(completions.Items.All(c => !c.IsSuggestionMode())); + } + + [Fact] + public async Task ScriptingIncludes7_1() + { + const string source = + @" + var number1 = 1; + var number2 = 2; + var tuple = (number1, number2); + tuple.n$$ + "; + + var completions = await FindCompletionsAsync("dummy.csx", source); + Assert.Contains(completions.Items, c => c.Label == "number1"); + Assert.Contains(completions.Items, c => c.Label == "number2"); + Assert.All(completions.Items, c => + { + switch (c.Label) + { + case "number1": + case "number2": + Assert.True(c.Preselect); + break; + default: + Assert.False(c.Preselect); + break; + } + }); + } + + [Fact] + public async Task ScriptingIncludes7_2() + { + const string source = + @" + public class Foo { private protected int myValue = 0; } + public class Bar : Foo + { + public Bar() + { + var x = myv$$ + } + } + "; + + var completions = await FindCompletionsAsync("dummy.csx", source); + Assert.Contains(completions.Items, c => c.Label == "myValue"); + Assert.All(completions.Items, c => + { + switch (c.Label) + { + case "myValue": + Assert.True(c.Preselect); + break; + default: + Assert.False(c.Preselect); + break; + } + }); + } + + [Fact] + public async Task ScriptingIncludes8_0() + { + const string source = + @" + class Point { + public Point(int x, int y) { + PositionX = x; + PositionY = y; + } + public int PositionX { get; } + public int PositionY { get; } + } + Point[] points = { new (1, 2), new (3, 4) }; + points[0].Po$$ + "; + + var completions = await FindCompletionsAsync("dummy.csx", source); + Assert.Contains(completions.Items, c => c.Label == "PositionX"); + Assert.Contains(completions.Items, c => c.Label == "PositionY"); + Assert.All(completions.Items, c => + { + switch (c.Label) + { + case "PositionX": + case "PositionY": + Assert.True(c.Preselect); + break; + default: + Assert.False(c.Preselect); + break; + } + }); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task TriggeredOnSpaceForObjectCreation(string filename) + { + const string input = +@"public class Class1 { + public M() + { + Class1 c = new $$ + } +}"; + + var completions = await FindCompletionsAsync(filename, input, triggerChar: ' '); + Assert.NotEmpty(completions.Items); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task ReturnsAtleastOnePreselectOnNew(string filename) + { + const string input = +@"public class Class1 { + public M() + { + Class1 c = new $$ + } +}"; + + var completions = await FindCompletionsAsync(filename, input, triggerChar: ' '); + Assert.NotEmpty(completions.Items.Where(completion => completion.Preselect == true)); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task NotTriggeredOnSpaceWithoutObjectCreation(string filename) + { + const string input = +@"public class Class1 { + public M() + { + $$ + } +}"; + + var completions = await FindCompletionsAsync(filename, input, triggerChar: ' '); + Assert.Empty(completions.Items); + } + + [Fact] + public async Task InternalsVisibleToCompletion() + { + var projectInfo = ProjectInfo.Create( + ProjectId.CreateNewId(), + VersionStamp.Create(), + "ProjectNameVal", + "AssemblyNameVal", + LanguageNames.CSharp, + "/path/to/project.csproj"); + + SharedOmniSharpTestHost.Workspace.AddProject(projectInfo); + + const string input = @" +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(""$$"; + + + var completions = await FindCompletionsAsync("dummy.cs", input); + Assert.Single(completions.Items); + Assert.Equal("AssemblyNameVal", completions.Items[0].Label); + Assert.Equal("AssemblyNameVal", completions.Items[0].InsertText); + } + + private CompletionService GetCompletionService(OmniSharpTestHost host) + => host.GetRequestHandler(EndpointName); + + protected async Task FindCompletionsAsync(string filename, string source, char? triggerChar = null) + { + var testFile = new TestFile(filename, source); + SharedOmniSharpTestHost.AddFilesToWorkspace(testFile); + var point = testFile.Content.GetPointFromPosition(); + + var request = new CompletionRequest + { + Line = point.Line, + Column = point.Offset, + FileName = testFile.FileName, + Buffer = testFile.Content.Code, + CompletionTrigger = triggerChar is object ? CompletionTriggerKind.TriggerCharacter : CompletionTriggerKind.Invoked, + TriggerCharacter = triggerChar + }; + + var requestHandler = GetCompletionService(SharedOmniSharpTestHost); + + return await requestHandler.Handle(request); + } + + protected async Task ResolveCompletionAsync(CompletionItem completionItem) + => await GetCompletionService(SharedOmniSharpTestHost).Handle(new CompletionResolveRequest { Item = completionItem }); + } + + internal static class CompletionResponseExtensions + { + public static bool IsSuggestionMode(this CompletionItem item) => (item.CommitCharacters?.IsDefaultOrEmpty ?? true) || !item.CommitCharacters.Contains(' '); + } +} diff --git a/tests/OmniSharp.Roslyn.CSharp.Tests/QuickInfoProviderFacts.cs b/tests/OmniSharp.Roslyn.CSharp.Tests/QuickInfoProviderFacts.cs index 63d4b1b5f2..c25bbe27e3 100644 --- a/tests/OmniSharp.Roslyn.CSharp.Tests/QuickInfoProviderFacts.cs +++ b/tests/OmniSharp.Roslyn.CSharp.Tests/QuickInfoProviderFacts.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; +using System.IO; using System.Threading.Tasks; using OmniSharp.Models; using OmniSharp.Options; @@ -232,7 +230,7 @@ public async Task DisplayFormatFor_TypeSymbol_ComplexType_DifferentNamespace() public async Task DisplayFormatFor_TypeSymbol_WithGenerics() { var response = await GetTypeLookUpResponse(line: 15, column: 36); - Assert.Equal("```csharp\ninterface System.Collections.Generic.IDictionary\n```\n```csharp\n\nTKey is string\nTValue is IEnumerable\n```", response.Markdown); + Assert.Equal("```csharp\ninterface System.Collections.Generic.IDictionary\n```\n\n\n\n```csharp\nTKey is string\nTValue is IEnumerable\n```", response.Markdown); } [Fact] @@ -340,7 +338,7 @@ class testissue } }"; var response = await GetTypeLookUpResponse(content); - Assert.Equal("```csharp\nbool testissue.Compare(int gameObject, string tagName)\n```\n\nReturns:\n\n Returns true if object is tagged with tag\\.", response.Markdown); + Assert.Equal("```csharp\nbool testissue.Compare(int gameObject, string tagName)\n```\n\n\n\nReturns:\n\n Returns true if object is tagged with tag\\.", response.Markdown); } [Fact] @@ -371,7 +369,7 @@ class testissue } }"; var response = await GetTypeLookUpResponse(content); - Assert.Equal("```csharp\nbool testissue.Compare(int gameObject, string tagName)\n```\n\nExceptions:\n\n A\n\n B", response.Markdown); + Assert.Equal("```csharp\nbool testissue.Compare(int gameObject, string tagName)\n```\n\n\n\nExceptions:\n\n A\n\n B", response.Markdown); } [Fact] @@ -602,7 +600,7 @@ class testissue }"; var response = await GetTypeLookUpResponse(content); Assert.Equal( - "```csharp\nT[] testissue.Compare(int gameObject)\n```\n\nChecks if object is tagged with the tag\\.\n\nYou may have some additional information about this class here\\.\n\nReturns:\n\n Returns an array of type `T`\\.\n\n\n\nExceptions:\n\n `System.Exception`", + "```csharp\nT[] testissue.Compare(int gameObject)\n```\n\nChecks if object is tagged with the tag\\.\n\nYou may have some additional information about this class here\\.\n\nReturns:\n\n Returns an array of type `T`\\.\n\n\n\nExceptions:\n\n```csharp\n System.Exception\n```", response.Markdown); } @@ -667,7 +665,7 @@ void M2() } }"; var response = await GetTypeLookUpResponse(content); - Assert.Equal("```csharp\nvoid C.M1<'a>('a t)\n```\n\nAnonymous Types:\n```csharp\n 'a is new { int X, int Y }\n```", response.Markdown); + Assert.Equal("```csharp\nvoid C.M1<'a>('a t)\n```\n\n\n\nAnonymous Types:\n\n```csharp\n 'a is new { int X, int Y }\n```", response.Markdown); } [Fact]