diff --git a/src/FsAutoComplete.Core/Commands.fs b/src/FsAutoComplete.Core/Commands.fs index 47e14ecb4..5e753e46d 100644 --- a/src/FsAutoComplete.Core/Commands.fs +++ b/src/FsAutoComplete.Core/Commands.fs @@ -24,6 +24,7 @@ open FSharp.Compiler.Symbols open System.Collections.Immutable open System.Collections.Generic open Ionide.ProjInfo.ProjectSystem +open FSharp.Compiler.Syntax [] @@ -506,52 +507,6 @@ module Commands = | Error e -> return CoreResponse.ErrorRes e } - let renameSymbol - (symbolUseWorkspace: - _ - -> _ - -> _ - -> _ - -> Async * Dictionary, Dictionary>, string>>) - (tryGetFileSource: _ -> Result) - (pos: Position) - (tyRes: ParseAndCheckResults) - (lineStr: LineStr) - (text: NamedText) - = - asyncResult { - match! symbolUseWorkspace pos lineStr text tyRes with - | Choice1Of2(declarationsByDocument, symbolUsesByDocument) -> - let totalSetOfRanges = Dictionary() - - for (KeyValue(filePath, declUsages)) in declarationsByDocument do - let! text = tryGetFileSource (UMX.tag filePath) - - match totalSetOfRanges.TryGetValue(text) with - | true, ranges -> totalSetOfRanges[text] <- Array.append ranges declUsages - | false, _ -> totalSetOfRanges[text] <- declUsages - - for (KeyValue(filePath, symbolUses)) in symbolUsesByDocument do - let! text = tryGetFileSource (UMX.tag filePath) - - match totalSetOfRanges.TryGetValue(text) with - | true, ranges -> totalSetOfRanges[text] <- Array.append ranges symbolUses - | false, _ -> totalSetOfRanges[text] <- symbolUses - - return totalSetOfRanges |> Seq.map (fun (KeyValue(k, v)) -> k, v) |> Array.ofSeq - | Choice2Of2(mixedDeclarationAndSymbolUsesByDocument) -> - let totalSetOfRanges = Dictionary() - - for (KeyValue(filePath, symbolUses)) in mixedDeclarationAndSymbolUsesByDocument do - let! text = tryGetFileSource (UMX.tag filePath) - - match totalSetOfRanges.TryGetValue(text) with - | true, ranges -> totalSetOfRanges[text] <- Array.append ranges symbolUses - | false, _ -> totalSetOfRanges[text] <- symbolUses - - return totalSetOfRanges |> Seq.map (fun (KeyValue(k, v)) -> k, v) |> Array.ofSeq - } - let typesig (tyRes: ParseAndCheckResults) (pos: Position) lineStr = tyRes.TryGetToolTip pos lineStr |> Result.bimap CoreResponse.Res CoreResponse.ErrorRes @@ -752,159 +707,290 @@ module Commands = Position = pos Scope = ic.ScopeKind })) + /// * `includeDeclarations`: + /// if `false` only returns usage locations and excludes declarations + /// * Note: if `true` you can still separate usages and declarations from each other + /// with `Symbol.partitionInfoDeclarationsAndUsages` + /// * `includeBackticks`: + /// if `true` returns ranges including existing backticks, otherwise without: + /// `let _ = ``my value`` + 42` + /// * `true`: ` ``my value`` ` + /// * `false`: `my value` + /// * `errorOnFailureToFixRange`: + /// Ranges returned by FCS don't just span the actual identifier, but include Namespace, Module, Type: `System.String.IsNullOrEmpty` + /// These ranges gets adjusted to include just the concrete identifier (`IsNullOrEmpty`) + /// * If `false` and range cannot be adjust, the original range gets used. + /// * When results are more important than always exact range + /// -> for "Find All References" + /// * If `true`: Instead of using the source range, this function instead returns an Error + /// * When exact ranges are required + /// -> for "Rename" let symbolUseWorkspace - getDeclarationLocation - (findReferencesForSymbolInFile: (string * FSharpProjectOptions * FSharpSymbol) -> Async) + (getDeclarationLocation: FSharpSymbolUse * NamedText -> SymbolDeclarationLocation option) + (findReferencesForSymbolInFile: (string * FSharpProjectOptions * FSharpSymbol) -> Async) (tryGetFileSource: string -> ResultOrString) - getProjectOptionsForFsproj + (tryGetProjectOptionsForFsproj: string -> FSharpProjectOptions option) + (getAllProjectOptions: unit -> FSharpProjectOptions seq) + (includeDeclarations: bool) + (includeBackticks: bool) + (errorOnFailureToFixRange: bool) pos lineStr (text: NamedText) (tyRes: ParseAndCheckResults) - = + : Async, Range[]>), string>> = asyncResult { + let! symbolUse = tyRes.TryGetSymbolUse pos lineStr |> Result.ofOption (fun _ -> "No symbol") + let symbol = symbolUse.Symbol - let findReferencesInFile - ( - file, - symbol: FSharpSymbol, - project: FSharpProjectOptions, - onFound: range -> Async - ) = - asyncResult { - try - let! (references: Range seq) = findReferencesForSymbolInFile (file, project, symbol) - - for reference in references do - do! onFound reference - with e -> - commandsLogger.error ( - Log.setMessage "Failed findReferencesForSymbolInFile with {file}" - >> Log.addExn e - >> Log.addContextDestructured "file" file - // >> Log.addContextDestructured "symbol" symbol - ) - } + let symbolNameCore = symbol.DisplayNameCore - let getSymbolUsesInProjects (symbol, projects: FSharpProjectOptions list, onFound) = - projects - |> List.collect (fun p -> - [ for file in p.SourceFiles do - yield findReferencesInFile (file, symbol, p, onFound) ]) - |> Async.Parallel - |> Async.map (Array.toList >> FsToolkit.ErrorHandling.List.sequenceResultM) - - let ranges (uses: FSharpSymbolUse[]) = uses |> Array.map (fun u -> u.Range) - - let splitByDeclaration (uses: FSharpSymbolUse[]) = - uses |> Array.partition (fun u -> u.IsFromDefinition) - - let toDict (symbolUseRanges: range[]) = - let dict = new System.Collections.Generic.Dictionary() - - symbolUseRanges - |> Array.collect (fun symbolUse -> - let file = symbolUse.FileName - // if we had a more complex project system (one that understood that the same file could be in multiple projects distinctly) - // then we'd need to map the files to some kind of document identfier and dedupe by that - // before issueing the renames. We don't, so this becomes very simple - [| file, symbolUse |]) - |> Array.groupBy fst - |> Array.iter (fun (key, items) -> - let itemsSeq = items |> Array.map snd - dict[key] <- itemsSeq - ()) - - dict - - let! symUse = - tyRes.TryGetSymbolUse pos lineStr - |> Result.ofOption (fun _ -> "No result found") + let tryAdjustRanges (text: NamedText, ranges: seq) = + let ranges = ranges |> Seq.map (fun range -> range.NormalizeDriveLetterCasing()) - let symbol = symUse.Symbol + if errorOnFailureToFixRange then + ranges + |> Seq.map (fun range -> + Tokenizer.tryFixupRange (symbolNameCore, range, text, includeBackticks) + |> Result.ofVOption (fun _ -> $"Cannot adjust range")) + |> Seq.sequenceResultM + |> Result.map (Seq.toArray) + else + ranges + |> Seq.map (fun range -> + Tokenizer.tryFixupRange (symbolNameCore, range, text, includeBackticks) + |> ValueOption.defaultValue range) + |> Seq.toArray + |> Ok - let! declLoc = - getDeclarationLocation (symUse, text) - |> Result.ofOption (fun _ -> "No declaration location found") + let declLoc = getDeclarationLocation (symbolUse, text) match declLoc with - | SymbolDeclarationLocation.CurrentDocument -> + // local symbol -> all uses are inside `text` + // Note: declarations in script files are currently always local! + | Some SymbolDeclarationLocation.CurrentDocument -> let! ct = Async.CancellationToken let symbolUses = tyRes.GetCheckResults.GetUsesOfSymbolInFile(symbol, ct) - let declarations, usages = splitByDeclaration symbolUses - - let declarationRanges, usageRanges = - toDict (ranges declarations), toDict (ranges usages) - return Choice1Of2(declarationRanges, usageRanges) - - | SymbolDeclarationLocation.Projects(projects, isInternalToProject) -> - let symbolUseRanges = ConcurrentBag<_>() - let symbolRange = symbol.DefinitionRange.NormalizeDriveLetterCasing() - let symbolFile = symbolRange.TaggedFileName - - let! symbolFileText = - tryGetFileSource (symbolFile) - |> Result.mapError (fun e -> e + $"Unable to get file source for file '{symbolFile}'") - - let! symbolText = symbolFileText.[symbolRange] - // |> Result.fold id (fun e -> failwith "Unable to get text for initial symbol use") - - let projects = - if isInternalToProject then - projects + let symbolUses: _ seq = + if includeDeclarations then + symbolUses else + symbolUses |> Seq.filter (fun u -> not u.IsFromDefinition) + + let ranges = symbolUses |> Seq.map (fun u -> u.Range) + // Note: tryAdjustRanges is designed to only be able to fail iff `errorOnFailureToFixRange` is `true` + let! ranges = tryAdjustRanges (text, ranges) + let ranges = dict [ (text.FileName, Seq.toArray ranges) ] + + return (symbol, ranges) + | scope -> + let projectsToCheck = + match scope with + | Some(SymbolDeclarationLocation.Projects(projects (*isLocalForProject=*) , true)) -> projects + | Some(SymbolDeclarationLocation.Projects(projects (*isLocalForProject=*) , false)) -> [ for project in projects do yield project yield! project.ReferencedProjects - |> Array.choose (fun p -> p.OutputFile |> getProjectOptionsForFsproj) ] + |> Array.choose (fun p -> UMX.tag p.OutputFile |> tryGetProjectOptionsForFsproj) ] |> List.distinctBy (fun x -> x.ProjectFileName) + | _ (*None*) -> + // symbol is declared external -> look through all F# projects + // (each script (including untitled) has its own project -> scripts get checked too. But only once they are loaded (-> inside `state`)) + getAllProjectOptions () + |> Seq.distinctBy (fun x -> x.ProjectFileName) + |> Seq.toList + + let tryAdjustRanges (file: string, ranges: Range[]) = + match tryGetFileSource file with + | Error _ when errorOnFailureToFixRange -> Error $"Cannot get source of '{file}'" + | Error _ -> Ok ranges + | Ok text -> + tryAdjustRanges (text, ranges) + // Note: `Error` only possible when `errorOnFailureToFixRange` + |> Result.mapError (fun _ -> $"Cannot adjust ranges in file '{file}'") + + let isDeclLocation = + if includeDeclarations then + // not actually used + fun _ -> false + else + symbol |> Symbol.getIsDeclaration + + let dict = ConcurrentDictionary() - let onFound (symbolUseRange: range) = - asyncResult { - let symbolUseRange = symbolUseRange.NormalizeDriveLetterCasing() - let symbolFile = symbolUseRange.TaggedFileName - let! sourceText = tryGetFileSource (symbolFile) - - - let! sourceSpan = - sourceText.[symbolUseRange] - |> Result.mapError (fun e -> e + "Unable to get text for symbol use") - - // There are two kinds of ranges we get back: - // * ranges that exactly match the short name of the symbol - // * ranges that are longer than the short name of the symbol, - // typically because we're talking about some kind of fully-qualified usage - // For the latter, we need to adjust the reported range to just be the portion - // of the fully-qualfied text that is the symbol name. - if sourceSpan = symbolText then - symbolUseRanges.Add symbolUseRange + /// Adds References of `symbol` in `file` to `dict` + /// + /// `Error` iff adjusting ranges failed (including cannot get source) and `errorOnFailureToFixRange`. Otherwise always `Ok` + let tryFindReferencesInFile (file: string, project: FSharpProjectOptions) = + async { + if dict.ContainsKey file then + return Ok() else - match sourceSpan.IndexOf(symbolText) with - | -1 -> () - | n -> - if sourceSpan.Length >= n + symbolText.Length then - let startPos = symbolUseRange.Start.IncColumn n - let endPos = symbolUseRange.Start.IncColumn(n + symbolText.Length) - - let actualUseRange = Range.mkRange symbolUseRange.FileName startPos endPos - symbolUseRanges.Add actualUseRange + let! references = findReferencesForSymbolInFile (file, project, symbol) + + let references = + if includeDeclarations then + references + else + references |> Seq.filter (not << isDeclLocation) + + let references = references |> Seq.toArray + + // Note: this check is important! + // otherwise `tryAdjustRanges` tries to get source for files like `AssemblyInfo.fs` + // (which fails -> error if `errorOnFailureToFixRange`) + if references |> Array.isEmpty then + return Ok() + else + let ranges = tryAdjustRanges (file, references) + + match ranges with + | Error msg when errorOnFailureToFixRange -> return Error msg + | Error _ -> + dict.TryAdd(file, references) |> ignore + return Ok() + | Ok ranges -> + dict.TryAdd(file, ranges) |> ignore + return Ok() } - |> Async.map (fun x -> - match x with - | Ok() -> () + |> AsyncResult.catch string + |> Async.map (function + | Ok() -> Ok() | Error e -> - commandsLogger.info (Log.setMessage "OnFound failed: {errpr}" >> Log.addContextDestructured "error" e)) + commandsLogger.info ( + Log.setMessage "tryFindReferencesInFile failed: {error}" + >> Log.addContextDestructured "error" e + ) - let! _ = getSymbolUsesInProjects (symbol, projects, onFound) + if errorOnFailureToFixRange then Error e else Ok()) - // Distinct these down because each TFM will produce a new 'project'. - // Unless guarded by a #if define, symbols with the same range will be added N times - let symbolUseRanges = symbolUseRanges.ToArray() |> Array.distinct + let iterProjects (projects: FSharpProjectOptions seq) = + // should: + // * check files in parallel + // * stop when error occurs + // -> `Async.Choice`: executes in parallel, returns first `Some` + // -> map `Error` to `Some` for `Async.Choice`, afterwards map `Some` back to `Error` + [ for project in projects do + for file in project.SourceFiles do + let file = UMX.tag file - return Choice2Of2(toDict symbolUseRanges) + async { + match! tryFindReferencesInFile (file, project) with + | Ok _ -> return None + | Error err -> return Some err + + } ] + |> Async.Choice + |> Async.map (function + | None -> Ok() + | Some err -> Error err) + + do! iterProjects projectsToCheck + + return (symbol, dict) + } + + /// Puts `newName` into backticks if necessary. + /// + /// + /// Also does very basic validation of `newName`: + /// * Must be valid operator name when operator + let adjustRenameSymbolNewName pos lineStr (text: NamedText) (tyRes: ParseAndCheckResults) (newName: string) = + asyncResult { + let! symbolUse = + tyRes.TryGetSymbolUse pos lineStr + |> Result.ofOption (fun _ -> "Nothing to rename") + + match symbolUse with + | SymbolUse.Operator _ -> + // different validation rules + // and no backticks for operator + if PrettyNaming.IsOperatorDisplayName newName then + return newName + else + return! Error $"'%s{newName}' is not a valid operator name!" + | _ -> + //ENHANCEMENT: more validation like check upper case start for types + + // `IsIdentifierName` doesn't work with backticks + // -> only check if no backticks + let newBacktickedName = newName |> PrettyNaming.NormalizeIdentifierBackticks + + if newBacktickedName.StartsWith "``" && newBacktickedName.EndsWith "``" then + return newBacktickedName + elif PrettyNaming.IsIdentifierName newName then + return newName + else + return! Error $"'%s{newName}' is not a valid identifier name!" + } + + /// `Error` if renaming is invalid at specified `pos`. + /// Otherwise range of identifier at specified `pos` + /// + /// Note: + /// Rename for Active Patterns is disabled: + /// Each case is its own identifier and complete Active Pattern name isn't correctly handled by FCS + /// + /// Note: + /// Rename for Active Pattern Cases is disabled: + /// `SymbolUseWorkspace` returns ranges for ALL Cases of that Active Pattern instead of just the single case + let renameSymbolRange + (getDeclarationLocation: FSharpSymbolUse * NamedText -> SymbolDeclarationLocation option) + (includeBackticks: bool) + pos + lineStr + (text: NamedText) + (tyRes: ParseAndCheckResults) + = + asyncResult { + let! symbolUse = + tyRes.TryGetSymbolUse pos lineStr + |> Result.ofOption (fun _ -> "Nothing to rename") + + let! _ = + // None: external symbol -> not under our control -> cannot rename + getDeclarationLocation (symbolUse, text) + |> Result.ofOption (fun _ -> "Must be declared inside current workspace, but is external.") + + do! + match symbolUse with + | SymbolUse.ActivePattern _ -> + // Active Pattern is not supported: + // ```fsharp + // let (|Even|Odd|) v = if v % 2 = 0 then Even else Odd + // match 42 with + // | Even -> () + // | Odd -> () + // let _ = (|Even|Odd|) 42 + // ``` + // -> + // `(|Even|Odd|)` at usage is own symbol -- NOT either Even or Odd (depending on pos) + // -> Rename at that location renames complete `(|Even|Odd|)` -- but not individual usages + Error "Renaming of Active Patterns is not supported" + | SymbolUse.ActivePatternCase _ -> + // Active Pattern Case is not supported: + // ```fsharp + // let (|Even|Odd|) v = if v % 2 = 0 then Even else Odd + // match 42 with + // | Even -> () + // | Odd -> () + // ``` + // -> `Even` -> finds all occurrences of `Odd` too -> get renamed too... + //Enhancement: Handle: Exclude cases that don't match symbol at pos + Error "Renaming of Active Pattern Cases is currently not supported" + | _ -> Ok() + + let symbol = symbolUse.Symbol + let nameCore = symbol.DisplayNameCore + + let! range = + Tokenizer.tryFixupRange (nameCore, symbolUse.Range, text, includeBackticks) + |> Result.ofVOption (fun _ -> "Cannot correctly isolate range of identifier") + + return (symbol, nameCore, range) } // given an enveloping range and the sub-ranges it overlaps, split out the enveloping range into a @@ -1851,45 +1937,79 @@ type Commands(checker: FSharpCompilerServiceChecker, state: State, hasAnalyzers: InsertText = formattedXmlDoc } } - member x.SymbolUseWorkspace(pos, lineStr, text: NamedText, tyRes: ParseAndCheckResults) = - asyncResult { + member private x.GetDeclarationLocation(symbolUse, text) = + SymbolLocation.getDeclarationLocation ( + symbolUse, + text, + state.GetProjectOptions, + state.ProjectController.ProjectsThatContainFile, + state.ProjectController.GetDependentProjectsOfProjects + ) - let getDeclarationLocation (symUse, text) = - SymbolLocation.getDeclarationLocation ( - symUse, - text, - state.GetProjectOptions, - state.ProjectController.ProjectsThatContainFile, - state.ProjectController.GetDependentProjectsOfProjects - ) + member x.SymbolUseWorkspace + ( + pos, + lineStr, + text: NamedText, + tyRes: ParseAndCheckResults, + includeDeclarations: bool, + includeBackticks: bool, + errorOnFailureToFixRange: bool + ) = + asyncResult { + let findReferencesForSymbolInFile (file: string, project, symbol) = + if File.Exists(UMX.untag file) then + // `FSharpChecker.FindBackgroundReferencesInFile` only works with existing files + checker.FindReferencesForSymbolInFile(UMX.untag file, project, symbol) + else + // untitled script files + async { + match state.TryGetFileCheckerOptionsWithLines(file) with + | Error _ -> return [||] + | Ok(opts, source) -> + match checker.TryGetRecentCheckResultsForFile(file, opts, source) with + | None -> return [||] + | Some tyRes -> + let! ct = Async.CancellationToken + let usages = tyRes.GetCheckResults.GetUsesOfSymbolInFile(symbol, ct) + return usages |> Seq.map (fun u -> u.Range) + } - let findReferencesForSymbolInFile (file, project, symbol) = - checker.FindReferencesForSymbolInFile(file, project, symbol) + let tryGetFileSource symbolFile = state.TryGetFileSource symbolFile - let tryGetFileSource symbolFile = state.TryGetFileSource(symbolFile) + let tryGetProjectOptionsForFsproj (fsprojPath: string) = + state.ProjectController.GetProjectOptionsForFsproj(UMX.untag fsprojPath) - let getProjectOptionsForFsproj fsprojPath = - state.ProjectController.GetProjectOptionsForFsproj fsprojPath + let getAllProjectOptions () = + state.ProjectController.ProjectOptions |> Seq.map snd return! Commands.symbolUseWorkspace - getDeclarationLocation + x.GetDeclarationLocation findReferencesForSymbolInFile tryGetFileSource - getProjectOptionsForFsproj + tryGetProjectOptionsForFsproj + getAllProjectOptions + includeDeclarations + includeBackticks + errorOnFailureToFixRange pos lineStr text tyRes } + member x.RenameSymbolRange(pos: Position, tyRes: ParseAndCheckResults, lineStr: LineStr, text: NamedText) = + Commands.renameSymbolRange x.GetDeclarationLocation false pos lineStr text tyRes + + /// Also checks if rename is valid via `RenameSymbolRange` (-> `Error` -> invalid) member x.RenameSymbol(pos: Position, tyRes: ParseAndCheckResults, lineStr: LineStr, text: NamedText) = asyncResult { - let symbolUseWorkspace pos lineStr text tyRes = - x.SymbolUseWorkspace(pos, lineStr, text, tyRes) + // safety check: rename valid? + let! _ = x.RenameSymbolRange(pos, tyRes, lineStr, text) - let tryGetFileSource filePath = state.TryGetFileSource filePath - return! Commands.renameSymbol symbolUseWorkspace tryGetFileSource pos tyRes lineStr text + let! (_, usages) = x.SymbolUseWorkspace(pos, lineStr, text, tyRes, true, true, true) + return usages } member x.SymbolImplementationProject (tyRes: ParseAndCheckResults) (pos: Position) lineStr = diff --git a/src/FsAutoComplete.Core/FileSystem.fs b/src/FsAutoComplete.Core/FileSystem.fs index 5f4f48690..04b06057b 100644 --- a/src/FsAutoComplete.Core/FileSystem.fs +++ b/src/FsAutoComplete.Core/FileSystem.fs @@ -30,6 +30,7 @@ module PositionExtensions = member x.IncColumn() = Position.mkPos x.Line (x.Column + 1) member x.IncColumn n = Position.mkPos x.Line (x.Column + n) + member inline p.WithColumn(col) = Position.mkPos p.Line col let inline (|Pos|) (p: FSharp.Compiler.Text.Position) = p.Line, p.Column @@ -59,6 +60,10 @@ module RangeExtensions = /// TODO: should we enforce this/use the Path members for normalization? member x.TaggedFileName: string = UMX.tag x.FileName + member inline r.With(start, fin) = Range.mkRange r.FileName start fin + member inline r.WithStart(start) = Range.mkRange r.FileName start r.End + member inline r.WithEnd(fin) = Range.mkRange r.FileName r.Start fin + /// A copy of the StringText type from F#.Compiler.Text, which is private. /// Adds a UOM-typed filename to make range manipulation easier, as well as /// safer traversals @@ -520,3 +525,181 @@ type FileSystem(actualFs: IFileSystem, tryFindFile: string -> Volatil actualFs.OpenFileForWriteShim(filePath, ?fileMode = fileMode, ?fileAccess = fileAccess, ?fileShare = fileShare) member _.AssemblyLoader = actualFs.AssemblyLoader + +module Symbol = + open FSharp.Compiler.Symbols + + /// Declaration, Implementation, Signature + let getDeclarationLocations (symbol: FSharpSymbol) = + [| symbol.DeclarationLocation + symbol.ImplementationLocation + symbol.SignatureLocation |] + |> Array.choose id + |> Array.distinct + |> Array.map (fun r -> r.NormalizeDriveLetterCasing()) + + /// `true` if `range` is inside at least one `declLocation` + /// + /// inside instead of equal: `declLocation` for Active Pattern Case is complete Active Pattern + /// (`Even` -> declLoc: `|Even|Odd|`) + let isDeclaration (declLocations: Range[]) (range: Range) = + declLocations |> Array.exists (fun l -> Range.rangeContainsRange l range) + + /// For multiple `isDeclaration` calls: + /// caches declaration locations (-> `getDeclarationLocations`) for multiple `isDeclaration` checks of same symbol + let getIsDeclaration (symbol: FSharpSymbol) = + let declLocs = getDeclarationLocations symbol + isDeclaration declLocs + + /// returns `(declarations, usages)` + let partitionIntoDeclarationsAndUsages (symbol: FSharpSymbol) (ranges: Range[]) = + let isDeclaration = getIsDeclaration symbol + ranges |> Array.partition isDeclaration + +module Tokenizer = + /// Extracts identifier by either looking at backticks or splitting at last `.`. + /// Removes leading paren too (from operator with Module name: `MyModule.(+++`) + /// + /// Note: doesn't handle operators containing `.`, + /// but does handle strange Active Patterns (like with linebreak) + /// + /// + /// based on: `dotnet/fsharp` `Tokenizer.fixupSpan` + let private tryFixupRangeBySplittingAtDot (range: Range, text: NamedText, includeBackticks: bool) : Range voption = + match text[range] with + | Error _ -> ValueNone + | Ok rangeText when rangeText.EndsWith "``" -> + // find matching opening backticks + + // backticks cannot contain linebreaks -- even for Active Pattern: + // `(``|Even|Odd|``)` is ok, but ` (``|Even|\n Odd|``) is not + + let pre = rangeText.AsSpan(0, rangeText.Length - 2 (*backticks*) ) + + match pre.LastIndexOf("``") with + | -1 -> + // invalid identifier -> should not happen + range |> ValueSome + | i when includeBackticks -> + let startCol = range.EndColumn - 2 (*backticks*) - (pre.Length - i) + range.WithStart(range.End.WithColumn(startCol)) |> ValueSome + | i -> + let startCol = + range.EndColumn - 2 (*backticks*) - (pre.Length - i - 2 (*backticks*) ) + + let endCol = range.EndColumn - 2 (*backticks*) + + range.With(range.Start.WithColumn(startCol), range.End.WithColumn(endCol)) + |> ValueSome + | Ok rangeText -> + // split at `.` + // identifier (after `.`) might contain linebreak -> multiple lines + // Note: Active Pattern cannot contain `.` -> split at `.` should be always valid because we handled backticks above + // (`(|``Hello.world``|Odd|)` is not valid (neither is a type name with `.`: `type ``Hello.World`` = ...`)) + match rangeText.LastIndexOf '.' with + | -1 -> range |> ValueSome + | i -> + // there might be a `(` after `.`: + // `MyModule.(+++` (Note: closing paren in not part of FSharpSymbolUse.Range) + // and there might be additional newlines and spaces afterwards + let ident = rangeText.AsSpan(i + 1 (*.*) ) + let trimmedIdent = ident.TrimStart('(').TrimStart("\n\r ") + let inFrontOfIdent = ident.Length - trimmedIdent.Length + + let pre = rangeText.AsSpan(0, i + 1 (*.*) + inFrontOfIdent) + // extract lines and columns + let nLines = pre.CountLines() + let lastLine = pre.LastLine() + let startLine = range.StartLine + (nLines - 1) + + let startCol = + match nLines with + | 1 -> range.StartColumn + lastLine.Length + | _ -> lastLine.Length + + range.WithStart(Position.mkPos startLine startCol) |> ValueSome + + /// Cleans `FSharpSymbolUse.Range` (and similar) to only contain main (= last) identifier + /// * Removes leading Namespace, Module, Type: `System.String.IsNullOrEmpty` -> `IsNullOrEmpty` + /// * Removes leftover open paren: `Microsoft.FSharp.Core.Operators.(+` -> `+` + /// * keeps backticks based on `includeBackticks` + /// -> full identifier range with backticks, just identifier name (~`symbolNameCore`) without backticks + /// + /// returns `None` iff `range` isn't inside `text` -> `range` & `text` for different states + let tryFixupRange (symbolNameCore: string, range: Range, text: NamedText, includeBackticks: bool) : Range voption = + // first: try match symbolNameCore in last line + // usually identifier cannot contain linebreak -> is in last line of range + // Exception: Active Pattern can span multiple lines: `(|Even|Odd|)` -> `(|Even|\n Odd|)` is valid too + + /// Range in last line with actual content (-> without indentation) + let contentRangeInLastLine (range: range, lastLineText: string) = + if range.StartLine = range.EndLine then + range + else + let text = lastLineText.AsSpan(0, range.EndColumn) + // remove leading indentation + let l = text.TrimStart(' ').Length + let startCol = (range.EndColumn - l) + range.WithStart(range.End.WithColumn(startCol)) + + match text.GetLine range.End with + | None -> ValueNone + | Some line -> + let contentRange = contentRangeInLastLine (range, line) + assert (contentRange.StartLine = contentRange.EndLine) + + let content = + line.AsSpan(contentRange.StartColumn, contentRange.EndColumn - contentRange.StartColumn) + + match content.LastIndexOf symbolNameCore with + | -1 -> + // cases this can happens: + // * Active Pattern with linebreak: `(|Even|\n Odd|)` + // -> spans multiple lines + // * Active Pattern with backticks in case: `(|``Even``|Odd|)` + // -> symbolNameCore doesn't match content + + // fall back to split at `.` + + // differences between `tryFixupRangeBySplittingAtDot` and current function (in other match clause) + // * `tryFixupRangeBySplittingAtDot`: + // * handles strange forms of Active Patterns (like linebreak) + // * handles empty symbolName of Active Patterns Case (in decl) + // * (allocates new string) + // * current function: + // * handles operators containing `.` + // * (uses Span) + + tryFixupRangeBySplittingAtDot (range, text, includeBackticks) + // Extra Pattern: `| -1 | _ when symbolNameCore = "" -> ...` is incorrect -> `when` clause applies to both... + | _ when symbolNameCore = "" -> + // happens for: + // * Active Pattern case inside Active Pattern declaration + // ```fsharp + // let (|Even|Odd|) v = + // if v % 2 = 0 then Even else Odd + // ^^^^ + // ``` + // -> `FSharpSymbolUse.Symbol.DisplayName` on marked position is empty + tryFixupRangeBySplittingAtDot (range, text, includeBackticks) + | i -> + let startCol = contentRange.StartColumn + i + let endCol = startCol + symbolNameCore.Length + + if + includeBackticks + && + // detect possible backticks around [startCol:endCol] + (contentRange.StartColumn <= startCol - 2 (*backticks*) + && endCol + 2 (*backticks*) <= contentRange.EndColumn + && (let maybeBackticks = content.Slice(i - 2, 2 + symbolNameCore.Length + 2) + maybeBackticks.StartsWith("``") && maybeBackticks.EndsWith("``"))) + then + contentRange.With( + contentRange.Start.WithColumn(startCol - 2 (*backticks*) ), + contentRange.End.WithColumn(endCol + 2 (*backticks*) ) + ) + |> ValueSome + else + contentRange.With(contentRange.Start.WithColumn(startCol), contentRange.End.WithColumn(endCol)) + |> ValueSome diff --git a/src/FsAutoComplete.Core/SymbolLocation.fs b/src/FsAutoComplete.Core/SymbolLocation.fs index dbb6a3647..9ad9e76b8 100644 --- a/src/FsAutoComplete.Core/SymbolLocation.fs +++ b/src/FsAutoComplete.Core/SymbolLocation.fs @@ -19,7 +19,12 @@ let getDeclarationLocation getDependentProjectsOfProjects // state: State ) : SymbolDeclarationLocation option = - if symbolUse.IsPrivateToFile then + + // `symbolUse.IsPrivateToFile` throws exception when no `DeclarationLocation` + if + symbolUse.Symbol.DeclarationLocation |> Option.isSome + && symbolUse.IsPrivateToFile + then Some SymbolDeclarationLocation.CurrentDocument else let isSymbolLocalForProject = symbolUse.Symbol.IsInternalToProject @@ -51,13 +56,14 @@ let getDeclarationLocation getProjectOptions (taggedFilePath) |> Option.map (fun p -> SymbolDeclarationLocation.Projects([ p ], isSymbolLocalForProject)) else - let projectsThatContainFile = projectsThatContainFile (taggedFilePath) - - let projectsThatDependOnContainingProjects = - getDependentProjectsOfProjects projectsThatContainFile + match projectsThatContainFile (taggedFilePath) with + | [] -> None + | projectsThatContainFile -> + let projectsThatDependOnContainingProjects = + getDependentProjectsOfProjects projectsThatContainFile - match projectsThatDependOnContainingProjects with - | [] -> Some(SymbolDeclarationLocation.Projects(projectsThatContainFile, isSymbolLocalForProject)) - | projects -> - Some(SymbolDeclarationLocation.Projects(projectsThatContainFile @ projects, isSymbolLocalForProject)) + match projectsThatDependOnContainingProjects with + | [] -> Some(SymbolDeclarationLocation.Projects(projectsThatContainFile, isSymbolLocalForProject)) + | projects -> + Some(SymbolDeclarationLocation.Projects(projectsThatContainFile @ projects, isSymbolLocalForProject)) | None -> None diff --git a/src/FsAutoComplete.Core/Utils.fs b/src/FsAutoComplete.Core/Utils.fs index 5699b192c..f7ce56206 100644 --- a/src/FsAutoComplete.Core/Utils.fs +++ b/src/FsAutoComplete.Core/Utils.fs @@ -9,6 +9,7 @@ open System open FSharp.Compiler.CodeAnalysis open FSharp.UMX open FSharp.Compiler.Symbols +open System.Runtime.CompilerServices /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. @@ -181,6 +182,11 @@ module Result = | Some x -> Ok x | None -> Error(recover ()) + let inline ofVOption recover o = + match o with + | ValueSome x -> Ok x + | ValueNone -> Error(recover ()) + /// ensure the condition is true before continuing let inline guard condition errorValue = if condition () then Ok() else Error errorValue @@ -589,6 +595,27 @@ module String = | -1 -> NoMatch | n -> Split(s.[0 .. n - 1], s.Substring(n + 1)) +[] +type ReadOnlySpanExtensions = + /// Note: empty string -> 1 line + [] + static member CountLines(text: ReadOnlySpan) = + let mutable count = 0 + + for _ in text.EnumerateLines() do + count <- count + 1 + + count + + [] + static member LastLine(text: ReadOnlySpan) = + let mutable line = ReadOnlySpan.Empty + + for current in text.EnumerateLines() do + line <- current + + line + type ConcurrentDictionary<'key, 'value> with member x.TryFind key = diff --git a/src/FsAutoComplete/LspServers/AdaptiveFSharpLspServer.fs b/src/FsAutoComplete/LspServers/AdaptiveFSharpLspServer.fs index 3756cc247..5258578c0 100644 --- a/src/FsAutoComplete/LspServers/AdaptiveFSharpLspServer.fs +++ b/src/FsAutoComplete/LspServers/AdaptiveFSharpLspServer.fs @@ -1056,6 +1056,10 @@ type AdaptiveFSharpLspServer(workspaceLoader: IWorkspaceLoader, lspClient: FShar let loses = sourceFileToProjectOptions |> AMap.map (fun k v -> AVal.constant v) AMap.union loses wins + let allProjectOptions' = + allProjectOptions |> AMap.toASetValues |> ASet.collect (ASet.ofAVal) + + let getProjectOptionsForFile (filePath: string) = aval { match! allProjectOptions |> AMap.tryFindA filePath with @@ -1720,36 +1724,63 @@ type AdaptiveFSharpLspServer(workspaceLoader: IWorkspaceLoader, lspClient: FShar } - let symbolUseWorkspace pos lineStr text tyRes = - - let findReferencesForSymbolInFile (file, project, symbol) = - let checker = checker |> AVal.force - checker.FindReferencesForSymbolInFile(file, project, symbol) - + let getDeclarationLocation (symbolUse, text) = let getProjectOptions file = getProjectOptionsForFile file |> AVal.force |> selectProject let projectsThatContainFile file = getProjectOptionsForFile file |> AVal.force - let getProjectOptionsForFsproj file = - forceLoadProjects () |> Seq.tryFind (fun x -> x.ProjectFileName = file) + SymbolLocation.getDeclarationLocation ( + symbolUse, + text, + getProjectOptions, + projectsThatContainFile, + getDependentProjectsOfProjects + ) + let symbolUseWorkspace + (includeDeclarations: bool) + (includeBackticks: bool) + (errorOnFailureToFixRange: bool) + pos + lineStr + text + tyRes + = + + let findReferencesForSymbolInFile (file: string, project, symbol) = + async { + let checker = checker |> AVal.force - let getDeclarationLocation (symUse, text) = - SymbolLocation.getDeclarationLocation ( - symUse, - text, - getProjectOptions, - projectsThatContainFile, - getDependentProjectsOfProjects - ) + if File.Exists(UMX.untag file) then + // `FSharpChecker.FindBackgroundReferencesInFile` only works with existing files + return! checker.FindReferencesForSymbolInFile(UMX.untag file, project, symbol) + else + // untitled script files + match forceGetRecentTypeCheckResults file with + | Error _ -> return [||] + | Ok tyRes -> + let! ct = Async.CancellationToken + let usages = tyRes.GetCheckResults.GetUsesOfSymbolInFile(symbol, ct) + return usages |> Seq.map (fun u -> u.Range) + } + + let tryGetProjectOptionsForFsproj (file: string) = + forceGetProjectOptions file |> Option.ofResult + + let getAllProjectOptions () : _ seq = + allProjectOptions'.Content |> AVal.force :> _ Commands.symbolUseWorkspace getDeclarationLocation findReferencesForSymbolInFile forceFindSourceText - getProjectOptionsForFsproj + tryGetProjectOptionsForFsproj + getAllProjectOptions + includeDeclarations + includeBackticks + errorOnFailureToFixRange pos lineStr text @@ -2570,6 +2601,25 @@ type AdaptiveFSharpLspServer(workspaceLoader: IWorkspaceLoader, lspClient: FShar return! LspResult.internalError (string e) } + override x.TextDocumentPrepareRename p = + asyncResult { + logger.info ( + Log.setMessage "TextDocumentOnPrepareRename Request: {parms}" + >> Log.addContextDestructured "parms" p + ) + + let (filePath, pos) = getFilePathAndPosition p + let! namedText = forceFindOpenFileOrRead filePath |> Result.ofStringErr + let! lineStr = namedText.Lines |> tryGetLineStr pos |> Result.ofStringErr + let! tyRes = forceGetTypeCheckResults filePath |> Result.ofStringErr + + let! (_, _, range) = + Commands.renameSymbolRange getDeclarationLocation false pos lineStr namedText.Lines tyRes + |> AsyncResult.mapError (fun msg -> JsonRpc.Error.Create(JsonRpc.ErrorCodes.invalidParams, msg)) + + return range |> fcsRangeToLsp |> PrepareRenameResult.Range |> Some + } + override x.TextDocumentRename(p: RenameParams) = asyncResult { let tags = [ "RenameParams", box p ] @@ -2586,37 +2636,45 @@ type AdaptiveFSharpLspServer(workspaceLoader: IWorkspaceLoader, lspClient: FShar let! lineStr = namedText.Lines |> tryGetLineStr pos |> Result.ofStringErr and! tyRes = forceGetTypeCheckResults filePath |> Result.ofStringErr + // validate name and surround with backticks if necessary + let! newName = + Commands.adjustRenameSymbolNewName pos lineStr namedText.Lines tyRes p.NewName + |> AsyncResult.mapError (fun msg -> JsonRpc.Error.Create(JsonRpc.ErrorCodes.invalidParams, msg)) - let! documentsAndRanges = - Commands.renameSymbol symbolUseWorkspace forceFindSourceText pos tyRes lineStr namedText.Lines - |> AsyncResult.ofStringErr + // safety check: rename valid? + let! _ = + Commands.renameSymbolRange getDeclarationLocation false pos lineStr namedText.Lines tyRes + |> AsyncResult.mapError (fun msg -> JsonRpc.Error.Create(JsonRpc.ErrorCodes.invalidParams, msg)) + + let! (_, ranges) = + symbolUseWorkspace true true true pos lineStr namedText.Lines tyRes + |> AsyncResult.mapError (fun msg -> JsonRpc.Error.Create(JsonRpc.ErrorCodes.invalidParams, msg)) let documentChanges = - documentsAndRanges - |> Seq.map (fun (namedText, symbols) -> + ranges + |> Seq.map (fun kvp -> let edits = - let newName = - p.NewName |> FSharp.Compiler.Syntax.PrettyNaming.NormalizeIdentifierBackticks - - symbols - |> Seq.map (fun sym -> - let range = fcsRangeToLsp sym + kvp.Value + |> Array.map (fun range -> + let range = fcsRangeToLsp range { Range = range; NewText = newName }) - |> Array.ofSeq + + let file: string = kvp.Key let version = - forceFindOpenFileOrRead namedText.FileName + forceFindOpenFileOrRead file |> Option.ofResult |> Option.bind (fun (f) -> f.Version) { TextDocument = - { Uri = Path.FilePathToUri(UMX.untag namedText.FileName) + { Uri = Path.FilePathToUri(UMX.untag file) Version = version } Edits = edits }) |> Array.ofSeq let clientCapabilities = clientCapabilities |> AVal.force |> Option.get return WorkspaceEdit.Create(documentChanges, clientCapabilities) |> Some + with e -> trace.SetStatusErrorSafe(e.Message).RecordExceptions(e) |> ignore @@ -2705,23 +2763,14 @@ type AdaptiveFSharpLspServer(workspaceLoader: IWorkspaceLoader, lspClient: FShar let! lineStr = tryGetLineStr pos namedText.Lines |> Result.ofStringErr and! tyRes = forceGetTypeCheckResults filePath |> Result.ofStringErr - let! usages = - symbolUseWorkspace pos lineStr namedText.Lines tyRes + let! (_, usages) = + symbolUseWorkspace true true false pos lineStr namedText.Lines tyRes |> AsyncResult.mapError (JsonRpc.Error.InternalErrorMessage) - match usages with - | Choice1Of2(decls, usages) -> - return - Seq.append decls.Values usages.Values - |> Seq.collect (fun kvp -> kvp |> Array.map fcsRangeToLspLocation) - |> Seq.toArray - |> Some - | Choice2Of2 combinedRanges -> - return - combinedRanges.Values - |> Seq.collect (fun kvp -> kvp |> Array.map fcsRangeToLspLocation) - |> Seq.toArray - |> Some + let references = + usages.Values |> Seq.collect (Seq.map fcsRangeToLspLocation) |> Seq.toArray + + return Some references with e -> trace.SetStatusErrorSafe(e.Message).RecordExceptions(e) |> ignore @@ -3214,19 +3263,19 @@ type AdaptiveFSharpLspServer(workspaceLoader: IWorkspaceLoader, lspClient: FShar return { p with Command = Some cmd } |> Some |> success elif typ = "reference" then - let! res = - symbolUseWorkspace pos lineStr lines tyRes + let! uses = + symbolUseWorkspace false true false pos lineStr lines tyRes |> AsyncResult.mapError (JsonRpc.Error.InternalErrorMessage) - let res = - match res with - | Core.Result.Error msg -> - logger.error ( - Log.setMessage "CodeLensResolve - error getting symbol use for {file} - {error}" - >> Log.addContextDestructured "file" file - >> Log.addContextDestructured "error" msg - ) + match uses with + | Error msg -> + logger.error ( + Log.setMessage "CodeLensResolve - error getting symbol use for {file}" + >> Log.addContextDestructured "file" file + >> Log.addContextDestructured "error" msg + ) + return success ( Some { p with @@ -3236,42 +3285,21 @@ type AdaptiveFSharpLspServer(workspaceLoader: IWorkspaceLoader, lspClient: FShar Command = "" Arguments = None } } ) - | Ok res -> - match res with - | Choice1Of2(_, uses) -> - let allUses = uses.Values |> Array.concat - - let cmd = - if allUses.Length = 0 then - { Title = "0 References" - Command = "" - Arguments = None } - else - { Title = $"%d{allUses.Length} References" - Command = "fsharp.showReferences" - Arguments = writePayload (file, pos, allUses) } - - { p with Command = Some cmd } |> Some |> success - | Choice2Of2 mixedUsages -> - // mixedUsages will contain the declaration, so we need to do a bit of work here - let allUses = mixedUsages.Values |> Array.concat - - let cmd = - if allUses.Length <= 1 then - // 1 reference means that it's only the declaration, so it's actually 0 references - { Title = "0 References" - Command = "" - Arguments = None } - else - // multiple references means that the declaration _and_ the references are all present. - // this is kind of a pain, so for now at least, we just return all of them - { Title = $"%d{allUses.Length - 1} References" - Command = "fsharp.showReferences" - Arguments = writePayload (file, pos, allUses) } - { p with Command = Some cmd } |> Some |> success + | Ok(_, uses) -> + let allUses = uses.Values |> Array.concat + + let cmd = + if Array.isEmpty allUses then + { Title = "0 References" + Command = "" + Arguments = None } + else + { Title = $"%d{allUses.Length} References" + Command = "fsharp.showReferences" + Arguments = writePayload (file, pos, allUses) } - return res + return { p with Command = Some cmd } |> Some |> success else logger.error ( Log.setMessage "CodeLensResolve - unknown type {file} - {typ}" @@ -3677,14 +3705,6 @@ type AdaptiveFSharpLspServer(workspaceLoader: IWorkspaceLoader, lspClient: FShar Helpers.notImplemented - override x.TextDocumentPrepareRename p = - logger.info ( - Log.setMessage "TextDocumentOnPrepareRename Request: {parms}" - >> Log.addContextDestructured "parms" p - ) - - Helpers.notImplemented - override x.TextDocumentSemanticTokensFullDelta p = logger.info ( Log.setMessage "TextDocumentSemanticTokensFullDelta Request: {parms}" diff --git a/src/FsAutoComplete/LspServers/Common.fs b/src/FsAutoComplete/LspServers/Common.fs index 1729c0e13..8cf5ba530 100644 --- a/src/FsAutoComplete/LspServers/Common.fs +++ b/src/FsAutoComplete/LspServers/Common.fs @@ -218,7 +218,7 @@ module Helpers = let defaultServerCapabilities = { ServerCapabilities.Default with HoverProvider = Some true - RenameProvider = Some(U2.First true) + RenameProvider = Some(U2.Second { PrepareProvider = Some true }) DefinitionProvider = Some true TypeDefinitionProvider = Some true ImplementationProvider = Some true diff --git a/src/FsAutoComplete/LspServers/FsAutoComplete.Lsp.fs b/src/FsAutoComplete/LspServers/FsAutoComplete.Lsp.fs index d8436c500..5b828fcb5 100644 --- a/src/FsAutoComplete/LspServers/FsAutoComplete.Lsp.fs +++ b/src/FsAutoComplete/LspServers/FsAutoComplete.Lsp.fs @@ -30,6 +30,9 @@ open Fantomas.Client.LSPFantomasService open FSharp.Compiler.Text.Position open FsAutoComplete.Lsp +open Ionide.LanguageServerProtocol.Types.AsyncLspResult +open Ionide.LanguageServerProtocol.Types.LspResult +open StreamJsonRpc type FSharpLspServer(state: State, lspClient: FSharpLspClient) = @@ -952,14 +955,6 @@ type FSharpLspServer(state: State, lspClient: FSharpLspClient) = Helpers.notImplemented - override x.TextDocumentPrepareRename p = - logger.info ( - Log.setMessage "TextDocumentOnPrepareRename Request: {parms}" - >> Log.addContextDestructured "parms" p - ) - - Helpers.notImplemented - override x.TextDocumentSemanticTokensFullDelta p = logger.info ( Log.setMessage "TextDocumentSemanticTokensFullDelta Request: {parms}" @@ -1593,6 +1588,22 @@ type FSharpLspServer(state: State, lspClient: FSharpLspClient) = async.Return(success (Some response)) | _ -> async.Return(success None)) + override x.TextDocumentPrepareRename p = + logger.info ( + Log.setMessage "TextDocumentOnPrepareRename Request: {parms}" + >> Log.addContextDestructured "parms" p + ) + + p + |> x.positionHandler (fun p pos tyRes lineStr lines -> + asyncResult { + let! (_, _, range) = + commands.RenameSymbolRange(pos, tyRes, lineStr, lines) + |> AsyncResult.mapError (fun msg -> JsonRpc.Error.Create(JsonRpc.ErrorCodes.invalidParams, msg)) + + return range |> fcsRangeToLsp |> PrepareRenameResult.Range |> Some + }) + override x.TextDocumentRename(p: RenameParams) = logger.info ( Log.setMessage "TextDocumentRename Request: {parms}" @@ -1602,30 +1613,29 @@ type FSharpLspServer(state: State, lspClient: FSharpLspClient) = p |> x.positionHandler (fun p pos tyRes lineStr lines -> asyncResult { - let! documentsAndRanges = + // validate name and surround with backticks if necessary + let! newName = + Commands.adjustRenameSymbolNewName pos lineStr lines tyRes p.NewName + |> AsyncResult.mapError (fun msg -> JsonRpc.Error.Create(JsonRpc.ErrorCodes.invalidParams, msg)) + + let! ranges = commands.RenameSymbol(pos, tyRes, lineStr, lines) - |> Async.Catch - |> Async.map (function - | Choice1Of2 v -> v - | Choice2Of2 err -> Error err.Message) - |> AsyncResult.mapError (JsonRpc.Error.InternalErrorMessage) + |> AsyncResult.mapError (fun msg -> JsonRpc.Error.Create(JsonRpc.ErrorCodes.invalidParams, msg)) let documentChanges = - documentsAndRanges - |> Seq.map (fun (namedText, symbols) -> + ranges + |> Seq.map (fun kvp -> let edits = - let newName = - p.NewName |> FSharp.Compiler.Syntax.PrettyNaming.NormalizeIdentifierBackticks - - symbols - |> Seq.map (fun sym -> - let range = fcsRangeToLsp sym + kvp.Value + |> Array.map (fun range -> + let range = fcsRangeToLsp range { Range = range; NewText = newName }) - |> Array.ofSeq + + let file: string = kvp.Key { TextDocument = - { Uri = Path.FilePathToUri(UMX.untag namedText.FileName) - Version = commands.TryGetFileVersion namedText.FileName } + { Uri = Path.FilePathToUri(UMX.untag file) + Version = commands.TryGetFileVersion file } Edits = edits }) |> Array.ofSeq @@ -1681,23 +1691,14 @@ type FSharpLspServer(state: State, lspClient: FSharpLspClient) = p |> x.positionHandler (fun p pos tyRes lineStr lines -> asyncResult { - let! usages = - commands.SymbolUseWorkspace(pos, lineStr, lines, tyRes) + let! (_, usages) = + commands.SymbolUseWorkspace(pos, lineStr, lines, tyRes, true, true, false) |> AsyncResult.mapError (JsonRpc.Error.InternalErrorMessage) - match usages with - | Choice1Of2(decls, usages) -> - return - Seq.append decls.Values usages.Values - |> Seq.collect (fun kvp -> kvp |> Array.map fcsRangeToLspLocation) - |> Seq.toArray - |> Some - | Choice2Of2 combinedRanges -> - return - combinedRanges.Values - |> Seq.collect (fun kvp -> kvp |> Array.map fcsRangeToLspLocation) - |> Seq.toArray - |> Some + let references = + usages.Values |> Seq.collect (Seq.map fcsRangeToLspLocation) |> Seq.toArray + + return Some references }) override x.TextDocumentDocumentHighlight(p) = @@ -2037,17 +2038,18 @@ type FSharpLspServer(state: State, lspClient: FSharpLspClient) = return { p with Command = Some cmd } |> Some |> success else - let! res = commands.SymbolUseWorkspace(pos, lineStr, lines, tyRes) + let! uses = + commands.SymbolUseWorkspace(pos, lineStr, lines, tyRes (*includeDeclarations:*) , false, true, false) - let res = - match res with - | Core.Result.Error msg -> - logger.error ( - Log.setMessage "CodeLensResolve - error getting symbol use for {file}" - >> Log.addContextDestructured "file" file - >> Log.addContextDestructured "error" msg - ) + match uses with + | Error msg -> + logger.error ( + Log.setMessage "CodeLensResolve - error getting symbol use for {file}" + >> Log.addContextDestructured "file" file + >> Log.addContextDestructured "error" msg + ) + return success ( Some { p with @@ -2057,42 +2059,21 @@ type FSharpLspServer(state: State, lspClient: FSharpLspClient) = Command = "" Arguments = None } } ) - | Ok res -> - match res with - | Choice1Of2(_, uses) -> - let allUses = uses.Values |> Array.concat - - let cmd = - if allUses.Length = 0 then - { Title = "0 References" - Command = "" - Arguments = None } - else - { Title = $"%d{allUses.Length} References" - Command = "fsharp.showReferences" - Arguments = writePayload (file, pos, allUses) } - - { p with Command = Some cmd } |> Some |> success - | Choice2Of2 mixedUsages -> - // mixedUsages will contain the declaration, so we need to do a bit of work here - let allUses = mixedUsages.Values |> Array.concat - - let cmd = - if allUses.Length <= 1 then - // 1 reference means that it's only the declaration, so it's actually 0 references - { Title = "0 References" - Command = "" - Arguments = None } - else - // multiple references means that the declaration _and_ the references are all present. - // this is kind of a pain, so for now at least, we just return all of them - { Title = $"%d{allUses.Length - 1} References" - Command = "fsharp.showReferences" - Arguments = writePayload (file, pos, allUses) } - { p with Command = Some cmd } |> Some |> success + | Ok(_, uses) -> + let allUses = uses.Values |> Array.concat - return res + let cmd = + if Array.isEmpty allUses then + { Title = "0 References" + Command = "" + Arguments = None } + else + { Title = $"%d{allUses.Length} References" + Command = "fsharp.showReferences" + Arguments = writePayload (file, pos, allUses) } + + return { p with Command = Some cmd } |> Some |> success }) p diff --git a/test/FsAutoComplete.Tests.Lsp/CoreTests.fs b/test/FsAutoComplete.Tests.Lsp/CoreTests.fs index b017a683b..7d80f8aec 100644 --- a/test/FsAutoComplete.Tests.Lsp/CoreTests.fs +++ b/test/FsAutoComplete.Tests.Lsp/CoreTests.fs @@ -80,7 +80,9 @@ let initTests createServer = Expect.equal res.Capabilities.HoverProvider (Some true) "Hover Provider" Expect.equal res.Capabilities.ImplementationProvider (Some true) "Implementation Provider" Expect.equal res.Capabilities.ReferencesProvider (Some true) "References Provider" - Expect.equal res.Capabilities.RenameProvider (Some(U2.First true)) "Rename Provider" + Expect.equal res.Capabilities.RenameProvider (Some(U2.Second { + PrepareProvider = Some true + })) "Rename Provider" Expect.equal res.Capabilities.SignatureHelpProvider diff --git a/test/FsAutoComplete.Tests.Lsp/FindReferencesTests.fs b/test/FsAutoComplete.Tests.Lsp/FindReferencesTests.fs index e004d7080..4b9fc8e00 100644 --- a/test/FsAutoComplete.Tests.Lsp/FindReferencesTests.fs +++ b/test/FsAutoComplete.Tests.Lsp/FindReferencesTests.fs @@ -5,14 +5,22 @@ open System.IO open FsAutoComplete open Helpers open Ionide.LanguageServerProtocol.Types +open Utils.ServerTests +open Utils.Server +open Utils.Utils +open Utils.TextEdit +open System.Collections.Generic +open FSharp.UMX +open FsAutoComplete.LspHelpers.Conversions +open FsToolkit.ErrorHandling +open FSharp.Compiler.CodeAnalysis open Helpers.Expecto.ShadowedTimeouts -let tests state = - testList - "Find All References tests" +let private scriptTests state = + testList "script" [ let server = async { - let path = Path.Combine(__SOURCE_DIRECTORY__, "TestCases", "FindReferences") + let path = Path.Combine(__SOURCE_DIRECTORY__, "TestCases", "FindReferences", "Script") let! (server, event) = serverInitialize path defaultConfigDto state do! waitForWorkspaceFinishedParsing event @@ -25,7 +33,7 @@ let tests state = } |> Async.Cache - ptestCaseAsync + testCaseAsync "Can find references for foo identifier in script" (async { let! server, scriptPath = server @@ -51,3 +59,1065 @@ let tests state = End = { Line = 0; Character = 7 } } "should point to the definition of `foo`" }) ] + +module private Cursor = + let cursor = Cursor.Marker + let usageStart = "$<" + let usageEnd = ">$" + let defStart = "$D<" + let defEnd = ">D$" + +let private extractRanges (sourceWithCursors: string) = + let (source, cursors) = + sourceWithCursors + |> Text.trimTripleQuotation + |> Cursors.extractWith [| Cursor.cursor; Cursor.usageStart; Cursor.usageEnd; Cursor.defStart; Cursor.defEnd |] + + let cursor, cursors = + let cs, cursors = + cursors + |> List.partition (fst >> (=) Cursor.cursor) + if cs |> List.isEmpty then + (None, cursors) + else + Expect.hasLength cs 1 "There should be either 0 or 1 cursor $0" + (Some (snd cs[0]), cursors) + + let mkRange start fin = { + Start = start + End = fin + } + + let rec collectRanges (cursors: (string*Position) list) ((decls, usages) as ranges) = + match cursors with + | [] -> ranges + | [(c,p)] -> failwith $"Lonely last cursor {c} at {p}" + | (c1,p1)::(c2,p2)::cursors when c1 = Cursor.usageStart && c2 = Cursor.usageEnd -> + let range = mkRange p1 p2 + let ranges = (range :: decls, usages) + collectRanges cursors ranges + | (c1,p1)::(c2,p2) :: cursors when c1 = Cursor.defStart && c2 = Cursor.defEnd -> + let range = mkRange p1 p2 + let ranges = (decls, range :: usages) + collectRanges cursors ranges + | (c1,p1)::(c2,p2):: _ -> + failwith $"Cursor pair {c1} & {c2} do not match (at {p1} & {p2})" + let (decls, usages) = collectRanges cursors ([],[]) + source, {| + Cursor = cursor + Declarations = decls |> List.rev |> List.toArray + Usages = usages |> List.rev |> List.toArray + |} +let private mkLocation doc range = + { + Uri = doc.Uri + Range = range + } +/// mark locations in text +/// -> differences gets highlighted in source instead of Location array +/// +/// Locations are marked with `〈...〉` +let private markRanges (source: string) (locs: Location[]) = + let ranges = + locs + |> Array.map (fun l -> l.Range) + |> Array.sortByDescending (fun r -> (r.Start.Line, r.Start.Character)) + ranges + |> Array.fold (fun source range -> + source + |> Text.insert range.End "〉" + |> Flip.Expect.wantOk "Should be valid insert position" + |> Text.insert range.Start "〈" + |> Flip.Expect.wantOk "Should be valid insert position" + ) source + +module Expect = + /// `exact`: + /// * `true`: ranges of `actual` & `expected` must match exactly + /// * `false`: ranges of `actual` must contain ranges of `expected` -> ranges in `actual` can cover a larger area + /// * Reason: ranges only get adjusted iff source file was loaded (into `state` in `Commands`). + /// Because of background loading (& maybe changes in FSAC implementation) not always predictable what is and isn't loaded + /// -> Just check if correct range is covered + /// * Example: Find References for `map`: FCS returns range covering `List.map`. + /// That range gets reduced to just `map` iff source file is loaded (-> NamedText available). + /// But if file not loaded range stays `List.map`. + /// + /// -> + /// * Solution tests: multiple projects -> not everything loaded -> use `exact=false` + /// -> tests for: every reference found? + /// * untitled & range tests: in untitled doc -> loaded because passed to FSAC -> use `exact=true` + /// -> tests for: correct range found? + let locationsEqual (getSource: DocumentUri -> string) (exact: bool) (actual: Location[]) (expected: Location[]) = + let inspect () = + // Note: simplification: only find 1st doc that differs + let actualByUri, expectedByUri = + ( + actual |> Array.groupBy (fun l -> l.Uri) |> Array.sortBy fst, + expected |> Array.groupBy (fun l -> l.Uri) |> Array.sortBy fst + ) + // cannot directly use zip: might be unequal number of docs + Expect.sequenceEqual (actualByUri |> Array.map fst) (expectedByUri |> Array.map fst) "Should find references in correct docs" + // from here on: actualByUri & expectedByUri have same length and same docs in same order (because sorted) + + for ((uri, actual), (_, expected)) in Array.zip actualByUri expectedByUri do + let source = getSource uri + + if exact then + Expect.equal (markRanges source actual) (markRanges source expected) $"Should find correct & exact references in doc %s{uri}" + else + let actual = actual |> Array.sortBy (fun l -> l.Range.Start) + let expected = expected |> Array.sortBy (fun l -> l.Range.Start) + // actual & expected might have different length + // again: find only first difference + + let exactDisclaimer = "\nNote: Ranges in actual might be longer than in expected. That's ok because `exact=false`\n" + + if actual.Length <> expected.Length then + let msg = $"Found %i{actual.Length} references in doc %s{uri}, but expected %i{expected.Length} references.%s{exactDisclaimer}" + // this fails -> used for pretty printing of diff + Expect.equal (markRanges source actual) (markRanges source expected) msg + + for (i, (aLoc, eLoc)) in Seq.zip actual expected |> Seq.indexed do + // expected must fit into actual + let inside = + aLoc.Range |> Range.containsStrictly eLoc.Range.Start + && + aLoc.Range |> Range.containsStrictly eLoc.Range.End + + if not inside then + let msg = $"%i{i}. reference inside %s{uri} has incorrect range.%s{exactDisclaimer}" + Expect.equal (markRanges source [|aLoc|]) (markRanges source [|eLoc|]) msg + + + if exact then + try + Expect.sequenceEqual actual expected "Should find all references with correct range" + with + | :? AssertException -> + // pretty printing: Source with marked locations instead of lists with locations + inspect () + else + inspect () + +let private solutionTests state = + + let marker = "//>" + /// Format of Locations in file `path`: + /// In line after range: + /// * Mark start of line with `//>` + /// * underline range (in prev line) with a char-sequence (like `^^^^^`) + /// * name after range marker (separated with single space from range) (name can contain spaces) + /// -> results are grouped by named + /// * no name: assigned empty string as name + /// + /// Example: + /// ```fsharp + /// let foo bar = + /// //> ^^^ parameter + /// 42 + bar + /// //> ^^^ parameter usage + /// let alpha beta = + /// //> ^^^^ parameter + /// beta + 42 + /// //> ^^^^ parameter usage + /// ``` + /// -> 4 locations, two `parameter` and two `parameter usage` + /// + /// Note: it's currently not possible to get two (or more) ranges for a single line! + let readReferences path = + let lines = File.ReadAllLines path + let refs = Dictionary>() + for i in 0..(lines.Length-1) do + let line = lines[i].TrimStart() + if line.StartsWith marker then + let l = line.Substring(marker.Length).Trim() + let splits = l.Split([|' '|], 2) + let mark = splits[0] + let ty = mark[0] + let range = + let col = line.IndexOf mark + let length = mark.Length + let line = i - 1 // marker is line AFTER actual range + { + Start = { Line = line; Character = col } + End = { Line = line; Character = col + length } + } + let loc = { + Uri = + path + |> normalizePath + |> Path.LocalPathToUri + Range = range + } + let name = + if splits.Length > 1 then + splits[1] + else + "" + + if not (refs.ContainsKey name) then + refs[name] <- List<_>() + + let existing = refs[name] + // Note: we're currently dismissing type (declaration, usage) + existing.Add loc |> ignore + refs + + let readAllReferences dir = + // `.fs` & `.fsx` + let files = Directory.GetFiles(dir, "*.fs*", SearchOption.AllDirectories) + files + |> Seq.map readReferences + |> Seq.map (fun dict -> + dict + |> Seq.map (fun kvp -> kvp.Key, kvp.Value) + ) + |> Seq.collect id + |> Seq.groupBy fst + |> Seq.map (fun (name, locs) -> (name, locs |> Seq.map snd |> Seq.collect id |> Seq.toArray)) + |> Seq.map (fun (name, locs) -> {| Name=name; Locations=locs |}) + |> Seq.toArray + + + let path = Path.Combine(__SOURCE_DIRECTORY__, "TestCases", "FindReferences", "Solution") + serverTestList "solution" state defaultConfigDto (Some path) (fun server -> [ + // extra function instead of (just) testCase in front: to be able to run single test case + let mutable scriptFilesLoaded = false + let assertScriptFilesLoaded = async { + if not scriptFilesLoaded then + let files = Directory.GetFiles(path, "*.fsx", SearchOption.AllDirectories) + for file in files do + let relativePath = Path.GetRelativePath(path, file) + let! (doc, _) = server |> Server.openDocument relativePath + // keep script files open: + // Unlike `FsharpLspServer`, AdaptiveLspServer doesn't keep closed scripts in cache + // -> cannot find references inside closed script files + // see https://github.com/fsharp/FsAutoComplete/pull/1037#issuecomment-1440016138 + // do! doc |> Document.close + () + scriptFilesLoaded <- true + } + testCaseAsync "open script files" (async { + // script files aren't loaded in background + // -> cannot find references in unopened script files + + // -> load script files (don't need to be kept open -- just loaded once -> in FSAC cache) + do! assertScriptFilesLoaded + + //Enhancement: implement auto-load (like for projects)? + }) + + let mainDoc = Path.Combine("B", "WorkingModule.fs") + documentTestList "inside B/WorkingModule.fs" server (Server.openDocument mainDoc) (fun doc -> [ + let refs = readAllReferences path + for r in refs do + testCaseAsync r.Name (async { + do! assertScriptFilesLoaded + + let! (doc, _) = doc + let cursor = + let cursor = + r.Locations + |> Seq.filter (fun l -> l.Uri = doc.Uri) + |> Seq.minBy (fun l -> l.Range.Start) + cursor.Range.Start + + let request: ReferenceParams = + { TextDocument = doc.TextDocumentIdentifier + Position = cursor + Context = { IncludeDeclaration = true } } + let! refs = doc.Server.Server.TextDocumentReferences request + let refs = + refs + |> Flip.Expect.wantOk "Should not fail" + |> Flip.Expect.wantSome "Should return references" + + let expected = r.Locations + + let getSource uri = + let path = Path.FileUriToLocalPath uri + File.ReadAllText path + + Expect.locationsEqual getSource false refs expected + }) + ]) + ]) + +/// multiple untitled files (-> all docs are unrelated) +/// -> Tests for external symbols (-> over all docs) & symbol just in current doc (-> no matches in other unrelated docs) +let private untitledTests state = + serverTestList "untitled" state defaultConfigDto None (fun server -> [ + testCaseAsync "can find external `Delay` in all open untitled docs" (async { + // Note: Cursor MUST be in first source + let sources = + [| + """ + open System + open System.Threading.Tasks + let _ = task { + do! Task.$$ (TimeSpan.MaxValue) + do! Task.$<``Delay``>$ (TimeSpan.MaxValue) + do! System.Threading.Tasks.Task.$$ (TimeSpan.MaxValue) + do! + System + .Threading + .Tasks + .Task + .$$ (TimeSpan.MaxValue) + } + """ + """ + open System + open System.Threading.Tasks + let _ = task { + do! Task.$$ (TimeSpan.MaxValue) + printfn "..." + do! Threading.Tasks.Task.$$ (TimeSpan.MaxValue) + } + let _ = task { + do! Task.$$ (TimeSpan.MaxValue) + } + """ + // No Task.Delay + """ + open System + printfn "do stuff" + """ + |] + |> Array.map (extractRanges) + + let! docs = + sources + |> Seq.map fst + |> Seq.map (fun source -> async { + let! (doc, diags) = server |> Server.createUntitledDocument source + Expect.hasLength diags 0 $"There should be no diags in doc {doc.Uri}" + return doc + }) + |> Async.Sequential + + let (cursorDoc, cursor) = + let cursors = + Array.zip docs sources + |> Array.choose (fun (doc, (_, cursors)) -> + cursors.Cursor + |> Option.map (fun cursor -> (doc, cursor)) + ) + Expect.hasLength cursors 1 "There should be exactly one cursor" + cursors[0] + let request: ReferenceParams = + { TextDocument = cursorDoc.TextDocumentIdentifier + Position = cursor + Context = { IncludeDeclaration = true } } + let! refs = cursorDoc.Server.Server.TextDocumentReferences request + let refs = + refs + |> Flip.Expect.wantOk "Should not fail" + |> Flip.Expect.wantSome "Should return references" + + let expected = + Array.zip docs sources + |> Array.collect (fun (doc, (_, cursors)) -> + Array.append cursors.Declarations cursors.Usages + |> Array.map (mkLocation doc) + ) + + let getSource uri = + let i = docs |> Array.findIndex (fun doc -> doc.Uri = uri) + fst sources[i] + + Expect.locationsEqual getSource true refs expected + + }) + ]) + +/// Tests to check references span the correct range. For example: `Delay`, not `Task.Delay` +let private rangeTests state = + let checkRanges + server + sourceWithCursors + = async { + let (source, cursors) = + sourceWithCursors + |> extractRanges + let! (doc, diags) = server |> Server.createUntitledDocument source + use doc = doc + Expect.hasLength diags 0 "There should be no diags" + + let request: ReferenceParams = + { TextDocument = doc.TextDocumentIdentifier + Position = cursors.Cursor.Value + Context = { IncludeDeclaration = true } } + let! refs = doc.Server.Server.TextDocumentReferences request + let refs = + refs + |> Flip.Expect.wantOk "Should not fail" + |> Flip.Expect.wantSome "Should return references" + |> Array.sortBy (fun l -> l.Range.Start) + + Expect.all refs (fun r -> r.Uri = doc.Uri) "there should only be references in current doc" + + let expected = + Array.append cursors.Declarations cursors.Usages + |> Array.sortBy (fun r -> r.Start) + |> Array.map (mkLocation doc) + + // Expect.sequenceEqual refs expected "Should find all refs with correct range" + if refs <> expected then + Expect.equal (markRanges source refs) (markRanges source expected) "Should find correct references" + } + serverTestList "range" state defaultConfigDto None (fun server -> [ + testCaseAsync "can get range of variable" <| + checkRanges server + """ + module MyModule = + let $DD$ = 42 + + open MyModule + let _ = $$ + 42 + let _ = $<``value``>$ + 42 + let _ = MyModule.$$ + 42 + let _ = MyModule.$<``value``>$ + 42 + """ + testCaseAsync "can get range of external function" <| + checkRanges server + """ + open System + open System.Threading.Tasks + let _ = task { + do! Task.$$ (TimeSpan.MaxValue) + do! Task.$<``Delay``>$ (TimeSpan.MaxValue) + do! System.Threading.Tasks.Task.$$ (TimeSpan.MaxValue) + do! + System + .Threading + .Tasks + .Task + .$$ (TimeSpan.MaxValue) + } + """ + testCaseAsync "can get range of variable with required backticks" <| + checkRanges server + """ + module MyModule = + let $D<``hello$0 world``>D$ = 42 + + open MyModule + let _ = $<``hello world``>$ + 42 + let _ = MyModule.$<``hello world``>$ + 43 + """ + testCaseAsync "can get range of operator" <| + // Note: Parens aren't part of result range + // Reason: range returned by FCS in last case (with namespace) contains opening paren, but not closing paren + checkRanges server + """ + let _ = 1 $0$<+>$ 2 + let _ = ($<+>$) 1 2 + let _ = Microsoft.FSharp.Core.Operators.($<+>$) 1 2 + """ + testCaseAsync "can get range of full Active Pattern" <| + // Active Pattern is strange: all together are single symbol, but each individual too + // * get references on `(|Even|Odd|)` -> finds exactly `(|Even|Odd|)` + // * get references on `Even` -> finds single `Even` and `Even` inside Declaration `let (|Even|Odd|)`, but not usage `(|Even|Odd|)` + // + // Note: Find References in FCS return range with Namespace, Module, Type -> Find Refs for `XXXX` -> range is `MyModule.XXXX` + // Note: When XXXX in parens and Namespace, FCS returns range including opening paren, but NOT closing paren `MyModule.(XXXX` (happens for operators) + checkRanges server + """ + module MyModule = + let ($D<|Ev$0en|Odd|>D$) value = + if value % 2 = 0 then Even else Odd + + open MyModule + let _ = ($<|Even|Odd|>$) 42 + let _ = MyModule.($<|Even|Odd|>$) 42 + let _ = + MyModule + .($<|Even| + Odd|>$) 42 + let _ = + match 42 with + | Even -> () + | Odd -> () + let _ = + match 42 with + | MyModule.Even -> () + | MyModule.Odd -> () + """ + testCaseAsync "can get range of partial Active Pattern (Even)" <| + // Note: `Even` is found in Active Pattern declaration (`let (|Even|Odd|) = ...`) + // but NOT in usage of Full Active Pattern Name (`(|Even|Odd|)`) + checkRanges server + """ + module MyModule = + let (|$DD$|Odd|) value = + if value % 2 = 0 then $$ else Odd + + open MyModule + let _ = (|Even|Odd|) 42 + let _ = MyModule.(|Even|Odd|) 42 + let _ = + MyModule + .(|Even| + Odd|) 42 + let _ = + match 42 with + | $$ -> () + | Odd -> () + let _ = + match 42 with + | MyModule.$$ -> () + | MyModule.Odd -> () + """ + testCaseAsync "can get range of type for static function call" <| + checkRanges server + """ + open System + open System.Threading.Tasks + let _ = task { + do! $$.Delay(TimeSpan.MaxValue) + do! $$.``Delay`` (TimeSpan.MaxValue) + do! System.Threading.Tasks.$$.Delay (TimeSpan.MaxValue) + do! + System + .Threading + .Tasks + .$$ + .Delay (TimeSpan.MaxValue) + } + """ + ]) + +let tests state = testList "Find All References tests" [ + scriptTests state + solutionTests state + untitledTests state + rangeTests state +] + + +let tryFixupRangeTests = testList (nameof Tokenizer.tryFixupRange) [ + let checker = lazy (FSharpChecker.Create()) + let getSymbolUses source cursor = async { + let checker = checker.Value + let file = "code.fsx" + let path: string = UMX.tag file + let source = NamedText(path, source) + + let! (projOptions, _) = checker.GetProjectOptionsFromScript(file, source, assumeDotNetFramework=false) + let! (parseResults, checkResults) = checker.ParseAndCheckFileInProject(file, 0, source, projOptions) + // Expect.isEmpty parseResults.Diagnostics "There should be no parse diags" + Expect.hasLength parseResults.Diagnostics 0 "There should be no parse diags" + let checkResults = + match checkResults with + | FSharpCheckFileAnswer.Succeeded checkResults -> checkResults + | _ -> failtest "CheckFile aborted" + // Expect.isEmpty checkResults.Diagnostics "There should be no check diags" + Expect.hasLength checkResults.Diagnostics 0 "There should be no check diags" + let line = source.Lines[cursor.Line] + let (col, idents) = + Lexer.findIdents cursor.Character line SymbolLookupKind.Fuzzy + |> Flip.Expect.wantSome "Should find idents" + let symbolUse = + checkResults.GetSymbolUseAtLocation(cursor.Line + 1, col, line, List.ofArray idents) + |> Flip.Expect.wantSome "Should find symbol" + + let! ct = Async.CancellationToken + let usages = checkResults.GetUsesOfSymbolInFile(symbolUse.Symbol, ct) + + return (source, symbolUse.Symbol, usages) + } + + /// Markers: + /// * Cursor: `$0` + /// * Ranges: Inside `$<` ... `>$` + let extractCursorAndRanges sourceWithCursorAndRanges = + let (source, cursors) = + sourceWithCursorAndRanges + |> Text.trimTripleQuotation + |> Cursors.extractWith [| "$0"; "$<"; ">$"|] + let (cursor, cursors) = + let (c, cs) = + cursors + |> List.partition (fst >> (=) "$0") + let c = c |> List.map snd + Expect.hasLength c 1 "There should be exactly 1 cursor (`$0`)" + (c[0], cs) + + let rec collectRanges cursors ranges = + match cursors with + | [] -> List.rev ranges + | ("$<", start)::(">$", fin)::cursors -> + let range = { + Start = start + End = fin + } + collectRanges cursors (range::ranges) + | _ -> + failtest $"Expected matching range pair '$<', '>$', but got: %A{cursors}" + let ranges = + collectRanges cursors [] + + (source, cursor, ranges) + + let check includeBackticks sourceWithCursorAndRanges = async { + let (source, cursor, expected) = extractCursorAndRanges sourceWithCursorAndRanges + + let! (source, symbol, usages) = getSymbolUses source cursor + + let symbolNameCore = symbol.DisplayNameCore + let actual = + usages + |> Seq.map (fun u -> + Tokenizer.tryFixupRange(symbolNameCore, u.Range, source, includeBackticks) + |> Option.ofValueOption + |> Flip.Expect.wantSome $"Should be able to fixup usage '%A{u}'" + ) + |> Seq.map fcsRangeToLsp + |> Seq.toArray + |> Array.sortBy (fun r -> (r.Start.Line, r.Start.Character)) + + let expected = expected |> Array.ofList + + // Expect.equal actual expected "Should be correct range" + if actual <> expected then + // mark ranges for visual diff instead of range diff + let markRanges (ranges: Range[]) = + let locs = + ranges + |> Array.map (fun r -> + { + Uri = "" + Range = r + } + ) + let marked = markRanges source.String locs + // append range strings for additional range diff + let rangeStrs = + ranges + |> Seq.map (fun r -> r.DebuggerDisplay) + |> String.concat "\n" + marked + + "\n" + + "\n" + + rangeStrs + + Expect.equal + (markRanges actual) + (markRanges expected) + "Should be correct ranges" + } + + testCaseAsync "Active Pattern - simple" <| + check false + """ + module MyModule = + let ($<|Even|Odd|>$) v = if v % 2 = 0 then Even else Odd + let _ = ($<|Ev$0en|Odd|>$) 42 + + // backticks + let _ = ($<|``Even``|Odd|>$) 42 + let _ = ($<|``Even``|``Odd``|>$) 42 + let _ = (``$<|Even|Odd|>$``) 42 + + // spaces + let _ = ($<| Even | Odd |>$) 42 + let _ = ($<| Even|Odd |>$) 42 + let _ = ($<|Even | Odd|>$) 42 + + // linebreaks + let _ = ($<|Even| + Odd|>$) 42 + let _ = ( + $<|Even|Odd|>$) 42 + let _ = ( + $<|Even| + Odd|>$ + ) 42 + + let _ = MyModule.($<|Even|Odd|>$) 42 + + // backticks + let _ = MyModule.($<|``Even``|Odd|>$) 42 + let _ = MyModule.($<|``Even``|``Odd``|>$) 42 + // Invalid: + // let _ = MyModule.(``|Even|Odd|``) 42 + + // spaces + let _ = MyModule.($<| Even | Odd |>$) 42 + let _ = MyModule.($<| Even|Odd |>$) 42 + let _ = MyModule.($<|Even | Odd|>$) 42 + + // linebreaks + let _ = MyModule.($<|Even| + Odd|>$) 42 + let _ = MyModule.( + $<|Even|Odd|>$) 42 + let _ = MyModule.( + $<|Even| + Odd|>$ + ) 42 + let _ = MyModule.( + + $<|Even| + Odd|>$ + + ) 42 + """ + testCaseAsync "Active Pattern - simple - with backticks" <| + check true + """ + module MyModule = + let ($<|Even|Odd|>$) v = if v % 2 = 0 then Even else Odd + let _ = ($<|Ev$0en|Odd|>$) 42 + + // backticks + let _ = ($<|``Even``|Odd|>$) 42 + let _ = ($<|``Even``|``Odd``|>$) 42 + let _ = ($<``|Even|Odd|``>$) 42 + + // spaces + let _ = ($<| Even | Odd |>$) 42 + let _ = ($<| Even|Odd |>$) 42 + let _ = ($<|Even | Odd|>$) 42 + + // linebreaks + let _ = ($<|Even| + Odd|>$) 42 + let _ = ( + $<|Even|Odd|>$) 42 + let _ = ( + $<|Even| + Odd|>$ + ) 42 + + let _ = MyModule.($<|Even|Odd|>$) 42 + + // backticks + let _ = MyModule.($<|``Even``|Odd|>$) 42 + let _ = MyModule.($<|``Even``|``Odd``|>$) 42 + // Invalid: + // let _ = MyModule.(``|Even|Odd|``) 42 + + // spaces + let _ = MyModule.($<| Even | Odd |>$) 42 + let _ = MyModule.($<| Even|Odd |>$) 42 + let _ = MyModule.($<|Even | Odd|>$) 42 + + // linebreaks + let _ = MyModule.($<|Even| + Odd|>$) 42 + let _ = MyModule.( + $<|Even|Odd|>$) 42 + let _ = MyModule.( + $<|Even| + Odd|>$ + ) 42 + let _ = MyModule.( + + $<|Even| + Odd|>$ + + ) 42 + """ + + testCaseAsync "Active Pattern - required backticks" <| + check false + """ + module MyModule = + let ($<|``Hello World``|_|>$) v = Some v + + let _ = ($<|``Hel$0lo World``|_|>$) 42 + let _ = (``$<|Hello World|_|>$``) 42 + + // spaces + let _ = ( $<| ``Hello World`` | _ |>$ ) 42 + let _ = ( ``$<|Hello World|_|>$`` ) 42 + + // linebreaks + let _r = + ( + $<| + ``Hello World`` + | + _ + |>$ + ) 42 + let _ = + ( + ``$<|Hello World|_|>$`` + ) 42 + + let _ = MyModule.($<|``Hello World``|_|>$) 42 + // invalid + // let _ = MyModule.(``|Hello World|_|``) 42 + + // spaces + let _ = MyModule.( $<| ``Hello World`` | _ |>$ ) 42 + // invalid + // let _ = MyModule.( ``|Hello World|_|`` ) 42 + + // linebreaks + let _r = + MyModule.( + $<| + ``Hello World`` + | + _ + |>$ + ) 42 + // invalid + // let _ = + // MyModule.( + // ``|Hello World|_|`` + // ) 42 + """ + testCaseAsync "Active Pattern - required backticks - with backticks" <| + check true + """ + module MyModule = + let ($<|``Hello World``|_|>$) v = Some v + + let _ = ($<|``Hel$0lo World``|_|>$) 42 + let _ = ($<``|Hello World|_|``>$) 42 + + // spaces + let _ = ( $<| ``Hello World`` | _ |>$ ) 42 + let _ = ( $<``|Hello World|_|``>$ ) 42 + + // linebreaks + let _r = + ( + $<| + ``Hello World`` + | + _ + |>$ + ) 42 + let _ = + ( + $<``|Hello World|_|``>$ + ) 42 + + let _ = MyModule.($<|``Hello World``|_|>$) 42 + // invalid + // let _ = MyModule.(``|Hello World|_|``) 42 + + // spaces + let _ = MyModule.( $<| ``Hello World`` | _ |>$ ) 42 + // invalid + // let _ = MyModule.( ``|Hello World|_|`` ) 42 + + // linebreaks + let _r = + MyModule.( + $<| + ``Hello World`` + | + _ + |>$ + ) 42 + // invalid + // let _ = + // MyModule.( + // ``|Hello World|_|`` + // ) 42 + """ + + testCaseAsync "Active Pattern Case - simple - at usage" <| + check false + """ + module MyModule = + let (|$$|Odd|) v = + if v % 2 = 0 then $$ else Odd + + do + match 42 with + | $$ -> () + | Odd -> () + + do + match 42 with + | ``$$`` -> () + | ``Odd`` -> () + + do + match 42 with + | MyModule.$$ -> () + | MyModule.Odd -> () + + do + match 42 with + | MyModule.``$$`` -> () + | MyModule.``Odd`` -> () + """ + testCaseAsync "Active Pattern Case - simple - at usage - with backticks" <| + check true + """ + module MyModule = + let (|$$|Odd|) v = + if v % 2 = 0 then $$ else Odd + + do + match 42 with + | $$ -> () + | Odd -> () + + do + match 42 with + | $<``Even``>$ -> () + | ``Odd`` -> () + + do + match 42 with + | MyModule.$$ -> () + | MyModule.Odd -> () + + do + match 42 with + | MyModule.$<``Even``>$ -> () + | MyModule.``Odd`` -> () + """ + + testCaseAsync "Active Pattern Case - simple - at decl" <| + // Somehow `FSharpSymbolUse.Symbol.DisplayNameCore` is empty -- but references correct Even symbol + // + // Why? Cannot reproduce with just FCS -> happens just in FSAC + check false + """ + module MyModule = + let (|$$|Odd|) v = + if v % 2 = 0 then $$ else Odd + + do + match 42 with + | $$ -> () + | Odd -> () + + do + match 42 with + | ``$$`` -> () + | ``Odd`` -> () + + do + match 42 with + | MyModule.$$ -> () + | MyModule.Odd -> () + + do + match 42 with + | MyModule.``$$`` -> () + | MyModule.``Odd`` -> () + """ + testCaseAsync "Active Pattern Case - simple - at decl - with backticks" <| + check true + """ + module MyModule = + let (|$$|Odd|) v = + if v % 2 = 0 then $$ else Odd + + do + match 42 with + | $$ -> () + | Odd -> () + + do + match 42 with + | $<``Even``>$ -> () + | ``Odd`` -> () + + do + match 42 with + | MyModule.$$ -> () + | MyModule.Odd -> () + + do + match 42 with + | MyModule.$<``Even``>$ -> () + | MyModule.``Odd`` -> () + """ + + testCaseAsync "operator -.-" <| + check false + """ + module MyModule = + let ($<-.->$) a b = a - b + + let _ = 1 $<-$0.->$ 2 + let _ = ($<-.->$) 1 2 + // invalid: + // let _ = (``-.-``) 1 2 + let _ = ( + $<-.->$ + ) 1 2 + + let _ = MyModule.($<-.->$) 1 2 + + // linebreaks + let _ = + MyModule + .($<-.->$) 1 2 + let _ = + MyModule. + ($<-.->$) 1 2 + let _ = + MyModule + .( + $<-.->$ + ) 1 2 + let _ = + MyModule + .( + + $<-.->$ + + ) 1 2 + let _ = + MyModule. + ( + $<-.->$ + ) 1 2 + """ + testCaseAsync "operator -.- - with backticks" <| + // same as above -- just to ensure same result + check true + """ + module MyModule = + let ($<-.->$) a b = a - b + + let _ = 1 $<-$0.->$ 2 + let _ = ($<-.->$) 1 2 + // invalid: + // let _ = (``-.-``) 1 2 + let _ = ( + $<-.->$ + ) 1 2 + + let _ = MyModule.($<-.->$) 1 2 + + // linebreaks + let _ = + MyModule + .($<-.->$) 1 2 + let _ = + MyModule. + ($<-.->$) 1 2 + let _ = + MyModule + .( + $<-.->$ + ) 1 2 + let _ = + MyModule + .( + + $<-.->$ + + ) 1 2 + let _ = + MyModule. + ( + $<-.->$ + ) 1 2 + """ +] diff --git a/test/FsAutoComplete.Tests.Lsp/Program.fs b/test/FsAutoComplete.Tests.Lsp/Program.fs index 158aab089..1f25dcf6c 100644 --- a/test/FsAutoComplete.Tests.Lsp/Program.fs +++ b/test/FsAutoComplete.Tests.Lsp/Program.fs @@ -65,7 +65,8 @@ let lspTests = for (lspName, lspFactory) in lspServers do testList $"{loaderName}.{lspName}" - [ Templates.tests () + [ + Templates.tests () let createServer () = lspFactory toolsPath workspaceLoaderFactory @@ -79,7 +80,6 @@ let lspTests = documentSymbolTest createServer Completion.autocompleteTest createServer Completion.autoOpenTests createServer - Rename.tests createServer foldingTests createServer tooltipTests createServer Highlighting.tests createServer @@ -101,7 +101,10 @@ let lspTests = CodeFixTests.Tests.tests createServer Completion.tests createServer GoTo.tests createServer + FindReferences.tests createServer + Rename.tests createServer + InfoPanelTests.docFormattingTest createServer DetectUnitTests.tests createServer XmlDocumentationGeneration.tests createServer @@ -110,16 +113,22 @@ let lspTests = UnusedDeclarationsTests.tests createServer ] ] + +/// Tests that do not require a LSP server +let generalTests = testList "general" [ + testList (nameof (Utils)) [ Utils.Tests.Utils.tests; Utils.Tests.TextEdit.tests ] + InlayHintTests.explicitTypeInfoTests + + FindReferences.tryFixupRangeTests +] [] let tests = testList "FSAC" [ - testList (nameof (Utils)) [ Utils.Tests.Utils.tests; Utils.Tests.TextEdit.tests ] - InlayHintTests.explicitTypeInfoTests - - lspTests + generalTests + lspTests ] @@ -128,30 +137,25 @@ let main args = let outputTemplate = "[{Timestamp:HH:mm:ss} {Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}" - let parseLogLevel args = - let debugMarker = "--debug" + let parseLogLevel (args: string[]) = let logMarker = "--log=" let logLevel = - if args |> Array.contains "--debug" then - Logging.LogLevel.Verbose - else - match - args - |> Array.tryFind (fun arg -> arg.StartsWith logMarker) - |> Option.map (fun log -> log.Substring(logMarker.Length)) - with - | Some ("warn" | "warning") -> Logging.LogLevel.Warn - | Some "error" -> Logging.LogLevel.Error - | Some "fatal" -> Logging.LogLevel.Fatal - | Some "info" -> Logging.LogLevel.Info - | Some "verbose" -> Logging.LogLevel.Verbose - | Some "debug" -> Logging.LogLevel.Debug - | _ -> Logging.LogLevel.Info + match + args + |> Array.tryFind (fun arg -> arg.StartsWith logMarker) + |> Option.map (fun log -> log.Substring(logMarker.Length)) + with + | Some ("warn" | "warning") -> Logging.LogLevel.Warn + | Some "error" -> Logging.LogLevel.Error + | Some "fatal" -> Logging.LogLevel.Fatal + | Some "info" -> Logging.LogLevel.Info + | Some "verbose" -> Logging.LogLevel.Verbose + | Some "debug" -> Logging.LogLevel.Debug + | _ -> Logging.LogLevel.Info let args = args - |> Array.except [ debugMarker ] |> Array.filter (fun arg -> not <| arg.StartsWith logMarker) logLevel, args diff --git a/test/FsAutoComplete.Tests.Lsp/RenameTests.fs b/test/FsAutoComplete.Tests.Lsp/RenameTests.fs index 5015066b5..e8f410c51 100644 --- a/test/FsAutoComplete.Tests.Lsp/RenameTests.fs +++ b/test/FsAutoComplete.Tests.Lsp/RenameTests.fs @@ -18,131 +18,136 @@ let private normalizePathCasing = >> Path.FileUriToLocalPath >> Path.FilePathToUri -let tests state = - let sameProjectTests = - let testDir = - Path.Combine(__SOURCE_DIRECTORY__, "TestCases", "RenameTest", "SameProject") - - serverTestList "Within Same Project" state defaultConfigDto (Some testDir) (fun server -> - [ testCaseAsync - "Rename from usage within same project but different file" - (async { - let! (doc, diags) = server |> Server.openDocument "Program.fs" - Expect.isEmpty diags "There should be no errors in the checked file" - - let p: RenameParams = - { TextDocument = doc.TextDocumentIdentifier - Position = { Line = 7; Character = 12 } - NewName = "y" } - - let! server = server - let! res = server.Server.TextDocumentRename p - - match res with - | Result.Error e -> failtestf "Request failed: %A" e - | Result.Ok None -> failtest "Request none" - | Result.Ok (Some res) -> - match res.DocumentChanges with - | None -> failtest "No changes" - | Some result -> - Expect.equal result.Length 2 "Rename has all changes" +let private sameProjectTests state = + let testDir = + Path.Combine(__SOURCE_DIRECTORY__, "TestCases", "RenameTest", "SameProject") - Expect.exists - result - (fun n -> - n.TextDocument.Uri.Contains "Program.fs" - && n.Edits - |> Seq.exists (fun r -> - r.Range = { Start = { Line = 7; Character = 12 } - End = { Line = 7; Character = 13 } })) - "Rename contains changes in Program.fs" + serverTestList "Within Same Project" state defaultConfigDto (Some testDir) (fun server -> + [ testCaseAsync + "Rename from usage within same project but different file" + (async { + let! (doc, diags) = server |> Server.openDocument "Program.fs" + Expect.isEmpty diags "There should be no errors in the checked file" - Expect.exists - result - (fun n -> - n.TextDocument.Uri.Contains "Test.fs" - && n.Edits - |> Seq.exists (fun r -> - r.Range = { Start = { Line = 2; Character = 4 } - End = { Line = 2; Character = 5 } })) - "Rename contains changes in Test.fs" - - () - }) - - testCaseAsync - "Rename from definition within the same project with usages across different files" - (async { - - let! (programDoc, programDiags) = server |> Server.openDocument "Program.fs" - let! (testDoc, testDiags) = server |> Server.openDocument "Test.fs" - - Expect.isEmpty programDiags "There should be no errors in Program.fs" - Expect.isEmpty testDiags "There should be no errors in Test.fs" - - let p: RenameParams = - { TextDocument = testDoc.TextDocumentIdentifier - Position = { Line = 2; Character = 4 } - NewName = "y" } - - let! server = server - let! res = server.Server.TextDocumentRename p - - match res with - | Result.Error e -> failtestf "Request failed: %A" e - | Result.Ok None -> failtest "Request none" - | Result.Ok (Some res) -> - match res.DocumentChanges with - | None -> failtest "No changes" - | Some result -> - // TODO - Expect.equal result.Length 2 "Rename has all changes" + let p: RenameParams = + { TextDocument = doc.TextDocumentIdentifier + Position = { Line = 7; Character = 12 } + NewName = "y" } - Expect.exists - result - (fun n -> - n.TextDocument.Uri.Contains "Program.fs" - && n.Edits - |> Seq.exists (fun r -> - r.Range = { Start = { Line = 7; Character = 12 } - End = { Line = 7; Character = 13 } })) - "Rename contains changes in Program.fs" + let! server = server + let! res = server.Server.TextDocumentRename p - Expect.exists - result - (fun n -> - n.TextDocument.Uri.Contains "Test.fs" - && n.Edits - |> Seq.exists (fun r -> - r.Range = { Start = { Line = 2; Character = 4 } - End = { Line = 2; Character = 5 } })) - "Rename contains changes in Test.fs" - - () - }) - - ]) - - let sameScriptTests = - serverTestList "Within same script file" state defaultConfigDto None (fun server -> [ - let checkRename textWithCursor newName expectedText = async { - let (cursor, text) = - textWithCursor - |> Text.trimTripleQuotation - |> Cursor.assertExtractPosition - - let! (doc, diags) = server |> Server.createUntitledDocument text - use doc = doc - Expect.isEmpty diags "There should be no diags" - - let p: RenameParams = - { - TextDocument = doc.TextDocumentIdentifier - Position = cursor - NewName = newName - } - let! res = doc.Server.Server.TextDocumentRename p - let edits = + match res with + | Result.Error e -> failtestf "Request failed: %A" e + | Result.Ok None -> failtest "Request none" + | Result.Ok (Some res) -> + match res.DocumentChanges with + | None -> failtest "No changes" + | Some result -> + Expect.equal result.Length 2 "Rename has all changes" + + Expect.exists + result + (fun n -> + n.TextDocument.Uri.Contains "Program.fs" + && n.Edits + |> Seq.exists (fun r -> + r.Range = { Start = { Line = 7; Character = 12 } + End = { Line = 7; Character = 13 } })) + "Rename contains changes in Program.fs" + + Expect.exists + result + (fun n -> + n.TextDocument.Uri.Contains "Test.fs" + && n.Edits + |> Seq.exists (fun r -> + r.Range = { Start = { Line = 2; Character = 4 } + End = { Line = 2; Character = 5 } })) + "Rename contains changes in Test.fs" + + () + }) + + testCaseAsync + "Rename from definition within the same project with usages across different files" + (async { + + let! (programDoc, programDiags) = server |> Server.openDocument "Program.fs" + let! (testDoc, testDiags) = server |> Server.openDocument "Test.fs" + + Expect.isEmpty programDiags "There should be no errors in Program.fs" + Expect.isEmpty testDiags "There should be no errors in Test.fs" + + let p: RenameParams = + { TextDocument = testDoc.TextDocumentIdentifier + Position = { Line = 2; Character = 4 } + NewName = "y" } + + let! server = server + let! res = server.Server.TextDocumentRename p + + match res with + | Result.Error e -> failtestf "Request failed: %A" e + | Result.Ok None -> failtest "Request none" + | Result.Ok (Some res) -> + match res.DocumentChanges with + | None -> failtest "No changes" + | Some result -> + // TODO + Expect.equal result.Length 2 "Rename has all changes" + + Expect.exists + result + (fun n -> + n.TextDocument.Uri.Contains "Program.fs" + && n.Edits + |> Seq.exists (fun r -> + r.Range = { Start = { Line = 7; Character = 12 } + End = { Line = 7; Character = 13 } })) + "Rename contains changes in Program.fs" + + Expect.exists + result + (fun n -> + n.TextDocument.Uri.Contains "Test.fs" + && n.Edits + |> Seq.exists (fun r -> + r.Range = { Start = { Line = 2; Character = 4 } + End = { Line = 2; Character = 5 } })) + "Rename contains changes in Test.fs" + + () + }) + + ]) + +let private sameScriptTests state = + serverTestList "Within same script file" state defaultConfigDto None (fun server -> [ + /// `expectedText = None` -> Rename not valid at location + let checkRename' textWithCursor newName (expectedText: string option) = async { + let (cursor, text) = + textWithCursor + |> Text.trimTripleQuotation + |> Cursor.assertExtractPosition + + let! (doc, diags) = server |> Server.createUntitledDocument text + use doc = doc + Expect.isEmpty diags "There should be no diags" + + let p: RenameParams = + { + TextDocument = doc.TextDocumentIdentifier + Position = cursor + NewName = newName + } + let! res = doc.Server.Server.TextDocumentRename p + match expectedText with + | None -> + // Note: `Error` instead of `Ok None` -> error message + Expect.isError res "Rename should not be valid!" + | Some expectedText -> + let edits = match res with | Result.Error e -> failtestf "Request failed: %A" e | Result.Ok None -> failtest "Request none" @@ -161,187 +166,424 @@ let tests state = let expected = expectedText |> Text.trimTripleQuotation Expect.equal actual expected "Text after TextEdits should be correct" - } - testCaseAsync "Rename from definition within script file" <| - checkRename - """ - let $0initial () = printfn "hi" - - initial () - """ - "afterwards" - """ - let afterwards () = printfn "hi" - - afterwards () - """ - testCaseAsync "Rename from usage within script file" <| - checkRename - """ - let initial () = printfn "hi" - - $0initial () - """ - "afterwards" - """ - let afterwards () = printfn "hi" - - afterwards () - """ - testCaseAsync "can add backticks to new name with space" <| - checkRename - """ - let initial () = printfn "hi" - - $0initial () - """ - "hello world" - """ - let ``hello world`` () = printfn "hi" - - ``hello world`` () - """ - testCaseAsync "doesn't add additional backticks to new name with backticks" <| - checkRename - """ - let initial () = printfn "hi" - - $0initial () - """ - "``hello world``" - """ - let ``hello world`` () = printfn "hi" - - ``hello world`` () - """ - ]) - - let crossProjectTests = - let server = - async { - let testDir = - Path.Combine(__SOURCE_DIRECTORY__, "TestCases", "RenameTest", "CrossProject") - - let! (server, event) = serverInitialize testDir defaultConfigDto state - do! waitForWorkspaceFinishedParsing event - - return (server, testDir, event) - } - - testSequenced - <| testList - "Across projects" - [ testCaseAsync - "Rename from usage across projects" - (async { - let! (server, rootDir, events) = server - let declarationFile = Path.Combine(rootDir, "LibA", "Library.fs") - let usageFile = Path.Combine(rootDir, "LibB", "Library.fs") - - // open and parse the usage file - let tdop: DidOpenTextDocumentParams = { TextDocument = loadDocument usageFile } - do! server.TextDocumentDidOpen tdop - - do! - waitForParseResultsForFile (Path.GetFileName usageFile) events - |> AsyncResult.foldResult id (fun e -> failtestf "%A" e) - - // now, request renames - let renameHelloUsageInUsageFile: RenameParams = - { TextDocument = { Uri = normalizePathCasing usageFile } - Position = { Line = 6; Character = 28 } - NewName = "sup" } - - let! res = server.TextDocumentRename(renameHelloUsageInUsageFile) - - match res with - | Result.Error e -> failtest $"Expected to get renames, but got error: {e.Message}" - | Result.Ok None -> failtest $"Expected to get renames, but got none" - | Result.Ok (Some { DocumentChanges = Some edits }) -> - Expect.equal edits.Length 2 "Rename has the correct expected edits" - - Expect.exists - edits - (fun n -> - n.TextDocument.Uri.Contains "LibA" - && n.TextDocument.Uri.Contains "Library.fs" - && n.Edits - |> Seq.exists (fun r -> - r.Range = { Start = { Line = 3; Character = 8 } - End = { Line = 3; Character = 13 } } - && r.NewText = "sup")) - "Rename contains changes in LibA" - - Expect.exists - edits - (fun n -> - n.TextDocument.Uri.Contains "LibB" - && n.TextDocument.Uri.Contains "Library.fs" - && n.Edits - |> Seq.exists (fun r -> - r.Range = { Start = { Line = 6; Character = 28 } - End = { Line = 6; Character = 33 } } - && r.NewText = "sup")) - "Rename contains changes in LibB" - | Result.Ok edits -> failtestf "got some unexpected edits: %A" edits - }) - testCaseAsync - "Rename where there are fully-qualified usages" - (async { - let! (server, rootDir, events) = server - let declarationFile = Path.Combine(rootDir, "LibA", "Library.fs") - let usageFile = Path.Combine(rootDir, "LibB", "Library.fs") - - // open and parse the usage file - let tdop: DidOpenTextDocumentParams = { TextDocument = loadDocument usageFile } - do! server.TextDocumentDidOpen tdop - - do! - waitForParseResultsForFile (Path.GetFileName usageFile) events - |> AsyncResult.foldResult id (fun e -> failtestf "%A" e) - - // now, request renames - let renameHelloUsageInUsageFile: RenameParams = - { TextDocument = { Uri = normalizePathCasing usageFile } - Position = { Line = 9; Character = 37 } // in the 'yell' part of 'A.Say.yell' - NewName = "sup" } - - let! res = server.TextDocumentRename(renameHelloUsageInUsageFile) - - match res with - | Result.Error e -> failtest $"Expected to get renames, but got error: {e.Message}" - | Result.Ok None -> failtest $"Expected to get renames, but got none" - | Result.Ok (Some { DocumentChanges = Some edits }) -> - Expect.equal edits.Length 2 "Rename has the correct expected edits" - - Expect.exists - edits - (fun n -> - n.TextDocument.Uri.Contains "LibA" - && n.TextDocument.Uri.Contains "Library.fs" - && n.Edits - |> Seq.exists (fun r -> - r.Range = { Start = { Line = 6; Character = 8 } - End = { Line = 6; Character = 12 } } - && r.NewText = "sup")) - $"Rename contains changes in LibA in the list %A{edits}" - - Expect.exists - edits - (fun n -> - n.TextDocument.Uri.Contains "LibB" - && n.TextDocument.Uri.Contains "Library.fs" - && n.Edits - |> Seq.exists (fun r -> - r.Range = { Start = { Line = 9; Character = 37 } - End = { Line = 9; Character = 41 } } - && r.NewText = "sup")) - $"Rename contains changes in LibB in the list %A{edits}" - | Result.Ok edits -> failtestf "got some unexpected edits: %A" edits - }) ] - - testList - "Rename Tests" - [ sameProjectTests - sameScriptTests - crossProjectTests ] + } + let checkRename textWithCursor newName expectedText = + checkRename' textWithCursor newName (Some expectedText) + let checkRenameNotValid textWithCursor newName = + checkRename' textWithCursor newName None + + testCaseAsync "Rename from definition within script file" <| + checkRename + """ + let $0initial () = printfn "hi" + + initial () + """ + "afterwards" + """ + let afterwards () = printfn "hi" + + afterwards () + """ + testCaseAsync "Rename from usage within script file" <| + checkRename + """ + let initial () = printfn "hi" + + $0initial () + """ + "afterwards" + """ + let afterwards () = printfn "hi" + + afterwards () + """ + testCaseAsync "can add backticks to new name with space" <| + checkRename + """ + let initial () = printfn "hi" + + $0initial () + """ + "hello world" + """ + let ``hello world`` () = printfn "hi" + + ``hello world`` () + """ + testCaseAsync "doesn't add additional backticks to new name with backticks" <| + checkRename + """ + let initial () = printfn "hi" + + $0initial () + """ + "``hello world``" + """ + let ``hello world`` () = printfn "hi" + + ``hello world`` () + """ + + testCaseAsync "can rename operator to valid operator name" <| + checkRename + """ + let (+$0++) a b = a + b + """ + "---" + """ + let (---) a b = a + b + """ + testCaseAsync "cannot rename operator to invalid operator name" <| + checkRenameNotValid + """ + let (+$0++) a b = a + b + """ + "foo" + + testCaseAsync "removes backticks for new name without backticks" <| + checkRename + """ + let ``my $0value`` = 42 + + let _ = ``my value`` + 42 + """ + "value" + """ + let value = 42 + + let _ = value + 42 + """ + ]) + +let private crossProjectTests state = + let server = + async { + let testDir = + Path.Combine(__SOURCE_DIRECTORY__, "TestCases", "RenameTest", "CrossProject") + + let! (server, event) = serverInitialize testDir defaultConfigDto state + do! waitForWorkspaceFinishedParsing event + + return (server, testDir, event) + } + + testSequenced + <| testList + "Across projects" + [ testCaseAsync + "Rename from usage across projects" + (async { + let! (server, rootDir, events) = server + let declarationFile = Path.Combine(rootDir, "LibA", "Library.fs") + let usageFile = Path.Combine(rootDir, "LibB", "Library.fs") + + // open and parse the usage file + let tdop: DidOpenTextDocumentParams = { TextDocument = loadDocument usageFile } + do! server.TextDocumentDidOpen tdop + + do! + waitForParseResultsForFile (Path.GetFileName usageFile) events + |> AsyncResult.foldResult id (fun e -> failtestf "%A" e) + + // now, request renames + let renameHelloUsageInUsageFile: RenameParams = + { TextDocument = { Uri = normalizePathCasing usageFile } + Position = { Line = 6; Character = 28 } + NewName = "sup" } + + let! res = server.TextDocumentRename(renameHelloUsageInUsageFile) + + match res with + | Result.Error e -> failtest $"Expected to get renames, but got error: {e.Message}" + | Result.Ok None -> failtest $"Expected to get renames, but got none" + | Result.Ok (Some { DocumentChanges = Some edits }) -> + Expect.equal edits.Length 2 "Rename has the correct expected edits" + + Expect.exists + edits + (fun n -> + n.TextDocument.Uri.Contains "LibA" + && n.TextDocument.Uri.Contains "Library.fs" + && n.Edits + |> Seq.exists (fun r -> + r.Range = { Start = { Line = 3; Character = 8 } + End = { Line = 3; Character = 13 } } + && r.NewText = "sup")) + "Rename contains changes in LibA" + + Expect.exists + edits + (fun n -> + n.TextDocument.Uri.Contains "LibB" + && n.TextDocument.Uri.Contains "Library.fs" + && n.Edits + |> Seq.exists (fun r -> + r.Range = { Start = { Line = 6; Character = 28 } + End = { Line = 6; Character = 33 } } + && r.NewText = "sup")) + "Rename contains changes in LibB" + | Result.Ok edits -> failtestf "got some unexpected edits: %A" edits + }) + testCaseAsync + "Rename where there are fully-qualified usages" + (async { + let! (server, rootDir, events) = server + let declarationFile = Path.Combine(rootDir, "LibA", "Library.fs") + let usageFile = Path.Combine(rootDir, "LibB", "Library.fs") + + // open and parse the usage file + let tdop: DidOpenTextDocumentParams = { TextDocument = loadDocument usageFile } + do! server.TextDocumentDidOpen tdop + + do! + waitForParseResultsForFile (Path.GetFileName usageFile) events + |> AsyncResult.foldResult id (fun e -> failtestf "%A" e) + + // now, request renames + let renameHelloUsageInUsageFile: RenameParams = + { TextDocument = { Uri = normalizePathCasing usageFile } + Position = { Line = 9; Character = 37 } // in the 'yell' part of 'A.Say.yell' + NewName = "sup" } + + let! res = server.TextDocumentRename(renameHelloUsageInUsageFile) + + match res with + | Result.Error e -> failtest $"Expected to get renames, but got error: {e.Message}" + | Result.Ok None -> failtest $"Expected to get renames, but got none" + | Result.Ok (Some { DocumentChanges = Some edits }) -> + Expect.equal edits.Length 2 "Rename has the correct expected edits" + + Expect.exists + edits + (fun n -> + n.TextDocument.Uri.Contains "LibA" + && n.TextDocument.Uri.Contains "Library.fs" + && n.Edits + |> Seq.exists (fun r -> + r.Range = { Start = { Line = 6; Character = 8 } + End = { Line = 6; Character = 12 } } + && r.NewText = "sup")) + $"Rename contains changes in LibA in the list %A{edits}" + + Expect.exists + edits + (fun n -> + n.TextDocument.Uri.Contains "LibB" + && n.TextDocument.Uri.Contains "Library.fs" + && n.Edits + |> Seq.exists (fun r -> + r.Range = { Start = { Line = 9; Character = 37 } + End = { Line = 9; Character = 41 } } + && r.NewText = "sup")) + $"Rename contains changes in LibB in the list %A{edits}" + | Result.Ok edits -> failtestf "got some unexpected edits: %A" edits + }) ] + +let private prepareRenameTests state = serverTestList "Prepare Rename tests" state defaultConfigDto None (fun server -> [ + let check shouldBeAbleToRename sourceWithCursor = async { + let (cursor, text) = + sourceWithCursor + |> Text.trimTripleQuotation + |> Cursor.assertExtractPosition + let! (doc, diags) = server |> Server.createUntitledDocument text + + let p: PrepareRenameParams = { + TextDocument = doc.TextDocumentIdentifier + Position = cursor + } + + let! res = doc.Server.Server.TextDocumentPrepareRename p + + if shouldBeAbleToRename then + res + |> Flip.Expect.wantOk "Should be able to rename" + |> Flip.Expect.isSome "Should be able to rename" + else + // Note: we always want `Error` instead of `Ok None` because of Error message + Expect.isError res "Should not be able to rename" + } + let checkCanRename = check true + let checkCannotRename = check false + + testCaseAsync "can rename variable at decl" <| + checkCanRename + """ + let val$0ue = 42 + let _ = value + 42 + """ + testCaseAsync "can rename variable at usage" <| + checkCanRename + """ + let value = 42 + let _ = val$0ue + 42 + """ + + testCaseAsync "can rename unnecessarily backticked variable at decl" <| + checkCanRename + """ + let ``val$0ue`` = 42 + let _ = value + 42 + """ + testCaseAsync "can rename unnecessarily backticked variable at usage" <| + checkCanRename + """ + let value = 42 + let _ = ``val$0ue`` + 42 + """ + + testCaseAsync "can rename variable with required backticks at decl" <| + checkCanRename + """ + let ``my v$0alue`` = 42 + let _ = ``my value`` + 42 + """ + testCaseAsync "can rename variable with required backticks at usage" <| + checkCanRename + """ + let ``my value`` = 42 + let _ = ``my va$0lue`` + 42 + """ + + testCaseAsync "can rename function at decl" <| + checkCanRename + """ + let myFun$0ction value = value + 42 + myFunction 42 |> ignore + """ + testCaseAsync "can rename function at usage" <| + checkCanRename + """ + let myFunction value = value + 42 + myFun$0ction 42 |> ignore + """ + testCaseAsync "can rename function parameter at decl" <| + checkCanRename + """ + let myFunction va$0lue = value + 42 + myFunction 42 |> ignore + """ + testCaseAsync "can rename function parameter at usage" <| + checkCanRename + """ + let myFunction va$0lue = value + 42 + myFunction 42 |> ignore + """ + + testCaseAsync "Can rename Type at decl" <| + checkCanRename + """ + type MyT$0ype () = class end + let v: MyType = MyType() + """ + testCaseAsync "Can rename Type at instantiation usage" <| + checkCanRename + """ + type MyType () = class end + let v: MyType = My$0Type() + """ + testCaseAsync "Can rename Type at Type usage" <| + checkCanRename + """ + type MyType () = class end + let v: MyT$0ype = MyType() + """ + + testCaseAsync "Can rename Method at decl" <| + checkCanRename + """ + type MyType = + static member DoS$0tuff () = () + MyType.DoStuff () + """ + testCaseAsync "Can rename Method at usage" <| + checkCanRename + """ + type MyType = + static member DoStuff () = () + MyType.DoS$0tuff () + """ + + + testCaseAsync "cannot rename Active Pattern at decl" <| + checkCannotRename + """ + let (|Ev$0en|Odd|) v = + if v % 2 = 0 then Even else Odd + let _ = (|Even|Odd|) 42 + """ + testCaseAsync "cannot rename Active Pattern at usage" <| + checkCannotRename + """ + let (|Even|Odd|) v = + if v % 2 = 0 then Even else Odd + let _ = (|Ev$0en|Odd|) 42 + """ + testCaseAsync "cannot rename Active Pattern Case at decl" <| + checkCannotRename + """ + let (|Ev$0en|Odd|) v = + if v % 2 = 0 then Ev$0en else Odd + match 42 with + | Even -> () + | Odd -> () + """ + testCaseAsync "cannot rename Active Pattern Case at usage" <| + checkCannotRename + """ + let (|Even|Odd|) v = + if v % 2 = 0 then Ev$0en else Odd + match 42 with + | Ev$0en -> () + | Odd -> () + """ + + testCaseAsync "cannot rename external function" <| + checkCannotRename + """ + 42 |> igno$0re + """ + testCaseAsync "cannot rename external method" <| + checkCannotRename + """ + String.IsNullOr$0WhiteSpace "foo" + |> ignore + """ + testCaseAsync "cannot rename number" <| + checkCannotRename + """ + 4$02 |> ignore + """ + testCaseAsync "cannot rename string" <| + checkCannotRename + """ + "hel$0lo world" |> ignore + """ + testCaseAsync "cannot rename keyword" <| + checkCannotRename + """ + l$0et foo = 42 + """ + testCaseAsync "cannot rename comment" <| + checkCannotRename + """ + /// So$0me value + let foo = 42 + """ + testCaseAsync "cannot rename space" <| + checkCannotRename + """ + let foo = 42 + $0 + let bar = 42 + """ +]) + +let tests state = + testList "Rename Tests" [ + sameProjectTests state + sameScriptTests state + crossProjectTests state + + prepareRenameTests state + ] diff --git a/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Script.fsx b/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Script/Script.fsx similarity index 100% rename from test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Script.fsx rename to test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Script/Script.fsx diff --git a/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/.gitignore b/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/.gitignore new file mode 100644 index 000000000..31eb3eea8 --- /dev/null +++ b/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/.gitignore @@ -0,0 +1,2 @@ +.vscode/ +.vs/ \ No newline at end of file diff --git a/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/A/A.fsproj b/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/A/A.fsproj new file mode 100644 index 000000000..7399bbd9c --- /dev/null +++ b/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/A/A.fsproj @@ -0,0 +1,11 @@ + + + + net6.0 + + + + + + + diff --git a/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/A/MyModule1.fs b/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/A/MyModule1.fs new file mode 100644 index 000000000..d84103e8e --- /dev/null +++ b/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/A/MyModule1.fs @@ -0,0 +1,16 @@ +module A.MyModule1 + +let _ = List.map +//> ^^^ List.map +let _ = List.map id +//> ^^^ List.map +let _ = [1;2] |> List.map id |> List.sum +//> ^^^ List.map + + +let value = "hello from A" +//> xxxxx function from different project +let _ = value +//> ^^^^^ function from different project +let _ = ``value`` +//> ^^^^^^^^^ function from different project diff --git a/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/B/B.fsproj b/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/B/B.fsproj new file mode 100644 index 000000000..ac1374813 --- /dev/null +++ b/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/B/B.fsproj @@ -0,0 +1,17 @@ + + + + net6.0 + + + + + + + + + + + + + diff --git a/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/B/MyModule1.fs b/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/B/MyModule1.fs new file mode 100644 index 000000000..524d972c9 --- /dev/null +++ b/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/B/MyModule1.fs @@ -0,0 +1,16 @@ +module B.MyModule1 + +let _ = List.map +//> ^^^ List.map +let _ = List.map id +//> ^^^ List.map +let _ = [1;2] |> List.map id |> List.sum +//> ^^^ List.map + +let value = "hello from B.MyModule1" +//> xxxxx function from same project +let _ = value +//> ^^^^^ function from same project +let _ = ``value`` +//> ^^^^^^^^^ function from same project + diff --git a/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/B/MyModule3.fs b/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/B/MyModule3.fs new file mode 100644 index 000000000..b93543735 --- /dev/null +++ b/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/B/MyModule3.fs @@ -0,0 +1,19 @@ +module B.MyModule3 + +let _ = List.map +//> ^^^ List.map +let _ = List.map id +//> ^^^ List.map +let _ = [1;2] |> List.map id |> List.sum +//> ^^^ List.map + +let _ = B.WorkingModule.doStuff () +//> ^^^^^^^ public function +open B.WorkingModule +let _ = doStuff () +//> ^^^^^^^ public function + +let _ = internalValue + 42 +//> ^^^^^^^^^^^^^ internal value +let _ = 2 * internalValue + 42 +//> ^^^^^^^^^^^^^ internal value \ No newline at end of file diff --git a/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/B/WorkingModule.fs b/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/B/WorkingModule.fs new file mode 100644 index 000000000..0782f5870 --- /dev/null +++ b/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/B/WorkingModule.fs @@ -0,0 +1,65 @@ +// all locations for "find all references" are in here +module B.WorkingModule + +// Only used here in module +// -> [B/WorkingModule] +let private localValue = 42 +//> xxxxxxxxxx private value +let _ = localValue + 42 +//> ^^^^^^^^^^ private value +let private _fJustInHere v = localValue + v +//> ^^^^^^^^^^ private value +let _ = ignore localValue +//> ^^^^^^^^^^ private value + +// internal -> only here and following modules in current project +// -> [B/WorkingModule; B/MyModule3] +let internal internalValue = 42 +//> xxxxxxxxxxxxx internal value +let _ = internalValue + 42 +//> ^^^^^^^^^^^^^ internal value +let _ = 2 * internalValue + 42 +//> ^^^^^^^^^^^^^ internal value + +// external, but only used here +// -> [B/WorkingModule] +let _ = Seq.map +//> ^^^ Seq.map +let _ = Seq.map id +//> ^^^ Seq.map +let _ = [1;2] |> Seq.map id |> Seq.sum +//> ^^^ Seq.map + +// external used all over solution (and script files) +// -> [A/MyModule1; B/MyModule1; B/WorkingModule; B/MyModule3; C/MyModule1; MyScript] +let _ = List.map +//> ^^^ List.map +let _ = List.map id +//> ^^^ List.map +let _ = [1;2] |> List.map id |> List.sum +//> ^^^ List.map + +// function defined here and used in following files +// -> [B/WorkingModule; B/MyModule3; C/MyModule1] +let doStuff () = () +//> xxxxxxx public function +let _ = doStuff () +//> ^^^^^^^ public function + +// function defined here and only used here +// -> [B/WorkingModule] +// Note: not private -> public, but nowhere else used +let doStuffJustHere () = () +//> xxxxxxxxxxxxxxx public function, only used here +let _ = doStuffJustHere () +//> ^^^^^^^^^^^^^^^ public function, only used here + +let _ = A.MyModule1.value +//> ^^^^^ function from different project +let _ = A.MyModule1.``value`` +//> ^^^^^^^^^ function from different project + +let _ = B.MyModule1.value +//> ^^^^^ function from same project +let _ = B.MyModule1.``value`` +//> ^^^^^^^^^ function from same project diff --git a/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/C/C.fsproj b/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/C/C.fsproj new file mode 100644 index 000000000..42920e5aa --- /dev/null +++ b/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/C/C.fsproj @@ -0,0 +1,15 @@ + + + + net6.0 + + + + + + + + + + + diff --git a/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/C/MyModule1.fs b/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/C/MyModule1.fs new file mode 100644 index 000000000..f7955cab6 --- /dev/null +++ b/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/C/MyModule1.fs @@ -0,0 +1,14 @@ +module C.MyModule1 + +let _ = List.map +//> ^^^ List.map +let _ = List.map id +//> ^^^ List.map +let _ = [1;2] |> List.map id |> List.sum +//> ^^^ List.map + +let _ = B.WorkingModule.doStuff () +//> ^^^^^^^ public function +open B.WorkingModule +let _ = doStuff () +//> ^^^^^^^ public function diff --git a/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/MyScript.fsx b/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/MyScript.fsx new file mode 100644 index 000000000..2c8320f35 --- /dev/null +++ b/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/MyScript.fsx @@ -0,0 +1,8 @@ +module MyScript + +let _ = List.map +//> ^^^ List.map +let _ = List.map id +//> ^^^ List.map +let _ = [1;2] |> List.map id |> List.sum +//> ^^^ List.map diff --git a/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/Solution.sln b/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/Solution.sln new file mode 100644 index 000000000..ec9879d26 --- /dev/null +++ b/test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/Solution.sln @@ -0,0 +1,42 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.3.32811.315 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "A", "A\A.fsproj", "{7713BAC9-DD4B-4AE2-985B-3C33CBA4C814}" +EndProject +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "B", "B\B.fsproj", "{32150CB0-D9DD-424E-9ADC-39E03EE4AAFD}" +EndProject +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "C", "C\C.fsproj", "{E19DBC00-8930-4496-8709-FAF165E9EB45}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{2E20A8C6-3AF6-4A25-868F-E3B627CA6650}" + ProjectSection(SolutionItems) = preProject + MyScript.fsx = MyScript.fsx + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {7713BAC9-DD4B-4AE2-985B-3C33CBA4C814}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7713BAC9-DD4B-4AE2-985B-3C33CBA4C814}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7713BAC9-DD4B-4AE2-985B-3C33CBA4C814}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7713BAC9-DD4B-4AE2-985B-3C33CBA4C814}.Release|Any CPU.Build.0 = Release|Any CPU + {32150CB0-D9DD-424E-9ADC-39E03EE4AAFD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {32150CB0-D9DD-424E-9ADC-39E03EE4AAFD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {32150CB0-D9DD-424E-9ADC-39E03EE4AAFD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {32150CB0-D9DD-424E-9ADC-39E03EE4AAFD}.Release|Any CPU.Build.0 = Release|Any CPU + {E19DBC00-8930-4496-8709-FAF165E9EB45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E19DBC00-8930-4496-8709-FAF165E9EB45}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E19DBC00-8930-4496-8709-FAF165E9EB45}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E19DBC00-8930-4496-8709-FAF165E9EB45}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {9B6A28C2-B988-41EC-90CE-0D3A13363133} + EndGlobalSection +EndGlobal diff --git a/test/FsAutoComplete.Tests.Lsp/Utils/Server.fs b/test/FsAutoComplete.Tests.Lsp/Utils/Server.fs index 5e2c8116c..0a3be94ab 100644 --- a/test/FsAutoComplete.Tests.Lsp/Utils/Server.fs +++ b/test/FsAutoComplete.Tests.Lsp/Utils/Server.fs @@ -299,20 +299,20 @@ module Document = ) let tcs = TaskCompletionSource<_>() - doc - |> diagnosticsStream - |> Observable.takeUntilOther ( + use _ = doc - // `fsharp/documentAnalyzed` signals all checks & analyzers done - |> analyzedStream - |> Observable.filter (fun n -> n.TextDocument.Version = Some doc.Version) - // wait for late diagnostics - |> Observable.delay waitForLateDiagnosticsDelay - ) - |> Observable.bufferSpan (timeout) - // |> Observable.timeoutSpan timeout - |> Observable.subscribe(fun x -> tcs.SetResult x) - |> ignore + |> diagnosticsStream + |> Observable.takeUntilOther ( + doc + // `fsharp/documentAnalyzed` signals all checks & analyzers done + |> analyzedStream + |> Observable.filter (fun n -> n.TextDocument.Version = Some doc.Version) + // wait for late diagnostics + |> Observable.delay waitForLateDiagnosticsDelay + ) + |> Observable.bufferSpan (timeout) + // |> Observable.timeoutSpan timeout + |> Observable.subscribe(fun x -> tcs.SetResult x) let! result = tcs.Task |> Async.AwaitTask