From c58230fe1355149f9d639f4f9a9757521717440a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saulius=20Menkevi=C4=8Dius?= Date: Fri, 17 Oct 2025 11:15:27 +0300 Subject: [PATCH 1/6] squash uh --- .../CSharpLanguageServer.fsproj | 1 - src/CSharpLanguageServer/Conversions.fs | 2 + .../Handlers/Completion.fs | 161 ++++++++++++-- .../Handlers/TextDocumentSync.fs | 203 +++++++++++++----- src/CSharpLanguageServer/RoslynHelpers.fs | 47 ++++ .../State/ServerRequestContext.fs | 50 ++++- src/CSharpLanguageServer/State/ServerState.fs | 172 +++++++++------ src/CSharpLanguageServer/Types.fs | 4 +- .../CodeActionTests.fs | 1 - .../CompletionTests.fs | 82 +++++++ .../DiagnosticTests.fs | 2 +- .../Project/Views/Test/CompletionTests.cshtml | 8 + .../Project/ClassWithExtensionMethods.cs | 23 ++ .../Project/Controllers/TestController.cs | 17 ++ .../Project/Models/Test/IndexViewModel.cs | 5 + .../genericProject/Project/Program.cs | 20 ++ .../genericProject/Project/Project.csproj | 14 +- .../genericProject/Project/Startup.cs | 30 +++ .../Project/Views/Test/Index.cshtml | 8 + .../Project/Views/_ViewImports.cshtml | 1 + .../Project/Views/_ViewStart.cshtml | 3 + .../CSharpLanguageServer.Tests/HoverTests.fs | 40 ++-- .../InitializationTests.fs | 17 +- .../ReferenceTests.fs | 90 +++++++- .../Project/Controllers/TestController.cs | 17 ++ .../Project/Models/Test/IndexViewModel.cs | 5 + .../Project/Program.cs | 20 ++ .../Project/Project.csproj | 10 + .../Project/Startup.cs | 30 +++ .../Project/Views/Test/Index.cshtml | 2 + .../Project/Views/_ViewImports.cshtml | 1 + .../Project/Views/_ViewStart.cshtml | 3 + .../Project/Controllers/TestController.cs | 17 ++ .../Project/Models/Test/IndexViewModel.cs | 5 + .../Project/Program.cs | 20 ++ .../Project/Project.csproj | 10 + .../Project/Startup.cs | 30 +++ .../Project/Views/Test/Index.cshtml | 2 + .../Project/Views/_ViewImports.cshtml | 1 + .../Project/Views/_ViewStart.cshtml | 3 + .../WorkspaceSymbolTests.fs | 3 +- 41 files changed, 994 insertions(+), 186 deletions(-) create mode 100644 tests/CSharpLanguageServer.Tests/Fixtures/aspnetProject/Project/Views/Test/CompletionTests.cshtml create mode 100644 tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/ClassWithExtensionMethods.cs create mode 100644 tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Controllers/TestController.cs create mode 100644 tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Models/Test/IndexViewModel.cs create mode 100644 tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Program.cs create mode 100644 tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Startup.cs create mode 100644 tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Views/Test/Index.cshtml create mode 100644 tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Views/_ViewImports.cshtml create mode 100644 tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Views/_ViewStart.cshtml create mode 100644 tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Controllers/TestController.cs create mode 100644 tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Models/Test/IndexViewModel.cs create mode 100644 tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Program.cs create mode 100644 tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Project.csproj create mode 100644 tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Startup.cs create mode 100644 tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Views/Test/Index.cshtml create mode 100644 tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Views/_ViewImports.cshtml create mode 100644 tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Views/_ViewStart.cshtml create mode 100644 tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Controllers/TestController.cs create mode 100644 tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Models/Test/IndexViewModel.cs create mode 100644 tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Program.cs create mode 100644 tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Project.csproj create mode 100644 tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Startup.cs create mode 100644 tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Views/Test/Index.cshtml create mode 100644 tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Views/_ViewImports.cshtml create mode 100644 tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Views/_ViewStart.cshtml diff --git a/src/CSharpLanguageServer/CSharpLanguageServer.fsproj b/src/CSharpLanguageServer/CSharpLanguageServer.fsproj index 17671f98..4e6b6045 100644 --- a/src/CSharpLanguageServer/CSharpLanguageServer.fsproj +++ b/src/CSharpLanguageServer/CSharpLanguageServer.fsproj @@ -17,7 +17,6 @@ README.md CHANGELOG.md enable - true diff --git a/src/CSharpLanguageServer/Conversions.fs b/src/CSharpLanguageServer/Conversions.fs index 7078593e..426366cc 100644 --- a/src/CSharpLanguageServer/Conversions.fs +++ b/src/CSharpLanguageServer/Conversions.fs @@ -88,6 +88,8 @@ module Location = |> Option.bind (fun filePath -> if File.Exists filePath then Some filePath else None) |> Option.map (fun filePath -> toLspLocation filePath (loc.GetLineSpan().Span)) + //Console.Error.WriteLine("loc={0}; mapped={1}; source={2}", loc, mappedSourceLocation, sourceLocation) + mappedSourceLocation |> Option.orElse sourceLocation | _ -> None diff --git a/src/CSharpLanguageServer/Handlers/Completion.fs b/src/CSharpLanguageServer/Handlers/Completion.fs index 6d16fb7e..a88d31f1 100644 --- a/src/CSharpLanguageServer/Handlers/Completion.fs +++ b/src/CSharpLanguageServer/Handlers/Completion.fs @@ -3,19 +3,23 @@ namespace CSharpLanguageServer.Handlers open System open System.Reflection +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.Text open Microsoft.Extensions.Caching.Memory open Ionide.LanguageServerProtocol.Server open Ionide.LanguageServerProtocol.Types open Ionide.LanguageServerProtocol.JsonRpc +open Microsoft.Extensions.Logging open CSharpLanguageServer.State open CSharpLanguageServer.Util open CSharpLanguageServer.Conversions open CSharpLanguageServer.Logging +open CSharpLanguageServer.RoslynHelpers [] module Completion = - let private _logger = Logging.getLoggerByName "Completion" + let private logger = Logging.getLoggerByName "Completion" let private completionItemMemoryCache = new MemoryCache(new MemoryCacheOptions()) @@ -180,13 +184,121 @@ module Completion = synopsis, documentationText | _, _ -> None, None - let handle + let getCompletionsForRazorDocument + (solution: Solution) + (p: CompletionParams) + : Async> = + async { + match! getRazorDocumentForUri solution p.TextDocument.Uri with + | None -> return None + | Some(project, compilation, cshtmlPath, cshtmlTree) -> + let! ct = Async.CancellationToken + let! sourceText = cshtmlTree.GetTextAsync() |> Async.AwaitTask + + let razorTextDocument = + solution.Projects + |> Seq.collect (fun p -> p.AdditionalDocuments) + |> Seq.filter (fun d -> Uri(d.FilePath, UriKind.Absolute) = Uri p.TextDocument.Uri) + |> Seq.head + + let! razorSourceText = razorTextDocument.GetTextAsync() |> Async.AwaitTask + + //logger.LogInformation("razorSourceText={0}", razorSourceText) + + //logger.LogInformation("doc={0}", sourceText) + + let posInCshtml = Position.toRoslynPosition sourceText.Lines p.Position + //logger.LogInformation("posInCshtml={posInCshtml=}", posInCshtml) + let pos = p.Position + + let root = cshtmlTree.GetRoot() + + let mutable position: int option = None + let mutable tokenForPosition: SyntaxToken option = None + let mutable debug: string option = None + + for t in root.DescendantTokens() do + let cshtmlSpan = cshtmlTree.GetMappedLineSpan(t.Span) + + if + cshtmlSpan.StartLinePosition.Line = (int pos.Line) + && cshtmlSpan.EndLinePosition.Line = (int pos.Line) + && cshtmlSpan.StartLinePosition.Character <= (int pos.Character) + then + let tokenStartCharacterOffset = + (int pos.Character - cshtmlSpan.StartLinePosition.Character) + + position <- Some(t.Span.Start + tokenStartCharacterOffset) + + debug <- + Some( + String.Format( + "token={0}; pos.Character={1}; cshtmlSpan.StartLinePosition.Character={2}; offset={3}", + t, + pos.Character, + cshtmlSpan.StartLinePosition.Character, + tokenStartCharacterOffset + ) + ) + + tokenForPosition <- Some(t) + + //logger.LogInformation(debug |> Option.defaultValue "") + + //let position = Position.toRoslynPosition sourceText.Lines translatedPosition + //logger.LogInformation("position in .cs={position}", position) + + let posInCS = sourceText.Lines.GetLinePosition(position.Value) + //logger.LogInformation("lineposition={x}", posInCS) + + // a hack to make @Model.| autocompletion to work: + // - force a dot if present on .cscshtml but missing on .cs + let newSourceText = + // TODO: check if the text in cshtml is '.', though! + let cshtmlPosition = Position.toRoslynPosition razorSourceText.Lines p.Position + let charInCshtml: char = razorSourceText[cshtmlPosition - 1] + + //logger.LogInformation("charInCshtml={0}", charInCshtml) + + if charInCshtml = '.' && string tokenForPosition <> "." then + sourceText.WithChanges(new TextChange(new TextSpan(position.Value - 1, 0), ".")) + else + sourceText + + //logger.LogInformation("newSourceText={0}", newSourceText) + + let! doc = tryAddDocument logger (cshtmlPath + ".cs") (newSourceText.ToString()) solution + + let doc = doc.Value + + //logger.LogError("handle: doc={doc}", doc) + + let completionService = + Microsoft.CodeAnalysis.Completion.CompletionService.GetService(doc) + |> RoslynCompletionServiceWrapper + + let completionOptions = + RoslynCompletionOptions.Default() + |> _.WithBool("ShowItemsFromUnimportedNamespaces", false) + |> _.WithBool("ShowNameSuggestions", false) + + let completionTrigger = CompletionContext.toCompletionTrigger p.Context + + let! roslynCompletions = + completionService.GetCompletionsAsync(doc, position.Value, completionOptions, completionTrigger, ct) + |> Async.map Option.ofObj + + return roslynCompletions |> Option.map (fun rcl -> rcl, doc) + } + + let getCompletionsForCSharpDocument (context: ServerRequestContext) (p: CompletionParams) - : Async option>> = + : Async> = async { match context.GetDocument p.TextDocument.Uri with - | None -> return None |> LspResult.success + | None -> return None + | Some doc -> let! ct = Async.CancellationToken let! sourceText = doc.GetTextAsync(ct) |> Async.AwaitTask @@ -216,6 +328,23 @@ module Completion = else async.Return None + return roslynCompletions |> Option.map (fun rcl -> rcl, doc) + } + + let handle + (context: ServerRequestContext) + (p: CompletionParams) + : Async option>> = + async { + let! roslynCompletionsAndDoc = + if p.TextDocument.Uri.EndsWith(".cshtml") then + getCompletionsForRazorDocument context.Solution p + else + getCompletionsForCSharpDocument context p + + match roslynCompletionsAndDoc with + | None -> return None |> LspResult.success + | Some(roslynCompletions, doc) -> let toLspCompletionItemsWithCacheInfo (completions: Microsoft.CodeAnalysis.Completion.CompletionList) = completions.ItemsList |> Seq.map (fun item -> (item, Guid.NewGuid() |> string)) @@ -232,26 +361,26 @@ module Completion = |> Array.ofSeq let lspCompletionItemsWithCacheInfo = - roslynCompletions |> Option.map toLspCompletionItemsWithCacheInfo + roslynCompletions |> toLspCompletionItemsWithCacheInfo // cache roslyn completion items - for (_, cacheItemId, roslynDoc, roslynItem) in - (lspCompletionItemsWithCacheInfo |> Option.defaultValue Array.empty) do + for (_, cacheItemId, roslynDoc, roslynItem) in lspCompletionItemsWithCacheInfo do completionItemMemoryCacheSet cacheItemId roslynDoc roslynItem + let items = + lspCompletionItemsWithCacheInfo |> Array.map (fun (item, _, _, _) -> item) + return - lspCompletionItemsWithCacheInfo - |> Option.map (fun itemsWithCacheInfo -> - itemsWithCacheInfo |> Array.map (fun (item, _, _, _) -> item)) - |> Option.map (fun items -> - { IsIncomplete = true - Items = items - ItemDefaults = None }) - |> Option.map U2.C2 + { IsIncomplete = true + Items = items + ItemDefaults = None } + |> U2.C2 + |> Some |> LspResult.success } let resolve (_context: ServerRequestContext) (item: CompletionItem) : AsyncLspResult = async { + let roslynDocAndItemMaybe = item.Data |> Option.bind deserialize @@ -259,6 +388,8 @@ module Completion = match roslynDocAndItemMaybe with | Some(doc, roslynCompletionItem) -> + logger.LogInformation("resolve, doc={0}, item={1}", doc, roslynCompletionItem) + let completionService = Microsoft.CodeAnalysis.Completion.CompletionService.GetService(doc) |> nonNull "Microsoft.CodeAnalysis.Completion.CompletionService.GetService(doc)" diff --git a/src/CSharpLanguageServer/Handlers/TextDocumentSync.fs b/src/CSharpLanguageServer/Handlers/TextDocumentSync.fs index 1e7685d2..e4d6b9a2 100644 --- a/src/CSharpLanguageServer/Handlers/TextDocumentSync.fs +++ b/src/CSharpLanguageServer/Handlers/TextDocumentSync.fs @@ -1,7 +1,10 @@ namespace CSharpLanguageServer.Handlers open System +open System.Text +open System.IO +open Microsoft.Extensions.Logging open Microsoft.CodeAnalysis.Text open Ionide.LanguageServerProtocol.Types open Ionide.LanguageServerProtocol.JsonRpc @@ -21,7 +24,6 @@ module TextDocumentSync = (changes: TextDocumentContentChangeEvent[]) (initialSourceText: SourceText) = - let applyLspContentChangeOnRoslynSourceText (sourceText: SourceText) (change: TextDocumentContentChangeEvent) = match change with | U2.C1 change -> @@ -42,74 +44,160 @@ module TextDocumentSync = Change = Some TextDocumentSyncKind.Incremental } |> Some - let didOpen (context: ServerRequestContext) (openParams: DidOpenTextDocumentParams) : Async> = - match context.GetDocumentForUriOfType AnyDocument openParams.TextDocument.Uri with - | Some(doc, docType) -> - match docType with - | UserDocument -> - // we want to load the document in case it has been changed since we have the solution loaded - // also, as a bonus we can recover from corrupted document view in case document in roslyn solution - // went out of sync with editor - let updatedDoc = SourceText.From(openParams.TextDocument.Text) |> doc.WithText + if openParams.TextDocument.Uri.EndsWith(".cshtml") then + let u = openParams.TextDocument.Uri |> string + let uri = Uri(u.Replace("%3A", ":", true, null)) + + let matchingAdditionalDoc = + context.Solution.Projects + |> Seq.collect (fun p -> p.AdditionalDocuments) + |> Seq.filter (fun d -> Uri(d.FilePath, UriKind.Absolute) = uri) + |> List.ofSeq + + let doc = + if matchingAdditionalDoc.Length = 1 then + matchingAdditionalDoc |> Seq.head |> Some + else + None + + let newSourceText = SourceText.From(openParams.TextDocument.Text, Encoding.UTF8) + + match doc with + | Some doc -> + let updatedDoc = + doc.Project + |> _.RemoveAdditionalDocument(doc.Id) + |> _.AddAdditionalDocument(doc.Name, newSourceText, doc.Folders, doc.FilePath) context.Emit(OpenDocAdd(openParams.TextDocument.Uri, openParams.TextDocument.Version, DateTime.Now)) context.Emit(SolutionChange updatedDoc.Project.Solution) - Ok() |> async.Return + | None -> + let cshtmlPath = Uri.toPath openParams.TextDocument.Uri + let project = getProjectForPathOnSolution context.Solution cshtmlPath - | _ -> Ok() |> async.Return + match project with + | Some project -> + let projectBaseDir = Path.GetDirectoryName(project.FilePath) + let relativePath = Path.GetRelativePath(projectBaseDir, cshtmlPath) - | None -> - let docFilePathMaybe = Util.tryParseFileUri openParams.TextDocument.Uri + let folders = relativePath.Split(Path.DirectorySeparatorChar) - match docFilePathMaybe with - | Some docFilePath -> async { - // ok, this document is not in solution, register a new document - let! newDocMaybe = tryAddDocument logger docFilePath openParams.TextDocument.Text context.Solution + let folders = folders |> Seq.take (folders.Length - 1) + + let newDoc = + project.AddAdditionalDocument(Path.GetFileName(cshtmlPath), newSourceText, folders, cshtmlPath) - match newDocMaybe with - | Some newDoc -> context.Emit(OpenDocAdd(openParams.TextDocument.Uri, openParams.TextDocument.Version, DateTime.Now)) context.Emit(SolutionChange newDoc.Project.Solution) + () | None -> () - return Ok() - } + Ok() |> async.Return + else + match context.GetDocumentForUriOfType AnyDocument openParams.TextDocument.Uri with + | Some(doc, docType) -> + match docType with + | UserDocument -> + // we want to load the document in case it has been changed since we have the solution loaded + // also, as a bonus we can recover from corrupted document view in case document in roslyn solution + // went out of sync with editor + let updatedDoc = SourceText.From(openParams.TextDocument.Text) |> doc.WithText + + context.Emit(OpenDocAdd(openParams.TextDocument.Uri, openParams.TextDocument.Version, DateTime.Now)) + context.Emit(SolutionChange updatedDoc.Project.Solution) + + Ok() |> async.Return + + | _ -> Ok() |> async.Return + + | None -> + let docFilePathMaybe = Util.tryParseFileUri openParams.TextDocument.Uri + + match docFilePathMaybe with + | Some docFilePath -> async { + // ok, this document is not in solution, register a new document + let! newDocMaybe = tryAddDocument logger docFilePath openParams.TextDocument.Text context.Solution + + match newDocMaybe with + | Some newDoc -> + context.Emit( + OpenDocAdd(openParams.TextDocument.Uri, openParams.TextDocument.Version, DateTime.Now) + ) + + context.Emit(SolutionChange newDoc.Project.Solution) + + | None -> () - | None -> Ok() |> async.Return + return Ok() + } + | None -> Ok() |> async.Return let didChange (context: ServerRequestContext) (changeParams: DidChangeTextDocumentParams) : Async> = async { - let docMaybe = context.GetUserDocument changeParams.TextDocument.Uri + if changeParams.TextDocument.Uri.EndsWith(".cshtml") then + let u = changeParams.TextDocument.Uri |> string + let uri = Uri(u.Replace("%3A", ":", true, null)) + + let matchingAdditionalDoc = + context.Solution.Projects + |> Seq.collect (fun p -> p.AdditionalDocuments) + |> Seq.filter (fun d -> Uri(d.FilePath, UriKind.Absolute) = uri) + |> List.ofSeq + + let doc = + if matchingAdditionalDoc.Length = 1 then + matchingAdditionalDoc |> Seq.head |> Some + else + None + + match doc with + | None -> () + | Some doc -> + let! ct = Async.CancellationToken + let! sourceText = doc.GetTextAsync(ct) |> Async.AwaitTask + + let updatedSourceText = + sourceText + |> applyLspContentChangesOnRoslynSourceText changeParams.ContentChanges - match docMaybe with - | None -> () - | Some doc -> - let! ct = Async.CancellationToken - let! sourceText = doc.GetTextAsync(ct) |> Async.AwaitTask - //logMessage (sprintf "TextDocumentDidChange: changeParams: %s" (string changeParams)) - //logMessage (sprintf "TextDocumentDidChange: sourceText: %s" (string sourceText)) + let updatedDoc = + doc.Project + |> _.RemoveAdditionalDocument(doc.Id) + |> _.AddAdditionalDocument(doc.Name, updatedSourceText, doc.Folders, doc.FilePath) + + context.Emit(OpenDocAdd(changeParams.TextDocument.Uri, changeParams.TextDocument.Version, DateTime.Now)) + context.Emit(SolutionChange updatedDoc.Project.Solution) - let updatedSourceText = - sourceText - |> applyLspContentChangesOnRoslynSourceText changeParams.ContentChanges + return Ok() + else + let docMaybe = context.GetUserDocument changeParams.TextDocument.Uri - let updatedDoc = doc.WithText(updatedSourceText) + match docMaybe with + | None -> () + | Some doc -> + let! ct = Async.CancellationToken + let! sourceText = doc.GetTextAsync(ct) |> Async.AwaitTask - //logMessage (sprintf "TextDocumentDidChange: newSourceText: %s" (string updatedSourceText)) + let updatedSourceText = + sourceText + |> applyLspContentChangesOnRoslynSourceText changeParams.ContentChanges - let updatedSolution = updatedDoc.Project.Solution + let updatedSolution = doc.WithText(updatedSourceText).Project.Solution - context.Emit(SolutionChange updatedSolution) - context.Emit(OpenDocAdd(changeParams.TextDocument.Uri, changeParams.TextDocument.Version, DateTime.Now)) + context.Emit(SolutionChange updatedSolution) + context.Emit(OpenDocAdd(changeParams.TextDocument.Uri, changeParams.TextDocument.Version, DateTime.Now)) - return Ok() + return Ok() } let didClose (context: ServerRequestContext) (closeParams: DidCloseTextDocumentParams) : Async> = - context.Emit(OpenDocRemove closeParams.TextDocument.Uri) - Ok() |> async.Return + if closeParams.TextDocument.Uri.EndsWith(".cshtml") then + Ok() |> async.Return + else + context.Emit(OpenDocRemove closeParams.TextDocument.Uri) + Ok() |> async.Return let willSave (_context: ServerRequestContext) (_p: WillSaveTextDocumentParams) : Async> = async { return Ok() @@ -122,22 +210,25 @@ module TextDocumentSync = async { return LspResult.notImplemented } let didSave (context: ServerRequestContext) (saveParams: DidSaveTextDocumentParams) : Async> = - // we need to add this file to solution if not already - let doc = context.GetDocument saveParams.TextDocument.Uri + if saveParams.TextDocument.Uri.EndsWith(".cshtml") then + Ok() |> async.Return + else + // we need to add this file to solution if not already + let doc = context.GetDocument saveParams.TextDocument.Uri - match doc with - | Some _ -> Ok() |> async.Return + match doc with + | Some _ -> Ok() |> async.Return - | None -> async { - let docFilePath = Util.parseFileUri saveParams.TextDocument.Uri - let! newDocMaybe = tryAddDocument logger docFilePath saveParams.Text.Value context.Solution + | None -> async { + let docFilePath = Util.parseFileUri saveParams.TextDocument.Uri + let! newDocMaybe = tryAddDocument logger docFilePath saveParams.Text.Value context.Solution - match newDocMaybe with - | Some newDoc -> - context.Emit(OpenDocTouch(saveParams.TextDocument.Uri, DateTime.Now)) - context.Emit(SolutionChange newDoc.Project.Solution) + match newDocMaybe with + | Some newDoc -> + context.Emit(OpenDocTouch(saveParams.TextDocument.Uri, DateTime.Now)) + context.Emit(SolutionChange newDoc.Project.Solution) - | None -> () + | None -> () - return Ok() - } + return Ok() + } diff --git a/src/CSharpLanguageServer/RoslynHelpers.fs b/src/CSharpLanguageServer/RoslynHelpers.fs index b84a0baf..fe0bda3e 100644 --- a/src/CSharpLanguageServer/RoslynHelpers.fs +++ b/src/CSharpLanguageServer/RoslynHelpers.fs @@ -872,3 +872,50 @@ let initializeMSBuild (logger: ILogger) : unit = ) MSBuildLocator.RegisterInstance(vsInstance) + + +let getRazorDocumentForUri + (solution: Solution) + (uri: string) + : Async<(Project * Compilation * string * SyntaxTree) option> = + async { + let cshtmlPath = Uri.toPath uri + let cshtmlDirectory = Path.GetDirectoryName(cshtmlPath) + let normalizedTargetDir = Path.GetFullPath(cshtmlDirectory) + + let projectForPath = + solution.Projects + |> Seq.tryFind (fun project -> + let projectDirectory = Path.GetDirectoryName(project.FilePath) + let normalizedProjectDir = Path.GetFullPath(projectDirectory) + + normalizedTargetDir.StartsWith( + normalizedProjectDir + Path.DirectorySeparatorChar.ToString(), + StringComparison.OrdinalIgnoreCase + )) + + let projectBaseDir = Path.GetDirectoryName(projectForPath.Value.FilePath) + + let! compilation = projectForPath.Value.GetCompilationAsync() |> Async.AwaitTask + + let mutable cshtmlTree: SyntaxTree option = None + + let cshtmlPathTranslated = + Path.GetRelativePath(projectBaseDir, cshtmlPath) + |> _.Replace(".", "_") + |> _.Replace(Path.DirectorySeparatorChar, '_') + |> (fun s -> s + ".g.cs") + + for tree in compilation.SyntaxTrees do + let path = tree.FilePath + + if path.StartsWith(projectBaseDir) then + let relativePath = Path.GetRelativePath(projectBaseDir, path) + + if relativePath.EndsWith(cshtmlPathTranslated) then + cshtmlTree <- Some tree + + return + cshtmlTree + |> Option.map (fun cst -> (projectForPath.Value, compilation, cshtmlPath, cst)) + } diff --git a/src/CSharpLanguageServer/State/ServerRequestContext.fs b/src/CSharpLanguageServer/State/ServerRequestContext.fs index 72e15f7e..002b222f 100644 --- a/src/CSharpLanguageServer/State/ServerRequestContext.fs +++ b/src/CSharpLanguageServer/State/ServerRequestContext.fs @@ -1,5 +1,8 @@ namespace CSharpLanguageServer.State +open System +open System.IO + open Microsoft.CodeAnalysis open Microsoft.CodeAnalysis.FindSymbols open Ionide.LanguageServerProtocol.Types @@ -33,7 +36,8 @@ type ServerRequestContext(requestId: int, state: ServerState, emitServerEvent) = ) | None -> async.Return() - member this.GetDocumentForUriOfType = getDocumentForUriOfType this.State + member this.GetDocumentForUriOfType docType uri = + getDocumentForUriOfType this.State docType uri member this.GetUserDocument(u: string) = this.GetDocumentForUriOfType UserDocument u |> Option.map fst @@ -135,14 +139,42 @@ type ServerRequestContext(requestId: int, state: ServerState, emitServerEvent) = } member this.FindSymbol' (uri: DocumentUri) (pos: Position) : Async<(ISymbol * Project * Document option) option> = async { - match this.GetDocument uri with - | None -> return None - | Some doc -> - let! ct = Async.CancellationToken - let! sourceText = doc.GetTextAsync(ct) |> Async.AwaitTask - let position = Position.toRoslynPosition sourceText.Lines pos - let! symbol = SymbolFinder.FindSymbolAtPositionAsync(doc, position, ct) |> Async.AwaitTask - return symbol |> Option.ofObj |> Option.map (fun sym -> sym, doc.Project, Some doc) + if uri.EndsWith(".cshtml") then + match! getRazorDocumentForUri state.Solution.Value uri with + | Some(project, compilation, cshtmlPath, cshtmlTree) -> + let model = compilation.GetSemanticModel(cshtmlTree) + + let root = cshtmlTree.GetRoot() + + let token = + root.DescendantTokens() + |> Seq.tryFind (fun t -> + let span = cshtmlTree.GetMappedLineSpan(t.Span) + + span.Path = cshtmlPath + && span.StartLinePosition.Line <= (int pos.Line) + && span.EndLinePosition.Line >= (int pos.Line) + && span.StartLinePosition.Character <= (int pos.Character) + && span.EndLinePosition.Character > (int pos.Character)) + + let symbol = + token + |> Option.bind (fun x -> x.Parent |> Option.ofObj) + |> Option.map (fun parentToken -> model.GetSymbolInfo(parentToken)) + |> Option.bind (fun x -> x.Symbol |> Option.ofObj) + + return symbol |> Option.map (fun sym -> (sym, project, None)) + + | None -> return None + else + match this.GetDocument uri with + | None -> return None + | Some doc -> + let! ct = Async.CancellationToken + let! sourceText = doc.GetTextAsync(ct) |> Async.AwaitTask + let position = Position.toRoslynPosition sourceText.Lines pos + let! symbol = SymbolFinder.FindSymbolAtPositionAsync(doc, position, ct) |> Async.AwaitTask + return symbol |> Option.ofObj |> Option.map (fun sym -> sym, doc.Project, Some doc) } member this.FindSymbol (uri: DocumentUri) (pos: Position) : Async = diff --git a/src/CSharpLanguageServer/State/ServerState.fs b/src/CSharpLanguageServer/State/ServerState.fs index 19aaaf15..fb7dd7c7 100644 --- a/src/CSharpLanguageServer/State/ServerState.fs +++ b/src/CSharpLanguageServer/State/ServerState.fs @@ -277,6 +277,107 @@ let processDumpAndResetRequestStats (logger: ILogger) state = LastStatsDumpTime = DateTime.Now } +let processPushDiagnosticsProcessPendingDocumentsEvent (logger: ILogger) state postSelf _msg : Async = async { + match state.PushDiagnosticsCurrentDocTask with + | Some _ -> + // another document is still being processed, do nothing + return state + + | None -> + // try pull next doc from the backlog to process + let nextDocUri, newBacklog = + match state.PushDiagnosticsDocumentBacklog with + | [] -> (None, []) + | uri :: remainder -> (Some uri, remainder) + + // push diagnostic is enabled only if pull diagnostics is + // not reported to be supported by the client + let diagnosticPullSupported = + state.ClientCapabilities.TextDocument + |> Option.map _.Diagnostic + |> Option.map _.IsSome + |> Option.defaultValue false + + match diagnosticPullSupported, nextDocUri with + | false, Some docUri -> + let newState = + { state with + PushDiagnosticsDocumentBacklog = newBacklog } + + let docAndTypeMaybe = docUri |> getDocumentForUriOfType state AnyDocument + + match docAndTypeMaybe with + | None -> + match! getRazorDocumentForUri state.Solution.Value docUri with + | Some(_, compilation, cshtmlPath, cshtmlTree) -> + let cshtmlUri = Uri.fromPath cshtmlPath + let semanticModelMaybe = compilation.GetSemanticModel(cshtmlTree) |> Option.ofObj + + match semanticModelMaybe with + | None -> + Error(Exception("could not GetSemanticModelAsync")) + |> PushDiagnosticsDocumentDiagnosticsResolution + |> postSelf + + | Some semanticModel -> + let diagnostics = + semanticModel.GetDiagnostics() + |> Seq.map Diagnostic.fromRoslynDiagnostic + |> Seq.filter (fun (_, uri) -> uri = cshtmlUri) + |> Seq.map fst + |> Array.ofSeq + + Ok(docUri, None, diagnostics) + |> PushDiagnosticsDocumentDiagnosticsResolution + |> postSelf + + | None -> + // could not find document for this enqueued uri + logger.LogDebug( + "PushDiagnosticsProcessPendingDocuments: could not find document w/ uri \"{docUri}\"", + string docUri + ) + + () + + return newState + + | Some(doc, _docType) -> + let resolveDocumentDiagnostics () : Task = task { + let! semanticModelMaybe = doc.GetSemanticModelAsync() + + match semanticModelMaybe |> Option.ofObj with + | None -> + Error(Exception("could not GetSemanticModelAsync")) + |> PushDiagnosticsDocumentDiagnosticsResolution + |> postSelf + + | Some semanticModel -> + let diagnostics = + semanticModel.GetDiagnostics() + |> Seq.map Diagnostic.fromRoslynDiagnostic + |> Seq.map fst + |> Array.ofSeq + + Ok(docUri, None, diagnostics) + |> PushDiagnosticsDocumentDiagnosticsResolution + |> postSelf + } + + let newTask = Task.Run(resolveDocumentDiagnostics) + + let newState = + { newState with + PushDiagnosticsCurrentDocTask = Some(docUri, newTask) } + + return newState + + | _, _ -> + // backlog is empty or pull diagnostics is enabled instead,--nothing to do + return state +} + + let processServerEvent (logger: ILogger) state postSelf msg : Async = async { match msg with | SettingsChange newSettings -> @@ -451,76 +552,7 @@ let processServerEvent (logger: ILogger) state postSelf msg : Async PushDiagnosticsDocumentBacklog = newBacklog } | PushDiagnosticsProcessPendingDocuments -> - match state.PushDiagnosticsCurrentDocTask with - | Some _ -> - // another document is still being processed, do nothing - return state - | None -> - // try pull next doc from the backlog to process - let nextDocUri, newBacklog = - match state.PushDiagnosticsDocumentBacklog with - | [] -> (None, []) - | uri :: remainder -> (Some uri, remainder) - - // push diagnostic is enabled only if pull diagnostics is - // not reported to be supported by the client - let diagnosticPullSupported = - state.ClientCapabilities.TextDocument - |> Option.map _.Diagnostic - |> Option.map _.IsSome - |> Option.defaultValue false - - match diagnosticPullSupported, nextDocUri with - | false, Some docUri -> - let newState = - { state with - PushDiagnosticsDocumentBacklog = newBacklog } - - let docAndTypeMaybe = docUri |> getDocumentForUriOfType state AnyDocument - - match docAndTypeMaybe with - | None -> - // could not find document for this enqueued uri - logger.LogDebug( - "PushDiagnosticsProcessPendingDocuments: could not find document w/ uri \"{docUri}\"", - string docUri - ) - - return newState - - | Some(doc, _docType) -> - let resolveDocumentDiagnostics () : Task = task { - let! semanticModelMaybe = doc.GetSemanticModelAsync() - - match semanticModelMaybe |> Option.ofObj with - | None -> - Error(Exception("could not GetSemanticModelAsync")) - |> PushDiagnosticsDocumentDiagnosticsResolution - |> postSelf - - | Some semanticModel -> - let diagnostics = - semanticModel.GetDiagnostics() - |> Seq.map Diagnostic.fromRoslynDiagnostic - |> Seq.map fst - |> Array.ofSeq - - Ok(docUri, None, diagnostics) - |> PushDiagnosticsDocumentDiagnosticsResolution - |> postSelf - } - - let newTask = Task.Run(resolveDocumentDiagnostics) - - let newState = - { newState with - PushDiagnosticsCurrentDocTask = Some(docUri, newTask) } - - return newState - - | _, _ -> - // backlog is empty or pull diagnostics is enabled instead,--nothing to do - return state + return! processPushDiagnosticsProcessPendingDocumentsEvent logger state postSelf msg | PushDiagnosticsDocumentDiagnosticsResolution result -> // enqueue processing for the next doc on the queue (if any) diff --git a/src/CSharpLanguageServer/Types.fs b/src/CSharpLanguageServer/Types.fs index 28e1fd62..109786c3 100644 --- a/src/CSharpLanguageServer/Types.fs +++ b/src/CSharpLanguageServer/Types.fs @@ -50,7 +50,9 @@ let razorCsharpDocumentFilter: TextDocumentFilter = Scheme = Some "file" Pattern = Some "**/*.cshtml" } -let defaultDocumentSelector: DocumentSelector = [| csharpDocumentFilter |> U2.C1 |] +// Type abbreviations cannot have augmentations, extensions +let defaultDocumentSelector: DocumentSelector = + [| csharpDocumentFilter |> U2.C1; razorCsharpDocumentFilter |> U2.C1 |] let emptyClientCapabilities: ClientCapabilities = { Workspace = None diff --git a/tests/CSharpLanguageServer.Tests/CodeActionTests.fs b/tests/CSharpLanguageServer.Tests/CodeActionTests.fs index 42de0f0c..fc2ed18c 100644 --- a/tests/CSharpLanguageServer.Tests/CodeActionTests.fs +++ b/tests/CSharpLanguageServer.Tests/CodeActionTests.fs @@ -124,5 +124,4 @@ let ``extract interface code action should extract an interface`` () = Assert.AreEqual(expectedImplementInterfaceEdits, implementEdits |> TextEdit.normalizeNewText) | _ -> failwith "Expected exactly one U2.C1 edit in both create/implement" - | _ -> failwith "Unexpected edit structure" diff --git a/tests/CSharpLanguageServer.Tests/CompletionTests.fs b/tests/CSharpLanguageServer.Tests/CompletionTests.fs index 24a65f77..1efc0ce9 100644 --- a/tests/CSharpLanguageServer.Tests/CompletionTests.fs +++ b/tests/CSharpLanguageServer.Tests/CompletionTests.fs @@ -136,3 +136,85 @@ let ``completion works for extension methods`` () = Assert.IsFalse(itemResolved.Documentation.IsSome) | _ -> failwith "Some U2.C1 was expected" + + +[] +let ``completion works in cshtml files`` () = + use client = activateFixture "aspnetProject" + + use cshtmlFile = client.Open("Project/Views/Test/CompletionTests.cshtml") + + let testCompletionResultContainsItem + line + character + expectedLabel + expectedCompletionItemKind + expectedDetail + documentationTestFn + = + let completionParams0: CompletionParams = + { TextDocument = { Uri = cshtmlFile.Uri } + Position = { Line = line; Character = character } + WorkDoneToken = None + PartialResultToken = None + Context = None } + + let completion: U2 option = + client.Request("textDocument/completion", completionParams0) + + match completion with + | Some(U2.C2 cl) -> + let expectedItem = cl.Items |> Seq.tryFind (fun i -> i.Label = expectedLabel) + + match expectedItem with + | None -> failwithf "an item with Label '%s' was expected for completion at this position" expectedLabel + | Some item -> + Assert.AreEqual(expectedLabel, item.Label) + Assert.IsFalse(item.Detail.IsSome) + Assert.IsFalse(item.Documentation.IsSome) + Assert.AreEqual(Some expectedCompletionItemKind, item.Kind) + + let itemResolved: CompletionItem = client.Request("completionItem/resolve", item) + + Assert.AreEqual(Some expectedDetail, itemResolved.Detail) + Assert.IsTrue(documentationTestFn itemResolved.Documentation) + + | _ -> failwith "Some U2.C1 was expected" + + // + // 1st completion test: (@Model.|) + // + testCompletionResultContainsItem + 1u + 14u + "Output" + CompletionItemKind.Property + "string? Project.Models.Test.IndexViewModel.Output { get; set; }" + _.IsNone + + // + // 2nd completion test: @Model.| + // + testCompletionResultContainsItem + 2u + 13u + "Output" + CompletionItemKind.Property + "string? Project.Models.Test.IndexViewModel.Output { get; set; }" + _.IsNone + + // + // 3nd completion test: @Model.Output.| + // + testCompletionResultContainsItem 3u 13u "ToString" CompletionItemKind.Method "string? object.ToString()" _.IsSome + + // + // 4nd completion test: x. + // + testCompletionResultContainsItem + 6u + 6u + "TryFormat" + CompletionItemKind.Method + "bool int.TryFormat(Span utf8Destination, out int bytesWritten, [ReadOnlySpan format = default], [IFormatProvider? provider = null])" + _.IsSome diff --git a/tests/CSharpLanguageServer.Tests/DiagnosticTests.fs b/tests/CSharpLanguageServer.Tests/DiagnosticTests.fs index 765ce983..8598560f 100644 --- a/tests/CSharpLanguageServer.Tests/DiagnosticTests.fs +++ b/tests/CSharpLanguageServer.Tests/DiagnosticTests.fs @@ -155,7 +155,7 @@ let testWorkspaceDiagnosticsWork () = let testWorkspaceDiagnosticsWorkWithStreaming () = use client = activateFixture "testDiagnosticsWork" - Thread.Sleep(500) + Thread.Sleep(1000) let partialResultToken: ProgressToken = System.Guid.NewGuid() |> string |> U2.C2 diff --git a/tests/CSharpLanguageServer.Tests/Fixtures/aspnetProject/Project/Views/Test/CompletionTests.cshtml b/tests/CSharpLanguageServer.Tests/Fixtures/aspnetProject/Project/Views/Test/CompletionTests.cshtml new file mode 100644 index 00000000..3a22cf69 --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/Fixtures/aspnetProject/Project/Views/Test/CompletionTests.cshtml @@ -0,0 +1,8 @@ +@model Project.Models.Test.IndexViewModel +(@Model.) +@Model. +@Model.Output. +@{ + var x = 1; + x. +} diff --git a/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/ClassWithExtensionMethods.cs b/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/ClassWithExtensionMethods.cs new file mode 100644 index 00000000..519314c2 --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/ClassWithExtensionMethods.cs @@ -0,0 +1,23 @@ +<<<<<<<< HEAD:tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/ClassForCompletionTestsWithExtensionMethods.cs +class ClassForCompletionWithExtensionMethods +======== +class ClassWithExtensionMethods +>>>>>>>> 902dbd5 (squash):tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/ClassWithExtensionMethods.cs +{ + public void MethodA(string arg) + { + this. + } +} + +public static class ClassExtensions +{ +<<<<<<<< HEAD:tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/ClassForCompletionTestsWithExtensionMethods.cs + public static string MethodB(this ClassForCompletionWithExtensionMethods input) +======== + public static string MethodB(this ClassWithExtensionMethods input) +>>>>>>>> 902dbd5 (squash):tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/ClassWithExtensionMethods.cs + { + return "ok"; + } +} diff --git a/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Controllers/TestController.cs b/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Controllers/TestController.cs new file mode 100644 index 00000000..e4f76f59 --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Controllers/TestController.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Mvc; +using Project.Models.Test; + +namespace Printlog.Web.ClientPart.Controllers; + +public class TestController : Controller +{ + public IActionResult Index() + { + var model = new IndexViewModel() + { + Output = "test" + }; + + return View(model); + } +} diff --git a/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Models/Test/IndexViewModel.cs b/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Models/Test/IndexViewModel.cs new file mode 100644 index 00000000..02f6157a --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Models/Test/IndexViewModel.cs @@ -0,0 +1,5 @@ +namespace Project.Models.Test; +public class IndexViewModel +{ + public string? Output { get; set; } +} diff --git a/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Program.cs b/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Program.cs new file mode 100644 index 00000000..698f461f --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Program.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; + +namespace Project; + +public class Program +{ + public static async Task Main(string[] args) + { + await BuildWebHost(args).RunAsync(); + } + + public static IWebHost BuildWebHost(string[] args) + { + var builder = WebHost.CreateDefaultBuilder(args); + builder = builder.UseKestrel().UseStartup(); + return builder.Build(); + } +} diff --git a/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Project.csproj b/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Project.csproj index 29aeae9e..9d715580 100644 --- a/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Project.csproj +++ b/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Project.csproj @@ -1,6 +1,10 @@ - - - Exe - net9.0 - + + + net9.0 + enable + + + + + diff --git a/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Startup.cs b/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Startup.cs new file mode 100644 index 00000000..d089977c --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Startup.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Project; + +public class Startup +{ + public Startup(IConfiguration configuration, IWebHostEnvironment env) + { + } + + public void ConfigureServices(IServiceCollection services) + { + services.AddOptions(); + } + + public void Configure( + IApplicationBuilder app, + IWebHostEnvironment env) + { + app.UseAuthentication(); + app.UseAuthorization(); + + app.UseEndpoints(endpoints => { + endpoints.MapControllers(); + }); + } +} diff --git a/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Views/Test/Index.cshtml b/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Views/Test/Index.cshtml new file mode 100644 index 00000000..5182deb0 --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Views/Test/Index.cshtml @@ -0,0 +1,8 @@ +@model Project.Models.Test.IndexViewModel +@(Model.) +@Model. +@Model.Output. +@{ + int x = 1; + x. +} diff --git a/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Views/_ViewImports.cshtml b/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Views/_ViewImports.cshtml new file mode 100644 index 00000000..a757b413 --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Views/_ViewImports.cshtml @@ -0,0 +1 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Views/_ViewStart.cshtml b/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Views/_ViewStart.cshtml new file mode 100644 index 00000000..a5f10045 --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/Views/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} diff --git a/tests/CSharpLanguageServer.Tests/HoverTests.fs b/tests/CSharpLanguageServer.Tests/HoverTests.fs index f1ce31eb..7ca585e3 100644 --- a/tests/CSharpLanguageServer.Tests/HoverTests.fs +++ b/tests/CSharpLanguageServer.Tests/HoverTests.fs @@ -47,19 +47,13 @@ let testHoverWorks () = Assert.IsTrue(hover1.IsSome) match hover1 with - | Some hover -> - match hover.Contents with - | U3.C1 c -> - Assert.AreEqual(MarkupKind.Markdown, c.Kind) - - Assert.AreEqual( - "```csharp\nstring\n```\n\nRepresents text as a sequence of UTF-16 code units.", - c.Value.ReplaceLineEndings("\n") - ) - | _ -> failwith "C1 was expected" - - Assert.IsTrue(hover.Range.IsNone) + | Some { Contents = U3.C1 c } -> + Assert.AreEqual(MarkupKind.Markdown, c.Kind) + Assert.AreEqual( + "```csharp\nstring\n```\n\nRepresents text as a sequence of UTF-16 code units.", + c.Value.ReplaceLineEndings("\n") + ) | _ -> failwith "Some (U3.C1 c) was expected" // @@ -73,3 +67,25 @@ let testHoverWorks () = let hover2: Hover option = client.Request("textDocument/hover", hover2Params) Assert.IsTrue(hover2.IsNone) + +[] +let testHoverWorksInRazorFile () = + use client = activateFixture "aspnetProject" + + use indexCshtmlFile = client.Open("Project/Views/Test/Index.cshtml") + + let hover0Params: HoverParams = + { TextDocument = { Uri = indexCshtmlFile.Uri } + Position = { Line = 1u; Character = 7u } + WorkDoneToken = None } + + let hover0: Hover option = client.Request("textDocument/hover", hover0Params) + + Assert.IsTrue(hover0.IsSome) + + match hover0 with + | Some { Contents = U3.C1 c } -> + Assert.AreEqual(MarkupKind.Markdown, c.Kind) + Assert.AreEqual("```csharp\nstring? IndexViewModel.Output\n```", c.Value.ReplaceLineEndings("\n")) + + | _ -> failwith "Some (U3.C1 c) was expected" diff --git a/tests/CSharpLanguageServer.Tests/InitializationTests.fs b/tests/CSharpLanguageServer.Tests/InitializationTests.fs index 604bb791..c64f707b 100644 --- a/tests/CSharpLanguageServer.Tests/InitializationTests.fs +++ b/tests/CSharpLanguageServer.Tests/InitializationTests.fs @@ -67,13 +67,18 @@ let testServerRegistersCapabilitiesWithTheClient () = Assert.AreEqual(null, serverCaps.InlineValueProvider) + let expectedDocumentSelector = + [| U2.C1 + { Language = Some "csharp" + Scheme = Some "file" + Pattern = Some "**/*.cs" } + U2.C1 + { Language = Some "razor" + Scheme = Some "file" + Pattern = Some "**/*.cshtml" } |] + Assert.AreEqual( - { DocumentSelector = - Some - [| U2.C1 - { Language = Some "csharp" - Scheme = Some "file" - Pattern = Some "**/*.cs" } |] + { DocumentSelector = Some expectedDocumentSelector WorkDoneProgress = None Identifier = None InterFileDependencies = false diff --git a/tests/CSharpLanguageServer.Tests/ReferenceTests.fs b/tests/CSharpLanguageServer.Tests/ReferenceTests.fs index 7bab5060..a255b4c6 100644 --- a/tests/CSharpLanguageServer.Tests/ReferenceTests.fs +++ b/tests/CSharpLanguageServer.Tests/ReferenceTests.fs @@ -142,14 +142,16 @@ let testReferenceWorksDotnet8 () = Assert.AreEqual(expectedLocations2, locations2.Value) - [] -let testReferenceWorksToAspNetRazorPageReferencedValue () = +let testReferenceWorksToRazorPageReferencedValue () = use client = activateFixture "aspnetProject" use testIndexViewModelCsFile = client.Open("Project/Models/Test/IndexViewModel.cs") use testControllerCsFile = client.Open("Project/Controllers/TestController.cs") - use viewsTestIndexCshtmlFile = client.Open("Project/Views/Test/Index.cshtml") + use indexCshtmlFile = client.Open("Project/Views/Test/Index.cshtml") + + use completionTestsCshtmlFile = + client.Open("Project/Views/Test/CompletionTests.cshtml") let referenceParams0: ReferenceParams = { TextDocument = { Uri = testIndexViewModelCsFile.Uri } @@ -162,7 +164,7 @@ let testReferenceWorksToAspNetRazorPageReferencedValue () = client.Request("textDocument/references", referenceParams0) Assert.IsTrue(locations0.IsSome) - Assert.AreEqual(2, locations0.Value.Length) + Assert.AreEqual(3, locations0.Value.Length) let expectedLocations0: Location array = [| { Uri = testControllerCsFile.Uri @@ -170,12 +172,17 @@ let testReferenceWorksToAspNetRazorPageReferencedValue () = { Start = { Line = 11u; Character = 12u } End = { Line = 11u; Character = 18u } } } - { Uri = viewsTestIndexCshtmlFile.Uri + { Uri = completionTestsCshtmlFile.Uri + Range = + { Start = { Line = 3u; Character = 13u } + End = { Line = 3u; Character = 19u } } } + + { Uri = indexCshtmlFile.Uri Range = { Start = { Line = 1u; Character = 7u } End = { Line = 1u; Character = 13u } } } |] - Assert.AreEqual(expectedLocations0, locations0.Value) + Assert.AreEqual(expectedLocations0, locations0.Value |> Array.sortBy _.Uri) // // do same but with IncludeDeclaration=true @@ -191,14 +198,19 @@ let testReferenceWorksToAspNetRazorPageReferencedValue () = client.Request("textDocument/references", referenceParams1) Assert.IsTrue(locations1.IsSome) - Assert.AreEqual(5, locations1.Value.Length) + Assert.AreEqual(6, locations1.Value.Length) let expectedLocations1: Location array = - [| { Uri = viewsTestIndexCshtmlFile.Uri + [| { Uri = indexCshtmlFile.Uri Range = { Start = { Line = 1u; Character = 7u } End = { Line = 1u; Character = 13u } } } + { Uri = completionTestsCshtmlFile.Uri + Range = + { Start = { Line = 3u; Character = 13u } + End = { Line = 3u; Character = 19u } } } + { Uri = testIndexViewModelCsFile.Uri Range = { Start = { Line = 3u; Character = 19u } @@ -224,3 +236,65 @@ let testReferenceWorksToAspNetRazorPageReferencedValue () = |> Array.sortBy (fun f -> (f.Range.Start.Line, f.Range.Start.Character)) Assert.AreEqual(expectedLocations1, sortedLocations1) + + +[] +let testReferenceWorksFromRazorPageReferencedValue () = + use client = activateFixture "aspnetProject" + + use testIndexViewModelCsFile = client.Open("Project/Models/Test/IndexViewModel.cs") + use testControllerCsFile = client.Open("Project/Controllers/TestController.cs") + use indexCshtmlFile = client.Open("Project/Views/Test/Index.cshtml") + + use completionTestsCshtmlFile = + client.Open("Project/Views/Test/CompletionTests.cshtml") + + let referenceParams0: ReferenceParams = + { TextDocument = { Uri = indexCshtmlFile.Uri } + Position = { Line = 1u; Character = 7u } + WorkDoneToken = None + PartialResultToken = None + Context = { IncludeDeclaration = true } } + + let locations0: Location[] option = + client.Request("textDocument/references", referenceParams0) + + Assert.IsTrue(locations0.IsSome) + Assert.AreEqual(6, locations0.Value.Length) + + let expectedLocations0: Location array = + [| { Uri = indexCshtmlFile.Uri + Range = + { Start = { Line = 1u; Character = 7u } + End = { Line = 1u; Character = 13u } } } + + { Uri = completionTestsCshtmlFile.Uri + Range = + { Start = { Line = 3u; Character = 13u } + End = { Line = 3u; Character = 19u } } } + + { Uri = testIndexViewModelCsFile.Uri + Range = + { Start = { Line = 3u; Character = 19u } + End = { Line = 3u; Character = 25u } } } + + { Uri = testIndexViewModelCsFile.Uri + Range = + { Start = { Line = 3u; Character = 28u } + End = { Line = 3u; Character = 31u } } } + + { Uri = testIndexViewModelCsFile.Uri + Range = + { Start = { Line = 3u; Character = 33u } + End = { Line = 3u; Character = 36u } } } + + { Uri = testControllerCsFile.Uri + Range = + { Start = { Line = 11u; Character = 12u } + End = { Line = 11u; Character = 18u } } } |] + + let sortedLocations0 = + locations0.Value + |> Array.sortBy (fun f -> (f.Range.Start.Line, f.Range.Start.Character)) + + Assert.AreEqual(expectedLocations0, sortedLocations0) diff --git a/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Controllers/TestController.cs b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Controllers/TestController.cs new file mode 100644 index 00000000..e4f76f59 --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Controllers/TestController.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Mvc; +using Project.Models.Test; + +namespace Printlog.Web.ClientPart.Controllers; + +public class TestController : Controller +{ + public IActionResult Index() + { + var model = new IndexViewModel() + { + Output = "test" + }; + + return View(model); + } +} diff --git a/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Models/Test/IndexViewModel.cs b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Models/Test/IndexViewModel.cs new file mode 100644 index 00000000..02f6157a --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Models/Test/IndexViewModel.cs @@ -0,0 +1,5 @@ +namespace Project.Models.Test; +public class IndexViewModel +{ + public string? Output { get; set; } +} diff --git a/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Program.cs b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Program.cs new file mode 100644 index 00000000..698f461f --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Program.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; + +namespace Project; + +public class Program +{ + public static async Task Main(string[] args) + { + await BuildWebHost(args).RunAsync(); + } + + public static IWebHost BuildWebHost(string[] args) + { + var builder = WebHost.CreateDefaultBuilder(args); + builder = builder.UseKestrel().UseStartup(); + return builder.Build(); + } +} diff --git a/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Project.csproj b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Project.csproj new file mode 100644 index 00000000..9d715580 --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Project.csproj @@ -0,0 +1,10 @@ + + + net9.0 + enable + + + + + + diff --git a/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Startup.cs b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Startup.cs new file mode 100644 index 00000000..d089977c --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Startup.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Project; + +public class Startup +{ + public Startup(IConfiguration configuration, IWebHostEnvironment env) + { + } + + public void ConfigureServices(IServiceCollection services) + { + services.AddOptions(); + } + + public void Configure( + IApplicationBuilder app, + IWebHostEnvironment env) + { + app.UseAuthentication(); + app.UseAuthorization(); + + app.UseEndpoints(endpoints => { + endpoints.MapControllers(); + }); + } +} diff --git a/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Views/Test/Index.cshtml b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Views/Test/Index.cshtml new file mode 100644 index 00000000..b094abcb --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Views/Test/Index.cshtml @@ -0,0 +1,2 @@ +@model Project.Models.Test.IndexViewModel +@Model.Output diff --git a/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Views/_ViewImports.cshtml b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Views/_ViewImports.cshtml new file mode 100644 index 00000000..a757b413 --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Views/_ViewImports.cshtml @@ -0,0 +1 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Views/_ViewStart.cshtml b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Views/_ViewStart.cshtml new file mode 100644 index 00000000..a5f10045 --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksFromRazorPageReferencedValue/Project/Views/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} diff --git a/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Controllers/TestController.cs b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Controllers/TestController.cs new file mode 100644 index 00000000..e4f76f59 --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Controllers/TestController.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Mvc; +using Project.Models.Test; + +namespace Printlog.Web.ClientPart.Controllers; + +public class TestController : Controller +{ + public IActionResult Index() + { + var model = new IndexViewModel() + { + Output = "test" + }; + + return View(model); + } +} diff --git a/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Models/Test/IndexViewModel.cs b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Models/Test/IndexViewModel.cs new file mode 100644 index 00000000..02f6157a --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Models/Test/IndexViewModel.cs @@ -0,0 +1,5 @@ +namespace Project.Models.Test; +public class IndexViewModel +{ + public string? Output { get; set; } +} diff --git a/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Program.cs b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Program.cs new file mode 100644 index 00000000..698f461f --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Program.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; + +namespace Project; + +public class Program +{ + public static async Task Main(string[] args) + { + await BuildWebHost(args).RunAsync(); + } + + public static IWebHost BuildWebHost(string[] args) + { + var builder = WebHost.CreateDefaultBuilder(args); + builder = builder.UseKestrel().UseStartup(); + return builder.Build(); + } +} diff --git a/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Project.csproj b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Project.csproj new file mode 100644 index 00000000..9d715580 --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Project.csproj @@ -0,0 +1,10 @@ + + + net9.0 + enable + + + + + + diff --git a/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Startup.cs b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Startup.cs new file mode 100644 index 00000000..d089977c --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Startup.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Project; + +public class Startup +{ + public Startup(IConfiguration configuration, IWebHostEnvironment env) + { + } + + public void ConfigureServices(IServiceCollection services) + { + services.AddOptions(); + } + + public void Configure( + IApplicationBuilder app, + IWebHostEnvironment env) + { + app.UseAuthentication(); + app.UseAuthorization(); + + app.UseEndpoints(endpoints => { + endpoints.MapControllers(); + }); + } +} diff --git a/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Views/Test/Index.cshtml b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Views/Test/Index.cshtml new file mode 100644 index 00000000..b094abcb --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Views/Test/Index.cshtml @@ -0,0 +1,2 @@ +@model Project.Models.Test.IndexViewModel +@Model.Output diff --git a/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Views/_ViewImports.cshtml b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Views/_ViewImports.cshtml new file mode 100644 index 00000000..a757b413 --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Views/_ViewImports.cshtml @@ -0,0 +1 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Views/_ViewStart.cshtml b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Views/_ViewStart.cshtml new file mode 100644 index 00000000..a5f10045 --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/TestData/testReferenceWorksToRazorPageReferencedValue/Project/Views/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} diff --git a/tests/CSharpLanguageServer.Tests/WorkspaceSymbolTests.fs b/tests/CSharpLanguageServer.Tests/WorkspaceSymbolTests.fs index 8e1c1700..5722a2f3 100644 --- a/tests/CSharpLanguageServer.Tests/WorkspaceSymbolTests.fs +++ b/tests/CSharpLanguageServer.Tests/WorkspaceSymbolTests.fs @@ -4,6 +4,7 @@ open NUnit.Framework open Ionide.LanguageServerProtocol.Types open CSharpLanguageServer.Tests.Tooling +open System.Threading [] let testWorkspaceSymbolWorks () = @@ -24,7 +25,7 @@ let testWorkspaceSymbolWorks () = match symbols0 with | Some(U2.C1 sis) -> - Assert.AreEqual(4, sis.Length) + Assert.AreEqual(5, sis.Length) let sym0 = sis[0] Assert.AreEqual("Class", sym0.Name) From c0acfe013172431ec20839db6ecd9f8995bd10df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saulius=20Menkevi=C4=8Dius?= Date: Sat, 18 Oct 2025 14:23:32 +0300 Subject: [PATCH 2/6] implement pull diagnostics for cshtml --- .../Handlers/Diagnostic.fs | 26 ++++++--------- .../State/ServerRequestContext.fs | 15 +++++++++ .../DiagnosticTests.fs | 32 ++++++++++++++++++- 3 files changed, 56 insertions(+), 17 deletions(-) diff --git a/src/CSharpLanguageServer/Handlers/Diagnostic.fs b/src/CSharpLanguageServer/Handlers/Diagnostic.fs index 57fda17f..0bf4d239 100644 --- a/src/CSharpLanguageServer/Handlers/Diagnostic.fs +++ b/src/CSharpLanguageServer/Handlers/Diagnostic.fs @@ -12,7 +12,7 @@ open CSharpLanguageServer.Types [] module Diagnostic = let provider - (clientCapabilities: ClientCapabilities) + (_cc: ClientCapabilities) : U2 option = let registrationOptions: DiagnosticRegistrationOptions = { DocumentSelector = Some defaultDocumentSelector @@ -35,24 +35,18 @@ module Diagnostic = Items = [||] RelatedDocuments = None } - match context.GetDocument p.TextDocument.Uri with - | None -> return emptyReport |> U2.C1 |> LspResult.success + let! semanticModel = context.GetSemanticModel p.TextDocument.Uri - | Some doc -> - let! ct = Async.CancellationToken - let! semanticModelMaybe = doc.GetSemanticModelAsync(ct) |> Async.AwaitTask - - match semanticModelMaybe |> Option.ofObj with + let diagnostics = + match semanticModel with + | None -> [||] | Some semanticModel -> - let diagnostics = - semanticModel.GetDiagnostics() - |> Seq.map Diagnostic.fromRoslynDiagnostic - |> Seq.map fst - |> Array.ofSeq - - return { emptyReport with Items = diagnostics } |> U2.C1 |> LspResult.success + semanticModel.GetDiagnostics() + |> Seq.map Diagnostic.fromRoslynDiagnostic + |> Seq.map fst + |> Array.ofSeq - | None -> return emptyReport |> U2.C1 |> LspResult.success + return { emptyReport with Items = diagnostics } |> U2.C1 |> LspResult.success } let private getWorkspaceDiagnosticReports diff --git a/src/CSharpLanguageServer/State/ServerRequestContext.fs b/src/CSharpLanguageServer/State/ServerRequestContext.fs index 002b222f..c37fa151 100644 --- a/src/CSharpLanguageServer/State/ServerRequestContext.fs +++ b/src/CSharpLanguageServer/State/ServerRequestContext.fs @@ -138,6 +138,21 @@ type ServerRequestContext(requestId: int, state: ServerState, emitServerEvent) = return aggregatedLspLocations } + member this.GetSemanticModel (uri: DocumentUri) : Async = async { + if uri.EndsWith(".cshtml") then + match! getRazorDocumentForUri state.Solution.Value uri with + | None -> return None + | Some(project, compilation, cshtmlPath, cshtmlTree) -> + return compilation.GetSemanticModel(cshtmlTree) |> Option.ofObj + else + match this.GetDocument uri with + | None -> return None + | Some doc -> + let! ct = Async.CancellationToken + let! semanticModel = doc.GetSemanticModelAsync() |> Async.AwaitTask + return semanticModel |> Option.ofObj + } + member this.FindSymbol' (uri: DocumentUri) (pos: Position) : Async<(ISymbol * Project * Document option) option> = async { if uri.EndsWith(".cshtml") then match! getRazorDocumentForUri state.Solution.Value uri with diff --git a/tests/CSharpLanguageServer.Tests/DiagnosticTests.fs b/tests/CSharpLanguageServer.Tests/DiagnosticTests.fs index 8598560f..39e13c0c 100644 --- a/tests/CSharpLanguageServer.Tests/DiagnosticTests.fs +++ b/tests/CSharpLanguageServer.Tests/DiagnosticTests.fs @@ -116,7 +116,37 @@ let testPullDiagnosticsWork () = Assert.AreEqual(0, report.Items.Length) | _ -> failwith "U2.C1 is expected" - () + +[] +let testPullDiagnosticsWorkForRazorFiles () = + use client = activateFixture "aspnetProject" + use cshtmlFile = client.Open("Project/Views/Test/Index.cshtml") + + let diagnosticParams: DocumentDiagnosticParams = + { WorkDoneToken = None + PartialResultToken = None + TextDocument = { Uri = cshtmlFile.Uri } + Identifier = None + PreviousResultId = None } + + let report0: DocumentDiagnosticReport option = + client.Request("textDocument/diagnostic", diagnosticParams) + + match report0 with + | Some(U2.C1 report) -> + Assert.AreEqual("full", report.Kind) + Assert.AreEqual(None, report.ResultId) + Assert.AreEqual(7, report.Items.Length) + + let reportItems = report.Items |> Array.sortBy _.Range + + let diagnostic0 = reportItems[0] + Assert.AreEqual(7, diagnostic0.Range.Start.Line) + Assert.AreEqual(4, diagnostic0.Range.Start.Character) + Assert.AreEqual(Some DiagnosticSeverity.Warning, diagnostic0.Severity) + Assert.AreEqual("Unnecessary using directive.", diagnostic0.Message) + + | _ -> failwith "U2.C1 is expected" [] From 1088e0d139accaf2ae83d844d650099c9e4581a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saulius=20Menkevi=C4=8Dius?= Date: Sat, 18 Oct 2025 14:55:18 +0300 Subject: [PATCH 3/6] more --- src/CSharpLanguageServer/Handlers/Diagnostic.fs | 4 +--- src/CSharpLanguageServer/Handlers/DocumentHighlight.fs | 2 +- src/CSharpLanguageServer/State/ServerRequestContext.fs | 4 ++-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/CSharpLanguageServer/Handlers/Diagnostic.fs b/src/CSharpLanguageServer/Handlers/Diagnostic.fs index 0bf4d239..ef3e1e09 100644 --- a/src/CSharpLanguageServer/Handlers/Diagnostic.fs +++ b/src/CSharpLanguageServer/Handlers/Diagnostic.fs @@ -11,9 +11,7 @@ open CSharpLanguageServer.Types [] module Diagnostic = - let provider - (_cc: ClientCapabilities) - : U2 option = + let provider (_cc: ClientCapabilities) : U2 option = let registrationOptions: DiagnosticRegistrationOptions = { DocumentSelector = Some defaultDocumentSelector WorkDoneProgress = None diff --git a/src/CSharpLanguageServer/Handlers/DocumentHighlight.fs b/src/CSharpLanguageServer/Handlers/DocumentHighlight.fs index 56a01698..559642bf 100644 --- a/src/CSharpLanguageServer/Handlers/DocumentHighlight.fs +++ b/src/CSharpLanguageServer/Handlers/DocumentHighlight.fs @@ -12,7 +12,7 @@ open CSharpLanguageServer.Conversions [] module DocumentHighlight = - let provider (_: ClientCapabilities) : U2 option = Some(U2.C1 true) + let provider (_cc: ClientCapabilities) : U2 option = Some(U2.C1 true) let private shouldHighlight (symbol: ISymbol) = match symbol with diff --git a/src/CSharpLanguageServer/State/ServerRequestContext.fs b/src/CSharpLanguageServer/State/ServerRequestContext.fs index c37fa151..c4ea6b2b 100644 --- a/src/CSharpLanguageServer/State/ServerRequestContext.fs +++ b/src/CSharpLanguageServer/State/ServerRequestContext.fs @@ -142,14 +142,14 @@ type ServerRequestContext(requestId: int, state: ServerState, emitServerEvent) = if uri.EndsWith(".cshtml") then match! getRazorDocumentForUri state.Solution.Value uri with | None -> return None - | Some(project, compilation, cshtmlPath, cshtmlTree) -> + | Some(_, compilation, _, cshtmlTree) -> return compilation.GetSemanticModel(cshtmlTree) |> Option.ofObj else match this.GetDocument uri with | None -> return None | Some doc -> let! ct = Async.CancellationToken - let! semanticModel = doc.GetSemanticModelAsync() |> Async.AwaitTask + let! semanticModel = doc.GetSemanticModelAsync(ct) |> Async.AwaitTask return semanticModel |> Option.ofObj } From 87c58500f44b66cf706e920171ab552093a8f7d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saulius=20Menkevi=C4=8Dius?= Date: Sat, 18 Oct 2025 15:08:27 +0300 Subject: [PATCH 4/6] tests for document highlight --- .../Handlers/Completion.fs | 3 +- .../Handlers/DocumentHighlight.fs | 64 +++++++++---------- src/CSharpLanguageServer/RoslynHelpers.fs | 64 +++++++++---------- .../State/ServerRequestContext.fs | 9 +-- src/CSharpLanguageServer/State/ServerState.fs | 5 +- .../CSharpLanguageServer.Tests.fsproj | 13 ++-- .../DocumentHighlightTests.fs | 59 +++++++++++++++++ 7 files changed, 136 insertions(+), 81 deletions(-) create mode 100644 tests/CSharpLanguageServer.Tests/DocumentHighlightTests.fs diff --git a/src/CSharpLanguageServer/Handlers/Completion.fs b/src/CSharpLanguageServer/Handlers/Completion.fs index a88d31f1..5af3e43d 100644 --- a/src/CSharpLanguageServer/Handlers/Completion.fs +++ b/src/CSharpLanguageServer/Handlers/Completion.fs @@ -191,7 +191,7 @@ module Completion = async { match! getRazorDocumentForUri solution p.TextDocument.Uri with | None -> return None - | Some(project, compilation, cshtmlPath, cshtmlTree) -> + | Some(project, compilation, cshtmlTree) -> let! ct = Async.CancellationToken let! sourceText = cshtmlTree.GetTextAsync() |> Async.AwaitTask @@ -267,6 +267,7 @@ module Completion = //logger.LogInformation("newSourceText={0}", newSourceText) + let cshtmlPath = Uri.toPath p.TextDocument.Uri let! doc = tryAddDocument logger (cshtmlPath + ".cs") (newSourceText.ToString()) solution let doc = doc.Value diff --git a/src/CSharpLanguageServer/Handlers/DocumentHighlight.fs b/src/CSharpLanguageServer/Handlers/DocumentHighlight.fs index 559642bf..5c03786d 100644 --- a/src/CSharpLanguageServer/Handlers/DocumentHighlight.fs +++ b/src/CSharpLanguageServer/Handlers/DocumentHighlight.fs @@ -19,46 +19,46 @@ module DocumentHighlight = | :? INamespaceSymbol -> false | _ -> true - let handle - (context: ServerRequestContext) - (p: DocumentHighlightParams) - : AsyncLspResult = - async { - let! ct = Async.CancellationToken - let filePath = Uri.toPath p.TextDocument.Uri + // We only need to find references in the file (not the whole workspace), so we don't use + // context.FindSymbol & context.FindReferences here. + let private getHighlights symbol (project: Project) (docMaybe: Document option) (filePath: string) = async { + let! ct = Async.CancellationToken - // We only need to find references in the file (not the whole workspace), so we don't use - // context.FindSymbol & context.FindReferences here. - let getHighlights (symbol: ISymbol) (doc: Document) = async { - let docSet = ImmutableHashSet.Create(doc) + let docSet: ImmutableHashSet option = + docMaybe |> Option.map (fun doc -> ImmutableHashSet.Create(doc)) - let! refs = - SymbolFinder.FindReferencesAsync(symbol, doc.Project.Solution, docSet, cancellationToken = ct) - |> Async.AwaitTask + let! refs = + SymbolFinder.FindReferencesAsync(symbol, project.Solution, docSet |> Option.toObj, cancellationToken = ct) + |> Async.AwaitTask - let! def = - SymbolFinder.FindSourceDefinitionAsync(symbol, doc.Project.Solution, cancellationToken = ct) - |> Async.AwaitTask + let! def = + SymbolFinder.FindSourceDefinitionAsync(symbol, project.Solution, cancellationToken = ct) + |> Async.AwaitTask - let locations = - refs - |> Seq.collect (fun r -> r.Locations) - |> Seq.map (fun rl -> rl.Location) - |> Seq.filter (fun l -> l.IsInSource && l.GetMappedLineSpan().Path = filePath) - |> Seq.append (def |> Option.ofObj |> Option.toList |> Seq.collect (fun sym -> sym.Locations)) + let locations = + refs + |> Seq.collect (fun r -> r.Locations) + |> Seq.map (fun rl -> rl.Location) + |> Seq.filter (fun l -> l.IsInSource && l.GetMappedLineSpan().Path = filePath) + |> Seq.append (def |> Option.ofObj |> Option.toList |> Seq.collect (fun sym -> sym.Locations)) - return - locations - |> Seq.choose Location.fromRoslynLocation - |> Seq.map (fun l -> - { Range = l.Range - Kind = Some DocumentHighlightKind.Read }) - } + return + locations + |> Seq.choose Location.fromRoslynLocation + |> Seq.map (fun l -> + { Range = l.Range + Kind = Some DocumentHighlightKind.Read }) + } + let handle + (context: ServerRequestContext) + (p: DocumentHighlightParams) + : AsyncLspResult = + async { match! context.FindSymbol' p.TextDocument.Uri p.Position with - | Some(symbol, _, Some doc) -> + | Some(symbol, project, docMaybe) -> if shouldHighlight symbol then - let! highlights = getHighlights symbol doc + let! highlights = getHighlights symbol project docMaybe (Uri.toPath p.TextDocument.Uri) return highlights |> Seq.toArray |> Some |> LspResult.success else return None |> LspResult.success diff --git a/src/CSharpLanguageServer/RoslynHelpers.fs b/src/CSharpLanguageServer/RoslynHelpers.fs index fe0bda3e..a7a38379 100644 --- a/src/CSharpLanguageServer/RoslynHelpers.fs +++ b/src/CSharpLanguageServer/RoslynHelpers.fs @@ -874,48 +874,42 @@ let initializeMSBuild (logger: ILogger) : unit = MSBuildLocator.RegisterInstance(vsInstance) -let getRazorDocumentForUri - (solution: Solution) - (uri: string) - : Async<(Project * Compilation * string * SyntaxTree) option> = - async { - let cshtmlPath = Uri.toPath uri - let cshtmlDirectory = Path.GetDirectoryName(cshtmlPath) - let normalizedTargetDir = Path.GetFullPath(cshtmlDirectory) +let getRazorDocumentForUri (solution: Solution) (uri: string) : Async<(Project * Compilation * SyntaxTree) option> = async { + let cshtmlPath = uri |> Uri.toPath + let cshtmlDirectory = Path.GetDirectoryName(cshtmlPath) + let normalizedTargetDir = Path.GetFullPath(cshtmlDirectory) - let projectForPath = - solution.Projects - |> Seq.tryFind (fun project -> - let projectDirectory = Path.GetDirectoryName(project.FilePath) - let normalizedProjectDir = Path.GetFullPath(projectDirectory) + let projectForPath = + solution.Projects + |> Seq.tryFind (fun project -> + let projectDirectory = Path.GetDirectoryName(project.FilePath) + let normalizedProjectDir = Path.GetFullPath(projectDirectory) - normalizedTargetDir.StartsWith( - normalizedProjectDir + Path.DirectorySeparatorChar.ToString(), - StringComparison.OrdinalIgnoreCase - )) + normalizedTargetDir.StartsWith( + normalizedProjectDir + Path.DirectorySeparatorChar.ToString(), + StringComparison.OrdinalIgnoreCase + )) - let projectBaseDir = Path.GetDirectoryName(projectForPath.Value.FilePath) + let projectBaseDir = Path.GetDirectoryName(projectForPath.Value.FilePath) - let! compilation = projectForPath.Value.GetCompilationAsync() |> Async.AwaitTask + let! compilation = projectForPath.Value.GetCompilationAsync() |> Async.AwaitTask - let mutable cshtmlTree: SyntaxTree option = None + let mutable cshtmlTree: SyntaxTree option = None - let cshtmlPathTranslated = - Path.GetRelativePath(projectBaseDir, cshtmlPath) - |> _.Replace(".", "_") - |> _.Replace(Path.DirectorySeparatorChar, '_') - |> (fun s -> s + ".g.cs") + let cshtmlPathTranslated = + Path.GetRelativePath(projectBaseDir, cshtmlPath) + |> _.Replace(".", "_") + |> _.Replace(Path.DirectorySeparatorChar, '_') + |> (fun s -> s + ".g.cs") - for tree in compilation.SyntaxTrees do - let path = tree.FilePath + for tree in compilation.SyntaxTrees do + let path = tree.FilePath - if path.StartsWith(projectBaseDir) then - let relativePath = Path.GetRelativePath(projectBaseDir, path) + if path.StartsWith(projectBaseDir) then + let relativePath = Path.GetRelativePath(projectBaseDir, path) - if relativePath.EndsWith(cshtmlPathTranslated) then - cshtmlTree <- Some tree + if relativePath.EndsWith(cshtmlPathTranslated) then + cshtmlTree <- Some tree - return - cshtmlTree - |> Option.map (fun cst -> (projectForPath.Value, compilation, cshtmlPath, cst)) - } + return cshtmlTree |> Option.map (fun cst -> (projectForPath.Value, compilation, cst)) +} diff --git a/src/CSharpLanguageServer/State/ServerRequestContext.fs b/src/CSharpLanguageServer/State/ServerRequestContext.fs index c4ea6b2b..863027c9 100644 --- a/src/CSharpLanguageServer/State/ServerRequestContext.fs +++ b/src/CSharpLanguageServer/State/ServerRequestContext.fs @@ -138,12 +138,11 @@ type ServerRequestContext(requestId: int, state: ServerState, emitServerEvent) = return aggregatedLspLocations } - member this.GetSemanticModel (uri: DocumentUri) : Async = async { + member this.GetSemanticModel(uri: DocumentUri) : Async = async { if uri.EndsWith(".cshtml") then match! getRazorDocumentForUri state.Solution.Value uri with | None -> return None - | Some(_, compilation, _, cshtmlTree) -> - return compilation.GetSemanticModel(cshtmlTree) |> Option.ofObj + | Some(_, compilation, cshtmlTree) -> return compilation.GetSemanticModel(cshtmlTree) |> Option.ofObj else match this.GetDocument uri with | None -> return None @@ -156,12 +155,14 @@ type ServerRequestContext(requestId: int, state: ServerState, emitServerEvent) = member this.FindSymbol' (uri: DocumentUri) (pos: Position) : Async<(ISymbol * Project * Document option) option> = async { if uri.EndsWith(".cshtml") then match! getRazorDocumentForUri state.Solution.Value uri with - | Some(project, compilation, cshtmlPath, cshtmlTree) -> + | Some(project, compilation, cshtmlTree) -> let model = compilation.GetSemanticModel(cshtmlTree) let root = cshtmlTree.GetRoot() let token = + let cshtmlPath = uri |> Uri.toPath + root.DescendantTokens() |> Seq.tryFind (fun t -> let span = cshtmlTree.GetMappedLineSpan(t.Span) diff --git a/src/CSharpLanguageServer/State/ServerState.fs b/src/CSharpLanguageServer/State/ServerState.fs index fb7dd7c7..eead78bd 100644 --- a/src/CSharpLanguageServer/State/ServerState.fs +++ b/src/CSharpLanguageServer/State/ServerState.fs @@ -309,8 +309,7 @@ let processPushDiagnosticsProcessPendingDocumentsEvent (logger: ILogger) state p match docAndTypeMaybe with | None -> match! getRazorDocumentForUri state.Solution.Value docUri with - | Some(_, compilation, cshtmlPath, cshtmlTree) -> - let cshtmlUri = Uri.fromPath cshtmlPath + | Some(_, compilation, cshtmlTree) -> let semanticModelMaybe = compilation.GetSemanticModel(cshtmlTree) |> Option.ofObj match semanticModelMaybe with @@ -323,7 +322,7 @@ let processPushDiagnosticsProcessPendingDocumentsEvent (logger: ILogger) state p let diagnostics = semanticModel.GetDiagnostics() |> Seq.map Diagnostic.fromRoslynDiagnostic - |> Seq.filter (fun (_, uri) -> uri = cshtmlUri) + |> Seq.filter (fun (_, uri) -> uri = docUri) |> Seq.map fst |> Array.ofSeq diff --git a/tests/CSharpLanguageServer.Tests/CSharpLanguageServer.Tests.fsproj b/tests/CSharpLanguageServer.Tests/CSharpLanguageServer.Tests.fsproj index 97bf2ef4..e8504224 100644 --- a/tests/CSharpLanguageServer.Tests/CSharpLanguageServer.Tests.fsproj +++ b/tests/CSharpLanguageServer.Tests/CSharpLanguageServer.Tests.fsproj @@ -11,18 +11,19 @@ + + + + + - - - - - + - + diff --git a/tests/CSharpLanguageServer.Tests/DocumentHighlightTests.fs b/tests/CSharpLanguageServer.Tests/DocumentHighlightTests.fs new file mode 100644 index 00000000..259d30b6 --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/DocumentHighlightTests.fs @@ -0,0 +1,59 @@ +module CSharpLanguageServer.Tests.DocumentHighlightTests + +open System + +open NUnit.Framework +open Ionide.LanguageServerProtocol.Types +open Ionide.LanguageServerProtocol.Server + +open CSharpLanguageServer.Tests.Tooling + +[] +let ``test textDocument/documentHighlight works in .cs file`` () = + use client = activateFixture "genericProject" + use classFile = client.Open("Project/Class.cs") + + let highlightParams: DocumentHighlightParams = + { TextDocument = { Uri = classFile.Uri } + Position = { Line = 9u; Character = 8u } + WorkDoneToken = None + PartialResultToken = None } + + let highlights: DocumentHighlight[] option = + client.Request("textDocument/documentHighlight", highlightParams) + + let expectedHighlights: DocumentHighlight list = + [ { Range = + { Start = { Line = 2u; Character = 16u } + End = { Line = 2u; Character = 23u } } + Kind = Some DocumentHighlightKind.Read } + + { Range = + { Start = { Line = 9u; Character = 8u } + End = { Line = 9u; Character = 15u } } + Kind = Some DocumentHighlightKind.Read } ] + + Assert.AreEqual(Some expectedHighlights, highlights |> Option.map List.ofArray) + + +[] +let ``test textDocument/documentHighlight works in .cshtml file`` () = + use client = activateFixture "aspnetProject" + use indexCshtmlFile = client.Open("Project/Views/Test/Index.cshtml") + + let highlightParams: DocumentHighlightParams = + { TextDocument = { Uri = indexCshtmlFile.Uri } + Position = { Line = 1u; Character = 1u } + WorkDoneToken = None + PartialResultToken = None } + + let highlights: DocumentHighlight[] option = + client.Request("textDocument/documentHighlight", highlightParams) + + let expectedHighlights: DocumentHighlight list = + [ { Range = + { Start = { Line = 1u; Character = 1u } + End = { Line = 1u; Character = 6u } } + Kind = Some DocumentHighlightKind.Read } ] + + Assert.AreEqual(Some expectedHighlights, highlights |> Option.map List.ofArray) From dcd609a66d903b8391cc46631b567a9e0c645b93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saulius=20Menkevi=C4=8Dius?= Date: Sat, 18 Oct 2025 19:37:42 +0300 Subject: [PATCH 5/6] less crashes --- .../Handlers/Completion.fs | 127 +++++++----------- .../Handlers/TextDocumentSync.fs | 4 +- .../Handlers/Workspace.fs | 2 +- src/CSharpLanguageServer/RoslynHelpers.fs | 2 +- 4 files changed, 56 insertions(+), 79 deletions(-) diff --git a/src/CSharpLanguageServer/Handlers/Completion.fs b/src/CSharpLanguageServer/Handlers/Completion.fs index 5af3e43d..fb3b3eff 100644 --- a/src/CSharpLanguageServer/Handlers/Completion.fs +++ b/src/CSharpLanguageServer/Handlers/Completion.fs @@ -185,37 +185,31 @@ module Completion = | _, _ -> None, None let getCompletionsForRazorDocument - (solution: Solution) (p: CompletionParams) + (context: ServerRequestContext) : Async> = async { - match! getRazorDocumentForUri solution p.TextDocument.Uri with + match! getRazorDocumentForUri context.Solution p.TextDocument.Uri with | None -> return None | Some(project, compilation, cshtmlTree) -> let! ct = Async.CancellationToken let! sourceText = cshtmlTree.GetTextAsync() |> Async.AwaitTask let razorTextDocument = - solution.Projects + context.Solution.Projects |> Seq.collect (fun p -> p.AdditionalDocuments) |> Seq.filter (fun d -> Uri(d.FilePath, UriKind.Absolute) = Uri p.TextDocument.Uri) |> Seq.head let! razorSourceText = razorTextDocument.GetTextAsync() |> Async.AwaitTask - //logger.LogInformation("razorSourceText={0}", razorSourceText) - - //logger.LogInformation("doc={0}", sourceText) - let posInCshtml = Position.toRoslynPosition sourceText.Lines p.Position //logger.LogInformation("posInCshtml={posInCshtml=}", posInCshtml) let pos = p.Position let root = cshtmlTree.GetRoot() - let mutable position: int option = None - let mutable tokenForPosition: SyntaxToken option = None - let mutable debug: string option = None + let mutable positionAndToken: (int * SyntaxToken) option = None for t in root.DescendantTokens() do let cshtmlSpan = cshtmlTree.GetMappedLineSpan(t.Span) @@ -228,73 +222,56 @@ module Completion = let tokenStartCharacterOffset = (int pos.Character - cshtmlSpan.StartLinePosition.Character) - position <- Some(t.Span.Start + tokenStartCharacterOffset) - - debug <- - Some( - String.Format( - "token={0}; pos.Character={1}; cshtmlSpan.StartLinePosition.Character={2}; offset={3}", - t, - pos.Character, - cshtmlSpan.StartLinePosition.Character, - tokenStartCharacterOffset - ) + positionAndToken <- Some(t.Span.Start + tokenStartCharacterOffset, t) + + match positionAndToken with + | None -> return None + | Some(position, tokenForPosition) -> + + let newSourceText = + let cshtmlPosition = Position.toRoslynPosition razorSourceText.Lines p.Position + let charInCshtml: char = razorSourceText[cshtmlPosition - 1] + + if charInCshtml = '.' && string tokenForPosition.Value <> "." then + // a hack to make @Model.| autocompletion to work: + // - force a dot if present on .cscshtml but missing on .cs + sourceText.WithChanges(new TextChange(new TextSpan(position - 1, 0), ".")) + else + sourceText + + let cshtmlPath = Uri.toPath p.TextDocument.Uri + let! doc = tryAddDocument logger context.Solution (cshtmlPath + ".cs") (newSourceText.ToString()) + + match doc with + | None -> return None + | Some doc -> + let completionService = + Microsoft.CodeAnalysis.Completion.CompletionService.GetService(doc) + |> RoslynCompletionServiceWrapper + + let completionOptions = + RoslynCompletionOptions.Default() + |> _.WithBool("ShowItemsFromUnimportedNamespaces", false) + |> _.WithBool("ShowNameSuggestions", false) + + let completionTrigger = CompletionContext.toCompletionTrigger p.Context + + let! roslynCompletions = + completionService.GetCompletionsAsync( + doc, + position, + completionOptions, + completionTrigger, + ct ) + |> Async.map Option.ofObj - tokenForPosition <- Some(t) - - //logger.LogInformation(debug |> Option.defaultValue "") - - //let position = Position.toRoslynPosition sourceText.Lines translatedPosition - //logger.LogInformation("position in .cs={position}", position) - - let posInCS = sourceText.Lines.GetLinePosition(position.Value) - //logger.LogInformation("lineposition={x}", posInCS) - - // a hack to make @Model.| autocompletion to work: - // - force a dot if present on .cscshtml but missing on .cs - let newSourceText = - // TODO: check if the text in cshtml is '.', though! - let cshtmlPosition = Position.toRoslynPosition razorSourceText.Lines p.Position - let charInCshtml: char = razorSourceText[cshtmlPosition - 1] - - //logger.LogInformation("charInCshtml={0}", charInCshtml) - - if charInCshtml = '.' && string tokenForPosition <> "." then - sourceText.WithChanges(new TextChange(new TextSpan(position.Value - 1, 0), ".")) - else - sourceText - - //logger.LogInformation("newSourceText={0}", newSourceText) - - let cshtmlPath = Uri.toPath p.TextDocument.Uri - let! doc = tryAddDocument logger (cshtmlPath + ".cs") (newSourceText.ToString()) solution - - let doc = doc.Value - - //logger.LogError("handle: doc={doc}", doc) - - let completionService = - Microsoft.CodeAnalysis.Completion.CompletionService.GetService(doc) - |> RoslynCompletionServiceWrapper - - let completionOptions = - RoslynCompletionOptions.Default() - |> _.WithBool("ShowItemsFromUnimportedNamespaces", false) - |> _.WithBool("ShowNameSuggestions", false) - - let completionTrigger = CompletionContext.toCompletionTrigger p.Context - - let! roslynCompletions = - completionService.GetCompletionsAsync(doc, position.Value, completionOptions, completionTrigger, ct) - |> Async.map Option.ofObj - - return roslynCompletions |> Option.map (fun rcl -> rcl, doc) + return roslynCompletions |> Option.map (fun rcl -> rcl, doc) } let getCompletionsForCSharpDocument - (context: ServerRequestContext) (p: CompletionParams) + (context: ServerRequestContext) : Async> = async { match context.GetDocument p.TextDocument.Uri with @@ -337,13 +314,13 @@ module Completion = (p: CompletionParams) : Async option>> = async { - let! roslynCompletionsAndDoc = + let getCompletions = if p.TextDocument.Uri.EndsWith(".cshtml") then - getCompletionsForRazorDocument context.Solution p + getCompletionsForRazorDocument else - getCompletionsForCSharpDocument context p + getCompletionsForCSharpDocument - match roslynCompletionsAndDoc with + match! getCompletions p context with | None -> return None |> LspResult.success | Some(roslynCompletions, doc) -> let toLspCompletionItemsWithCacheInfo (completions: Microsoft.CodeAnalysis.Completion.CompletionList) = diff --git a/src/CSharpLanguageServer/Handlers/TextDocumentSync.fs b/src/CSharpLanguageServer/Handlers/TextDocumentSync.fs index e4d6b9a2..4641e984 100644 --- a/src/CSharpLanguageServer/Handlers/TextDocumentSync.fs +++ b/src/CSharpLanguageServer/Handlers/TextDocumentSync.fs @@ -119,7 +119,7 @@ module TextDocumentSync = match docFilePathMaybe with | Some docFilePath -> async { // ok, this document is not in solution, register a new document - let! newDocMaybe = tryAddDocument logger docFilePath openParams.TextDocument.Text context.Solution + let! newDocMaybe = tryAddDocument logger context.Solution docFilePath openParams.TextDocument.Text match newDocMaybe with | Some newDoc -> @@ -221,7 +221,7 @@ module TextDocumentSync = | None -> async { let docFilePath = Util.parseFileUri saveParams.TextDocument.Uri - let! newDocMaybe = tryAddDocument logger docFilePath saveParams.Text.Value context.Solution + let! newDocMaybe = tryAddDocument logger context.Solution docFilePath saveParams.Text.Value match newDocMaybe with | Some newDoc -> diff --git a/src/CSharpLanguageServer/Handlers/Workspace.fs b/src/CSharpLanguageServer/Handlers/Workspace.fs index a18a1aa1..47a27a37 100644 --- a/src/CSharpLanguageServer/Handlers/Workspace.fs +++ b/src/CSharpLanguageServer/Handlers/Workspace.fs @@ -61,7 +61,7 @@ module Workspace = | Some docFilePath -> // ok, this document is not on solution, register a new one let fileText = docFilePath |> File.ReadAllText - let! newDocMaybe = tryAddDocument logger docFilePath fileText context.Solution + let! newDocMaybe = tryAddDocument logger context.Solution docFilePath fileText match newDocMaybe with | Some newDoc -> context.Emit(SolutionChange newDoc.Project.Solution) diff --git a/src/CSharpLanguageServer/RoslynHelpers.fs b/src/CSharpLanguageServer/RoslynHelpers.fs index a7a38379..fc8c7030 100644 --- a/src/CSharpLanguageServer/RoslynHelpers.fs +++ b/src/CSharpLanguageServer/RoslynHelpers.fs @@ -763,9 +763,9 @@ let getProjectForPathOnSolution (solution: Solution) (filePath: string) : Projec let tryAddDocument (logger: ILogger) + (solution: Solution) (docFilePath: string) (text: string) - (solution: Solution) : Async = async { let projectOnPath = getProjectForPathOnSolution solution docFilePath From f2768bda0fa4bf885ee6275d9639e549a39f9772 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saulius=20Menkevi=C4=8Dius?= Date: Sat, 18 Oct 2025 19:40:05 +0300 Subject: [PATCH 6/6] less crashes --- .../State/ServerRequestContext.fs | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/CSharpLanguageServer/State/ServerRequestContext.fs b/src/CSharpLanguageServer/State/ServerRequestContext.fs index 863027c9..6c21a0d1 100644 --- a/src/CSharpLanguageServer/State/ServerRequestContext.fs +++ b/src/CSharpLanguageServer/State/ServerRequestContext.fs @@ -139,17 +139,20 @@ type ServerRequestContext(requestId: int, state: ServerState, emitServerEvent) = } member this.GetSemanticModel(uri: DocumentUri) : Async = async { - if uri.EndsWith(".cshtml") then - match! getRazorDocumentForUri state.Solution.Value uri with - | None -> return None - | Some(_, compilation, cshtmlTree) -> return compilation.GetSemanticModel(cshtmlTree) |> Option.ofObj - else - match this.GetDocument uri with - | None -> return None - | Some doc -> - let! ct = Async.CancellationToken - let! semanticModel = doc.GetSemanticModelAsync(ct) |> Async.AwaitTask - return semanticModel |> Option.ofObj + match state.Solution with + | None -> return None + | Some solution -> + if uri.EndsWith(".cshtml") then + match! getRazorDocumentForUri solution uri with + | None -> return None + | Some(_, compilation, cshtmlTree) -> return compilation.GetSemanticModel(cshtmlTree) |> Option.ofObj + else + match this.GetDocument uri with + | None -> return None + | Some doc -> + let! ct = Async.CancellationToken + let! semanticModel = doc.GetSemanticModelAsync(ct) |> Async.AwaitTask + return semanticModel |> Option.ofObj } member this.FindSymbol' (uri: DocumentUri) (pos: Position) : Async<(ISymbol * Project * Document option) option> = async {