Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Implement basic nested language detection support #1159

Draft
wants to merge 12 commits into
base: main
Choose a base branch
from
4 changes: 4 additions & 0 deletions src/FsAutoComplete.Core/Commands.fs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ type NotificationEvent =
| Canceled of errorMessage: string
| FileParsed of string<LocalPath>
| TestDetected of file: string<LocalPath> * tests: TestAdapter.TestAdapterEntry<range>[]
| NestedLanguagesFound of
file: string<LocalPath> *
version: int *
nestedLanguages: NestedLanguages.NestedLanguageDocument array

module Commands =
open System.Collections.Concurrent
Expand Down
2 changes: 2 additions & 0 deletions src/FsAutoComplete.Core/FsAutoComplete.Core.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<TargetFrameworks Condition="'$(BuildNet7)' == 'true'">net6.0;net7.0</TargetFrameworks>
<TargetFrameworks Condition="'$(BuildNet8)' == 'true'">net6.0;net7.0;net8.0</TargetFrameworks>
<IsPackable>false</IsPackable>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\FsAutoComplete.Logging\FsAutoComplete.Logging.fsproj" />
Expand Down Expand Up @@ -58,6 +59,7 @@
<Compile Include="SignatureHelp.fs" />
<Compile Include="InlayHints.fs" />
<Compile Include="SymbolLocation.fs" />
<Compile Include="NestedLanguages.fs" />
<Compile Include="Commands.fs" />
</ItemGroup>
<Import Project="..\..\.paket\Paket.Restore.targets" />
Expand Down
212 changes: 212 additions & 0 deletions src/FsAutoComplete.Core/NestedLanguages.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
module FsAutoComplete.NestedLanguages

open FsAutoComplete.Logging
open FsToolkit.ErrorHandling
open FSharp.Compiler.Syntax
open FSharp.Compiler.Text
open FSharp.Compiler.CodeAnalysis
open FSharp.Compiler.Symbols

#nowarn "57" // from-end slicing

let logger = LogProvider.getLoggerByName "NestedLanguages"

type private StringParameter =
{ methodIdent: LongIdent
parameterRange: Range
rangesToRemove: Range[]
Fixed Show fixed Hide fixed
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[]> =
Fixed Show fixed Hide fixed
Fixed Show fixed Hide fixed
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 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[] }
Fixed Show fixed Hide fixed

let rangeMinusRanges (totalRange: Range) (rangesToRemove: Range[]) : Range[] =
Fixed Show fixed Hide fixed
Fixed Show fixed Hide fixed
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[],
Fixed Show fixed Hide fixed
Fixed Show fixed Hide fixed
checkResults: FSharpCheckFileResults,
text: IFSACSourceText
) : Async<NestedLanguageDocument[]> =
Fixed Show fixed Hide fixed
Fixed Show fixed Hide fixed
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[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, text: VolatileFile) : NestedLanguageDocument[] Async =
Fixed Show fixed Hide fixed
async {
// get all string constants
let potentialParameters = findParametersForParseTree tyRes.GetAST

logger.info (
Log.setMessageI
$"Found {potentialParameters.Length:stringParams} potential parameters in {text.FileName:filename}@{text.Version:version}"
)

for p in potentialParameters do
logger.info (
Log.setMessageI
$"Potential parameter: {p.parameterRange:range} in member {p.methodIdent:methodName} of {text.FileName:filename}@{text.Version:version} -> {text.Source[p.parameterRange]:sourceText}"
)

let! actualStringSyntaxParameters =
parametersThatAreStringSyntax (potentialParameters, tyRes.GetCheckResults, text.Source)

logger.info (
Log.setMessageI
$"Found {actualStringSyntaxParameters.Length:stringParams} actual parameters in {text.FileName:filename}@{text.Version:version}"
)

return actualStringSyntaxParameters
}
7 changes: 5 additions & 2 deletions src/FsAutoComplete.Core/UntypedAstUtils.fs
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@ module Syntax =

loop [] pats

[<AbstractClass>]
type SyntaxCollectorBase() =
abstract WalkSynModuleOrNamespace: SynModuleOrNamespace -> unit
default _.WalkSynModuleOrNamespace _ = ()
abstract WalkAttribute: SynAttribute -> unit
default _.WalkAttribute _ = ()
default _.WalkAttribute(_: SynAttribute) = ()
abstract WalkSynModuleDecl: SynModuleDecl -> unit
default _.WalkSynModuleDecl _ = ()
abstract WalkExpr: SynExpr -> unit
Expand Down Expand Up @@ -59,8 +60,10 @@ module Syntax =
default _.WalkClause _ = ()
abstract WalkInterpolatedStringPart: SynInterpolatedStringPart -> unit
default _.WalkInterpolatedStringPart _ = ()

abstract WalkMeasure: SynMeasure -> unit
default _.WalkMeasure _ = ()
default _.WalkMeasure(_: SynMeasure) = ()

abstract WalkComponentInfo: SynComponentInfo -> unit
default _.WalkComponentInfo _ = ()
abstract WalkTypeDefnSigRepr: SynTypeDefnSigRepr -> unit
Expand Down
29 changes: 29 additions & 0 deletions src/FsAutoComplete.Core/UntypedAstUtils.fsi
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,65 @@ namespace FSharp.Compiler
module Syntax =
open FSharp.Compiler.Syntax

[<AbstractClass>]
type SyntaxCollectorBase =
new: unit -> SyntaxCollectorBase
abstract WalkSynModuleOrNamespace: SynModuleOrNamespace -> unit
default WalkSynModuleOrNamespace: SynModuleOrNamespace -> unit
abstract WalkAttribute: SynAttribute -> unit
default WalkAttribute: SynAttribute -> unit
abstract WalkSynModuleDecl: SynModuleDecl -> unit
default WalkSynModuleDecl: SynModuleDecl -> unit
abstract WalkExpr: SynExpr -> unit
default WalkExpr: SynExpr -> unit
abstract WalkTypar: SynTypar -> unit
default WalkTypar: SynTypar -> unit
abstract WalkTyparDecl: SynTyparDecl -> unit
default WalkTyparDecl: SynTyparDecl -> unit
abstract WalkTypeConstraint: SynTypeConstraint -> unit
default WalkTypeConstraint: SynTypeConstraint -> unit
abstract WalkType: SynType -> unit
default WalkType: SynType -> unit
abstract WalkMemberSig: SynMemberSig -> unit
default WalkMemberSig: SynMemberSig -> unit
abstract WalkPat: SynPat -> unit
default WalkPat: SynPat -> unit
abstract WalkValTyparDecls: SynValTyparDecls -> unit
default WalkValTyparDecls: SynValTyparDecls -> unit
abstract WalkBinding: SynBinding -> unit
default WalkBinding: SynBinding -> unit
abstract WalkSimplePat: SynSimplePat -> unit
default WalkSimplePat: SynSimplePat -> unit
abstract WalkInterfaceImpl: SynInterfaceImpl -> unit
default WalkInterfaceImpl: SynInterfaceImpl -> unit
abstract WalkClause: SynMatchClause -> unit
default WalkClause: SynMatchClause -> unit
abstract WalkInterpolatedStringPart: SynInterpolatedStringPart -> unit
default WalkInterpolatedStringPart: SynInterpolatedStringPart -> unit
abstract WalkMeasure: SynMeasure -> unit
default WalkMeasure: SynMeasure -> unit
abstract WalkComponentInfo: SynComponentInfo -> unit
default WalkComponentInfo: SynComponentInfo -> unit
abstract WalkTypeDefnSigRepr: SynTypeDefnSigRepr -> unit
default WalkTypeDefnSigRepr: SynTypeDefnSigRepr -> unit
abstract WalkUnionCaseType: SynUnionCaseKind -> unit
default WalkUnionCaseType: SynUnionCaseKind -> unit
abstract WalkEnumCase: SynEnumCase -> unit
default WalkEnumCase: SynEnumCase -> unit
abstract WalkField: SynField -> unit
default WalkField: SynField -> unit
abstract WalkTypeDefnSimple: SynTypeDefnSimpleRepr -> unit
default WalkTypeDefnSimple: SynTypeDefnSimpleRepr -> unit
abstract WalkValSig: SynValSig -> unit
default WalkValSig: SynValSig -> unit
abstract WalkMember: SynMemberDefn -> unit
default WalkMember: SynMemberDefn -> unit
abstract WalkUnionCase: SynUnionCase -> unit
default WalkUnionCase: SynUnionCase -> unit
abstract WalkTypeDefnRepr: SynTypeDefnRepr -> unit
default WalkTypeDefnRepr: SynTypeDefnRepr -> unit
abstract WalkTypeDefn: SynTypeDefn -> unit
default WalkTypeDefn: SynTypeDefn -> unit

val walkAst: walker: SyntaxCollectorBase -> input: ParsedInput -> unit

Expand Down
34 changes: 27 additions & 7 deletions src/FsAutoComplete/LspServers/AdaptiveServerState.fs
Original file line number Diff line number Diff line change
Expand Up @@ -392,19 +392,26 @@ type AdaptiveState(lspClient: FSharpLspClient, sourceTextFactory: ISourceTextFac
Loggers.analyzers.error (Log.setMessageI $"Run failed for {file:file}" >> Log.addExn ex)
}

let checkForNestedLanguages _config parseAndCheckResults (volatileFile: VolatileFile) =
async {
let! languages = NestedLanguages.findNestedLanguages (parseAndCheckResults, volatileFile)

notifications.Trigger(
NotificationEvent.NestedLanguagesFound(volatileFile.FileName, volatileFile.Version, languages),
CancellationToken.None
)
}

do
disposables.Add
<| fileChecked.Publish.Subscribe(fun (parseAndCheck, volatileFile, ct) ->
if volatileFile.Source.Length = 0 then
() // Don't analyze and error on an empty file
else
async {
let config = config |> AVal.force
do! builtInCompilerAnalyzers config volatileFile parseAndCheck
do! runAnalyzers config parseAndCheck volatileFile

}
|> Async.StartWithCT ct)
let config = config |> AVal.force
Async.Start(builtInCompilerAnalyzers config volatileFile parseAndCheck, ct)
Async.Start(runAnalyzers config parseAndCheck volatileFile, ct)
Async.Start(checkForNestedLanguages config parseAndCheck volatileFile, ct))


let handleCommandEvents (n: NotificationEvent, ct: CancellationToken) =
Expand Down Expand Up @@ -594,6 +601,19 @@ type AdaptiveState(lspClient: FSharpLspClient, sourceTextFactory: ISourceTextFac
{ File = Path.LocalPathToUri file
Tests = tests |> Array.map map }
|> lspClient.NotifyTestDetected
| NotificationEvent.NestedLanguagesFound(file, version, nestedLanguages) ->
let uri = Path.LocalPathToUri file

do!
lspClient.NotifyNestedLanguages(
{ TextDocument = { Version = version; Uri = uri }
NestedLanguages =
nestedLanguages
|> Array.map (fun n ->
{ Language = n.Language
Ranges = n.Ranges |> Array.map fcsRangeToLsp }) }
)

with ex ->
logger.error (
Log.setMessage "Exception while handling command event {evt}: {ex}"
Expand Down
Loading
Loading