Skip to content

Commit

Permalink
Add support for inlay hints (#907)
Browse files Browse the repository at this point in the history
* add inlay hints based on Phillip's work

* Add tests
  • Loading branch information
baronfel authored Apr 2, 2022
1 parent d849918 commit 8bf4e4a
Show file tree
Hide file tree
Showing 10 changed files with 372 additions and 13 deletions.
3 changes: 3 additions & 0 deletions src/FsAutoComplete.Core/Commands.fs
Original file line number Diff line number Diff line change
Expand Up @@ -1600,6 +1600,9 @@ type Commands
// return CoreResponse.Res html
// }

member _.InlayHints (text, tyRes: ParseAndCheckResults, range) =
FsAutoComplete.Core.InlayHints.provideHints(text, tyRes, range)

member __.PipelineHints(tyRes: ParseAndCheckResults) =
result {
let! contents = state.TryGetFileSource tyRes.FileName
Expand Down
1 change: 1 addition & 0 deletions src/FsAutoComplete.Core/FsAutoComplete.Core.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
<Compile Include="Fsdn.fs" />
<!-- <Compile Include="Lint.fs" /> -->
<Compile Include="SignatureHelp.fs" />
<Compile Include="InlayHints.fs" />
<Compile Include="Commands.fs" />
</ItemGroup>
<Import Project="..\..\.paket\Paket.Restore.targets" />
Expand Down
172 changes: 172 additions & 0 deletions src/FsAutoComplete.Core/InlayHints.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
module FsAutoComplete.Core.InlayHints

open System
open FSharp.Compiler.Text
open FSharp.Compiler.Syntax
open FsToolkit.ErrorHandling
open FsAutoComplete
open FSharp.Compiler.Symbols
open FSharp.UMX
open System.Linq
open System.Collections.Immutable
open FSharp.Compiler.CodeAnalysis
open System.Text

type HintKind = Parameter | Type
type Hint = { Text: string; Pos: Position; Kind: HintKind }

let private getArgumentsFor (state: FsAutoComplete.State, p: ParseAndCheckResults, identText: Range) =
option {

let! contents =
state.TryGetFileSource p.FileName
|> Option.ofResult

let! line = contents.GetLine identText.End
let! symbolUse = p.TryGetSymbolUse identText.End line

match symbolUse.Symbol with
| :? FSharpMemberOrFunctionOrValue as mfv when
mfv.IsFunction
|| mfv.IsConstructor
|| mfv.CurriedParameterGroups.Count <> 0
->
let parameters = mfv.CurriedParameterGroups

let formatted =
parameters
|> Seq.collect (fun pGroup -> pGroup |> Seq.map (fun p -> p.DisplayName + ":"))

return formatted |> Array.ofSeq
| _ -> return! None
}

let private isSignatureFile (f: string<LocalPath>) =
System.IO.Path.GetExtension(UMX.untag f) = ".fsi"

let getFirstPositionAfterParen (str: string) startPos =
match str with
| null -> -1
| str when startPos > str.Length -> -1
| str -> str.IndexOf('(') + 1

let provideHints (text: NamedText, p: ParseAndCheckResults, range: Range) : Hint [] =
let parseFileResults, checkFileResults = p.GetParseResults, p.GetCheckResults

let symbolUses =
checkFileResults.GetAllUsesOfAllSymbolsInFile(System.Threading.CancellationToken.None)
|> Seq.filter (fun su -> Range.rangeContainsRange range su.Range)
|> Seq.toList

let typeHints = ImmutableArray.CreateBuilder()
let parameterHints = ImmutableArray.CreateBuilder()

let isValidForTypeHint (funcOrValue: FSharpMemberOrFunctionOrValue) (symbolUse: FSharpSymbolUse) =
let isLambdaIfFunction =
funcOrValue.IsFunction
&& parseFileResults.IsBindingALambdaAtPosition symbolUse.Range.Start

(funcOrValue.IsValue || isLambdaIfFunction)
&& not (parseFileResults.IsTypeAnnotationGivenAtPosition symbolUse.Range.Start)
&& symbolUse.IsFromDefinition
&& not funcOrValue.IsMember
&& not funcOrValue.IsMemberThisValue
&& not funcOrValue.IsConstructorThisValue
&& not (PrettyNaming.IsOperatorDisplayName funcOrValue.DisplayName)

for symbolUse in symbolUses do
match symbolUse.Symbol with
| :? FSharpMemberOrFunctionOrValue as funcOrValue when isValidForTypeHint funcOrValue symbolUse ->
let layout =
": "
+ funcOrValue.ReturnParameter.Type.Format symbolUse.DisplayContext

let hint =
{ Text = layout
Pos = symbolUse.Range.End
Kind = Type }

typeHints.Add(hint)

| :? FSharpMemberOrFunctionOrValue as func when func.IsFunction && not symbolUse.IsFromDefinition ->
let appliedArgRangesOpt =
parseFileResults.GetAllArgumentsForFunctionApplicationAtPostion symbolUse.Range.Start

match appliedArgRangesOpt with
| None -> ()
| Some [] -> ()
| Some appliedArgRanges ->
let parameters = func.CurriedParameterGroups |> Seq.concat
let appliedArgRanges = appliedArgRanges |> Array.ofList
let definitionArgs = parameters |> Array.ofSeq

for idx = 0 to appliedArgRanges.Length - 1 do
let appliedArgRange = appliedArgRanges.[idx]
let definitionArgName = definitionArgs.[idx].DisplayName

if not (String.IsNullOrWhiteSpace(definitionArgName)) then
let hint =
{ Text = definitionArgName + " ="
Pos = appliedArgRange.Start
Kind = Parameter }

parameterHints.Add(hint)

| :? FSharpMemberOrFunctionOrValue as methodOrConstructor when methodOrConstructor.IsConstructor -> // TODO: support methods when this API comes into FCS
let endPosForMethod = symbolUse.Range.End
let line, _ = Position.toZ endPosForMethod

let afterParenPosInLine =
getFirstPositionAfterParen (text.Lines.[line].ToString()) (endPosForMethod.Column)

let tupledParamInfos =
parseFileResults.FindParameterLocations(Position.fromZ line afterParenPosInLine)

let appliedArgRanges =
parseFileResults.GetAllArgumentsForFunctionApplicationAtPostion symbolUse.Range.Start

match tupledParamInfos, appliedArgRanges with
| None, None -> ()

// Prefer looking at the "tupled" view if it exists, even if the other ranges exist.
// M(1, 2) can give results for both, but in that case we want the "tupled" view.
| Some tupledParamInfos, _ ->
let parameters =
methodOrConstructor.CurriedParameterGroups
|> Seq.concat
|> Array.ofSeq // TODO: need ArgumentLocations to be surfaced

for idx = 0 to parameters.Length - 1 do
// let paramLocationInfo = tupledParamInfos. .ArgumentLocations.[idx]
// let paramName = parameters.[idx].DisplayName
// if not paramLocationInfo.IsNamedArgument && not (String.IsNullOrWhiteSpace(paramName)) then
// let hint = { Text = paramName + " ="; Pos = paramLocationInfo.ArgumentRange.Start; Kind = Parameter }
// parameterHints.Add(hint)
()

// This will only happen for curried methods defined in F#.
| _, Some appliedArgRanges ->
let parameters =
methodOrConstructor.CurriedParameterGroups
|> Seq.concat

let appliedArgRanges = appliedArgRanges |> Array.ofList
let definitionArgs = parameters |> Array.ofSeq

for idx = 0 to appliedArgRanges.Length - 1 do
let appliedArgRange = appliedArgRanges.[idx]
let definitionArgName = definitionArgs.[idx].DisplayName

if not (String.IsNullOrWhiteSpace(definitionArgName)) then
let hint =
{ Text = definitionArgName + " ="
Pos = appliedArgRange.Start
Kind = Parameter }

parameterHints.Add(hint)
| _ -> ()

let typeHints = typeHints.ToImmutableArray()
let parameterHints = parameterHints.ToImmutableArray()

typeHints.AddRange(parameterHints).ToArray()
38 changes: 37 additions & 1 deletion src/FsAutoComplete/FsAutoComplete.Lsp.fs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ type OptionallyVersionedTextDocumentPositionParams =
member this.TextDocument with get() = { Uri = this.TextDocument.Uri }
member this.Position with get() = this.Position

[<RequireQualifiedAccess>]
type InlayHintKind = Type | Parameter

type LSPInlayHint = {
Text : string
Pos : Types.Position
Kind : InlayHintKind
}

module Result =
let ofCoreResponse (r: CoreResponse<'a>) =
match r with
Expand Down Expand Up @@ -662,7 +671,7 @@ type FSharpLspServer(backgroundServiceEnabled: bool, state: State, lspClient: FS

///Helper function for handling file requests using **recent** type check results
member x.fileHandler<'a>
(f: string<LocalPath> -> ParseAndCheckResults -> ISourceText -> AsyncLspResult<'a>)
(f: string<LocalPath> -> ParseAndCheckResults -> NamedText -> AsyncLspResult<'a>)
(file: string<LocalPath>)
: AsyncLspResult<'a> =
async {
Expand Down Expand Up @@ -2652,6 +2661,32 @@ type FSharpLspServer(backgroundServiceEnabled: bool, state: State, lspClient: FS
// return res
// }

member x.FSharpInlayHints(p: LspHelpers.FSharpInlayHintsRequest) =
let mapHintKind (k: FsAutoComplete.Core.InlayHints.HintKind): InlayHintKind =
match k with
| FsAutoComplete.Core.InlayHints.HintKind.Type -> InlayHintKind.Type
| FsAutoComplete.Core.InlayHints.HintKind.Parameter -> InlayHintKind.Parameter

logger.info (
Log.setMessage "FSharpInlayHints Request: {parms}"
>> Log.addContextDestructured "parms" p
)

let fn = p.TextDocument.GetFilePath() |> Utils.normalizePath
let fcsRange = protocolRangeToRange (UMX.untag fn) p.Range
fn
|> x.fileHandler (fun fn tyRes lines ->
let hints = commands.InlayHints(lines, tyRes, fcsRange)
let lspHints =
hints
|> Array.map (fun h -> {
Text = h.Text
Pos = fcsPosToLsp h.Pos
Kind = mapHintKind h.Kind
})
AsyncLspResult.success lspHints
)

member x.FSharpPipelineHints(p: FSharpPipelineHintRequest) =
logger.info (
Log.setMessage "FSharpPipelineHints Request: {parms}"
Expand Down Expand Up @@ -2705,6 +2740,7 @@ let startCore backgroundServiceEnabled toolsPath workspaceLoaderFactory =
|> Map.add "fsproj/addFileAbove" (requestHandling (fun s p -> s.FsProjAddFileAbove(p)))
|> Map.add "fsproj/addFileBelow" (requestHandling (fun s p -> s.FsProjAddFileBelow(p)))
|> Map.add "fsproj/addFile" (requestHandling (fun s p -> s.FsProjAddFile(p)))
|> Map.add "fsharp/inlayHints" (requestHandling (fun s p -> s.FSharpInlayHints(p)))

let state =
State.Initial toolsPath workspaceLoaderFactory
Expand Down
6 changes: 6 additions & 0 deletions src/FsAutoComplete/LspHelpers.fs
Original file line number Diff line number Diff line change
Expand Up @@ -848,3 +848,9 @@ let encodeSemanticHighlightRanges (rangesAndHighlights: (struct(Ionide.LanguageS
prev <- currentRange
idx <- idx + 5
Some finalArray


type FSharpInlayHintsRequest = {
TextDocument: TextDocumentIdentifier
Range: Range
}
15 changes: 14 additions & 1 deletion test/FsAutoComplete.Tests.Lsp/Helpers.fs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ let logger = Expecto.Logging.Log.create "LSPTests"
type Cacher<'t> = System.Reactive.Subjects.ReplaySubject<'t>
type ClientEvents = IObservable<string * obj>

module Range =
let rangeContainsPos (range : Range) (pos : Position) =
range.Start <= pos && pos <= range.End

let record (cacher: Cacher<_>) =
fun name payload ->
cacher.OnNext (name, payload);
Expand Down Expand Up @@ -482,8 +486,17 @@ let waitForTestDetected (fileName: string) (events: ClientEvents): Async<TestDet
testNotificationFileName = fileName)
|> Async.AwaitObservable


let waitForEditsForFile file =
workspaceEdits
>> editsFor file
>> Async.AwaitObservable

let trySerialize (t: string): 't option =
try
JsonSerializer.readJson t |> Some
with _ -> None

let (|As|_|) (m: PlainNotification): 't option =
match trySerialize m.Content with
| Some(r: FsAutoComplete.CommandResponse.ResponseMsg<'t>) -> Some r.Data
| None -> None
11 changes: 0 additions & 11 deletions test/FsAutoComplete.Tests.Lsp/InfoPanelTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,9 @@ open Expecto
open System.IO
open Ionide.LanguageServerProtocol.Types
open FsAutoComplete
open FsAutoComplete.LspHelpers
open Helpers
open FsToolkit.ErrorHandling

let trySerialize (t: string): 't option =
try
JsonSerializer.readJson t |> Some
with _ -> None

let (|As|_|) (m: PlainNotification): 't option =
match trySerialize m.Content with
| Some(r: FsAutoComplete.CommandResponse.ResponseMsg<'t>) -> Some r.Data
| None -> None

let docFormattingTest state =
let server =
async {
Expand Down
Loading

0 comments on commit 8bf4e4a

Please sign in to comment.