diff --git a/src/FsAutoComplete.Core/FileSystem.fs b/src/FsAutoComplete.Core/FileSystem.fs index 607447c68..a4b5de9c9 100644 --- a/src/FsAutoComplete.Core/FileSystem.fs +++ b/src/FsAutoComplete.Core/FileSystem.fs @@ -214,7 +214,11 @@ type NamedText(fileName: string, str: string) = /// Provides safe access to a substring of the file via FCS-provided Range member x.GetText(m: FSharp.Compiler.Text.Range) : Result = - if not (Range.rangeContainsRange x.TotalRange m) then + // indexing into first line of empty file can be encountered when typing from an empty file + // if we don't check it, GetLineString will throw IndexOutOfRangeException + if (x :> ISourceText).GetLineCount() = 0 then + Ok "" + else if not (Range.rangeContainsRange x.TotalRange m) then Error $"%A{m} is outside of the bounds of the file" else if m.StartLine = m.EndLine then // slice of a single line, just do that let lineText = (x :> ISourceText).GetLineString(m.StartLine - 1) @@ -248,7 +252,10 @@ type NamedText(fileName: string, str: string) = /// Provides safe access to a line of the file via FCS-provided Position member x.GetLine(pos: FSharp.Compiler.Text.Position) : string option = - if pos.Line < 1 || pos.Line > getLines.Value.Length then + // indexing into first line of empty file can be encountered when typing from an empty file + if (x :> ISourceText).GetLineCount() = 0 then + Some "" + else if pos.Line < 1 || pos.Line > getLines.Value.Length then None else Some(x.GetLineUnsafe pos) @@ -265,6 +272,9 @@ type NamedText(fileName: string, str: string) = /// Also available in indexer form: x[pos] member x.TryGetChar(pos: FSharp.Compiler.Text.Position) : char option = option { + // indexing into first line of empty file can be encountered when typing from an empty file + // if we don't check it, GetLineUnsafe will throw IndexOutOfRangeException + do! Option.guard ((x :> ISourceText).GetLineCount() > 0) do! Option.guard (Range.rangeContainsPos (x.TotalRange) pos) let lineText = x.GetLineUnsafe(pos) diff --git a/src/FsAutoComplete/LspServers/AdaptiveFSharpLspServer.fs b/src/FsAutoComplete/LspServers/AdaptiveFSharpLspServer.fs index b92ac2875..49cd59c15 100644 --- a/src/FsAutoComplete/LspServers/AdaptiveFSharpLspServer.fs +++ b/src/FsAutoComplete/LspServers/AdaptiveFSharpLspServer.fs @@ -416,13 +416,16 @@ type AdaptiveFSharpLspServer do disposables.Add <| fileChecked.Publish.Subscribe(fun (parseAndCheck, volatileFile, ct) -> - async { - let config = config |> AVal.force - do! builtInCompilerAnalyzers config volatileFile parseAndCheck - do! runAnalyzers config parseAndCheck volatileFile + if volatileFile.Source.Length = 0 then + () // Don't analyze and error on an empty file + else + async { + let config = config |> AVal.force + do! builtInCompilerAnalyzers config volatileFile parseAndCheck + do! runAnalyzers config parseAndCheck volatileFile - } - |> Async.StartWithCT ct) + } + |> Async.StartWithCT ct) let handleCommandEvents (n: NotificationEvent, ct: CancellationToken) = @@ -1055,15 +1058,21 @@ type AdaptiveFSharpLspServer >> Log.addContextDestructured "file" file ) - use s = File.openFileStreamForReadingAsync file + if File.Exists(UMX.untag file) then + use s = File.openFileStreamForReadingAsync file - let! source = sourceTextFactory.Create(file, s) |> Async.AwaitCancellableValueTask + let! source = sourceTextFactory.Create(file, s) |> Async.AwaitCancellableValueTask - return - { LastTouched = File.getLastWriteTimeOrDefaultNow file - Source = source - Version = 0 } + return + { LastTouched = File.getLastWriteTimeOrDefaultNow file + Source = source + Version = 0 } + else // When a user does "File -> New Text File -> Select a language -> F#" without saving, the file won't exist + return + { LastTouched = DateTime.UtcNow + Source = sourceTextFactory.Create(file, "") + Version = 0 } with e -> logger.warn ( Log.setMessage "Could not read file {file}" @@ -2475,141 +2484,145 @@ type AdaptiveFSharpLspServer let! volatileFile = forceFindOpenFileOrRead filePath |> AsyncResult.ofStringErr - let! lineStr = volatileFile.Source |> tryGetLineStr pos |> Result.ofStringErr + if volatileFile.Source.Length = 0 then + return None // An empty file has empty completions. Otherwise we would error down there + else - if lineStr.StartsWith "#" then - let completionList = - { IsIncomplete = false - Items = KeywordList.hashSymbolCompletionItems - ItemDefaults = None } + let! lineStr = volatileFile.Source |> tryGetLineStr pos |> Result.ofStringErr + if lineStr.StartsWith "#" then + let completionList = + { IsIncomplete = false + Items = KeywordList.hashSymbolCompletionItems + ItemDefaults = None } - return! success (Some completionList) - else - let config = AVal.force config - let rec retryAsyncOption (delay: TimeSpan) timesLeft handleError action = - async { - match! action with - | Ok x -> return Ok x - | Error e when timesLeft >= 0 -> - let nextAction = handleError e - do! Async.Sleep(delay) - return! retryAsyncOption delay (timesLeft - 1) handleError nextAction - | Error e -> return Error e - } - - let getCompletions forceGetTypeCheckResultsStale = - asyncResult { - - let! volatileFile = forceFindOpenFileOrRead filePath - let! lineStr = volatileFile.Source |> tryGetLineStr pos - - // TextDocumentCompletion will sometimes come in before TextDocumentDidChange - // This will require the trigger character to be at the place VSCode says it is - // Otherwise we'll fail here and our retry logic will come into place - do! - match p.Context with - | Some({ triggerKind = CompletionTriggerKind.TriggerCharacter } as context) -> - volatileFile.Source.TryGetChar pos = context.triggerCharacter - | _ -> true - |> Result.requireTrue $"TextDocumentCompletion was sent before TextDocumentDidChange" + return! success (Some completionList) + else + let config = AVal.force config - // Special characters like parentheses, brackets, etc. require a full type check - let isSpecialChar = Option.exists (Char.IsLetterOrDigit >> not) + let rec retryAsyncOption (delay: TimeSpan) timesLeft handleError action = + async { + match! action with + | Ok x -> return Ok x + | Error e when timesLeft >= 0 -> + let nextAction = handleError e + do! Async.Sleep(delay) + return! retryAsyncOption delay (timesLeft - 1) handleError nextAction + | Error e -> return Error e + } - let previousCharacter = volatileFile.Source.TryGetChar(FcsPos.subtractColumn pos 1) + let getCompletions forceGetTypeCheckResultsStale = + asyncResult { - let! typeCheckResults = - if isSpecialChar previousCharacter then - forceGetTypeCheckResults filePath - else - forceGetTypeCheckResultsStale filePath + let! volatileFile = forceFindOpenFileOrRead filePath + let! lineStr = volatileFile.Source |> tryGetLineStr pos - let getAllSymbols () = - if config.ExternalAutocomplete then - typeCheckResults.GetAllEntities true - else - [] - - let! (decls, residue, shouldKeywords) = - Debug.measure "TextDocumentCompletion.TryGetCompletions" (fun () -> - typeCheckResults.TryGetCompletions pos lineStr None getAllSymbols - |> AsyncResult.ofOption (fun () -> "No TryGetCompletions results")) - - do! Result.requireNotEmpty "Should not have empty completions" decls - - return Some(decls, residue, shouldKeywords, typeCheckResults, getAllSymbols, volatileFile) - } - - let handleError e = - match e with - | "Should not have empty completions" -> - // If we don't get any completions, assume we need to wait for a full typecheck - getCompletions forceGetTypeCheckResults - | _ -> getCompletions forceGetTypeCheckResultsStale - - match! - retryAsyncOption - (TimeSpan.FromMilliseconds(15.)) - 100 - handleError - (getCompletions forceGetTypeCheckResultsStale) - |> AsyncResult.ofStringErr - with - | None -> return! success (None) - | Some(decls, _, shouldKeywords, typeCheckResults, _, volatileFile) -> - - return! - Debug.measure "TextDocumentCompletion.TryGetCompletions success" - <| fun () -> - transact (fun () -> - HashMap.OfList( - [ for d in decls do - d.NameInList, (d, pos, filePath, volatileFile.Source.GetLine, typeCheckResults.GetAST) ] - ) - |> autoCompleteItems.UpdateTo) - |> ignore - - let includeKeywords = config.KeywordsAutocomplete && shouldKeywords - - let items = - decls - |> Array.mapi (fun id d -> - let code = - if - System.Text.RegularExpressions.Regex.IsMatch(d.NameInList, """^[a-zA-Z][a-zA-Z0-9']+$""") - then - d.NameInList - elif d.NamespaceToOpen.IsSome then - d.NameInList - else - FSharpKeywords.NormalizeIdentifierBackticks d.NameInList - - let label = - match d.NamespaceToOpen with - | Some no -> sprintf "%s (open %s)" d.NameInList no - | None -> d.NameInList - - { CompletionItem.Create(d.NameInList) with - Kind = (AVal.force glyphToCompletionKind) d.Glyph - InsertText = Some code - SortText = Some(sprintf "%06d" id) - FilterText = Some d.NameInList - Label = label }) - - let its = - if not includeKeywords then - items + // TextDocumentCompletion will sometimes come in before TextDocumentDidChange + // This will require the trigger character to be at the place VSCode says it is + // Otherwise we'll fail here and our retry logic will come into place + do! + match p.Context with + | Some({ triggerKind = CompletionTriggerKind.TriggerCharacter } as context) -> + volatileFile.Source.TryGetChar pos = context.triggerCharacter + | _ -> true + |> Result.requireTrue $"TextDocumentCompletion was sent before TextDocumentDidChange" + + // Special characters like parentheses, brackets, etc. require a full type check + let isSpecialChar = Option.exists (Char.IsLetterOrDigit >> not) + + let previousCharacter = volatileFile.Source.TryGetChar(FcsPos.subtractColumn pos 1) + + let! typeCheckResults = + if isSpecialChar previousCharacter then + forceGetTypeCheckResults filePath else - Array.append items KeywordList.keywordCompletionItems + forceGetTypeCheckResultsStale filePath - let completionList = - { IsIncomplete = false - Items = its - ItemDefaults = None } + let getAllSymbols () = + if config.ExternalAutocomplete then + typeCheckResults.GetAllEntities true + else + [] + + let! (decls, residue, shouldKeywords) = + Debug.measure "TextDocumentCompletion.TryGetCompletions" (fun () -> + typeCheckResults.TryGetCompletions pos lineStr None getAllSymbols + |> AsyncResult.ofOption (fun () -> "No TryGetCompletions results")) - success (Some completionList) + do! Result.requireNotEmpty "Should not have empty completions" decls + + return Some(decls, residue, shouldKeywords, typeCheckResults, getAllSymbols, volatileFile) + } + + let handleError e = + match e with + | "Should not have empty completions" -> + // If we don't get any completions, assume we need to wait for a full typecheck + getCompletions forceGetTypeCheckResults + | _ -> getCompletions forceGetTypeCheckResultsStale + + match! + retryAsyncOption + (TimeSpan.FromMilliseconds(15.)) + 100 + handleError + (getCompletions forceGetTypeCheckResultsStale) + |> AsyncResult.ofStringErr + with + | None -> return! success (None) + | Some(decls, _, shouldKeywords, typeCheckResults, _, volatileFile) -> + + return! + Debug.measure "TextDocumentCompletion.TryGetCompletions success" + <| fun () -> + transact (fun () -> + HashMap.OfList( + [ for d in decls do + d.NameInList, (d, pos, filePath, volatileFile.Source.GetLine, typeCheckResults.GetAST) ] + ) + |> autoCompleteItems.UpdateTo) + |> ignore + + let includeKeywords = config.KeywordsAutocomplete && shouldKeywords + + let items = + decls + |> Array.mapi (fun id d -> + let code = + if + System.Text.RegularExpressions.Regex.IsMatch(d.NameInList, """^[a-zA-Z][a-zA-Z0-9']+$""") + then + d.NameInList + elif d.NamespaceToOpen.IsSome then + d.NameInList + else + FSharpKeywords.NormalizeIdentifierBackticks d.NameInList + + let label = + match d.NamespaceToOpen with + | Some no -> sprintf "%s (open %s)" d.NameInList no + | None -> d.NameInList + + { CompletionItem.Create(d.NameInList) with + Kind = (AVal.force glyphToCompletionKind) d.Glyph + InsertText = Some code + SortText = Some(sprintf "%06d" id) + FilterText = Some d.NameInList + Label = label }) + + let its = + if not includeKeywords then + items + else + Array.append items KeywordList.keywordCompletionItems + + let completionList = + { IsIncomplete = false + Items = its + ItemDefaults = None } + + success (Some completionList) with e -> trace |> Tracing.recordException e diff --git a/test/FsAutoComplete.Tests.Lsp/EmptyFileTests.fs b/test/FsAutoComplete.Tests.Lsp/EmptyFileTests.fs new file mode 100644 index 000000000..d665ddd18 --- /dev/null +++ b/test/FsAutoComplete.Tests.Lsp/EmptyFileTests.fs @@ -0,0 +1,109 @@ +module FsAutoComplete.Tests.EmptyFileTests + +open Expecto +open System.IO +open Helpers +open Ionide.LanguageServerProtocol.Types +open FsAutoComplete.Utils +open FsAutoComplete.Lsp +open FsToolkit.ErrorHandling +open Utils.Server +open Helpers.Expecto.ShadowedTimeouts + +let tests state = + let createServer() = + async { + let path = Path.Combine(__SOURCE_DIRECTORY__, "TestCases", "EmptyFileTests") + + let scriptPath = Path.Combine(path, "EmptyFile.fsx") + + let! (server, events) = serverInitialize path defaultConfigDto state + + do! waitForWorkspaceFinishedParsing events + return server, events, scriptPath + } + |> Async.Cache + let server1 = createServer() + let server2 = createServer() + + testList + "empty file features" + [ testList + "tests" + [ + testCaseAsync + "no parsing/checking errors" + (async { + let! server, events, scriptPath = server1 + do! server.TextDocumentDidOpen { TextDocument = loadDocument scriptPath } + + match! waitForParseResultsForFile "EmptyFile.fsx" events with + | Ok _ -> () // all good, no parsing/checking errors + | Core.Result.Error errors -> failwithf "Errors while parsing script %s: %A" scriptPath errors + }) + + testCaseAsync + "auto completion does not throw and is empty" + (async { + let! server, _, path = server1 + do! server.TextDocumentDidOpen { TextDocument = loadDocument path } + + let completionParams: CompletionParams = + { TextDocument = { Uri = Path.FilePathToUri path } + Position = { Line = 0; Character = 0 } + Context = + Some + { triggerKind = CompletionTriggerKind.Invoked + triggerCharacter = None } } + + match! server.TextDocumentCompletion completionParams with + | Ok (Some _) -> failtest "An empty file has empty completions" + | Ok None -> () + | Error e -> failtestf "Got an error while retrieving completions: %A" e + }) + testCaseAsync + "type 'c' for checking error and autocompletion starts with 'async'" + (async { + let! server, events, scriptPath = server2 + do! server.TextDocumentDidOpen { TextDocument = loadDocument scriptPath } + + do! server.TextDocumentDidChange { + TextDocument = { Uri = Path.FilePathToUri scriptPath; Version = 1 } + ContentChanges = [| { + Range = Some { Start = { Line = 0; Character = 0 }; End = { Line = 0; Character = 0 } } + RangeLength = Some 0 + Text = "c" + } |] + } + + let! completions = + server.TextDocumentCompletion { + TextDocument = { Uri = Path.FilePathToUri scriptPath } + Position = { Line = 0; Character = 1 } + Context = + Some + { triggerKind = CompletionTriggerKind.Invoked + triggerCharacter = None } + } |> Async.StartChild + + let! compilerResults = waitForCompilerDiagnosticsForFile "EmptyFile.fsx" events |> Async.StartChild + + match! compilerResults with + | Ok () -> failtest "should get an F# compiler checking error from a 'c' by itself" + | Core.Result.Error errors -> + Expect.hasLength errors 1 "should have only an error FS0039: identifier not defined" + Expect.exists errors (fun error -> error.Code = Some "39") "should have an error FS0039: identifier not defined" + + match! completions with + | Ok (Some completions) -> + Expect.isGreaterThan + completions.Items.Length + 30 + "should have a complete completion list all containing c" + + let firstItem = completions.Items.[0] + Expect.equal firstItem.Label "async" "first member should be async" + | Ok None -> failtest "Should have gotten some completion items" + | Error e -> failtestf "Got an error while retrieving completions: %A" e + }) + ]] \ No newline at end of file diff --git a/test/FsAutoComplete.Tests.Lsp/Program.fs b/test/FsAutoComplete.Tests.Lsp/Program.fs index e2d7287f2..52d053492 100644 --- a/test/FsAutoComplete.Tests.Lsp/Program.fs +++ b/test/FsAutoComplete.Tests.Lsp/Program.fs @@ -119,7 +119,7 @@ let lspTests = InlayHintTests.tests createServer DependentFileChecking.tests createServer UnusedDeclarationsTests.tests createServer - + EmptyFileTests.tests createServer ] ] /// Tests that do not require a LSP server diff --git a/test/FsAutoComplete.Tests.Lsp/TestCases/EmptyFileTests/EmptyFile.fsx b/test/FsAutoComplete.Tests.Lsp/TestCases/EmptyFileTests/EmptyFile.fsx new file mode 100644 index 000000000..e69de29bb