From a3fd40ec485fab21ea32187808405589810f9dbb Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Sat, 25 Nov 2023 15:57:09 -0600 Subject: [PATCH 01/12] initial nested language support --- Directory.Build.props | 1 + src/Directory.Build.props | 4 +- src/FsAutoComplete.Core/Commands.fs | 4 + .../FsAutoComplete.Core.fsproj | 2 + src/FsAutoComplete.Core/NestedLanguages.fs | 190 ++++++++++++++++++ src/FsAutoComplete.Core/UntypedAstUtils.fs | 7 +- src/FsAutoComplete.Core/UntypedAstUtils.fsi | 29 +++ .../LspServers/AdaptiveServerState.fs | 13 ++ .../LspServers/FSharpLspClient.fs | 13 +- .../LspServers/FSharpLspClient.fsi | 10 + .../NestedLanguageTests.fs | 45 +++++ test/FsAutoComplete.Tests.Lsp/Program.fs | 1 + test/FsAutoComplete.Tests.Lsp/Utils/Server.fs | 7 +- .../FsAutoComplete.Tests.Lsp/Utils/Server.fsi | 188 ++++++++--------- 14 files changed, 412 insertions(+), 102 deletions(-) create mode 100644 src/FsAutoComplete.Core/NestedLanguages.fs create mode 100644 test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs diff --git a/Directory.Build.props b/Directory.Build.props index 33d31a741..203521a26 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,6 +14,7 @@ $(WarnOn);3390 $(NoWarn);NU1701 + $(NoWarn);NU1510 true $(MSBuildThisFileDirectory)CHANGELOG.md diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 00d2be6f3..a61f3638a 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -10,8 +10,8 @@ $(NoWarn);NU5104 $(NoWarn);FS0075 - - $(NoWarn);FS3391 + + $(NoWarn);FS3391;NU1510 $(OtherFlags) --test:GraphBasedChecking --test:DumpCheckingGraph diff --git a/src/FsAutoComplete.Core/Commands.fs b/src/FsAutoComplete.Core/Commands.fs index b31c157d7..53817e0fe 100644 --- a/src/FsAutoComplete.Core/Commands.fs +++ b/src/FsAutoComplete.Core/Commands.fs @@ -84,6 +84,10 @@ type NotificationEvent = | Canceled of errorMessage: string | FileParsed of string | TestDetected of file: string * tests: TestAdapter.TestAdapterEntry[] + | NestedLanguagesFound of + file: string * + version: int * + nestedLanguages: NestedLanguages.NestedLanguageDocument array module Commands = open System.Collections.Concurrent diff --git a/src/FsAutoComplete.Core/FsAutoComplete.Core.fsproj b/src/FsAutoComplete.Core/FsAutoComplete.Core.fsproj index 04812cf9f..c884bd2e9 100644 --- a/src/FsAutoComplete.Core/FsAutoComplete.Core.fsproj +++ b/src/FsAutoComplete.Core/FsAutoComplete.Core.fsproj @@ -4,6 +4,7 @@ net8.0;net9.0 false $(NoWarn);FS0057 + preview @@ -61,6 +62,7 @@ + diff --git a/src/FsAutoComplete.Core/NestedLanguages.fs b/src/FsAutoComplete.Core/NestedLanguages.fs new file mode 100644 index 000000000..f88cabf1c --- /dev/null +++ b/src/FsAutoComplete.Core/NestedLanguages.fs @@ -0,0 +1,190 @@ +module FsAutoComplete.NestedLanguages + +open FsToolkit.ErrorHandling +open FSharp.Compiler.Syntax +open FSharp.Compiler.Text +open FSharp.Compiler.CodeAnalysis +open FSharp.Compiler.Symbols + +#nowarn "57" // from-end slicing + +type private StringParameter = + { methodIdent: LongIdent + 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 = + match e with + // lines inside a binding + // let doThing () = + // c.M("
") + // c.M($"
{1 + 1}") + // "
" |> c.M + // $"
{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("
") + | SynExpr.App( + funcExpr = Ident(ident); argExpr = SynExpr.Paren(expr = SynExpr.Const(SynConst.String(_text, _kind, range), _))) + // method call with string parameter - c.M "
" + | 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 $"
{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($"
{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 - "
" |> c.M + // piped method call with interpolated parameter - $"
{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("
", true) and/or c.M(true, "
") + // 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 - [] let html = "
" + // all of the above but with literals + | _ -> None + +/// +type private StringParameterFinder() = + inherit SyntaxCollectorBase() + + let languages = ResizeArray() + + 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 = + 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, text: IFSACSourceText) : NestedLanguageDocument[] Async = + async { + // get all string constants + let potentialParameters = findParametersForParseTree tyRes.GetAST + let! actualStringSyntaxParameters = parametersThatAreStringSyntax (potentialParameters, tyRes.GetCheckResults, text) + return actualStringSyntaxParameters + } diff --git a/src/FsAutoComplete.Core/UntypedAstUtils.fs b/src/FsAutoComplete.Core/UntypedAstUtils.fs index d06bc6af3..7722b0726 100644 --- a/src/FsAutoComplete.Core/UntypedAstUtils.fs +++ b/src/FsAutoComplete.Core/UntypedAstUtils.fs @@ -26,11 +26,12 @@ module Syntax = loop [] pats + [] 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 @@ -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 diff --git a/src/FsAutoComplete.Core/UntypedAstUtils.fsi b/src/FsAutoComplete.Core/UntypedAstUtils.fsi index d99122114..40cab0b89 100644 --- a/src/FsAutoComplete.Core/UntypedAstUtils.fsi +++ b/src/FsAutoComplete.Core/UntypedAstUtils.fsi @@ -3,36 +3,65 @@ namespace FSharp.Compiler module Syntax = open FSharp.Compiler.Syntax + [] 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 diff --git a/src/FsAutoComplete/LspServers/AdaptiveServerState.fs b/src/FsAutoComplete/LspServers/AdaptiveServerState.fs index c521a4eb7..4d242e2b7 100644 --- a/src/FsAutoComplete/LspServers/AdaptiveServerState.fs +++ b/src/FsAutoComplete/LspServers/AdaptiveServerState.fs @@ -784,6 +784,19 @@ type AdaptiveState { 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}" diff --git a/src/FsAutoComplete/LspServers/FSharpLspClient.fs b/src/FsAutoComplete/LspServers/FSharpLspClient.fs index 5ab36cd5c..4c41d6f7e 100644 --- a/src/FsAutoComplete/LspServers/FSharpLspClient.fs +++ b/src/FsAutoComplete/LspServers/FSharpLspClient.fs @@ -3,7 +3,6 @@ namespace FsAutoComplete.Lsp open FsAutoComplete open Ionide.LanguageServerProtocol -open Ionide.LanguageServerProtocol.Types.LspResult open Ionide.LanguageServerProtocol.Server open Ionide.LanguageServerProtocol.Types open FsAutoComplete.LspHelpers @@ -13,6 +12,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) = @@ -64,6 +71,10 @@ type FSharpLspClient(sendServerNotification: ClientNotificationSender, sendServe member __.NotifyTestDetected(p: TestDetectedNotification) = sendServerNotification "fsharp/testDetected" (box p) |> Async.Ignore + member _.NotifyNestedLanguages(p: TextDocumentNestedLanguages) = + sendServerNotification "fsharp/textDocument/nestedLanguages" (box p) + |> Async.Ignore + member x.CodeLensRefresh() = match x.ClientCapabilities with | Some { Workspace = Some { CodeLens = Some { RefreshSupport = Some true } } } -> diff --git a/src/FsAutoComplete/LspServers/FSharpLspClient.fsi b/src/FsAutoComplete/LspServers/FSharpLspClient.fsi index 4fbadc76e..0d69ca6bb 100644 --- a/src/FsAutoComplete/LspServers/FSharpLspClient.fsi +++ b/src/FsAutoComplete/LspServers/FSharpLspClient.fsi @@ -8,6 +8,14 @@ open System open System.Threading open IcedTasks +type NestedLanguage = + { Language: string + Ranges: Types.Range[] } + +type TextDocumentNestedLanguages = + { TextDocument: VersionedTextDocumentIdentifier + NestedLanguages: NestedLanguage[] } + type FSharpLspClient = new: sendServerNotification: ClientNotificationSender * sendServerRequest: ClientRequestSender -> FSharpLspClient inherit LspClient @@ -32,9 +40,11 @@ type FSharpLspClient = member NotifyDocumentAnalyzed: p: DocumentAnalyzedNotification -> Async member NotifyTestDetected: p: TestDetectedNotification -> Async member CodeLensRefresh: unit -> Async + member NotifyNestedLanguages: p: TextDocumentNestedLanguages -> Async override WorkDoneProgressCreate: ProgressToken -> AsyncLspResult override Progress: ProgressToken * 'Progress -> Async + /// /// Represents a progress report that can be used to report progress to the client. /// diff --git a/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs b/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs new file mode 100644 index 000000000..4aa733f1a --- /dev/null +++ b/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs @@ -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 ("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", [| (1u, 38u), (1u, 58u) |]) |] + server ]) ] diff --git a/test/FsAutoComplete.Tests.Lsp/Program.fs b/test/FsAutoComplete.Tests.Lsp/Program.fs index e85198116..0e546776f 100644 --- a/test/FsAutoComplete.Tests.Lsp/Program.fs +++ b/test/FsAutoComplete.Tests.Lsp/Program.fs @@ -135,6 +135,7 @@ let lspTests = UnusedDeclarationsTests.tests createServer EmptyFileTests.tests createServer CallHierarchy.tests createServer + NestedLanguageTests.tests createServer diagnosticsTest createServer ] ] ] diff --git a/test/FsAutoComplete.Tests.Lsp/Utils/Server.fs b/test/FsAutoComplete.Tests.Lsp/Utils/Server.fs index 065617682..62d6e77d1 100644 --- a/test/FsAutoComplete.Tests.Lsp/Utils/Server.fs +++ b/test/FsAutoComplete.Tests.Lsp/Utils/Server.fs @@ -85,8 +85,7 @@ module Server = match! server.Initialize p with | Ok _ -> - do! server.Initialized() - + do! server.Initialized () return { RootPath = path Server = server @@ -200,8 +199,8 @@ module Document = open System.Reactive.Linq open System.Threading.Tasks - let private typedEvents<'t> typ : _ -> System.IObservable<'t> = - Observable.choose (fun (typ', _o) -> if typ' = typ then Some(unbox _o) else None) + let typedEvents<'t> eventName : _ -> System.IObservable<'t> = + Observable.choose (fun (typ', _o) -> if typ' = eventName then Some(unbox _o) else None) /// `textDocument/publishDiagnostics` /// diff --git a/test/FsAutoComplete.Tests.Lsp/Utils/Server.fsi b/test/FsAutoComplete.Tests.Lsp/Utils/Server.fsi index e31695c57..6c788e9ef 100644 --- a/test/FsAutoComplete.Tests.Lsp/Utils/Server.fsi +++ b/test/FsAutoComplete.Tests.Lsp/Utils/Server.fsi @@ -16,111 +16,113 @@ open Utils open Ionide.ProjInfo.Logging type Server = - { RootPath: string option - Server: IFSharpLspServer - Events: ClientEvents - mutable UntitledCounter: int } + { RootPath: string option + Server: IFSharpLspServer + Events: ClientEvents + mutable UntitledCounter: int } /// `Server` cached with `Async.Cache` type CachedServer = Async type Document = - { Server: Server - FilePath: string - Uri: DocumentUri - mutable Version: int } + { Server: Server + FilePath: string + Uri: DocumentUri + mutable Version: int } - member TextDocumentIdentifier: TextDocumentIdentifier - member VersionedTextDocumentIdentifier: VersionedTextDocumentIdentifier - member Diagnostics: IObservable - member CompilerDiagnostics: IObservable - interface IDisposable + member TextDocumentIdentifier: TextDocumentIdentifier + member VersionedTextDocumentIdentifier: VersionedTextDocumentIdentifier + member Diagnostics: IObservable + member CompilerDiagnostics: IObservable + interface IDisposable module Server = - val create: - path: string option -> - config: FSharpConfigDto -> - createServer: (unit -> IFSharpLspServer * IObservable) -> - CachedServer + val create: + path: string option -> + config: FSharpConfigDto -> + createServer: (unit -> IFSharpLspServer * IObservable) -> + CachedServer - val shutdown: server: CachedServer -> Async - val createUntitledDocument: initialText: string -> server: CachedServer -> Async - /// `path` can be absolute or relative. - /// For relative path `server.RootPath` must be specified! - /// - /// Note: When `path` is relative: relative to `server.RootPath`! - val openDocument: path: string -> server: CachedServer -> Async + val shutdown: server: CachedServer -> Async + val createUntitledDocument: initialText: string -> server: CachedServer -> Async + /// `path` can be absolute or relative. + /// For relative path `server.RootPath` must be specified! + /// + /// Note: When `path` is relative: relative to `server.RootPath`! + val openDocument: path: string -> server: CachedServer -> Async - /// Like `Server.openDocument`, but instead of reading source text from `path`, - /// this here instead uses `initialText` (which can be different from content of `path`!). - /// - /// This way an existing file with different text can be faked. - /// Logically equal to `Server.openDocument`, and later changing its text via `Document.changeTextTo`. - /// But this here doesn't have to parse and check everything twice (once for open, once for changed) - /// and is WAY faster than `Server.openDocument` followed by `Document.changeTextTo` when involving multiple documents. - /// (For example with CodeFix tests using `fsi` file and corresponding `fs` file) - val openDocumentWithText: - path: string -> initialText: string -> server: CachedServer -> Async + /// Like `Server.openDocument`, but instead of reading source text from `path`, + /// this here instead uses `initialText` (which can be different from content of `path`!). + /// + /// This way an existing file with different text can be faked. + /// Logically equal to `Server.openDocument`, and later changing its text via `Document.changeTextTo`. + /// But this here doesn't have to parse and check everything twice (once for open, once for changed) + /// and is WAY faster than `Server.openDocument` followed by `Document.changeTextTo` when involving multiple documents. + /// (For example with CodeFix tests using `fsi` file and corresponding `fs` file) + val openDocumentWithText: + path: string -> initialText: string -> server: CachedServer -> Async module Document = - open System.Reactive.Linq - open System.Threading.Tasks + open System.Reactive.Linq + open System.Threading.Tasks - /// `textDocument/publishDiagnostics` - /// - /// Note: for each analyzing round there are might be multiple `publishDiagnostics` events (F# compiler, for each built-in Analyzer, for Custom Analyzers) - /// - /// Note: Because source `doc.Server.Events` is `ReplaySubject`, subscribing to Stream returns ALL past diagnostics too! - val diagnosticsStream: doc: Document -> IObservable - /// `fsharp/documentAnalyzed` - val analyzedStream: doc: Document -> IObservable - /// in ms - /// Waits (if necessary) and gets latest diagnostics. - /// - /// To detect newest diags: - /// * Waits for `fsharp/documentAnalyzed` for passed `doc` and its `doc.Version`. - /// * Then waits a but more for potential late diags. - /// * Then returns latest diagnostics. - /// - /// - /// ### Explanation: Get latest & correct diagnostics - /// Diagnostics aren't collected and then sent once, but instead sent after each parsing/analyzing step. - /// -> There are multiple `textDocument/publishDiagnostics` sent for each parsing/analyzing round: - /// * one when file parsed by F# compiler - /// * one for each built-in (enabled) Analyzers (in `src\FsAutoComplete\FsAutoComplete.Lsp.fs` > `FsAutoComplete.Lsp.FSharpLspServer.analyzeFile`), - /// * for linter (currently disabled) - /// * for custom analyzers - /// - /// -> To receive ALL diagnostics: use Diagnostics of last `textDocument/publishDiagnostics` event. - /// - /// Issue: What is the last `publishDiagnostics`? Might already be here or arrive in future. - /// -> `fsharp/documentAnalyzed` was introduced. Notification when a doc was completely analyzed - /// -> wait for `documentAnalyzed` - /// - /// But issue: last `publishDiagnostics` might be received AFTER `documentAnalyzed` (because of async notifications & sending) - /// -> after receiving `documentAnalyzed` wait a bit for late `publishDiagnostics` - /// - /// But issue: Wait for how long? Too long: extends test execution time. Too short: Might miss diags. - /// -> unresolved. Current wait based on testing on modern_ish PC. Seems to work on CI too. - /// - /// - /// *Inconvenience*: Only newest diags can be retrieved this way. Diags for older file versions cannot be extracted reliably: - /// `doc.Server.Events` is a `ReplaySubject` -> returns ALL previous events on new subscription - /// -> All past `documentAnalyzed` events and their diags are all received at once - /// -> waiting a bit after a version-specific `documentAnalyzed` always returns latest diags. - val waitForLatestDiagnostics: timeout: TimeSpan -> doc: Document -> Async - val openWith: initialText: string -> doc: Document -> Async - val close: doc: Document -> Async - /// - /// Fire a textDocument/didChange request for the specified document with the given text - /// as the entire new text of the document, then wait for diagnostics for the document. - /// - val changeTextTo: text: string -> doc: Document -> Async - val saveText: text: string -> doc: Document -> Async + val typedEvents: eventName: string -> (ClientEvents -> IObservable<'t>) - /// Note: diagnostics aren't filtered to match passed range in here - val codeActionAt: - diagnostics: Diagnostic[] -> range: Range -> doc: Document -> Async + /// `textDocument/publishDiagnostics` + /// + /// Note: for each analyzing round there are might be multiple `publishDiagnostics` events (F# compiler, for each built-in Analyzer, for Custom Analyzers) + /// + /// Note: Because source `doc.Server.Events` is `ReplaySubject`, subscribing to Stream returns ALL past diagnostics too! + val diagnosticsStream: doc: Document -> IObservable + /// `fsharp/documentAnalyzed` + val analyzedStream: doc: Document -> IObservable + /// in ms + /// Waits (if necessary) and gets latest diagnostics. + /// + /// To detect newest diags: + /// * Waits for `fsharp/documentAnalyzed` for passed `doc` and its `doc.Version`. + /// * Then waits a but more for potential late diags. + /// * Then returns latest diagnostics. + /// + /// + /// ### Explanation: Get latest & correct diagnostics + /// Diagnostics aren't collected and then sent once, but instead sent after each parsing/analyzing step. + /// -> There are multiple `textDocument/publishDiagnostics` sent for each parsing/analyzing round: + /// * one when file parsed by F# compiler + /// * one for each built-in (enabled) Analyzers (in `src\FsAutoComplete\FsAutoComplete.Lsp.fs` > `FsAutoComplete.Lsp.FSharpLspServer.analyzeFile`), + /// * for linter (currently disabled) + /// * for custom analyzers + /// + /// -> To receive ALL diagnostics: use Diagnostics of last `textDocument/publishDiagnostics` event. + /// + /// Issue: What is the last `publishDiagnostics`? Might already be here or arrive in future. + /// -> `fsharp/documentAnalyzed` was introduced. Notification when a doc was completely analyzed + /// -> wait for `documentAnalyzed` + /// + /// But issue: last `publishDiagnostics` might be received AFTER `documentAnalyzed` (because of async notifications & sending) + /// -> after receiving `documentAnalyzed` wait a bit for late `publishDiagnostics` + /// + /// But issue: Wait for how long? Too long: extends test execution time. Too short: Might miss diags. + /// -> unresolved. Current wait based on testing on modern_ish PC. Seems to work on CI too. + /// + /// + /// *Inconvenience*: Only newest diags can be retrieved this way. Diags for older file versions cannot be extracted reliably: + /// `doc.Server.Events` is a `ReplaySubject` -> returns ALL previous events on new subscription + /// -> All past `documentAnalyzed` events and their diags are all received at once + /// -> waiting a bit after a version-specific `documentAnalyzed` always returns latest diags. + val waitForLatestDiagnostics: timeout: TimeSpan -> doc: Document -> Async + val openWith: initialText: string -> doc: Document -> Async + val close: doc: Document -> Async + /// + /// Fire a textDocument/didChange request for the specified document with the given text + /// as the entire new text of the document, then wait for diagnostics for the document. + /// + val changeTextTo: text: string -> doc: Document -> Async + val saveText: text: string -> doc: Document -> Async - val inlayHintsAt: range: Range -> doc: Document -> Async - val resolveInlayHint: inlayHint: InlayHint -> doc: Document -> Async + /// Note: diagnostics aren't filtered to match passed range in here + val codeActionAt: + diagnostics: Diagnostic[] -> range: Range -> doc: Document -> Async + + val inlayHintsAt: range: Range -> doc: Document -> Async + val resolveInlayHint: inlayHint: InlayHint -> doc: Document -> Async From 01b635ac5f2c738ccb316652b1600f44b98eb826 Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Sat, 25 Nov 2023 16:04:34 -0600 Subject: [PATCH 02/12] fix bad merge --- test/FsAutoComplete.Tests.Lsp/Program.fs | 123 +++++++++++------------ 1 file changed, 60 insertions(+), 63 deletions(-) diff --git a/test/FsAutoComplete.Tests.Lsp/Program.fs b/test/FsAutoComplete.Tests.Lsp/Program.fs index 0e546776f..4b9fa2b9e 100644 --- a/test/FsAutoComplete.Tests.Lsp/Program.fs +++ b/test/FsAutoComplete.Tests.Lsp/Program.fs @@ -53,7 +53,7 @@ let loaders = let adaptiveLspServerFactory toolsPath workspaceLoaderFactory sourceTextFactory = - Helpers.createAdaptiveServer (fun () -> workspaceLoaderFactory toolsPath) sourceTextFactory + Helpers.createAdaptiveServer (fun () -> workspaceLoaderFactory toolsPath) sourceTextFactory true let sourceTextFactory: ISourceTextFactory = RoslynSourceTextFactory() @@ -81,70 +81,67 @@ let lspTests = testList $"{loaderName}" [ - for (compilerName, useTransparentCompiler) in compilers do - testList - $"{compilerName}" - [ - Templates.tests () - let createServer () = - adaptiveLspServerFactory toolsPath workspaceLoaderFactory sourceTextFactory useTransparentCompiler - - initTests createServer - closeTests createServer - - Utils.Tests.Server.tests createServer - Utils.Tests.CursorbasedTests.tests createServer - - CodeLens.tests createServer - documentSymbolTest createServer - workspaceSymbolTest createServer - Completion.autocompleteTest createServer - Completion.autoOpenTests createServer - Completion.fullNameExternalAutocompleteTest createServer - foldingTests createServer - tooltipTests createServer - Highlighting.tests createServer - scriptPreviewTests createServer - scriptEvictionTests createServer - scriptProjectOptionsCacheTests createServer - dependencyManagerTests createServer - interactiveDirectivesUnitTests - - // commented out because FSDN is down - //fsdnTest createServer - - //linterTests createServer - uriTests - formattingTests createServer - analyzerTests createServer - signatureTests createServer - SignatureHelp.tests createServer - InlineHints.tests createServer - CodeFixTests.Tests.tests sourceTextFactory createServer - Completion.tests createServer - GoTo.tests createServer - - FindReferences.tests createServer - Rename.tests createServer - - InfoPanelTests.docFormattingTest createServer - DetectUnitTests.tests createServer - XmlDocumentationGeneration.tests createServer - InlayHintTests.tests createServer - DependentFileChecking.tests createServer - UnusedDeclarationsTests.tests createServer - EmptyFileTests.tests createServer - CallHierarchy.tests createServer - NestedLanguageTests.tests createServer - diagnosticsTest createServer - ] ] ] + Templates.tests () + let createServer () = + adaptiveLspServerFactory toolsPath workspaceLoaderFactory sourceTextFactory + + initTests createServer + closeTests createServer + + Utils.Tests.Server.tests createServer + Utils.Tests.CursorbasedTests.tests createServer + + CodeLens.tests createServer + documentSymbolTest createServer + workspaceSymbolTest createServer + Completion.autocompleteTest createServer + Completion.autoOpenTests createServer + Completion.fullNameExternalAutocompleteTest createServer + foldingTests createServer + tooltipTests createServer + Highlighting.tests createServer + scriptPreviewTests createServer + scriptEvictionTests createServer + scriptProjectOptionsCacheTests createServer + dependencyManagerTests createServer + interactiveDirectivesUnitTests + + // commented out because FSDN is down + //fsdnTest createServer + + //linterTests createServer + uriTests + formattingTests createServer + analyzerTests createServer + signatureTests createServer + SignatureHelp.tests createServer + InlineHints.tests createServer + CodeFixTests.Tests.tests sourceTextFactory createServer + Completion.tests createServer + GoTo.tests createServer + + FindReferences.tests createServer + Rename.tests createServer + + InfoPanelTests.docFormattingTest createServer + DetectUnitTests.tests createServer + XmlDocumentationGeneration.tests createServer + InlayHintTests.tests createServer + DependentFileChecking.tests createServer + UnusedDeclarationsTests.tests createServer + EmptyFileTests.tests createServer + CallHierarchy.tests createServer + NestedLanguageTests.tests createServer + diagnosticsTest 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 sourceTextFactory - FindReferences.tryFixupRangeTests sourceTextFactory -] +let generalTests = + testList + "general" + [ testList (nameof (Utils)) [ Utils.Tests.Utils.tests; Utils.Tests.TextEdit.tests ] + InlayHintTests.explicitTypeInfoTests sourceTextFactory + FindReferences.tryFixupRangeTests sourceTextFactory ] [] let tests = testList "FSAC" [ From 8cf5b05e0e6d87e32581a88d2a7b5acf54f87a6e Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Sun, 26 Nov 2023 11:21:44 -0600 Subject: [PATCH 03/12] actually hook up nested language events and add more tests --- src/FsAutoComplete.Core/NestedLanguages.fs | 28 +++++++++++++++-- .../LspServers/AdaptiveServerState.fs | 21 ++++++++----- .../NestedLanguageTests.fs | 30 ++++++++++++++----- 3 files changed, 62 insertions(+), 17 deletions(-) diff --git a/src/FsAutoComplete.Core/NestedLanguages.fs b/src/FsAutoComplete.Core/NestedLanguages.fs index f88cabf1c..77e94089b 100644 --- a/src/FsAutoComplete.Core/NestedLanguages.fs +++ b/src/FsAutoComplete.Core/NestedLanguages.fs @@ -1,5 +1,6 @@ module FsAutoComplete.NestedLanguages +open FsAutoComplete.Logging open FsToolkit.ErrorHandling open FSharp.Compiler.Syntax open FSharp.Compiler.Text @@ -8,6 +9,8 @@ open FSharp.Compiler.Symbols #nowarn "57" // from-end slicing +let logger = LogProvider.getLoggerByName "NestedLanguages" + type private StringParameter = { methodIdent: LongIdent parameterRange: Range @@ -164,7 +167,7 @@ let private parametersThatAreStringSyntax match sym with | :? FSharpMemberOrFunctionOrValue as mfv -> let allParameters = mfv.CurriedParameterGroups |> Seq.collect id |> Seq.toArray - let fsharpP = allParameters |> Seq.item p.parameterPosition + let fsharpP = allParameters[p.parameterPosition] match fsharpP.Attributes |> Seq.tryPick (|IsStringSyntax|_|) with | Some language -> @@ -181,10 +184,29 @@ let private parametersThatAreStringSyntax /// * 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: IFSACSourceText) : NestedLanguageDocument[] Async = +let findNestedLanguages (tyRes: ParseAndCheckResults, text: VolatileFile) : NestedLanguageDocument[] Async = async { // get all string constants let potentialParameters = findParametersForParseTree tyRes.GetAST - let! actualStringSyntaxParameters = parametersThatAreStringSyntax (potentialParameters, tyRes.GetCheckResults, text) + + 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 } diff --git a/src/FsAutoComplete/LspServers/AdaptiveServerState.fs b/src/FsAutoComplete/LspServers/AdaptiveServerState.fs index 4d242e2b7..c5f5dc67c 100644 --- a/src/FsAutoComplete/LspServers/AdaptiveServerState.fs +++ b/src/FsAutoComplete/LspServers/AdaptiveServerState.fs @@ -563,19 +563,26 @@ type AdaptiveState 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) = diff --git a/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs b/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs index 4aa733f1a..a6b6310f6 100644 --- a/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs +++ b/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs @@ -16,7 +16,8 @@ type Document with let hasLanguages name source expectedLanguages server = testAsync name { - let! (doc, _) = server |> Server.createUntitledDocument source + let! (doc, diags) = server |> Server.createUntitledDocument source + Expect.isEmpty diags "no diagnostics" let! nestedLanguages = doc.NestedLanguages |> Async.AwaitObservable let mappedExpectedLanguages: FsAutoComplete.Lsp.NestedLanguage array = @@ -35,11 +36,26 @@ let hasLanguages name source expectedLanguages server = let tests state = testList "nested languages" - [ serverTestList "class member" state defaultConfigDto None (fun server -> - [ hasLanguages - "with single string parameter" - """ + [ testList + "BCL" + [ serverTestList "class member" state defaultConfigDto None (fun server -> + [ hasLanguages + "with single string parameter" + """ let b = System.UriBuilder("https://google.com") """ - [| ("uri", [| (1u, 38u), (1u, 58u) |]) |] - server ]) ] + [| ("uri", [| (1u, 38u), (1u, 58u) |]) |] + server ]) ] + ftestList + "FSharp Code" + [ serverTestList "class member" state defaultConfigDto None (fun server -> + [ hasLanguages + "with single string parameter" + """ + type Foo() = + static member Uri([] uriString: string) = () + + let u = Foo.Uri("https://google.com") + """ + [| ("uri", [| (5u, 31u), (5u, 51u) |]) |] + server ]) ] ] From 4dc12b1d8d1d836f433960e40e5e451f8deec7a0 Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Fri, 29 Dec 2023 14:30:45 -0600 Subject: [PATCH 04/12] more WIP testing --- src/FsAutoComplete.Core/NestedLanguages.fs | 39 ++++++++++++------- .../NestedLanguageTests.fs | 16 ++++++-- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/src/FsAutoComplete.Core/NestedLanguages.fs b/src/FsAutoComplete.Core/NestedLanguages.fs index 77e94089b..cadb8c5fe 100644 --- a/src/FsAutoComplete.Core/NestedLanguages.fs +++ b/src/FsAutoComplete.Core/NestedLanguages.fs @@ -26,6 +26,7 @@ let discoverRangesToRemoveForInterpolatedString (list: SynInterpolatedStringPart let private (|Ident|_|) (e: SynExpr) = match e with + | SynExpr.Ident(ident) -> Some([ ident ]) | SynExpr.LongIdent(longDotId = SynLongIdent(id = ident)) -> Some ident | _ -> None @@ -61,12 +62,10 @@ let rec private (|IsApplicationWithStringParameters|_|) (e: SynExpr) : option{1 + 1}" | SynExpr.App( - funcExpr = SynExpr.LongIdent(longDotId = SynLongIdent(id = ident)) + funcExpr = Ident(ident) argExpr = SynExpr.Paren(expr = SynExpr.InterpolatedString(contents = parts; range = range))) // method call with interpolated string parameter - c.M($"
{1 + 1}") - | SynExpr.App( - funcExpr = SynExpr.LongIdent(longDotId = SynLongIdent(id = ident)) - argExpr = SynExpr.InterpolatedString(contents = parts; range = range)) -> + | SynExpr.App(funcExpr = Ident(ident); argExpr = SynExpr.InterpolatedString(contents = parts; range = range)) -> let rangesToRemove = discoverRangesToRemoveForInterpolatedString parts Some( @@ -90,11 +89,12 @@ type private StringParameterFinder() = let languages = ResizeArray() - override _.WalkBinding(SynBinding(expr = expr)) = - match expr with - | IsApplicationWithStringParameters(stringParameters) -> languages.AddRange stringParameters + override _.WalkBinding(binding) = + match binding with + | SynBinding(expr = IsApplicationWithStringParameters(stringParameters)) -> languages.AddRange stringParameters | _ -> () + override _.WalkSynModuleDecl(decl) = match decl with | SynModuleDecl.Expr(expr = IsApplicationWithStringParameters(stringParameters)) -> @@ -137,29 +137,38 @@ let private parametersThatAreStringSyntax ( parameters: StringParameter[], checkResults: FSharpCheckFileResults, - text: IFSACSourceText + text: VolatileFile ) : Async = async { let returnVal = ResizeArray() for p in parameters do + logger.info ( + Log.setMessageI + $"Checking parameter: {p.parameterRange.ToString():range} in member {p.methodIdent.ToString():methodName} of {text.FileName:filename}@{text.Version:version} -> {text.Source[p.parameterRange]:sourceText}" + ) + let precedingParts, lastPart = p.methodIdent.[0..^1], p.methodIdent[^0] let endOfFinalTextToken = lastPart.idRange.End - match text.GetLine(endOfFinalTextToken) with + match text.Source.GetLine(endOfFinalTextToken) with | None -> () | Some lineText -> match checkResults.GetSymbolUseAtLocation( endOfFinalTextToken.Line, - endOfFinalTextToken.Column, + endOfFinalTextToken.Column - 1, lineText, precedingParts |> List.map (fun i -> i.idText) ) with | None -> () | Some usage -> + logger.info ( + Log.setMessageI + $"Found symbol use: {usage.Symbol.ToString():symbol} in member {p.methodIdent.ToString():methodName} of {text.FileName:filename}@{text.Version:version} -> {text.Source[p.parameterRange]:sourceText}" + ) let sym = usage.Symbol // todo: keep MRU map of symbols to parameters and MRU of parameters to stringsyntax status @@ -169,6 +178,11 @@ let private parametersThatAreStringSyntax let allParameters = mfv.CurriedParameterGroups |> Seq.collect id |> Seq.toArray let fsharpP = allParameters[p.parameterPosition] + logger.info ( + Log.setMessageI + $"Found parameter: {fsharpP.ToString():symbol} with {fsharpP.Attributes.Count:attributeCount} in member {p.methodIdent.ToString():methodName} of {text.FileName:filename}@{text.Version:version} -> {text.Source[p.parameterRange]:sourceText}" + ) + match fsharpP.Attributes |> Seq.tryPick (|IsStringSyntax|_|) with | Some language -> returnVal.Add @@ -197,11 +211,10 @@ let findNestedLanguages (tyRes: ParseAndCheckResults, text: VolatileFile) : Nest 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}" + $"Potential parameter: {p.parameterRange.ToString():range} in member {p.methodIdent.ToString():methodName} of {text.FileName:filename}@{text.Version:version} -> {text.Source[p.parameterRange]:sourceText}" ) - let! actualStringSyntaxParameters = - parametersThatAreStringSyntax (potentialParameters, tyRes.GetCheckResults, text.Source) + let! actualStringSyntaxParameters = parametersThatAreStringSyntax (potentialParameters, tyRes.GetCheckResults, text) logger.info ( Log.setMessageI diff --git a/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs b/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs index a6b6310f6..9a00a0407 100644 --- a/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs +++ b/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs @@ -46,16 +46,26 @@ let tests state = """ [| ("uri", [| (1u, 38u), (1u, 58u) |]) |] server ]) ] - ftestList + testList "FSharp Code" [ serverTestList "class member" state defaultConfigDto None (fun server -> [ hasLanguages "with single string parameter" """ type Foo() = - static member Uri([] uriString: string) = () + member x.Uri([] uriString: string) = () + let f = new Foo() + let u = f.Uri("https://google.com") + """ + [| ("uri", [| (5, 31), (5, 51) |]) |] + server ]) - let u = Foo.Uri("https://google.com") + fserverTestList "let bound function member" state defaultConfigDto None (fun server -> + [ hasLanguages + "with single string parameter" + """ + let foo ([] uriString: string) = () + let u = foo "https://google.com" """ [| ("uri", [| (5u, 31u), (5u, 51u) |]) |] server ]) ] ] From bab3f9572ed6f1169b55f0cbb85890bc518fb2ad Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Fri, 29 Dec 2023 16:15:10 -0600 Subject: [PATCH 05/12] Enable support for tagging single-string-parameter functions' arguments as that function's named language --- src/FsAutoComplete.Core/NestedLanguages.fs | 73 ++++++++++++++++++- .../NestedLanguageTests.fs | 41 ++++++++++- 2 files changed, 107 insertions(+), 7 deletions(-) diff --git a/src/FsAutoComplete.Core/NestedLanguages.fs b/src/FsAutoComplete.Core/NestedLanguages.fs index cadb8c5fe..b75bd4abd 100644 --- a/src/FsAutoComplete.Core/NestedLanguages.fs +++ b/src/FsAutoComplete.Core/NestedLanguages.fs @@ -79,7 +79,7 @@ let rec private (|IsApplicationWithStringParameters|_|) (e: SynExpr) : option", true) and/or c.M(true, "
") // 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 - [] let html = "
" + // let binding that is a string value that has the StringSyntax attribute on it - [] let html = "
" // all of the above but with literals | _ -> None @@ -158,7 +158,7 @@ let private parametersThatAreStringSyntax match checkResults.GetSymbolUseAtLocation( endOfFinalTextToken.Line, - endOfFinalTextToken.Column - 1, + endOfFinalTextToken.Column - 1, // TODO: check off-by-one here? lineText, precedingParts |> List.map (fun i -> i.idText) ) @@ -171,7 +171,7 @@ let private parametersThatAreStringSyntax ) let sym = usage.Symbol - // todo: keep MRU map of symbols to parameters and MRU of parameters to stringsyntax status + // todo: keep MRU map of symbols to parameters and MRU of parameters to StringSyntax status match sym with | :? FSharpMemberOrFunctionOrValue as mfv -> @@ -194,6 +194,71 @@ let private parametersThatAreStringSyntax return returnVal.ToArray() } +let private hasSingleStringParameter ( + parameters: StringParameter[], + checkResults: FSharpCheckFileResults, + text: VolatileFile + ) : Async = + async { + let returnVal = ResizeArray() + + for p in parameters do + logger.info ( + Log.setMessageI + $"Checking parameter: {p.parameterRange.ToString():range} in member {p.methodIdent.ToString():methodName} of {text.FileName:filename}@{text.Version:version} -> {text.Source[p.parameterRange]:sourceText}" + ) + + let lastPart = p.methodIdent[^0] + let endOfFinalTextToken = lastPart.idRange.End + + match text.Source.GetLine(endOfFinalTextToken) with + | None -> () + | Some lineText -> + + match + checkResults.GetSymbolUseAtLocation( + endOfFinalTextToken.Line, + endOfFinalTextToken.Column + 1, + lineText, + p.methodIdent |> List.map (fun x -> x.idText) + ) + with + | None -> () + | Some usage -> + logger.info ( + Log.setMessageI + $"Found symbol use: {usage.Symbol.ToString():symbol} in member {p.methodIdent.ToString():methodName} of {text.FileName:filename}@{text.Version:version} -> {text.Source[p.parameterRange]:sourceText}" + ) + + 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 languageName = sym.DisplayName // TODO: what about funky names? + let allParameters = mfv.CurriedParameterGroups |> Seq.collect id + let firstParameter = allParameters |> Seq.tryHead + let hasOthers = allParameters |> Seq.skip 1 |> Seq.isEmpty |> not + match hasOthers, firstParameter with + | _, None -> () + | true, _ -> () + | false, Some fsharpP -> + logger.info ( + Log.setMessageI + $"Found parameter: {fsharpP.ToString():symbol} with {fsharpP.Attributes.Count:attributeCount} in member {p.methodIdent.ToString():methodName} of {text.FileName:filename}@{text.Version:version} -> {text.Source[p.parameterRange]:sourceText}" + ) + let baseType = fsharpP.Type.StripAbbreviations() + if baseType.BasicQualifiedName = "System.String" then + returnVal.Add + { Language = languageName + Ranges = rangeMinusRanges p.parameterRange p.rangesToRemove } + else + () + | _ -> () + + 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 @@ -214,7 +279,7 @@ let findNestedLanguages (tyRes: ParseAndCheckResults, text: VolatileFile) : Nest $"Potential parameter: {p.parameterRange.ToString():range} in member {p.methodIdent.ToString():methodName} of {text.FileName:filename}@{text.Version:version} -> {text.Source[p.parameterRange]:sourceText}" ) - let! actualStringSyntaxParameters = parametersThatAreStringSyntax (potentialParameters, tyRes.GetCheckResults, text) + let! actualStringSyntaxParameters = hasSingleStringParameter (potentialParameters, tyRes.GetCheckResults, text) // || parametersThatAreStringSyntax (potentialParameters, tyRes.GetCheckResults, text) logger.info ( Log.setMessageI diff --git a/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs b/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs index 9a00a0407..98ad19ac8 100644 --- a/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs +++ b/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs @@ -57,10 +57,10 @@ let tests state = let f = new Foo() let u = f.Uri("https://google.com") """ - [| ("uri", [| (5, 31), (5, 51) |]) |] + [| ("uri", [| (5u, 31u), (5u, 51u) |]) |] server ]) - fserverTestList "let bound function member" state defaultConfigDto None (fun server -> + serverTestList "let bound function member" state defaultConfigDto None (fun server -> [ hasLanguages "with single string parameter" """ @@ -68,4 +68,39 @@ let tests state = let u = foo "https://google.com" """ [| ("uri", [| (5u, 31u), (5u, 51u) |]) |] - server ]) ] ] + server ]) ] + ftestList "Sql" [ + serverTestList "loose function" state defaultConfigDto None (fun server -> [ + hasLanguages + "with single string parameter and string literal" + """ + let foo (s: string) = () + let u = foo "https://google.com" + """ + [| ("foo", [| (2u, 24u), (2u, 44u) |]) |] + server + + hasLanguages + "with single string parameter and interpolated string literal" + """ + let foo (s: string) = () + let u = foo $"https://{true}.com" + """ + [| ("foo", [| (2u, 24u), (2u, 35u) + (2u, 39u), (2u, 45u) |]) |] + server + + hasLanguages + "multiple lanuages in the same document" + """ + let html (s: string) = () + let sql (s: string) = () + let myWebPage = html "WOWEE" + let myQuery = sql "select * from accounts where net_worth > 1000000" + """ + [| ("html", [| (3u, 33u), (3u, 53u) |]) + ("sql", [| (4u, 30u), (4u, 80u) |]) |] + server + ] + ) + ]] From 9059bed62d888b8540483fcccba85079b0ad3b96 Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Fri, 29 Dec 2023 17:16:06 -0600 Subject: [PATCH 06/12] tests based on StringSyntax --- .vscode/launch.json | 34 +++++++-- src/FsAutoComplete.Core/NestedLanguages.fs | 52 +++++++------ .../NestedLanguageTests.fs | 76 ++++++++++--------- 3 files changed, 95 insertions(+), 67 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 58e99a0d4..114bf6b95 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,7 +9,10 @@ "type": "coreclr", "request": "launch", "program": "${workspaceFolder}/src/FsAutoComplete/bin/Debug/net8.0/fsautocomplete.dll", - "args": ["--mode", "lsp"], + "args": [ + "--mode", + "lsp" + ], "cwd": "${workspaceFolder}", "console": "internalConsole", "stopAtEntry": false, @@ -47,7 +50,11 @@ } }, "enableStepFiltering": false, - "args": ["--debug", "--filter", "FSAC.lsp.${input:loader}.${input:compiler}.${input:testName}"], + "args": [ + "--debug", + "--filter", + "FSAC.lsp.${input:loader}.${input:compiler}.${input:testName}" + ], "preLaunchTask": "build debug" }, { @@ -69,7 +76,11 @@ } }, "enableStepFiltering": false, - "args": ["--debug", "--filter", "FSAC.general.${input:testName}"], + "args": [ + "--debug", + "--filter", + "FSAC.general.${input:testName}" + ], "preLaunchTask": "build debug" } ], @@ -77,21 +88,30 @@ { "id": "tfm", "description": "The TFM of the test to run", - "options": ["net8.0", "net9.0"], + "options": [ + "net8.0", + "net9.0" + ], "default": "net8.0", "type": "pickString" }, { "id": "loader", "description": "The loader to use for the test", - "options": ["Ionide WorkspaceLoader", "MSBuild Project Graph WorkspaceLoader"], + "options": [ + "Ionide WorkspaceLoader", + "MSBuild Project Graph WorkspaceLoader" + ], "default": "WorkspaceLoader", "type": "pickString" }, { "id": "compiler", "description": "The compiler to use", - "options": ["BackgroundCompiler", "TransparentCompiler"], + "options": [ + "BackgroundCompiler", + "TransparentCompiler" + ], "default": "BackgroundCompiler", "type": "pickString" }, @@ -101,4 +121,4 @@ "type": "promptString" } ] -} +} \ No newline at end of file diff --git a/src/FsAutoComplete.Core/NestedLanguages.fs b/src/FsAutoComplete.Core/NestedLanguages.fs index b75bd4abd..9555df72e 100644 --- a/src/FsAutoComplete.Core/NestedLanguages.fs +++ b/src/FsAutoComplete.Core/NestedLanguages.fs @@ -148,7 +148,7 @@ let private parametersThatAreStringSyntax $"Checking parameter: {p.parameterRange.ToString():range} in member {p.methodIdent.ToString():methodName} of {text.FileName:filename}@{text.Version:version} -> {text.Source[p.parameterRange]:sourceText}" ) - let precedingParts, lastPart = p.methodIdent.[0..^1], p.methodIdent[^0] + let lastPart = p.methodIdent[^0] let endOfFinalTextToken = lastPart.idRange.End match text.Source.GetLine(endOfFinalTextToken) with @@ -158,9 +158,9 @@ let private parametersThatAreStringSyntax match checkResults.GetSymbolUseAtLocation( endOfFinalTextToken.Line, - endOfFinalTextToken.Column - 1, // TODO: check off-by-one here? + endOfFinalTextToken.Column, lineText, - precedingParts |> List.map (fun i -> i.idText) + p.methodIdent |> List.map (fun x -> x.idText) ) with | None -> () @@ -194,6 +194,9 @@ let private parametersThatAreStringSyntax return returnVal.ToArray() } +let private safeNestedLanguageNames = + System.Collections.Generic.HashSet(["html"; "svg"; "css"; "sql"; "js"; "python"; "uri"; "regex"; "xml"; "json"], System.StringComparer.OrdinalIgnoreCase) + let private hasSingleStringParameter ( parameters: StringParameter[], checkResults: FSharpCheckFileResults, @@ -236,24 +239,26 @@ let private hasSingleStringParameter ( match sym with | :? FSharpMemberOrFunctionOrValue as mfv -> let languageName = sym.DisplayName // TODO: what about funky names? - let allParameters = mfv.CurriedParameterGroups |> Seq.collect id - let firstParameter = allParameters |> Seq.tryHead - let hasOthers = allParameters |> Seq.skip 1 |> Seq.isEmpty |> not - match hasOthers, firstParameter with - | _, None -> () - | true, _ -> () - | false, Some fsharpP -> - logger.info ( - Log.setMessageI - $"Found parameter: {fsharpP.ToString():symbol} with {fsharpP.Attributes.Count:attributeCount} in member {p.methodIdent.ToString():methodName} of {text.FileName:filename}@{text.Version:version} -> {text.Source[p.parameterRange]:sourceText}" - ) - let baseType = fsharpP.Type.StripAbbreviations() - if baseType.BasicQualifiedName = "System.String" then - returnVal.Add - { Language = languageName - Ranges = rangeMinusRanges p.parameterRange p.rangesToRemove } - else - () + if safeNestedLanguageNames.Contains(languageName) + then + let allParameters = mfv.CurriedParameterGroups |> Seq.collect id + let firstParameter = allParameters |> Seq.tryHead + let hasOthers = allParameters |> Seq.skip 1 |> Seq.isEmpty |> not + match hasOthers, firstParameter with + | _, None -> () + | true, _ -> () + | false, Some fsharpP -> + logger.info ( + Log.setMessageI + $"Found parameter: {fsharpP.ToString():symbol} with {fsharpP.Attributes.Count:attributeCount} in member {p.methodIdent.ToString():methodName} of {text.FileName:filename}@{text.Version:version} -> {text.Source[p.parameterRange]:sourceText}" + ) + let baseType = fsharpP.Type.StripAbbreviations() + if baseType.BasicQualifiedName = "System.String" then + returnVal.Add + { Language = languageName + Ranges = rangeMinusRanges p.parameterRange p.rangesToRemove } + else + () | _ -> () return returnVal.ToArray() @@ -279,8 +284,9 @@ let findNestedLanguages (tyRes: ParseAndCheckResults, text: VolatileFile) : Nest $"Potential parameter: {p.parameterRange.ToString():range} in member {p.methodIdent.ToString():methodName} of {text.FileName:filename}@{text.Version:version} -> {text.Source[p.parameterRange]:sourceText}" ) - let! actualStringSyntaxParameters = hasSingleStringParameter (potentialParameters, tyRes.GetCheckResults, text) // || parametersThatAreStringSyntax (potentialParameters, tyRes.GetCheckResults, text) - + //let! singleStringParameters = hasSingleStringParameter (potentialParameters, tyRes.GetCheckResults, text) + let! actualStringSyntaxParameters = parametersThatAreStringSyntax (potentialParameters, tyRes.GetCheckResults, text) + //let actualStringSyntaxParameters = Array.append singleStringParameters stringSyntaxParameters logger.info ( Log.setMessageI $"Found {actualStringSyntaxParameters.Length:stringParams} actual parameters in {text.FileName:filename}@{text.Version:version}" diff --git a/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs b/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs index 98ad19ac8..3adebe944 100644 --- a/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs +++ b/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs @@ -38,7 +38,8 @@ let tests state = "nested languages" [ testList "BCL" - [ serverTestList "class member" state defaultConfigDto None (fun server -> + // pending because class members don't return attributes in the FCS Parameter API + [ pserverTestList "class member" state defaultConfigDto None (fun server -> [ hasLanguages "with single string parameter" """ @@ -48,59 +49,60 @@ let tests state = server ]) ] testList "FSharp Code" - [ serverTestList "class member" state defaultConfigDto None (fun server -> + // pending because class members don't return attributes in the FCS Parameter API + [ pserverTestList "class member" state defaultConfigDto None (fun server -> [ hasLanguages "with single string parameter" """ type Foo() = - member x.Uri([] uriString: string) = () + member x.Boo([] uriString: string) = () let f = new Foo() - let u = f.Uri("https://google.com") + let u = f.Boo("https://google.com") """ - [| ("uri", [| (5u, 31u), (5u, 51u) |]) |] + [| ("uri", [| (4u, 26u), (4u, 46u) |]) |] server ]) serverTestList "let bound function member" state defaultConfigDto None (fun server -> [ hasLanguages "with single string parameter" """ - let foo ([] uriString: string) = () - let u = foo "https://google.com" + let boo ([] uriString: string) = () + let u = boo "https://google.com" """ - [| ("uri", [| (5u, 31u), (5u, 51u) |]) |] - server ]) ] - ftestList "Sql" [ - serverTestList "loose function" state defaultConfigDto None (fun server -> [ - hasLanguages - "with single string parameter and string literal" - """ - let foo (s: string) = () - let u = foo "https://google.com" - """ - [| ("foo", [| (2u, 24u), (2u, 44u) |]) |] + [| ("uri", [| (2u, 24u), (2u, 44u) |]) |] server - hasLanguages - "with single string parameter and interpolated string literal" - """ - let foo (s: string) = () - let u = foo $"https://{true}.com" + hasLanguages + "with single string parameter and string literal" + """ + let uri ([]s: string) = () + let u = uri "https://google.com" """ - [| ("foo", [| (2u, 24u), (2u, 35u) - (2u, 39u), (2u, 45u) |]) |] - server + [| ("uri", [| (2u, 24u), (2u, 44u) |]) |] + server - hasLanguages - "multiple lanuages in the same document" - """ - let html (s: string) = () - let sql (s: string) = () - let myWebPage = html "WOWEE" + hasLanguages + "with single string parameter and interpolated string literal" + """ + let uri ([]s: string) = () + let u = uri $"https://{true}.com" + """ + [| ("uri", [| (2u, 24u), (2u, 35u) + (2u, 39u), (2u, 45u) |]) |] + server + + hasLanguages + "multiple languages in the same document" + """ + let html ([]s: string) = () + let sql ([]s: string) = () + let myWebPage = html "wow" let myQuery = sql "select * from accounts where net_worth > 1000000" """ - [| ("html", [| (3u, 33u), (3u, 53u) |]) - ("sql", [| (4u, 30u), (4u, 80u) |]) |] - server - ] + [| ("html", [| (3u, 33u), (3u, 51u) |]) + ("sql", [| (4u, 30u), (4u, 80u) |]) |] + server + ] ) - ]] + ] + ] From c9982b1c3386798466eaefdbe70c140e31fedcae Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Fri, 29 Dec 2023 17:18:47 -0600 Subject: [PATCH 07/12] formatting --- src/FsAutoComplete.Core/NestedLanguages.fs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/FsAutoComplete.Core/NestedLanguages.fs b/src/FsAutoComplete.Core/NestedLanguages.fs index 9555df72e..581f2d064 100644 --- a/src/FsAutoComplete.Core/NestedLanguages.fs +++ b/src/FsAutoComplete.Core/NestedLanguages.fs @@ -195,9 +195,13 @@ let private parametersThatAreStringSyntax } let private safeNestedLanguageNames = - System.Collections.Generic.HashSet(["html"; "svg"; "css"; "sql"; "js"; "python"; "uri"; "regex"; "xml"; "json"], System.StringComparer.OrdinalIgnoreCase) + System.Collections.Generic.HashSet( + [ "html"; "svg"; "css"; "sql"; "js"; "python"; "uri"; "regex"; "xml"; "json" ], + System.StringComparer.OrdinalIgnoreCase + ) -let private hasSingleStringParameter ( +let private hasSingleStringParameter + ( parameters: StringParameter[], checkResults: FSharpCheckFileResults, text: VolatileFile @@ -239,20 +243,23 @@ let private hasSingleStringParameter ( match sym with | :? FSharpMemberOrFunctionOrValue as mfv -> let languageName = sym.DisplayName // TODO: what about funky names? - if safeNestedLanguageNames.Contains(languageName) - then + + if safeNestedLanguageNames.Contains(languageName) then let allParameters = mfv.CurriedParameterGroups |> Seq.collect id let firstParameter = allParameters |> Seq.tryHead let hasOthers = allParameters |> Seq.skip 1 |> Seq.isEmpty |> not + match hasOthers, firstParameter with | _, None -> () - | true, _ -> () + | true, _ -> () | false, Some fsharpP -> logger.info ( Log.setMessageI $"Found parameter: {fsharpP.ToString():symbol} with {fsharpP.Attributes.Count:attributeCount} in member {p.methodIdent.ToString():methodName} of {text.FileName:filename}@{text.Version:version} -> {text.Source[p.parameterRange]:sourceText}" ) + let baseType = fsharpP.Type.StripAbbreviations() + if baseType.BasicQualifiedName = "System.String" then returnVal.Add { Language = languageName From 9fd9ad54d4442ef42c5911757874b6b94823bbee Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Fri, 29 Dec 2023 19:25:08 -0600 Subject: [PATCH 08/12] bump to .NET 8 --- global.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/global.json b/global.json index 689afa9ed..c08f7e6f5 100644 --- a/global.json +++ b/global.json @@ -4,4 +4,4 @@ "rollForward": "latestMajor", "allowPrerelease": true } -} +} \ No newline at end of file From d15f69e36d6e06d943f9d0ac55b5dae00b7e6b44 Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Fri, 29 Dec 2023 19:43:39 -0600 Subject: [PATCH 09/12] try to workaround checkout issue in CI --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 909cfa5c7..ca2b5bdfe 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -57,7 +57,7 @@ jobs: name: Build on ${{matrix.os}} for ${{ matrix.label }} ${{ matrix.workspace-loader }} ${{ matrix.use-transparent-compiler }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 # setup .NET per the repo global.json - name: Setup .NET From 4007171c9d8cf74f65a9bbd9f156bcf2e1caf934 Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Fri, 29 Dec 2023 19:47:20 -0600 Subject: [PATCH 10/12] try this other way to work around it --- .github/workflows/build.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ca2b5bdfe..612e0c3c9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -58,6 +58,12 @@ jobs: steps: - uses: actions/checkout@v4 + if: ${{ github.event_name == 'pull_request' }} + with: + ref: ${{ github.event.pull_request.head.sha }} + + - uses: actions/checkout@v4 + if: ${{ github.event_name == 'push' }} # setup .NET per the repo global.json - name: Setup .NET From f771230d3ae79f6c981391a86eca9816b95b056d Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Sun, 31 Dec 2023 15:03:28 -0600 Subject: [PATCH 11/12] a lot more testing and fine tuning based on Ionide integration work --- src/FsAutoComplete.Core/NestedLanguages.fs | 240 +++++++++--------- .../NestedLanguageTests.fs | 168 +++++++++--- 2 files changed, 259 insertions(+), 149 deletions(-) diff --git a/src/FsAutoComplete.Core/NestedLanguages.fs b/src/FsAutoComplete.Core/NestedLanguages.fs index 581f2d064..0982c8d92 100644 --- a/src/FsAutoComplete.Core/NestedLanguages.fs +++ b/src/FsAutoComplete.Core/NestedLanguages.fs @@ -14,15 +14,71 @@ let logger = LogProvider.getLoggerByName "NestedLanguages" type private StringParameter = { methodIdent: LongIdent parameterRange: Range - rangesToRemove: Range[] + rangesToRemove: Range array parameterPosition: int } -let discoverRangesToRemoveForInterpolatedString (list: SynInterpolatedStringPart list) = - list - |> List.choose (function - | SynInterpolatedStringPart.FillExpr(fillExpr = e) -> Some e.Range - | _ -> None) - |> List.toArray + +/// for virtual documents based on interpolated strings we need to remove two kinds of trivia from the overall string portions. +/// * for interpolation expressions we need to remove the entire range of the expression - this will be invisible to the virtual document since it is F# code. +/// * for string literals, we need to remove the prefix/suffix tokens (quotes, interpolation brackets, format specifiers, etc) so that the only content visible +/// to the virtual document is the actual string content. +/// +/// FEATURE GAP: we don't know in the AST the locations of the string trivia, so we can't support format specifiers or variable-length +/// interpolation start/end tokens. +let private discoverRangesToRemoveForInterpolatedString + (stringKind: SynStringKind) + (parts: SynInterpolatedStringPart[]) + = + parts + |> Array.indexed + |> Array.collect (fun (index, part) -> + match part with + | SynInterpolatedStringPart.FillExpr(fillExpr = e) -> [| e.Range |] + // for the first part we have whatever 'leading' element on the left and a trailing interpolation piece (which can include a format specifier) on the right + | SynInterpolatedStringPart.String(range = range) when index = 0 -> + [| + // leading tokens adjustment + // GAP: we don't know how many interpolation $ or " there are, so we are guessing + match stringKind with + | SynStringKind.Regular -> + // 'regular' means $" leading identifier + range.WithEnd(range.Start.WithColumn(range.StartColumn + 2)) + | SynStringKind.TripleQuote -> + // 'triple quote' means $""" leading identifier + range.WithEnd(range.Start.WithColumn(range.StartColumn + 4)) + // there's no such thing as a verbatim interpolated string + | SynStringKind.Verbatim -> () + + // trailing token adjustment- only an opening bracket { + // GAP: this is the feature gap - we don't know about format specifiers + range.WithStart(range.End.WithColumn(range.EndColumn - 1)) + + |] + // for the last part we have a single-character interpolation bracket on the left and the 'trailing' string elements on the right + | SynInterpolatedStringPart.String(range = range) when index = parts.Length - 1 -> + [| + // leading token adjustment - only a closing bracket } + range.WithEnd(range.Start.WithColumn(range.StartColumn + 1)) + + // trailing tokens adjustment + // GAP: we don't know how many """ to adjust for triple-quote interpolated string endings + match stringKind with + | SynStringKind.Regular -> + // 'regular' means trailing identifier " + range.WithStart(range.End.WithColumn(range.EndColumn - 1)) + | SynStringKind.TripleQuote -> + // 'triple quote' means trailing identifier """ + range.WithStart(range.End.WithColumn(range.EndColumn - 3)) + // no such thing as verbatim interpolated strings + | SynStringKind.Verbatim -> () |] + // for all other parts we have a single-character interpolation bracket on the left and a trailing interpolation piece (which can include a format specifier) on the right + | SynInterpolatedStringPart.String(range = range) -> + [| + // leading token adjustment - only a closing bracket } + range.WithEnd(range.Start.WithColumn(range.StartColumn + 1)) + // trailing token adjustment- only an opening bracket { + // GAP: this is the feature gap - we don't know about format specifiers here + range.WithStart(range.End.WithColumn(range.EndColumn - 1)) |]) let private (|Ident|_|) (e: SynExpr) = match e with @@ -30,7 +86,24 @@ let private (|Ident|_|) (e: SynExpr) = | SynExpr.LongIdent(longDotId = SynLongIdent(id = ident)) -> Some ident | _ -> None -let rec private (|IsApplicationWithStringParameters|_|) (e: SynExpr) : option = +/// in order for nested documents to be recognized as their document types, the string quotes (and other tokens) need to be removed +/// from the actual string content. +let private removeStringTokensFromStringRange (kind: SynStringKind) (range: Range) : Range array = + match kind with + | SynStringKind.Regular -> + // we need to trim the double-quote off of the start and end + [| Range.mkRange range.FileName range.Start (range.Start.WithColumn(range.StartColumn + 1)) + Range.mkRange range.FileName (range.End.WithColumn(range.EndColumn - 1)) range.End |] + | SynStringKind.Verbatim -> + // we need to trim the @+double-quote off of the start and double-quote off the end + [| Range.mkRange range.FileName range.Start (range.Start.WithColumn(range.StartColumn + 2)) + Range.mkRange range.FileName (range.End.WithColumn(range.EndColumn - 1)) range.End |] + | SynStringKind.TripleQuote -> + // we need to trim the @+double-quote off of the start and double-quote off the end + [| Range.mkRange range.FileName range.Start (range.Start.WithColumn(range.StartColumn + 2)) + Range.mkRange range.FileName (range.End.WithColumn(range.EndColumn - 1)) range.End |] + +let rec private (|IsApplicationWithStringParameters|_|) (e: SynExpr) : StringParameter array option = match e with // lines inside a binding // let doThing () = @@ -39,34 +112,46 @@ let rec private (|IsApplicationWithStringParameters|_|) (e: SynExpr) : option" |> c.M // $"
{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 + let e1Parameters = + match e1 with + | IsApplicationWithStringParameters(stringParameter) when not (Array.isEmpty stringParameter) -> + ValueSome stringParameter + | _ -> ValueNone + + let e2Parameters = + match e2 with + | IsApplicationWithStringParameters(stringParameter) when not (Array.isEmpty stringParameter) -> + ValueSome stringParameter + | _ -> ValueNone + + match e1Parameters, e2Parameters with + | ValueNone, ValueNone -> None + | ValueSome e1Parameters, ValueNone -> Some e1Parameters + | ValueNone, ValueSome e2Parameters -> Some e2Parameters + | ValueSome e1Parameters, ValueSome e2Parameters -> Some(Array.append e1Parameters e2Parameters) // method call with string parameter - c.M("
") | SynExpr.App( - funcExpr = Ident(ident); argExpr = SynExpr.Paren(expr = SynExpr.Const(SynConst.String(_text, _kind, range), _))) + funcExpr = Ident(ident); argExpr = SynExpr.Paren(expr = SynExpr.Const(SynConst.String(_text, kind, range), _))) // method call with string parameter - c.M "
" - | SynExpr.App(funcExpr = Ident(ident); argExpr = SynExpr.Const(SynConst.String(_text, _kind, range), _)) -> + | SynExpr.App(funcExpr = Ident(ident); argExpr = SynExpr.Const(SynConst.String(_text, kind, range), _)) -> Some( [| { methodIdent = ident parameterRange = range - rangesToRemove = [||] + rangesToRemove = removeStringTokensFromStringRange kind range parameterPosition = 0 } |] ) // method call with interpolated string parameter - c.M $"
{1 + 1}" | SynExpr.App( funcExpr = Ident(ident) - argExpr = SynExpr.Paren(expr = SynExpr.InterpolatedString(contents = parts; range = range))) + argExpr = SynExpr.Paren( + expr = SynExpr.InterpolatedString(contents = parts; synStringKind = stringKind; range = range))) // method call with interpolated string parameter - c.M($"
{1 + 1}") - | SynExpr.App(funcExpr = Ident(ident); argExpr = SynExpr.InterpolatedString(contents = parts; range = range)) -> - let rangesToRemove = discoverRangesToRemoveForInterpolatedString parts + | SynExpr.App( + funcExpr = Ident(ident) + argExpr = SynExpr.InterpolatedString(contents = parts; synStringKind = stringKind; range = range)) -> + let rangesToRemove = + discoverRangesToRemoveForInterpolatedString stringKind (Array.ofList parts) Some( [| { methodIdent = ident @@ -117,9 +202,11 @@ let private (|IsStringSyntax|_|) (a: FSharpAttribute) = | _ -> None | _ -> None -type NestedLanguageDocument = { Language: string; Ranges: Range[] } +type NestedLanguageDocument = + { Language: string + Ranges: Range array } -let rangeMinusRanges (totalRange: Range) (rangesToRemove: Range[]) : Range[] = +let rangeMinusRanges (totalRange: Range) (rangesToRemove: Range array) : Range array = match rangesToRemove with | [||] -> [| totalRange |] | _ -> @@ -127,18 +214,22 @@ let rangeMinusRanges (totalRange: Range) (rangesToRemove: Range[]) : Range[] = let mutable currentStart = totalRange.Start for r in rangesToRemove do - returnVal.Add(Range.mkRange totalRange.FileName currentStart r.Start) - currentStart <- r.End + if currentStart = r.Start then + // no gaps, so just advance the current pointer + currentStart <- r.End + else + returnVal.Add(Range.mkRange totalRange.FileName currentStart r.Start) + currentStart <- r.End + + // only need to add the final range if there is a gap between where we are and the end of the string + if currentStart <> totalRange.End then + returnVal.Add(Range.mkRange totalRange.FileName currentStart totalRange.End) - returnVal.Add(Range.mkRange totalRange.FileName currentStart totalRange.End) returnVal.ToArray() let private parametersThatAreStringSyntax - ( - parameters: StringParameter[], - checkResults: FSharpCheckFileResults, - text: VolatileFile - ) : Async = + (parameters: StringParameter array, checkResults: FSharpCheckFileResults, text: VolatileFile) + : NestedLanguageDocument array Async = async { let returnVal = ResizeArray() @@ -194,90 +285,12 @@ let private parametersThatAreStringSyntax return returnVal.ToArray() } -let private safeNestedLanguageNames = - System.Collections.Generic.HashSet( - [ "html"; "svg"; "css"; "sql"; "js"; "python"; "uri"; "regex"; "xml"; "json" ], - System.StringComparer.OrdinalIgnoreCase - ) - -let private hasSingleStringParameter - ( - parameters: StringParameter[], - checkResults: FSharpCheckFileResults, - text: VolatileFile - ) : Async = - async { - let returnVal = ResizeArray() - - for p in parameters do - logger.info ( - Log.setMessageI - $"Checking parameter: {p.parameterRange.ToString():range} in member {p.methodIdent.ToString():methodName} of {text.FileName:filename}@{text.Version:version} -> {text.Source[p.parameterRange]:sourceText}" - ) - - let lastPart = p.methodIdent[^0] - let endOfFinalTextToken = lastPart.idRange.End - - match text.Source.GetLine(endOfFinalTextToken) with - | None -> () - | Some lineText -> - - match - checkResults.GetSymbolUseAtLocation( - endOfFinalTextToken.Line, - endOfFinalTextToken.Column + 1, - lineText, - p.methodIdent |> List.map (fun x -> x.idText) - ) - with - | None -> () - | Some usage -> - logger.info ( - Log.setMessageI - $"Found symbol use: {usage.Symbol.ToString():symbol} in member {p.methodIdent.ToString():methodName} of {text.FileName:filename}@{text.Version:version} -> {text.Source[p.parameterRange]:sourceText}" - ) - - 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 languageName = sym.DisplayName // TODO: what about funky names? - - if safeNestedLanguageNames.Contains(languageName) then - let allParameters = mfv.CurriedParameterGroups |> Seq.collect id - let firstParameter = allParameters |> Seq.tryHead - let hasOthers = allParameters |> Seq.skip 1 |> Seq.isEmpty |> not - - match hasOthers, firstParameter with - | _, None -> () - | true, _ -> () - | false, Some fsharpP -> - logger.info ( - Log.setMessageI - $"Found parameter: {fsharpP.ToString():symbol} with {fsharpP.Attributes.Count:attributeCount} in member {p.methodIdent.ToString():methodName} of {text.FileName:filename}@{text.Version:version} -> {text.Source[p.parameterRange]:sourceText}" - ) - - let baseType = fsharpP.Type.StripAbbreviations() - - if baseType.BasicQualifiedName = "System.String" then - returnVal.Add - { Language = languageName - Ranges = rangeMinusRanges p.parameterRange p.rangesToRemove } - else - () - | _ -> () - - 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 = +let findNestedLanguages (tyRes: ParseAndCheckResults, text: VolatileFile) : NestedLanguageDocument array Async = async { - // get all string constants let potentialParameters = findParametersForParseTree tyRes.GetAST logger.info ( @@ -291,9 +304,8 @@ let findNestedLanguages (tyRes: ParseAndCheckResults, text: VolatileFile) : Nest $"Potential parameter: {p.parameterRange.ToString():range} in member {p.methodIdent.ToString():methodName} of {text.FileName:filename}@{text.Version:version} -> {text.Source[p.parameterRange]:sourceText}" ) - //let! singleStringParameters = hasSingleStringParameter (potentialParameters, tyRes.GetCheckResults, text) let! actualStringSyntaxParameters = parametersThatAreStringSyntax (potentialParameters, tyRes.GetCheckResults, text) - //let actualStringSyntaxParameters = Array.append singleStringParameters stringSyntaxParameters + logger.info ( Log.setMessageI $"Found {actualStringSyntaxParameters.Length:stringParams} actual parameters in {text.FileName:filename}@{text.Version:version}" diff --git a/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs b/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs index 3adebe944..cf2b4490a 100644 --- a/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs +++ b/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs @@ -14,6 +14,56 @@ type Document with |> Document.typedEvents ("fsharp/textDocument/nestedLanguages") |> Observable.filter (fun n -> n.TextDocument = x.VersionedTextDocumentIdentifier) +let private getDocumentText (lines: string[]) (ranges: Range array) : string = + ranges + |> Array.map (fun r -> + let startLine = lines.[int r.Start.Line] + let endLine = lines.[int r.End.Line] + + if r.Start.Line = r.End.Line then + startLine.Substring(int r.Start.Character, int (r.End.Character - r.Start.Character)) + else + let start = startLine.Substring(int r.Start.Character) + let ``end`` = endLine.Substring(0, int r.End.Character) + + let middle = + lines.[int (r.Start.Line + 1u) .. int (r.End.Line - 1u)] |> Array.map (fun l -> l.Trim()) + + let middle = String.Join(" ", middle) + start + middle + ``end``) + |> String.concat "\n" + + + +let private contentErrorMessage + (actual: FsAutoComplete.Lsp.NestedLanguage array) + (expected: FsAutoComplete.Lsp.NestedLanguage array) + (sourceText: string) + = + let builder = System.Text.StringBuilder() + let lines = sourceText.Split([| '\n'; '\r' |], StringSplitOptions.None) + + builder.AppendLine "Expected nested documents to be equivalent, but found differences" + |> ignore + + if actual.Length <> expected.Length then + builder.AppendLine $"Expected %d{expected.Length} nested languages, but found %d{actual.Length}" + |> ignore + else + for (index, (expected, actual)) in Array.zip expected actual |> Array.indexed do + if expected.Language <> actual.Language then + builder.AppendLine + $"Expected document #${index}'s language to be %s{expected.Language}, but was %s{actual.Language}" + |> ignore + + let expectedText = getDocumentText lines expected.Ranges + let actualText = getDocumentText lines actual.Ranges + + builder.AppendLine $"Expected document #{index} to be \n\t%s{expectedText}\nbut was\n\t%s{actualText}" + |> ignore + + builder.ToString() + let hasLanguages name source expectedLanguages server = testAsync name { let! (doc, diags) = server |> Server.createUntitledDocument source @@ -30,29 +80,29 @@ let hasLanguages name source expectedLanguages server = { Start = { Line = sl; Character = sc } End = { Line = el; Character = ec } }) }) - Expect.equal nestedLanguages.NestedLanguages mappedExpectedLanguages "languages" + Expect.equal + nestedLanguages.NestedLanguages + mappedExpectedLanguages + (contentErrorMessage nestedLanguages.NestedLanguages mappedExpectedLanguages source) } let tests state = testList "nested languages" - [ testList - "BCL" + [ ptestList + "unsupported scenarios" // pending because class members don't return attributes in the FCS Parameter API - [ pserverTestList "class member" state defaultConfigDto None (fun server -> + [ serverTestList "class member" state defaultConfigDto None (fun server -> [ hasLanguages - "with single string parameter" + "BCL type" """ let b = System.UriBuilder("https://google.com") """ [| ("uri", [| (1u, 38u), (1u, 58u) |]) |] - server ]) ] - testList - "FSharp Code" - // pending because class members don't return attributes in the FCS Parameter API - [ pserverTestList "class member" state defaultConfigDto None (fun server -> - [ hasLanguages - "with single string parameter" + server + + hasLanguages + "F#-defined type" """ type Foo() = member x.Boo([] uriString: string) = () @@ -61,48 +111,96 @@ let tests state = """ [| ("uri", [| (4u, 26u), (4u, 46u) |]) |] server ]) + serverTestList "functions" state defaultConfigDto None (fun server -> + [ hasLanguages + "interpolated string with format specifier" + """ + let uri ([]s: string) = () + let u = uri $"https://%b{true}.com" + """ + [| ("uri", [| (2u, 26u), (2u, 34u); (2u, 42u), (2u, 46u) |]) |] + server + - serverTestList "let bound function member" state defaultConfigDto None (fun server -> + // commented out because I can't figure out how to get the new string interpolation working + // hasLanguages + // "more than triple-quoted interpolated string with format specifier" + // """ + // let uri ([]s: string) = () + // let u = uri $$""""https://%b{{true}}.com"""" + // """ + // [| ("uri", [| (2, 24), (2, 35); (2, 39), (2, 45) |]) |] + // server + ]) ] + testList + "FSharp Code" + [ serverTestList "let bound function member" state defaultConfigDto None (fun server -> [ hasLanguages - "with single string parameter" + "normal string value" """ let boo ([] uriString: string) = () let u = boo "https://google.com" """ - [| ("uri", [| (2u, 24u), (2u, 44u) |]) |] + // note for reader - 24 is the start quote, 44 is the end quote, so we want a doc including 25-43 + [| ("uri", [| (2u, 25u), (2u, 43u) |]) |] server hasLanguages - "with single string parameter and string literal" - """ - let uri ([]s: string) = () - let u = uri "https://google.com" + "verbatim string value" + """ + let boo ([] uriString: string) = () + let u = boo @"https://google.com" + """ + [| ("uri", [| (2u, 26u), (2u, 44u) |]) |] + server + + hasLanguages + "triple-quote string value" + """ + let boo ([] uriString: string) = () + let u = boo "https://google.com" """ - [| ("uri", [| (2u, 24u), (2u, 44u) |]) |] - server + [| ("uri", [| (2u, 25u), (2u, 43u) |]) |] + server hasLanguages - "with single string parameter and interpolated string literal" - """ + "simple interpolated string" + """ let uri ([]s: string) = () let u = uri $"https://{true}.com" """ - [| ("uri", [| (2u, 24u), (2u, 35u) - (2u, 39u), (2u, 45u) |]) |] - server + [| ("uri", [| (2u, 26u), (2u, 34u); (2u, 40u), (2u, 44u) |]) |] + server + + // commented out because I can't figure out how to get the new string interpolation working + // hasLanguages + // "triple-quote interpolated string" + // """ + // let uri ([]s: string) = () + // let u = uri $\"\"\"https://{true}.com"\"\\" + // """ + // [| ("uri", [| (2, 24), (2, 35); (2, 39), (2, 45) |]) |] + // server + + + + // commented out because I can't figure out how to get the new string interpolation working + // hasLanguages + // "triple-quoted interpolated string with format specifier" + // """ + // let uri ([]s: string) = () + // let u = uri $"https://%b{true}.com" + // """ + // [| ("uri", [| (2, 24), (2, 35); (2, 39), (2, 45) |]) |] + // server hasLanguages - "multiple languages in the same document" - """ + "multiple languages in the same document" + """ let html ([]s: string) = () let sql ([]s: string) = () let myWebPage = html "wow" let myQuery = sql "select * from accounts where net_worth > 1000000" """ - [| ("html", [| (3u, 33u), (3u, 51u) |]) - ("sql", [| (4u, 30u), (4u, 80u) |]) |] - server - ] - ) - ] - ] + [| ("html", [| (3u, 34u), (3u, 50u) |]); ("sql", [| (4u, 31u), (4u, 79u) |]) |] + server ]) ] ] From 5ae9faef357e0671d4ea2a1c75cb7082c0f4ce51 Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Wed, 12 Mar 2025 22:32:24 -0500 Subject: [PATCH 12/12] try to account for format specifiers --- src/FsAutoComplete.Core/NestedLanguages.fs | 41 ++++++++++++++----- .../NestedLanguageTests.fs | 9 ++-- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/src/FsAutoComplete.Core/NestedLanguages.fs b/src/FsAutoComplete.Core/NestedLanguages.fs index 0982c8d92..7ac9166f3 100644 --- a/src/FsAutoComplete.Core/NestedLanguages.fs +++ b/src/FsAutoComplete.Core/NestedLanguages.fs @@ -17,6 +17,13 @@ type private StringParameter = rangesToRemove: Range array parameterPosition: int } +let private pattern = + System.Text.RegularExpressions.Regex( + @"(?%)(?[0\+\-]?)(?\d*)(\.(?\d+))?(?[bscdiuxXoBeEfFgGMOAat%])", + System.Text.RegularExpressions.RegexOptions.Compiled + ) + +let private isFormatSpecifier (text: string) = pattern.Match(text) /// for virtual documents based on interpolated strings we need to remove two kinds of trivia from the overall string portions. /// * for interpolation expressions we need to remove the entire range of the expression - this will be invisible to the virtual document since it is F# code. @@ -35,19 +42,33 @@ let private discoverRangesToRemoveForInterpolatedString match part with | SynInterpolatedStringPart.FillExpr(fillExpr = e) -> [| e.Range |] // for the first part we have whatever 'leading' element on the left and a trailing interpolation piece (which can include a format specifier) on the right - | SynInterpolatedStringPart.String(range = range) when index = 0 -> + | SynInterpolatedStringPart.String(value = value; range = range) when index = 0 -> [| // leading tokens adjustment // GAP: we don't know how many interpolation $ or " there are, so we are guessing - match stringKind with - | SynStringKind.Regular -> - // 'regular' means $" leading identifier - range.WithEnd(range.Start.WithColumn(range.StartColumn + 2)) - | SynStringKind.TripleQuote -> - // 'triple quote' means $""" leading identifier - range.WithEnd(range.Start.WithColumn(range.StartColumn + 4)) - // there's no such thing as a verbatim interpolated string - | SynStringKind.Verbatim -> () + let startRange = + match stringKind with + | SynStringKind.Regular -> + // 'regular' means $" leading identifier + range.WithEnd(range.Start.WithColumn(range.StartColumn + 2)) + | SynStringKind.TripleQuote -> + // 'triple quote' means $""" leading identifier + range.WithEnd(range.Start.WithColumn(range.StartColumn + 4)) + // there's no such thing as a verbatim interpolated string + | SynStringKind.Verbatim -> range + + + // GAP: we don't know if there's a format specifier at the front: %[flags][width][.precision][type] + // flags are 0,+,-, width is an integer, precision is `.` followed by an integer, type is one of the following: b, s, c, d, i, u, x, X, o, B, e, E, f, F, g, G, M, O, A, a, t, % + let adjustedRangeForFormatSpecifier = + let formatmatch = isFormatSpecifier value + + if formatmatch.Success then + startRange.WithEnd(startRange.End.WithColumn(startRange.StartColumn + formatmatch.Index - 1)) + else + startRange + + adjustedRangeForFormatSpecifier // trailing token adjustment- only an opening bracket { // GAP: this is the feature gap - we don't know about format specifiers diff --git a/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs b/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs index cf2b4490a..e8b73772d 100644 --- a/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs +++ b/test/FsAutoComplete.Tests.Lsp/NestedLanguageTests.fs @@ -21,13 +21,14 @@ let private getDocumentText (lines: string[]) (ranges: Range array) : string = let endLine = lines.[int r.End.Line] if r.Start.Line = r.End.Line then - startLine.Substring(int r.Start.Character, int (r.End.Character - r.Start.Character)) + startLine.Substring(int r.Start.Character, int (r.End.Character - r.Start.Character)) else - let start = startLine.Substring(int r.Start.Character) + let start = startLine.Substring(int r.Start.Character) let ``end`` = endLine.Substring(0, int r.End.Character) let middle = - lines.[int (r.Start.Line + 1u) .. int (r.End.Line - 1u)] |> Array.map (fun l -> l.Trim()) + lines.[int (r.Start.Line + 1u) .. int (r.End.Line - 1u)] + |> Array.map (fun l -> l.Trim()) let middle = String.Join(" ", middle) start + middle + ``end``) @@ -89,7 +90,7 @@ let hasLanguages name source expectedLanguages server = let tests state = testList "nested languages" - [ ptestList + [ testList "unsupported scenarios" // pending because class members don't return attributes in the FCS Parameter API [ serverTestList "class member" state defaultConfigDto None (fun server ->