Skip to content

Commit

Permalink
implement basic nested language support and add first end to end test
Browse files Browse the repository at this point in the history
this is blocked by dotnet/fsharp#15925 because FCS APIs don't provide us with attribute lists
  • Loading branch information
baronfel committed Sep 4, 2023
1 parent 257a9ce commit ddd5195
Show file tree
Hide file tree
Showing 10 changed files with 419 additions and 227 deletions.
2 changes: 1 addition & 1 deletion src/FsAutoComplete.Core/Commands.fs
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ type NotificationEvent =
| NestedLanguagesFound of
file: string<LocalPath> *
version: int *
nestedLanguages: {| language: string; range: Range |}[]
nestedLanguages: NestedLanguages.NestedLanguageDocument array

module Commands =
open System.Collections.Concurrent
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 @@ -3,6 +3,7 @@
<TargetFrameworks>net6.0</TargetFrameworks>
<TargetFrameworks Condition="'$(BuildNet7)' == 'true'">net6.0;net7.0</TargetFrameworks>
<IsPackable>false</IsPackable>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\FsAutoComplete.Logging\FsAutoComplete.Logging.fsproj" />
Expand Down
179 changes: 175 additions & 4 deletions src/FsAutoComplete.Core/NestedLanguages.fs
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,189 @@ module FsAutoComplete.NestedLanguages

open FsToolkit.ErrorHandling
open FSharp.Compiler.Syntax
open FSharp.Compiler.Syntax.SyntaxTraversal
open FSharp.Compiler.Text
open FSharp.Compiler.CodeAnalysis
open FSharp.Compiler.Symbols

#nowarn "57" // from-end slicing

type private StringParameter =
{ methodIdent: LongIdent
parameterRange: Range }
parameterRange: Range
rangesToRemove: Range[]
parameterPosition: int }

let discoverRangesToRemoveForInterpolatedString (list: SynInterpolatedStringPart list) =
list
|> List.choose (function
| SynInterpolatedStringPart.FillExpr(fillExpr = e) -> Some e.Range
| _ -> None)
|> List.toArray

let private (|Ident|_|) (e: SynExpr) =
match e with
| SynExpr.LongIdent(longDotId = SynLongIdent(id = ident)) -> Some ident
| _ -> None

let rec private (|IsApplicationWithStringParameters|_|) (e: SynExpr) : option<StringParameter[]> =
match e with
// lines inside a binding
// let doThing () =
// c.M("<div>")
// c.M($"<div>{1 + 1}")
// "<div>" |> c.M
// $"<div>{1 + 1}" |> c.M
| SynExpr.Sequential(expr1 = e1; expr2 = e2) ->
[| match e1 with
| IsApplicationWithStringParameters(stringParameter) -> yield! stringParameter
| _ -> ()

match e2 with
| IsApplicationWithStringParameters(stringParameter) -> yield! stringParameter
| _ -> () |]
// TODO: check if the array would be empty and return none
|> Some

// method call with string parameter - c.M("<div>")
| SynExpr.App(
funcExpr = Ident(ident); argExpr = SynExpr.Paren(expr = SynExpr.Const(SynConst.String(text, kind, range), _)))
// method call with string parameter - c.M "<div>"
| SynExpr.App(funcExpr = Ident(ident); argExpr = SynExpr.Const(SynConst.String(text, kind, range), _)) ->
Some(
[| { methodIdent = ident
parameterRange = range
rangesToRemove = [||]
parameterPosition = 0 } |]
)
// method call with interpolated string parameter - c.M $"<div>{1 + 1}"
| SynExpr.App(
funcExpr = SynExpr.LongIdent(longDotId = SynLongIdent(id = ident))
argExpr = SynExpr.Paren(expr = SynExpr.InterpolatedString(contents = parts; range = range)))
// method call with interpolated string parameter - c.M($"<div>{1 + 1}")
| SynExpr.App(
funcExpr = SynExpr.LongIdent(longDotId = SynLongIdent(id = ident))
argExpr = SynExpr.InterpolatedString(contents = parts; range = range)) ->
let rangesToRemove = discoverRangesToRemoveForInterpolatedString parts

Some(
[| { methodIdent = ident
parameterRange = range
rangesToRemove = rangesToRemove
parameterPosition = 0 } |]
)
// piped method call with string parameter - "<div>" |> c.M
// piped method call with interpolated parameter - $"<div>{1 + 1}" |> c.M
// method call with multiple string or interpolated string parameters (this also covers the case when not all parameters of the member are strings)
// c.M("<div>", true) and/or c.M(true, "<div>")
// piped method call with multiple string or interpolated string parameters (this also covers the case when not all parameters of the member are strings)
// let binding that is a string value that has the stringsyntax attribute on it - [<StringSyntax("html")>] let html = "<div />"
// all of the above but with literals
| _ -> None

/// <summary></summary>
type private StringParameterFinder() =
inherit SyntaxVisitorBase<StringParameter[]>()
inherit SyntaxCollectorBase()

let languages = ResizeArray<StringParameter>()

override _.WalkBinding(SynBinding(expr = expr)) =
match expr with
| IsApplicationWithStringParameters(stringParameters) -> languages.AddRange stringParameters
| _ -> ()

override _.WalkSynModuleDecl(decl) =
match decl with
| SynModuleDecl.Expr(expr = IsApplicationWithStringParameters(stringParameters)) ->
languages.AddRange stringParameters
| _ -> ()

member _.NestedLanguages = languages.ToArray()


let private findParametersForParseTree (p: ParsedInput) =
let walker = StringParameterFinder()
walkAst walker p
walker.NestedLanguages

let private (|IsStringSyntax|_|) (a: FSharpAttribute) =
match a.AttributeType.FullName with
| "System.Diagnostics.CodeAnalysis.StringSyntaxAttribute" ->
match a.ConstructorArguments |> Seq.tryHead with
| Some(_ty, languageValue) -> Some(languageValue :?> string)
| _ -> None
| _ -> None

type NestedLanguageDocument = { Language: string; Ranges: Range[] }

let rangeMinusRanges (totalRange: Range) (rangesToRemove: Range[]) : Range[] =
match rangesToRemove with
| [||] -> [| totalRange |]
| _ ->
let mutable returnVal = ResizeArray()
let mutable currentStart = totalRange.Start

for r in rangesToRemove do
returnVal.Add(Range.mkRange totalRange.FileName currentStart r.Start)
currentStart <- r.End

returnVal.Add(Range.mkRange totalRange.FileName currentStart totalRange.End)
returnVal.ToArray()

let private parametersThatAreStringSyntax
(
parameters: StringParameter[],
checkResults: FSharpCheckFileResults,
text: IFSACSourceText
) : Async<NestedLanguageDocument[]> =
async {
let returnVal = ResizeArray()

for p in parameters do
let precedingParts, lastPart = p.methodIdent.[0..^1], p.methodIdent[^0]
let endOfFinalTextToken = lastPart.idRange.End

match text.GetLine(endOfFinalTextToken) with
| None -> ()
| Some lineText ->

match
checkResults.GetSymbolUseAtLocation(
endOfFinalTextToken.Line,
endOfFinalTextToken.Column,
lineText,
precedingParts |> List.map (fun i -> i.idText)
)
with
| None -> ()
| Some usage ->

let sym = usage.Symbol
// todo: keep MRU map of symbols to parameters and MRU of parameters to stringsyntax status

match sym with
| :? FSharpMemberOrFunctionOrValue as mfv ->
let allParameters = mfv.CurriedParameterGroups |> Seq.collect id |> Seq.toArray
let fsharpP = allParameters |> Seq.item p.parameterPosition

match fsharpP.Attributes |> Seq.tryPick (|IsStringSyntax|_|) with
| Some language ->
returnVal.Add
{ Language = language
Ranges = rangeMinusRanges p.parameterRange p.rangesToRemove }
| None -> ()
| _ -> ()

return returnVal.ToArray()
}

/// to find all of the nested language highlights, we're going to do the following:
/// * find all of the interpolated strings or string literals in the file that are in parameter-application positions
/// * get the method calls happening at those positions to check if that method has the StringSyntaxAttribute
/// * if so, return a) the language in the StringSyntaxAttribute, and b) the range of the interpolated string
let findNestedLanguages (tyRes: ParseAndCheckResults) = async { return [||] }
let findNestedLanguages (tyRes: ParseAndCheckResults, text: IFSACSourceText) : NestedLanguageDocument[] Async =
async {
// get all string constants
let potentialParameters = findParametersForParseTree tyRes.GetAST
let! actualStringSyntaxParameters = parametersThatAreStringSyntax (potentialParameters, tyRes.GetCheckResults, text)
return actualStringSyntaxParameters
}
16 changes: 9 additions & 7 deletions src/FsAutoComplete/LspServers/AdaptiveFSharpLspServer.fs
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ type AdaptiveFSharpLspServer
use progress = new ServerProgressReport(lspClient)
do! progress.Begin($"Checking simplifing of names {fileName}...", message = filePathUntag)

let! nestedLanguages = NestedLanguages.findNestedLanguages tyRes
let! nestedLanguages = NestedLanguages.findNestedLanguages (tyRes, source)
let! ct = Async.CancellationToken
notifications.Trigger(NotificationEvent.NestedLanguagesFound(filePath, version, nestedLanguages), ct)
with e ->
Expand All @@ -371,7 +371,9 @@ type AdaptiveFSharpLspServer
config.SimplifyNameAnalyzer
&& isNotExcluded config.SimplifyNameAnalyzerExclusions
then
checkSimplifiedNames ]
checkSimplifiedNames
// todo: add config flag for nested languages
findNestedLanguages ]

async {
do! analyzers |> Async.parallel75 |> Async.Ignore<unit[]>
Expand Down Expand Up @@ -631,14 +633,14 @@ type AdaptiveFSharpLspServer
| NotificationEvent.NestedLanguagesFound(file, version, nestedLanguages) ->
do!
lspClient.NotifyNestedLanguages(
{| nestedLanguages =
{ NestedLanguages =
nestedLanguages
|> Array.map (fun l ->
{| language = l.language
range = fcsRangeToLsp l.range |})
textDocument =
{ Language = l.Language
Ranges = l.Ranges |> Array.map fcsRangeToLsp })
TextDocument =
{ Uri = Path.LocalPathToUri file
Version = version } |}
Version = version } }
)
with ex ->
logger.error (
Expand Down
17 changes: 9 additions & 8 deletions src/FsAutoComplete/LspServers/FSharpLspClient.fs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ namespace FsAutoComplete.Lsp


open Ionide.LanguageServerProtocol
open Ionide.LanguageServerProtocol.Types.LspResult
open Ionide.LanguageServerProtocol.Server
open Ionide.LanguageServerProtocol.Types
open FsAutoComplete.LspHelpers
Expand All @@ -12,6 +11,14 @@ open FsAutoComplete.Utils
open System.Threading
open IcedTasks

type NestedLanguage =
{ Language: string
Ranges: Types.Range[] }

type TextDocumentNestedLanguages =
{ TextDocument: VersionedTextDocumentIdentifier
NestedLanguages: NestedLanguage[] }


type FSharpLspClient(sendServerNotification: ClientNotificationSender, sendServerRequest: ClientRequestSender) =

Expand Down Expand Up @@ -72,13 +79,7 @@ type FSharpLspClient(sendServerNotification: ClientNotificationSender, sendServe
member __.NotifyTestDetected(p: TestDetectedNotification) =
sendServerNotification "fsharp/testDetected" (box p) |> Async.Ignore

member _.NotifyNestedLanguages
(p:
{| textDocument: VersionedTextDocumentIdentifier
nestedLanguages:
{| language: string
range: Ionide.LanguageServerProtocol.Types.Range |}[] |})
=
member _.NotifyNestedLanguages(p: TextDocumentNestedLanguages) =
sendServerNotification "fsharp/textDocument/nestedLanguages" (box p)
|> Async.Ignore

Expand Down
10 changes: 5 additions & 5 deletions src/FsAutoComplete/LspServers/FsAutoComplete.Lsp.fs
Original file line number Diff line number Diff line change
Expand Up @@ -386,14 +386,14 @@ type FSharpLspServer(state: State, lspClient: FSharpLspClient, sourceTextFactory

| NotificationEvent.NestedLanguagesFound(file, version, nestedLanguages) ->
lspClient.NotifyNestedLanguages(
{| nestedLanguages =
{ NestedLanguages =
nestedLanguages
|> Array.map (fun l ->
{| language = l.language
range = fcsRangeToLsp l.range |})
textDocument =
{ Language = l.Language
Ranges = l.Ranges |> Array.map fcsRangeToLsp })
TextDocument =
{ Uri = Path.LocalPathToUri file
Version = version } |}
Version = version } }
)
|> Async.Start
with ex ->
Expand Down
45 changes: 45 additions & 0 deletions test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
module FsAutoComplete.Tests.NestedLanguageTests

open Expecto
open Utils.ServerTests
open Helpers
open Utils.Server
open System
open Ionide.LanguageServerProtocol.Types

type Document with

member x.NestedLanguages =
x.Server.Events
|> Document.typedEvents<FsAutoComplete.Lsp.TextDocumentNestedLanguages> ("fsharp/textDocument/nestedLanguages")
|> Observable.filter (fun n -> n.TextDocument = x.VersionedTextDocumentIdentifier)

let hasLanguages name source expectedLanguages server =
testAsync name {
let! (doc, _) = server |> Server.createUntitledDocument source
let! nestedLanguages = doc.NestedLanguages |> Async.AwaitObservable

let mappedExpectedLanguages: FsAutoComplete.Lsp.NestedLanguage array =
expectedLanguages
|> Array.map (fun (l, rs) ->
{ Language = l
Ranges =
rs
|> Array.map (fun ((sl, sc), (el, ec)) ->
{ Start = { Line = sl; Character = sc }
End = { Line = el; Character = ec } }) })

Expect.equal nestedLanguages.NestedLanguages mappedExpectedLanguages "languages"
}

let tests state =
testList
"nested languages"
[ serverTestList "class member" state defaultConfigDto None (fun server ->
[ hasLanguages
"with single string parameter"
"""
let b = System.UriBuilder("https://google.com")
"""
[| ("uri", [| (1, 38), (1, 58) |]) |]
server ]) ]
Loading

0 comments on commit ddd5195

Please sign in to comment.