Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix completion with backticks, underscores, numbers #10500

Merged
merged 3 commits into from Nov 25, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -43,6 +43,13 @@ type internal FSharpCompletionService
.WithDismissIfLastCharacterDeleted(true)
.WithDefaultEnterKeyRule(enterKeyRule)

/// Indicates the text span to be replaced by a committed completion list item.
override _.GetDefaultCompletionListSpan(sourceText, caretIndex) =
let documentId = workspace.GetDocumentIdInCurrentContext(sourceText.Container)
let document = workspace.CurrentSolution.GetDocument(documentId)
let defines = projectInfoManager.GetCompilationDefinesForEditingDocument(document)
CompletionUtils.getDefaultCompletionListSpan(sourceText, caretIndex, documentId, document.FilePath, defines)

[<Shared>]
[<ExportLanguageServiceFactory(typeof<CompletionService>, FSharpConstants.FSharpLanguageName)>]
type internal FSharpCompletionServiceFactory
Expand Down
58 changes: 57 additions & 1 deletion vsintegration/src/FSharp.Editor/Completion/CompletionUtils.fs
Expand Up @@ -11,6 +11,7 @@ open Microsoft.CodeAnalysis.Completion
open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Completion
open System.Globalization
open FSharp.Compiler.SourceCodeServices
open FSharp.Compiler.PrettyNaming

module internal CompletionUtils =

Expand Down Expand Up @@ -112,4 +113,59 @@ module internal CompletionUtils =
| CompletionItemKind.Event -> 4
| CompletionItemKind.Argument -> 5
| CompletionItemKind.Other -> 6
| CompletionItemKind.Method (isExtension = true) -> 7
| CompletionItemKind.Method (isExtension = true) -> 7

/// Indicates the text span to be replaced by a committed completion list item.
let getDefaultCompletionListSpan(sourceText: SourceText, caretIndex, documentId, filePath, defines) =

// Gets connected identifier-part characters backward and forward from caret.
let getIdentifierChars() =
let mutable startIndex = caretIndex
let mutable endIndex = caretIndex
while startIndex > 0 && IsIdentifierPartCharacter sourceText.[startIndex - 1] do startIndex <- startIndex - 1
if startIndex <> caretIndex then
while endIndex < sourceText.Length && IsIdentifierPartCharacter sourceText.[endIndex] do endIndex <- endIndex + 1
TextSpan.FromBounds(startIndex, endIndex)

let line = sourceText.Lines.GetLineFromPosition(caretIndex)
if line.ToString().IndexOf "``" < 0 then
// No backticks on the line, capture standard identifier chars.
getIdentifierChars()
else
// Line contains backticks.
// Use tokenizer to check for identifier, in order to correctly handle extraneous backticks in comments, strings, etc.

// If caret is at a backtick-identifier, then that is our span.

// Else, check if we are after an unclosed ``, to support the common case of a manually typed leading ``.
// Tokenizer will not consider this an identifier, it will consider the bare `` a Keyword, followed by
// arbitrary tokens (Identifier, Operator, Text, etc.) depending on the trailing text.

// Else, backticks are not involved in caret location, fall back to standard identifier character scan.

// There may still be edge cases where backtick related spans are incorrectly captured, such as unclosed
// backticks before later valid backticks on a line, this is an acceptable compromise in order to support
// the majority of common cases.

let classifiedSpans = Tokenizer.getClassifiedSpans(documentId, sourceText, line.Span, Some filePath, defines, CancellationToken.None)

let isBacktickIdentifier (classifiedSpan: ClassifiedSpan) =
classifiedSpan.ClassificationType = ClassificationTypeNames.Identifier
&& Tokenizer.isDoubleBacktickIdent (sourceText.ToString(classifiedSpan.TextSpan))
let isUnclosedBacktick (classifiedSpan: ClassifiedSpan) =
classifiedSpan.ClassificationType = ClassificationTypeNames.Keyword
&& sourceText.ToString(classifiedSpan.TextSpan) = "``"

match classifiedSpans |> Seq.tryFind (fun cs -> isBacktickIdentifier cs && cs.TextSpan.IntersectsWith caretIndex) with
| Some backtickIdentifier ->
// Backtick enclosed identifier found intersecting with caret, use its span.
backtickIdentifier.TextSpan
| _ ->
match classifiedSpans |> Seq.tryFindBack (fun cs -> isUnclosedBacktick cs && caretIndex >= cs.TextSpan.Start) with
| Some unclosedBacktick ->
// Unclosed backtick found before caret, use span from backtick to end of line.
let lastSpan = classifiedSpans.[classifiedSpans.Count - 1]
TextSpan.FromBounds(unclosedBacktick.TextSpan.Start, lastSpan.TextSpan.End)
| _ ->
// No backticks involved at caret position, fall back to standard identifier chars.
getIdentifierChars()
19 changes: 10 additions & 9 deletions vsintegration/src/FSharp.Editor/LanguageService/Tokenizer.fs
@@ -1,4 +1,4 @@
namespace Microsoft.VisualStudio.FSharp.Editor
namespace Microsoft.VisualStudio.FSharp.Editor

open System
open System.Collections.Generic
Expand Down Expand Up @@ -805,15 +805,16 @@ module internal Tokenizer =
| -1 | 0 -> span
| index -> TextSpan(span.Start + index + 1, text.Length - index - 1)

let private doubleBackTickDelimiter = "``"

let isDoubleBacktickIdent (s: string) =
let doubledDelimiter = 2 * doubleBackTickDelimiter.Length
if s.Length > doubledDelimiter && s.StartsWith(doubleBackTickDelimiter, StringComparison.Ordinal) && s.EndsWith(doubleBackTickDelimiter, StringComparison.Ordinal) then
let inner = s.AsSpan(doubleBackTickDelimiter.Length, s.Length - doubledDelimiter)
not (inner.Contains(doubleBackTickDelimiter.AsSpan(), StringComparison.Ordinal))
else false

let isValidNameForSymbol (lexerSymbolKind: LexerSymbolKind, symbol: FSharpSymbol, name: string) : bool =
let doubleBackTickDelimiter = "``"

let isDoubleBacktickIdent (s: string) =
let doubledDelimiter = 2 * doubleBackTickDelimiter.Length
if s.Length > doubledDelimiter && s.StartsWith(doubleBackTickDelimiter, StringComparison.Ordinal) && s.EndsWith(doubleBackTickDelimiter, StringComparison.Ordinal) then
let inner = s.AsSpan(doubleBackTickDelimiter.Length, s.Length - doubledDelimiter)
not (inner.Contains(doubleBackTickDelimiter.AsSpan(), StringComparison.Ordinal))
else false

let isIdentifier (ident: string) =
if isDoubleBacktickIdent ident then
Expand Down
49 changes: 49 additions & 0 deletions vsintegration/tests/UnitTests/CompletionProviderTests.fs
Expand Up @@ -125,6 +125,13 @@ let VerifyCompletionListExactly(fileContents: string, marker: string, expected:
let VerifyNoCompletionList(fileContents: string, marker: string) =
VerifyCompletionListExactly(fileContents, marker, [])

let VerifyCompletionListSpan(fileContents: string, marker: string, expected: string) =
let caretPosition = fileContents.IndexOf(marker) + marker.Length
let documentId = DocumentId.CreateNewId(ProjectId.CreateNewId())
let sourceText = SourceText.From(fileContents)
let resultSpan = CompletionUtils.getDefaultCompletionListSpan(sourceText, caretPosition, documentId, filePath, [])
Assert.AreEqual(expected, sourceText.ToString(resultSpan))

[<Test>]
let ShouldTriggerCompletionAtCorrectMarkers() =
let testCases =
Expand Down Expand Up @@ -687,6 +694,48 @@ module Extensions =
"""
VerifyCompletionList(fileContents, "wrappedMessage.", ["PrintRef"], [])

[<Test>]
let ``Completion list span works with underscore in identifier``() =
let fileContents = """
let x = A.B_C
"""
VerifyCompletionListSpan(fileContents, "A.B_C", "B_C")

[<Test>]
let ``Completion list span works with digit in identifier``() =
let fileContents = """
let x = A.B1C
"""
VerifyCompletionListSpan(fileContents, "A.B1C", "B1C")

[<Test>]
let ``Completion list span works with enclosed backtick identifier``() =
let fileContents = """
let x = A.``B C``
"""
VerifyCompletionListSpan(fileContents, "A.``B C``", "``B C``")

[<Test>]
let ``Completion list span works with partial backtick identifier``() =
let fileContents = """
let x = A.``B C
"""
VerifyCompletionListSpan(fileContents, "A.``B C", "``B C")

[<Test>]
let ``Completion list span works with first of multiple enclosed backtick identifiers``() =
let fileContents = """
let x = A.``B C`` + D.``E F``
"""
VerifyCompletionListSpan(fileContents, "A.``B C``", "``B C``")

[<Test>]
let ``Completion list span works with last of multiple enclosed backtick identifiers``() =
let fileContents = """
let x = A.``B C`` + D.``E F``
"""
VerifyCompletionListSpan(fileContents, "D.``E F``", "``E F``")

#if EXE
ShouldDisplaySystemNamespace()
#endif