diff --git a/LSP.sln b/LSP.sln index 004b16cdf..085107475 100644 --- a/LSP.sln +++ b/LSP.sln @@ -1,7 +1,6 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 -VisualStudioVersion = 15.0.26403.7 +VisualStudioVersion = 15.0.26730.16 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{D764E024-3D3F-4112-B932-2DB722A1BACC}" EndProject @@ -28,36 +27,108 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonRpc.Tests", "test\JsonR EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lsp.Tests", "test\Lsp.Tests\Lsp.Tests.csproj", "{482B180B-FD5C-4705-BBE1-094C905F1E1F}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SampleServer", "sample\SampleServer\SampleServer.csproj", "{F2067F5F-FA4E-4990-B301-E7898FC4C45F}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SampleServer", "sample\SampleServer\SampleServer.csproj", "{F2067F5F-FA4E-4990-B301-E7898FC4C45F}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sample", "sample", "{A316FCEC-81AD-45FB-93EE-C62CA09300DC}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Client", "src\Client\Client.csproj", "{417E95B2-5AB9-49D5-B7CD-12255472E2E7}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Client.Tests", "test\Client.Tests\Client.Tests.csproj", "{97437BE1-2EC3-4F6B-AC75-C3E099040A07}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {9AF43FA2-EF35-435E-B59E-724877E44DDA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9AF43FA2-EF35-435E-B59E-724877E44DDA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9AF43FA2-EF35-435E-B59E-724877E44DDA}.Debug|x64.ActiveCfg = Debug|Any CPU + {9AF43FA2-EF35-435E-B59E-724877E44DDA}.Debug|x64.Build.0 = Debug|Any CPU + {9AF43FA2-EF35-435E-B59E-724877E44DDA}.Debug|x86.ActiveCfg = Debug|Any CPU + {9AF43FA2-EF35-435E-B59E-724877E44DDA}.Debug|x86.Build.0 = Debug|Any CPU {9AF43FA2-EF35-435E-B59E-724877E44DDA}.Release|Any CPU.ActiveCfg = Release|Any CPU {9AF43FA2-EF35-435E-B59E-724877E44DDA}.Release|Any CPU.Build.0 = Release|Any CPU + {9AF43FA2-EF35-435E-B59E-724877E44DDA}.Release|x64.ActiveCfg = Release|Any CPU + {9AF43FA2-EF35-435E-B59E-724877E44DDA}.Release|x64.Build.0 = Release|Any CPU + {9AF43FA2-EF35-435E-B59E-724877E44DDA}.Release|x86.ActiveCfg = Release|Any CPU + {9AF43FA2-EF35-435E-B59E-724877E44DDA}.Release|x86.Build.0 = Release|Any CPU {50EA648A-67D3-4127-9517-EC7C1426E2E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {50EA648A-67D3-4127-9517-EC7C1426E2E7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {50EA648A-67D3-4127-9517-EC7C1426E2E7}.Debug|x64.ActiveCfg = Debug|Any CPU + {50EA648A-67D3-4127-9517-EC7C1426E2E7}.Debug|x64.Build.0 = Debug|Any CPU + {50EA648A-67D3-4127-9517-EC7C1426E2E7}.Debug|x86.ActiveCfg = Debug|Any CPU + {50EA648A-67D3-4127-9517-EC7C1426E2E7}.Debug|x86.Build.0 = Debug|Any CPU {50EA648A-67D3-4127-9517-EC7C1426E2E7}.Release|Any CPU.ActiveCfg = Release|Any CPU {50EA648A-67D3-4127-9517-EC7C1426E2E7}.Release|Any CPU.Build.0 = Release|Any CPU + {50EA648A-67D3-4127-9517-EC7C1426E2E7}.Release|x64.ActiveCfg = Release|Any CPU + {50EA648A-67D3-4127-9517-EC7C1426E2E7}.Release|x64.Build.0 = Release|Any CPU + {50EA648A-67D3-4127-9517-EC7C1426E2E7}.Release|x86.ActiveCfg = Release|Any CPU + {50EA648A-67D3-4127-9517-EC7C1426E2E7}.Release|x86.Build.0 = Release|Any CPU {35F9B883-36D0-4F3B-A191-9BBD05B798A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {35F9B883-36D0-4F3B-A191-9BBD05B798A7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {35F9B883-36D0-4F3B-A191-9BBD05B798A7}.Debug|x64.ActiveCfg = Debug|Any CPU + {35F9B883-36D0-4F3B-A191-9BBD05B798A7}.Debug|x64.Build.0 = Debug|Any CPU + {35F9B883-36D0-4F3B-A191-9BBD05B798A7}.Debug|x86.ActiveCfg = Debug|Any CPU + {35F9B883-36D0-4F3B-A191-9BBD05B798A7}.Debug|x86.Build.0 = Debug|Any CPU {35F9B883-36D0-4F3B-A191-9BBD05B798A7}.Release|Any CPU.ActiveCfg = Release|Any CPU {35F9B883-36D0-4F3B-A191-9BBD05B798A7}.Release|Any CPU.Build.0 = Release|Any CPU + {35F9B883-36D0-4F3B-A191-9BBD05B798A7}.Release|x64.ActiveCfg = Release|Any CPU + {35F9B883-36D0-4F3B-A191-9BBD05B798A7}.Release|x64.Build.0 = Release|Any CPU + {35F9B883-36D0-4F3B-A191-9BBD05B798A7}.Release|x86.ActiveCfg = Release|Any CPU + {35F9B883-36D0-4F3B-A191-9BBD05B798A7}.Release|x86.Build.0 = Release|Any CPU {482B180B-FD5C-4705-BBE1-094C905F1E1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {482B180B-FD5C-4705-BBE1-094C905F1E1F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {482B180B-FD5C-4705-BBE1-094C905F1E1F}.Debug|x64.ActiveCfg = Debug|Any CPU + {482B180B-FD5C-4705-BBE1-094C905F1E1F}.Debug|x64.Build.0 = Debug|Any CPU + {482B180B-FD5C-4705-BBE1-094C905F1E1F}.Debug|x86.ActiveCfg = Debug|Any CPU + {482B180B-FD5C-4705-BBE1-094C905F1E1F}.Debug|x86.Build.0 = Debug|Any CPU {482B180B-FD5C-4705-BBE1-094C905F1E1F}.Release|Any CPU.ActiveCfg = Release|Any CPU {482B180B-FD5C-4705-BBE1-094C905F1E1F}.Release|Any CPU.Build.0 = Release|Any CPU + {482B180B-FD5C-4705-BBE1-094C905F1E1F}.Release|x64.ActiveCfg = Release|Any CPU + {482B180B-FD5C-4705-BBE1-094C905F1E1F}.Release|x64.Build.0 = Release|Any CPU + {482B180B-FD5C-4705-BBE1-094C905F1E1F}.Release|x86.ActiveCfg = Release|Any CPU + {482B180B-FD5C-4705-BBE1-094C905F1E1F}.Release|x86.Build.0 = Release|Any CPU {F2067F5F-FA4E-4990-B301-E7898FC4C45F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F2067F5F-FA4E-4990-B301-E7898FC4C45F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F2067F5F-FA4E-4990-B301-E7898FC4C45F}.Debug|x64.ActiveCfg = Debug|Any CPU + {F2067F5F-FA4E-4990-B301-E7898FC4C45F}.Debug|x64.Build.0 = Debug|Any CPU + {F2067F5F-FA4E-4990-B301-E7898FC4C45F}.Debug|x86.ActiveCfg = Debug|Any CPU + {F2067F5F-FA4E-4990-B301-E7898FC4C45F}.Debug|x86.Build.0 = Debug|Any CPU {F2067F5F-FA4E-4990-B301-E7898FC4C45F}.Release|Any CPU.ActiveCfg = Release|Any CPU {F2067F5F-FA4E-4990-B301-E7898FC4C45F}.Release|Any CPU.Build.0 = Release|Any CPU + {F2067F5F-FA4E-4990-B301-E7898FC4C45F}.Release|x64.ActiveCfg = Release|Any CPU + {F2067F5F-FA4E-4990-B301-E7898FC4C45F}.Release|x64.Build.0 = Release|Any CPU + {F2067F5F-FA4E-4990-B301-E7898FC4C45F}.Release|x86.ActiveCfg = Release|Any CPU + {F2067F5F-FA4E-4990-B301-E7898FC4C45F}.Release|x86.Build.0 = Release|Any CPU + {417E95B2-5AB9-49D5-B7CD-12255472E2E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {417E95B2-5AB9-49D5-B7CD-12255472E2E7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {417E95B2-5AB9-49D5-B7CD-12255472E2E7}.Debug|x64.ActiveCfg = Debug|Any CPU + {417E95B2-5AB9-49D5-B7CD-12255472E2E7}.Debug|x64.Build.0 = Debug|Any CPU + {417E95B2-5AB9-49D5-B7CD-12255472E2E7}.Debug|x86.ActiveCfg = Debug|Any CPU + {417E95B2-5AB9-49D5-B7CD-12255472E2E7}.Debug|x86.Build.0 = Debug|Any CPU + {417E95B2-5AB9-49D5-B7CD-12255472E2E7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {417E95B2-5AB9-49D5-B7CD-12255472E2E7}.Release|Any CPU.Build.0 = Release|Any CPU + {417E95B2-5AB9-49D5-B7CD-12255472E2E7}.Release|x64.ActiveCfg = Release|Any CPU + {417E95B2-5AB9-49D5-B7CD-12255472E2E7}.Release|x64.Build.0 = Release|Any CPU + {417E95B2-5AB9-49D5-B7CD-12255472E2E7}.Release|x86.ActiveCfg = Release|Any CPU + {417E95B2-5AB9-49D5-B7CD-12255472E2E7}.Release|x86.Build.0 = Release|Any CPU + {97437BE1-2EC3-4F6B-AC75-C3E099040A07}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {97437BE1-2EC3-4F6B-AC75-C3E099040A07}.Debug|Any CPU.Build.0 = Debug|Any CPU + {97437BE1-2EC3-4F6B-AC75-C3E099040A07}.Debug|x64.ActiveCfg = Debug|Any CPU + {97437BE1-2EC3-4F6B-AC75-C3E099040A07}.Debug|x64.Build.0 = Debug|Any CPU + {97437BE1-2EC3-4F6B-AC75-C3E099040A07}.Debug|x86.ActiveCfg = Debug|Any CPU + {97437BE1-2EC3-4F6B-AC75-C3E099040A07}.Debug|x86.Build.0 = Debug|Any CPU + {97437BE1-2EC3-4F6B-AC75-C3E099040A07}.Release|Any CPU.ActiveCfg = Release|Any CPU + {97437BE1-2EC3-4F6B-AC75-C3E099040A07}.Release|Any CPU.Build.0 = Release|Any CPU + {97437BE1-2EC3-4F6B-AC75-C3E099040A07}.Release|x64.ActiveCfg = Release|Any CPU + {97437BE1-2EC3-4F6B-AC75-C3E099040A07}.Release|x64.Build.0 = Release|Any CPU + {97437BE1-2EC3-4F6B-AC75-C3E099040A07}.Release|x86.ActiveCfg = Release|Any CPU + {97437BE1-2EC3-4F6B-AC75-C3E099040A07}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -68,5 +139,10 @@ Global {35F9B883-36D0-4F3B-A191-9BBD05B798A7} = {2F323ED5-EBF8-45E1-B9D3-C014561B3DDA} {482B180B-FD5C-4705-BBE1-094C905F1E1F} = {2F323ED5-EBF8-45E1-B9D3-C014561B3DDA} {F2067F5F-FA4E-4990-B301-E7898FC4C45F} = {A316FCEC-81AD-45FB-93EE-C62CA09300DC} + {417E95B2-5AB9-49D5-B7CD-12255472E2E7} = {D764E024-3D3F-4112-B932-2DB722A1BACC} + {97437BE1-2EC3-4F6B-AC75-C3E099040A07} = {2F323ED5-EBF8-45E1-B9D3-C014561B3DDA} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {D38DD0EC-D095-4BCD-B8AF-2D788AF3B9AE} EndGlobalSection EndGlobal diff --git a/src/Client/Client.csproj b/src/Client/Client.csproj new file mode 100644 index 000000000..a64afa939 --- /dev/null +++ b/src/Client/Client.csproj @@ -0,0 +1,21 @@ + + + + netstandard2.0 + AnyCPU + OmniSharp.Extensions.LanguageClient + OmniSharp.Extensions.LanguageServerProtocol.Client + + + + + + + + + + + + + + diff --git a/src/Client/Clients/TextDocumentClient.Completions.cs b/src/Client/Clients/TextDocumentClient.Completions.cs new file mode 100644 index 000000000..fd54700ed --- /dev/null +++ b/src/Client/Clients/TextDocumentClient.Completions.cs @@ -0,0 +1,65 @@ +using OmniSharp.Extensions.LanguageServer.Models; +using OmniSharp.Extensions.LanguageServerProtocol.Client.Utilities; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Clients +{ + /// + /// Client for the LSP Text Document API. + /// + public partial class TextDocumentClient + { + /// + /// Request completions at the specified document position. + /// + /// + /// The full file-system path of the text document. + /// + /// + /// The target line (0-based). + /// + /// + /// The target column (0-based). + /// + /// + /// An optional that can be used to cancel the request. + /// + /// + /// A that resolves to the completions or null if no completions are available at the specified position. + /// + public Task Completions(string filePath, int line, int column, CancellationToken cancellationToken = default(CancellationToken)) + { + if (String.IsNullOrWhiteSpace(filePath)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(filePath)}.", nameof(filePath)); + + Uri documentUri = DocumentUri.FromFileSystemPath(filePath); + + return Completions(documentUri, line, column, cancellationToken); + } + + /// + /// Request completions at the specified document position. + /// + /// + /// The document URI. + /// + /// + /// The target line (0-based). + /// + /// + /// The target column (0-based). + /// + /// + /// An optional that can be used to cancel the request. + /// + /// + /// A that resolves to the completions or null if no completions are available at the specified position. + /// + public Task Completions(Uri documentUri, int line, int column, CancellationToken cancellationToken = default(CancellationToken)) + { + return PositionalRequest("textDocument/completion", documentUri, line, column, cancellationToken); + } + } +} diff --git a/src/Client/Clients/TextDocumentClient.Diagnostics.cs b/src/Client/Clients/TextDocumentClient.Diagnostics.cs new file mode 100644 index 000000000..4cc773875 --- /dev/null +++ b/src/Client/Clients/TextDocumentClient.Diagnostics.cs @@ -0,0 +1,45 @@ +using OmniSharp.Extensions.LanguageServer.Models; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Clients +{ + /// + /// Client for the LSP Text Document API. + /// + public partial class TextDocumentClient + { + /// + /// Register a handler for diagnostics published by the language server. + /// + /// + /// A that is called to publish the diagnostics. + /// + /// + /// An representing the registration. + /// + /// + /// The diagnostics should replace any previously published diagnostics for the specified document. + /// + public IDisposable OnPublishDiagnostics(PublishDiagnosticsHandler handler) + { + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + return Client.HandleNotification("textDocument/publishDiagnostics", notification => + { + if (notification.Diagnostics == null) + return; // Invalid notification. + + List diagnostics = new List(); + if (notification.Diagnostics != null) + diagnostics.AddRange(notification.Diagnostics); + + handler(notification.Uri, diagnostics); + }); + } + } +} diff --git a/src/Client/Clients/TextDocumentClient.Hover.cs b/src/Client/Clients/TextDocumentClient.Hover.cs new file mode 100644 index 000000000..c028bfd65 --- /dev/null +++ b/src/Client/Clients/TextDocumentClient.Hover.cs @@ -0,0 +1,66 @@ +using OmniSharp.Extensions.LanguageServer.Models; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using OmniSharp.Extensions.LanguageServerProtocol.Client.Utilities; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Clients +{ + /// + /// Client for the LSP Text Document API. + /// + public partial class TextDocumentClient + { + /// + /// Request hover information at the specified document position. + /// + /// + /// The full file-system path of the text document. + /// + /// + /// The target line (0-based). + /// + /// + /// The target column (0-based). + /// + /// + /// An optional that can be used to cancel the request. + /// + /// + /// A that resolves to the hover information or null if no hover information is available at the specified position. + /// + public Task Hover(string filePath, int line, int column, CancellationToken cancellationToken = default(CancellationToken)) + { + if (String.IsNullOrWhiteSpace(filePath)) + throw new ArgumentException("Argument cannot be null, empty, or entirely composed of whitespace: 'filePath'.", nameof(filePath)); + + Uri documentUri = DocumentUri.FromFileSystemPath(filePath); + + return Hover(documentUri, line, column, cancellationToken); + } + + /// + /// Request hover information at the specified document position. + /// + /// + /// The document URI. + /// + /// + /// The target line (0-based). + /// + /// + /// The target column (0-based). + /// + /// + /// An optional that can be used to cancel the request. + /// + /// + /// A that resolves to the hover information or null if no hover information is available at the specified position. + /// + public Task Hover(Uri documentUri, int line, int column, CancellationToken cancellationToken = default(CancellationToken)) + { + return PositionalRequest("textDocument/hover", documentUri, line, column, cancellationToken); + } + } +} diff --git a/src/Client/Clients/TextDocumentClient.Sync.cs b/src/Client/Clients/TextDocumentClient.Sync.cs new file mode 100644 index 000000000..0cd9d0454 --- /dev/null +++ b/src/Client/Clients/TextDocumentClient.Sync.cs @@ -0,0 +1,278 @@ +using OmniSharp.Extensions.LanguageServer.Models; +using OmniSharp.Extensions.LanguageServerProtocol.Client.Utilities; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Clients +{ + /// + /// Client for the LSP Text Document API. + /// + public partial class TextDocumentClient + { + /// + /// Notify the language server that the client has opened a text document. + /// + /// + /// The full path to the text document. + /// + /// + /// The document language type (e.g. "xml"). + /// + /// + /// The document version (optional). + /// + /// + /// Will automatically populate the document text, if available. + /// + public void DidOpen(string filePath, string languageId, int version = 0) + { + if (String.IsNullOrWhiteSpace(filePath)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(filePath)}.", nameof(filePath)); + + string text = null; + if (File.Exists(filePath)) + text = File.ReadAllText(filePath); + + DidOpen( + DocumentUri.FromFileSystemPath(filePath), + languageId, + text, + version + ); + } + + /// + /// Notify the language server that the client has opened a text document. + /// + /// + /// The full file-system path of the text document. + /// + /// + /// The document language type (e.g. "xml"). + /// + /// + /// The document text (pass null to have the language server retrieve the text itself). + /// + /// + /// The document version (optional). + /// + public void DidOpen(string filePath, string languageId, string text, int version = 0) + { + if (String.IsNullOrWhiteSpace(filePath)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(filePath)}.", nameof(filePath)); + + Uri documentUri = DocumentUri.FromFileSystemPath(filePath); + + DidOpen(documentUri, languageId, text, version); + } + + /// + /// Notify the language server that the client has opened a text document. + /// + /// + /// The document URI. + /// + /// + /// The document language type (e.g. "xml"). + /// + /// + /// The document text. + /// + /// + /// The document version (optional). + /// + public void DidOpen(Uri documentUri, string languageId, string text, int version = 0) + { + if (documentUri == null) + throw new ArgumentNullException(nameof(documentUri)); + + Client.SendNotification("textDocument/didOpen", new DidOpenTextDocumentParams + { + TextDocument = new TextDocumentItem + { + Text = text, + LanguageId = languageId, + Version = version, + Uri = documentUri + } + }); + } + + /// + /// Notify the language server that the client has changed a text document. + /// + /// + /// The full path to the text document. + /// + /// + /// The document language type (e.g. "xml"). + /// + /// + /// The document version (optional). + /// + /// + /// This style of notification is used when the client does not support partial updates (i.e. one or more updates with an associated range). + /// + /// Will automatically populate the document text, if available. + /// + public void DidChange(string filePath, string languageId, int version = 0) + { + if (String.IsNullOrWhiteSpace(filePath)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(filePath)}.", nameof(filePath)); + + string text = null; + if (File.Exists(filePath)) + text = File.ReadAllText(filePath); + + DidChange( + DocumentUri.FromFileSystemPath(filePath), + languageId, + text, + version + ); + } + + /// + /// Notify the language server that the client has changed a text document. + /// + /// + /// The full file-system path of the text document. + /// + /// + /// The document language type (e.g. "xml"). + /// + /// + /// The document text (pass null to have the language server retrieve the text itself). + /// + /// + /// The document version (optional). + /// + /// + /// This style of notification is used when the client does not support partial updates (i.e. one or more updates with an associated range). + /// + public void DidChange(string filePath, string languageId, string text, int version = 0) + { + if (String.IsNullOrWhiteSpace(filePath)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(filePath)}.", nameof(filePath)); + + Uri documentUri = DocumentUri.FromFileSystemPath(filePath); + + DidChange(documentUri, languageId, text, version); + } + + /// + /// Notify the language server that the client has changed a text document. + /// + /// + /// The document URI. + /// + /// + /// The document language type (e.g. "xml"). + /// + /// + /// The document text. + /// + /// + /// The document version (optional). + /// + /// + /// This style of notification is used when the client does not support partial updates (i.e. one or more updates with an associated range). + /// + public void DidChange(Uri documentUri, string languageId, string text, int version = 0) + { + if (documentUri == null) + throw new ArgumentNullException(nameof(documentUri)); + + Client.SendNotification("textDocument/didChange", new DidChangeTextDocumentParams + { + TextDocument = new VersionedTextDocumentIdentifier + { + Version = version, + Uri = documentUri + }, + ContentChanges = new TextDocumentContentChangeEvent[] + { + new TextDocumentContentChangeEvent + { + Text = text + } + } + }); + } + + /// + /// Notify the language server that the client has closed a text document. + /// + /// + /// The full file-system path of the text document. + /// + public void DidClose(string filePath) + { + if (String.IsNullOrWhiteSpace(filePath)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(filePath)}.", nameof(filePath)); + + DidClose( + DocumentUri.FromFileSystemPath(filePath) + ); + } + + /// + /// Notify the language server that the client has closed a text document. + /// + /// + /// The document URI. + /// + public void DidClose(Uri documentUri) + { + if (documentUri == null) + throw new ArgumentNullException(nameof(documentUri)); + + Client.SendNotification("textDocument/didClose", new DidCloseTextDocumentParams + { + TextDocument = new TextDocumentItem + { + Uri = documentUri + } + }); + } + + /// + /// Notify the language server that the client has saved a text document. + /// + /// + /// The full file-system path of the text document. + /// + public void DidSave(string filePath) + { + if (String.IsNullOrWhiteSpace(filePath)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(filePath)}.", nameof(filePath)); + + DidSave( + DocumentUri.FromFileSystemPath(filePath) + ); + } + + /// + /// Notify the language server that the client has saved a text document. + /// + /// + /// The document URI. + /// + public void DidSave(Uri documentUri) + { + if (documentUri == null) + throw new ArgumentNullException(nameof(documentUri)); + + Client.SendNotification("textDocument/didSave", new DidSaveTextDocumentParams + { + TextDocument = new TextDocumentItem + { + Uri = documentUri + } + }); + } + } +} diff --git a/src/Client/Clients/TextDocumentClient.cs b/src/Client/Clients/TextDocumentClient.cs new file mode 100644 index 000000000..bde0d3107 --- /dev/null +++ b/src/Client/Clients/TextDocumentClient.cs @@ -0,0 +1,82 @@ +using OmniSharp.Extensions.LanguageServer.Models; +using OmniSharp.Extensions.LanguageServerProtocol.Client.Utilities; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Clients +{ + /// + /// Client for the LSP Text Document API. + /// + public partial class TextDocumentClient + { + /// + /// Create a new . + /// + /// + /// The language client providing the API. + /// + public TextDocumentClient(LanguageClient client) + { + if (client == null) + throw new ArgumentNullException(nameof(client)); + + Client = client; + } + + /// + /// The language client providing the API. + /// + public LanguageClient Client { get; } + + /// + /// Make a request to the server for the specified document position. + /// + /// + /// The response payload type. + /// + /// + /// The name of the operation to invoke. + /// + /// + /// The URI of the target document. + /// + /// + /// The target line numer (0-based). + /// + /// + /// The target column (0-based). + /// + /// + /// A cancellation token that can be used to cancel the request. + /// + /// + /// A representing the request. + /// + async Task PositionalRequest(string method, Uri documentUri, int line, int column, CancellationToken cancellationToken) + { + if (String.IsNullOrWhiteSpace(method)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(method)}.", nameof(method)); + + if (documentUri == null) + throw new ArgumentNullException(nameof(documentUri)); + + var request = new TextDocumentPositionParams + { + TextDocument = new TextDocumentItem + { + Uri = documentUri + }, + Position = new Position + { + Line = line, + Character = column + } + }; + + return await Client.SendRequest(method, request, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/Client/Clients/WindowClient.cs b/src/Client/Clients/WindowClient.cs new file mode 100644 index 000000000..77d20120a --- /dev/null +++ b/src/Client/Clients/WindowClient.cs @@ -0,0 +1,49 @@ +using OmniSharp.Extensions.LanguageServer.Models; +using System; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Clients +{ + /// + /// Client for the LSP Window API. + /// + public class WindowClient + { + /// + /// Create a new . + /// + /// + /// The language client providing the API. + /// + public WindowClient(LanguageClient client) + { + if (client == null) + throw new ArgumentNullException(nameof(client)); + + Client = client; + } + + /// + /// The language client providing the API. + /// + public LanguageClient Client { get; } + + /// + /// Register a handler for "window/logMessage" notifications from the server. + /// + /// + /// The that will be called for each log message. + /// + /// + /// An representing the registration. + /// + public IDisposable OnLogMessage(LogMessageHandler handler) + { + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + return Client.HandleNotification("window/logMessage", + notification => handler(notification.Message, notification.Type) + ); + } + } +} diff --git a/src/Client/Clients/WorkspaceClient.cs b/src/Client/Clients/WorkspaceClient.cs new file mode 100644 index 000000000..c2d21f840 --- /dev/null +++ b/src/Client/Clients/WorkspaceClient.cs @@ -0,0 +1,46 @@ +using Newtonsoft.Json.Linq; +using System; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Clients +{ + /// + /// Client for the LSP Workspace API. + /// + public class WorkspaceClient + { + /// + /// Create a new . + /// + /// + /// The language client providing the API. + /// + public WorkspaceClient(LanguageClient client) + { + if (client == null) + throw new ArgumentNullException(nameof(client)); + + Client = client; + } + + /// + /// The language client providing the API. + /// + public LanguageClient Client { get; } + + /// + /// Notify the language server that workspace configuration has changed. + /// + /// + /// A representing the workspace configuration (or a subset thereof). + /// + public void DidChangeConfiguration(JObject configuration) + { + if (configuration == null) + throw new ArgumentNullException(nameof(configuration)); + + Client.SendNotification("workspace/didChangeConfiguration", new JObject( + new JProperty("settings", configuration) + )); + } + } +} diff --git a/src/Client/Dispatcher/LspDispatcher.cs b/src/Client/Dispatcher/LspDispatcher.cs new file mode 100644 index 000000000..2282b27ad --- /dev/null +++ b/src/Client/Dispatcher/LspDispatcher.cs @@ -0,0 +1,129 @@ +using Newtonsoft.Json.Linq; +using OmniSharp.Extensions.LanguageServerProtocol.Client.Handlers; +using System; +using System.Collections.Concurrent; +using System.Reactive.Disposables; +using System.Threading; +using System.Threading.Tasks; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Dispatcher +{ + /// + /// Dispatches requests and notifications from a language server to a language client. + /// + public class LspDispatcher + { + /// + /// Invokers for registered handlers. + /// + readonly ConcurrentDictionary _handlers = new ConcurrentDictionary(); + + /// + /// Create a new . + /// + public LspDispatcher() + { + } + + /// + /// Register a handler invoker. + /// + /// + /// The handler. + /// + /// + /// An representing the registration. + /// + public IDisposable RegisterHandler(IHandler handler) + { + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + string method = handler.Method; + + if (!_handlers.TryAdd(method, handler)) + throw new InvalidOperationException($"There is already a handler registered for method '{handler.Method}'."); + + return Disposable.Create( + () => _handlers.TryRemove(method, out _) + ); + } + + /// + /// Attempt to handle an empty notification. + /// + /// + /// The notification method name. + /// + /// + /// true, if an empty notification handler was registered for specified method; otherwise, false. + /// + public async Task TryHandleEmptyNotification(string method) + { + if (String.IsNullOrWhiteSpace(method)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(method)}.", nameof(method)); + + if (_handlers.TryGetValue(method, out IHandler handler) && handler is IInvokeEmptyNotificationHandler emptyNotificationHandler) + { + await emptyNotificationHandler.Invoke(); + + return true; + } + + return false; + } + + /// + /// Attempt to handle a notification. + /// + /// + /// The notification method name. + /// + /// + /// The notification message. + /// + /// + /// true, if a notification handler was registered for specified method; otherwise, false. + /// + public async Task TryHandleNotification(string method, JObject notification) + { + if (String.IsNullOrWhiteSpace(method)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(method)}.", nameof(method)); + + if (_handlers.TryGetValue(method, out IHandler handler) && handler is IInvokeNotificationHandler notificationHandler) + { + await notificationHandler.Invoke(notification); + + return true; + } + + return false; + } + + /// + /// Attempt to handle a request. + /// + /// + /// The request method name. + /// + /// + /// The request message. + /// + /// + /// A that can be used to cancel the operation. + /// + /// + /// If a registered handler was found, a representing the operation; otherwise, null. + /// + public Task TryHandleRequest(string method, JObject request, CancellationToken cancellationToken) + { + if (String.IsNullOrWhiteSpace(method)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(method)}.", nameof(method)); + + if (_handlers.TryGetValue(method, out IHandler handler) && handler is IInvokeRequestHandler requestHandler) + return requestHandler.Invoke(request, cancellationToken); + + return null; + } + } +} diff --git a/src/Client/Dispatcher/LspDispatcherExtensions.cs b/src/Client/Dispatcher/LspDispatcherExtensions.cs new file mode 100644 index 000000000..cacbbe6b2 --- /dev/null +++ b/src/Client/Dispatcher/LspDispatcherExtensions.cs @@ -0,0 +1,147 @@ +using OmniSharp.Extensions.LanguageServerProtocol.Client.Handlers; +using System; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Dispatcher +{ + /// + /// Extension methods for enabling various styles of handler registration. + /// + public static class LspDispatcherExtensions + { + /// + /// Register a handler for empty notifications. + /// + /// + /// The . + /// + /// + /// The name of the notification method to handle. + /// + /// + /// A delegate that implements the handler. + /// + /// + /// An representing the registration. + /// + public static IDisposable HandleEmptyNotification(this LspDispatcher clientDispatcher, string method, NotificationHandler handler) + { + if (clientDispatcher == null) + throw new ArgumentNullException(nameof(clientDispatcher)); + + if (String.IsNullOrWhiteSpace(method)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(method)}.", nameof(method)); + + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + return clientDispatcher.RegisterHandler( + new DelegateEmptyNotificationHandler(method, handler) + ); + } + + /// + /// Register a handler for notifications. + /// + /// + /// The notification message type. + /// + /// + /// The . + /// + /// + /// The name of the notification method to handle. + /// + /// + /// A delegate that implements the handler. + /// + /// + /// An representing the registration. + /// + public static IDisposable HandleNotification(this LspDispatcher clientDispatcher, string method, NotificationHandler handler) + { + if (clientDispatcher == null) + throw new ArgumentNullException(nameof(clientDispatcher)); + + if (String.IsNullOrWhiteSpace(method)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(method)}.", nameof(method)); + + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + return clientDispatcher.RegisterHandler( + new DelegateNotificationHandler(method, handler) + ); + } + + /// + /// Register a handler for requests. + /// + /// + /// The request message type. + /// + /// + /// The . + /// + /// + /// The name of the request method to handle. + /// + /// + /// A delegate that implements the handler. + /// + /// + /// An representing the registration. + /// + public static IDisposable HandleRequest(this LspDispatcher clientDispatcher, string method, RequestHandler handler) + { + if (clientDispatcher == null) + throw new ArgumentNullException(nameof(clientDispatcher)); + + if (String.IsNullOrWhiteSpace(method)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(method)}.", nameof(method)); + + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + return clientDispatcher.RegisterHandler( + new DelegateRequestHandler(method, handler) + ); + } + + /// + /// Register a handler for requests. + /// + /// + /// The request message type. + /// + /// + /// The response message type. + /// + /// + /// The . + /// + /// + /// The name of the request method to handle. + /// + /// + /// A delegate that implements the handler. + /// + /// + /// An representing the registration. + /// + public static IDisposable HandleRequest(this LspDispatcher clientDispatcher, string method, RequestHandler handler) + { + if (clientDispatcher == null) + throw new ArgumentNullException(nameof(clientDispatcher)); + + if (String.IsNullOrWhiteSpace(method)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(method)}.", nameof(method)); + + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + return clientDispatcher.RegisterHandler( + new DelegateRequestResponseHandler(method, handler) + ); + } + } +} diff --git a/src/Client/Exceptions.cs b/src/Client/Exceptions.cs new file mode 100644 index 000000000..92a2c38e5 --- /dev/null +++ b/src/Client/Exceptions.cs @@ -0,0 +1,409 @@ +using System; +using System.Runtime.Serialization; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client +{ + /// + /// Exception raised when a Language Server Protocol error is encountered. + /// + [Serializable] + public class LspException + : Exception + { + /// + /// Create a new . + /// + /// + /// The exception message. + /// + public LspException(string message) + : base(message) + { + } + + /// + /// Create a new . + /// + /// + /// The exception message. + /// + /// + /// The exception that caused this exception to be raised. + /// + public LspException(string message, Exception inner) + : base(message, inner) + { + } + + /// + /// Serialisation constructor. + /// + /// + /// The serialisation data-store. + /// + /// + /// The serialisation streaming context. + /// + protected LspException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + } + + /// + /// Exception raised when a Language Server Protocol error is encountered while processing a request. + /// + [Serializable] + public class LspRequestException + : LspException + { + /// + /// The request Id used when no valid request Id was supplied. + /// + public const string UnknownRequestId = "(unknown)"; + + /// + /// Create a new without an error code (). + /// + /// + /// The exception message. + /// + /// + /// The LSP / JSON-RPC request Id (if known). + /// + public LspRequestException(string message, string requestId) + : this(message, requestId, LspErrorCodes.None) + { + } + + /// + /// Create a new . + /// + /// + /// The exception message. + /// + /// + /// The LSP / JSON-RPC request Id (if known). + /// + /// + /// The LSP / JSON-RPC error code. + /// + public LspRequestException(string message, string requestId, int errorCode) + : base(message) + { + RequestId = !String.IsNullOrWhiteSpace(requestId) ? requestId : UnknownRequestId; + ErrorCode = errorCode; + } + + /// + /// Create a new . + /// + /// + /// The exception message. + /// + /// + /// The LSP / JSON-RPC request Id (if known). + /// + /// + /// The exception that caused this exception to be raised. + /// + public LspRequestException(string message, string requestId, Exception inner) + : this(message, requestId, LspErrorCodes.None, inner) + { + } + + /// + /// Create a new . + /// + /// + /// The exception message. + /// + /// + /// The LSP / JSON-RPC request Id (if known). + /// + /// + /// The LSP / JSON-RPC error code. + /// + /// + /// The exception that caused this exception to be raised. + /// + public LspRequestException(string message, string requestId, int errorCode, Exception inner) + : base(message, inner) + { + RequestId = !String.IsNullOrWhiteSpace(requestId) ? requestId : UnknownRequestId; + ErrorCode = errorCode; + } + + /// + /// Serialisation constructor. + /// + /// + /// The serialisation data-store. + /// + /// + /// The serialisation streaming context. + /// + protected LspRequestException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + RequestId = info.GetString(nameof(RequestId)); + ErrorCode = info.GetInt32(nameof(ErrorCode)); + } + + /// + /// Get exception data for serialisation. + /// + /// + /// The serialisation data-store. + /// + /// + /// The serialisation streaming context. + /// + public override void GetObjectData(SerializationInfo info, StreamingContext context) + { + base.GetObjectData(info, context); + + info.AddValue(nameof(RequestId), RequestId); + info.AddValue(nameof(ErrorCode), ErrorCode); + } + + /// + /// The LSP / JSON-RPC request Id (if known). + /// + public string RequestId { get; } + + /// + /// The LSP / JSON-RPC error code. + /// + public int ErrorCode { get; } + + /// + /// Does the represent an LSP / JSON-RPC protocol error? + /// + public bool IsProtocolError => ErrorCode != LspErrorCodes.None; + } + + /// + /// Exception raised when an LSP request is made for a method not supported by the remote process. + /// + [Serializable] + public class LspMethodNotSupportedException + : LspRequestException + { + /// + /// Create a new . + /// + /// + /// The LSP / JSON-RPC request Id (if known). + /// + /// + /// The name of the target method. + /// + public LspMethodNotSupportedException(string method, string requestId) + : base($"Method not found: '{method}'.", requestId, LspErrorCodes.MethodNotSupported) + { + Method = !String.IsNullOrWhiteSpace(method) ? method : "(unknown)"; + } + + /// + /// Serialisation constructor. + /// + /// + /// The serialisation data-store. + /// + /// + /// The serialisation streaming context. + /// + protected LspMethodNotSupportedException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + Method = info.GetString("Method"); + } + + /// + /// Get exception data for serialisation. + /// + /// + /// The serialisation data-store. + /// + /// + /// The serialisation streaming context. + /// + public override void GetObjectData(SerializationInfo info, StreamingContext context) + { + base.GetObjectData(info, context); + + info.AddValue("Method", Method); + } + + /// + /// The name of the method that was not supported by the remote process. + /// + public string Method { get; } + } + + /// + /// Exception raised an LSP request is invalid. + /// + [Serializable] + public class LspInvalidRequestException + : LspRequestException + { + /// + /// Create a new . + /// + /// + /// The LSP / JSON-RPC request Id (if known). + /// + public LspInvalidRequestException(string requestId) + : base("Invalid request.", requestId, LspErrorCodes.InvalidRequest) + { + } + + /// + /// Serialisation constructor. + /// + /// + /// The serialisation data-store. + /// + /// + /// The serialisation streaming context. + /// + protected LspInvalidRequestException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + } + + /// + /// Exception raised when request parameters are invalid according to the target method. + /// + [Serializable] + public class LspInvalidParametersException + : LspRequestException + { + /// + /// Create a new . + /// + /// + /// The LSP / JSON-RPC request Id (if known). + /// + public LspInvalidParametersException(string requestId) + : base("Invalid parameters.", requestId, LspErrorCodes.InvalidParameters) + { + } + + /// + /// Serialisation constructor. + /// + /// + /// The serialisation data-store. + /// + /// + /// The serialisation streaming context. + /// + protected LspInvalidParametersException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + } + + /// + /// Exception raised when an internal error has occurred in the language server. + /// + [Serializable] + public class LspInternalErrorException + : LspRequestException + { + /// + /// Create a new . + /// + /// + /// The LSP / JSON-RPC request Id (if known). + /// + public LspInternalErrorException(string requestId) + : base("Internal error.", requestId, LspErrorCodes.InternalError) + { + } + + /// + /// Serialisation constructor. + /// + /// + /// The serialisation data-store. + /// + /// + /// The serialisation streaming context. + /// + protected LspInternalErrorException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + } + + /// + /// Exception raised when an LSP request could not be parsed. + /// + [Serializable] + public class LspParseErrorException + : LspRequestException + { + /// + /// Create a new . + /// + /// + /// The LSP / JSON-RPC request Id (if known). + /// + public LspParseErrorException(string requestId) + : base("Error parsing request.", requestId, LspErrorCodes.ParseError) + { + } + + /// + /// Serialisation constructor. + /// + /// + /// The serialisation data-store. + /// + /// + /// The serialisation streaming context. + /// + protected LspParseErrorException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + } + + /// + /// Exception raised when an LSP request is cancelled. + /// + [Serializable] + public class LspRequestCancelledException + : LspRequestException + { + /// + /// Create a new . + /// + /// + /// The LSP / JSON-RPC request Id (if known). + /// + public LspRequestCancelledException(string requestId) + : base("Request was cancelled.", requestId, LspErrorCodes.RequestCancelled) + { + } + + /// + /// Serialisation constructor. + /// + /// + /// The serialisation data-store. + /// + /// + /// The serialisation streaming context. + /// + protected LspRequestCancelledException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + } +} diff --git a/src/Client/HandlerDelegates.cs b/src/Client/HandlerDelegates.cs new file mode 100644 index 000000000..c67679899 --- /dev/null +++ b/src/Client/HandlerDelegates.cs @@ -0,0 +1,89 @@ +using OmniSharp.Extensions.LanguageServer.Models; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client +{ + /// + /// A handler for empty notifications. + /// + /// + /// A representing the operation. + /// + public delegate void NotificationHandler(); + + /// + /// A handler for notifications. + /// + /// + /// The notification message type. + /// + /// + /// The notification message. + /// + public delegate void NotificationHandler(TNotification notification); + + /// + /// A handler for requests. + /// + /// + /// The request message type. + /// + /// + /// The request message. + /// + /// + /// A that can be used to cancel the operation. + /// + /// + /// A representing the operation. + /// + public delegate Task RequestHandler(TRequest request, CancellationToken cancellationToken); + + /// + /// A handler for requests that return responses. + /// + /// + /// The request message type. + /// + /// + /// The response message type. + /// + /// + /// The request message. + /// + /// + /// A that can be used to cancel the operation. + /// + /// + /// A representing the operation that resolves to the response message. + /// + public delegate Task RequestHandler(TRequest request, CancellationToken cancellationToken); + + /// + /// A handler for log messages sent from the language server to the client. + /// + /// + /// The log message. + /// + /// + /// The log message type. + /// + public delegate void LogMessageHandler(string message, MessageType messageType); + + /// + /// A handler for diagnostics published by the language server. + /// + /// + /// The URI of the document that the diagnostics apply to. + /// + /// + /// A list of s. + /// + /// + /// The diagnostics should replace any previously published diagnostics for the specified document. + /// + public delegate void PublishDiagnosticsHandler(Uri documentUri, List diagnostics); +} diff --git a/src/Client/Handlers/DelegateEmptyNotificationHandler.cs b/src/Client/Handlers/DelegateEmptyNotificationHandler.cs new file mode 100644 index 000000000..e6d4be8a0 --- /dev/null +++ b/src/Client/Handlers/DelegateEmptyNotificationHandler.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading.Tasks; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Handlers +{ + /// + /// A delegate-based handler for empty notifications. + /// + public class DelegateEmptyNotificationHandler + : DelegateHandler, IInvokeEmptyNotificationHandler + { + /// + /// Create a new . + /// + /// + /// The name of the method handled by the handler. + /// + /// + /// The delegate that implements the handler. + /// + public DelegateEmptyNotificationHandler(string method, NotificationHandler handler) + : base(method) + { + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + Handler = handler; + } + + /// + /// The delegate that implements the handler. + /// + public NotificationHandler Handler { get; } + + /// + /// Invoke the handler. + /// + /// + /// A representing the operation. + /// + public async Task Invoke() + { + await Task.Yield(); + + Handler(); + } + } +} diff --git a/src/Client/Handlers/DelegateHandler.cs b/src/Client/Handlers/DelegateHandler.cs new file mode 100644 index 000000000..86b4169fd --- /dev/null +++ b/src/Client/Handlers/DelegateHandler.cs @@ -0,0 +1,30 @@ +using System; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Handlers +{ + /// + /// The base class for delegate-based message handlers. + /// + public abstract class DelegateHandler + : IHandler + { + /// + /// Create a new . + /// + /// + /// The name of the method handled by the handler. + /// + protected DelegateHandler(string method) + { + if (String.IsNullOrWhiteSpace(method)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(method)}.", nameof(method)); + + Method = method; + } + + /// + /// The name of the method handled by the handler. + /// + public string Method { get; } + } +} diff --git a/src/Client/Handlers/DelegateNotificationHandler.cs b/src/Client/Handlers/DelegateNotificationHandler.cs new file mode 100644 index 000000000..1aad278ce --- /dev/null +++ b/src/Client/Handlers/DelegateNotificationHandler.cs @@ -0,0 +1,57 @@ +using Newtonsoft.Json.Linq; +using System; +using System.Threading.Tasks; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Handlers +{ + /// + /// A delegate-based handler for notifications. + /// + /// + /// The notification message type. + /// + public class DelegateNotificationHandler + : DelegateHandler, IInvokeNotificationHandler + { + /// + /// Create a new . + /// + /// + /// The name of the method handled by the handler. + /// + /// + /// The delegate that implements the handler. + /// + public DelegateNotificationHandler(string method, NotificationHandler handler) + : base(method) + { + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + Handler = handler; + } + + /// + /// The delegate that implements the handler. + /// + public NotificationHandler Handler { get; } + + /// + /// Invoke the handler. + /// + /// + /// The notification message. + /// + /// + /// A representing the operation. + /// + public async Task Invoke(JObject notification) + { + await Task.Yield(); + + Handler( + notification != null ? notification.ToObject() : default(TNotification) + ); + } + } +} diff --git a/src/Client/Handlers/DelegateRequestHandler.cs b/src/Client/Handlers/DelegateRequestHandler.cs new file mode 100644 index 000000000..988aafc81 --- /dev/null +++ b/src/Client/Handlers/DelegateRequestHandler.cs @@ -0,0 +1,62 @@ +using Newtonsoft.Json.Linq; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Handlers +{ + /// + /// A delegate-based handler for requests whose responses have no payload (i.e. void return type). + /// + /// + /// The request message type. + /// + public class DelegateRequestHandler + : DelegateHandler, IInvokeRequestHandler + { + /// + /// Create a new . + /// + /// + /// The name of the method handled by the handler. + /// + /// + /// The delegate that implements the handler. + /// + public DelegateRequestHandler(string method, RequestHandler handler) + : base(method) + { + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + Handler = handler; + } + + /// + /// The delegate that implements the handler. + /// + public RequestHandler Handler { get; } + + /// + /// Invoke the handler. + /// + /// + /// The request message. + /// + /// + /// A that can be used to cancel the operation. + /// + /// + /// A representing the operation. + /// + public async Task Invoke(JObject request, CancellationToken cancellationToken) + { + await Handler( + request != null ? request.ToObject() : default(TRequest), + cancellationToken + ); + + return null; + } + } +} diff --git a/src/Client/Handlers/DelegateRequestResponseHandler.cs b/src/Client/Handlers/DelegateRequestResponseHandler.cs new file mode 100644 index 000000000..8384508b1 --- /dev/null +++ b/src/Client/Handlers/DelegateRequestResponseHandler.cs @@ -0,0 +1,63 @@ +using Newtonsoft.Json.Linq; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Handlers +{ + /// + /// A delegate-based handler for requests whose responses have payloads. + /// + /// + /// The request message type. + /// + /// + /// The response message type. + /// + public class DelegateRequestResponseHandler + : DelegateHandler, IInvokeRequestHandler + { + /// + /// Create a new . + /// + /// + /// The name of the method handled by the handler. + /// + /// + /// The delegate that implements the handler. + /// + public DelegateRequestResponseHandler(string method, RequestHandler handler) + : base(method) + { + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + Handler = handler; + } + + /// + /// The delegate that implements the handler. + /// + public RequestHandler Handler { get; } + + /// + /// Invoke the handler. + /// + /// + /// The request message. + /// + /// + /// A that can be used to cancel the operation. + /// + /// + /// A representing the operation. + /// + public async Task Invoke(JObject request, CancellationToken cancellationToken) + { + return await Handler( + request != null ? request.ToObject() : default(TRequest), + cancellationToken + ); + } + } +} diff --git a/src/Client/Handlers/DynamicRegistrationHandler.cs b/src/Client/Handlers/DynamicRegistrationHandler.cs new file mode 100644 index 000000000..37f1402c3 --- /dev/null +++ b/src/Client/Handlers/DynamicRegistrationHandler.cs @@ -0,0 +1,54 @@ +using OmniSharp.Extensions.LanguageServer.Capabilities.Server; +using Newtonsoft.Json.Linq; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Handlers +{ + /// + /// Handler for "client/registerCapability". + /// + /// + /// For now, this handler does nothing other than a void reply; we don't support dynamic registrations yet. + /// + public class DynamicRegistrationHandler + : IInvokeRequestHandler + { + /// + /// Create a new . + /// + public DynamicRegistrationHandler() + { + } + + /// + /// Server capabilities dynamically updated by the handler. + /// + public ServerCapabilities ServerCapabilities { get; set; } = new ServerCapabilities(); + + /// + /// The name of the method handled by the handler. + /// + public string Method => "client/registerCapability"; + + /// + /// Invoke the handler. + /// + /// + /// The request message. + /// + /// + /// A that can be used to cancel the operation. + /// + /// + /// A representing the operation. + /// + public Task Invoke(JObject request, CancellationToken cancellationToken) + { + // For now, we don't really support dynamic registration but OmniSharp's implementation sends a request even when dynamic registrations are not supported. + + return Task.FromResult(null); + } + } +} diff --git a/src/Client/Handlers/IHandler.cs b/src/Client/Handlers/IHandler.cs new file mode 100644 index 000000000..3ac672423 --- /dev/null +++ b/src/Client/Handlers/IHandler.cs @@ -0,0 +1,13 @@ +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Handlers +{ + /// + /// Represents a client-side message handler. + /// + public interface IHandler + { + /// + /// The name of the method handled by the handler. + /// + string Method { get; } + } +} diff --git a/src/Client/Handlers/IInvokeEmptyNotificationHandler.cs b/src/Client/Handlers/IInvokeEmptyNotificationHandler.cs new file mode 100644 index 000000000..fe0ad2eba --- /dev/null +++ b/src/Client/Handlers/IInvokeEmptyNotificationHandler.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Handlers +{ + /// + /// Represents a handler for empty notifications. + /// + public interface IInvokeEmptyNotificationHandler + : IHandler + { + /// + /// Invoke the handler. + /// + /// + /// A representing the operation. + /// + Task Invoke(); + } +} diff --git a/src/Client/Handlers/IInvokeNotificationHandler.cs b/src/Client/Handlers/IInvokeNotificationHandler.cs new file mode 100644 index 000000000..d34868749 --- /dev/null +++ b/src/Client/Handlers/IInvokeNotificationHandler.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json.Linq; +using System.Threading.Tasks; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Handlers +{ + /// + /// Represents a handler for notifications. + /// + public interface IInvokeNotificationHandler + : IHandler + { + /// + /// Invoke the handler. + /// + /// + /// The notification message. + /// + /// + /// A representing the operation. + /// + Task Invoke(JObject notification); + } +} diff --git a/src/Client/Handlers/IInvokeRequestHandler.cs b/src/Client/Handlers/IInvokeRequestHandler.cs new file mode 100644 index 000000000..bfc251ca7 --- /dev/null +++ b/src/Client/Handlers/IInvokeRequestHandler.cs @@ -0,0 +1,27 @@ +using Newtonsoft.Json.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Handlers +{ + /// + /// Represents a handler for requests. + /// + public interface IInvokeRequestHandler + : IHandler + { + /// + /// Invoke the handler. + /// + /// + /// The request message. + /// + /// + /// A that can be used to cancel the operation. + /// + /// + /// A representing the operation. + /// + Task Invoke(JObject request, CancellationToken cancellationToken); + } +} diff --git a/src/Client/Handlers/JsonRpcEmptyNotificationHandler.cs b/src/Client/Handlers/JsonRpcEmptyNotificationHandler.cs new file mode 100644 index 000000000..a4a2e7c95 --- /dev/null +++ b/src/Client/Handlers/JsonRpcEmptyNotificationHandler.cs @@ -0,0 +1,44 @@ +using OmniSharp.Extensions.JsonRpc; +using System; +using System.Threading.Tasks; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Handlers +{ + /// + /// An empty notification handler that invokes a JSON-RPC . + /// + public class JsonRpcEmptyNotificationHandler + : JsonRpcHandler, IInvokeEmptyNotificationHandler + { + /// + /// Create a new . + /// + /// + /// The name of the method handled by the handler. + /// + /// + /// The underlying JSON-RPC . + /// + public JsonRpcEmptyNotificationHandler(string method, INotificationHandler handler) + : base(method) + { + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + Handler = handler; + } + + /// + /// The underlying JSON-RPC . + /// + public INotificationHandler Handler { get; } + + /// + /// Invoke the handler. + /// + /// + /// A representing the operation. + /// + public Task Invoke() => Handler.Handle(); + } +} diff --git a/src/Client/Handlers/JsonRpcHandler.cs b/src/Client/Handlers/JsonRpcHandler.cs new file mode 100644 index 000000000..d9af687fe --- /dev/null +++ b/src/Client/Handlers/JsonRpcHandler.cs @@ -0,0 +1,31 @@ +using OmniSharp.Extensions.JsonRpc; +using System; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Handlers +{ + /// + /// The base class for message handlers based on JSON-RPC s. + /// + public abstract class JsonRpcHandler + : IHandler + { + /// + /// Create a new . + /// + /// + /// The name of the method handled by the handler. + /// + protected JsonRpcHandler(string method) + { + if (String.IsNullOrWhiteSpace(method)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(method)}.", nameof(method)); + + Method = method; + } + + /// + /// The name of the method handled by the handler. + /// + public string Method { get; } + } +} diff --git a/src/Client/Handlers/JsonRpcNotificationHandler.cs b/src/Client/Handlers/JsonRpcNotificationHandler.cs new file mode 100644 index 000000000..95a9f2315 --- /dev/null +++ b/src/Client/Handlers/JsonRpcNotificationHandler.cs @@ -0,0 +1,53 @@ +using OmniSharp.Extensions.JsonRpc; +using Newtonsoft.Json.Linq; +using System; +using System.Threading.Tasks; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Handlers +{ + /// + /// A notification handler that invokes a JSON-RPC . + /// + /// + /// The notification message handler. + /// + public class JsonRpcNotificationHandler + : JsonRpcHandler, IInvokeNotificationHandler + { + /// + /// Create a new . + /// + /// + /// The name of the method handled by the handler. + /// + /// + /// The underlying JSON-RPC . + /// + public JsonRpcNotificationHandler(string method, INotificationHandler handler) + : base(method) + { + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + Handler = handler; + } + + /// + /// The underlying JSON-RPC . + /// + public INotificationHandler Handler { get; } + + /// + /// Invoke the handler. + /// + /// + /// A representing the notification parameters. + /// + /// + /// A representing the operation. + /// + public Task Invoke(JObject notification) => Handler.Handle( + notification != null ? notification.ToObject() : default(TNotification) + ); + } +} diff --git a/src/Client/LanguageClient.cs b/src/Client/LanguageClient.cs new file mode 100644 index 000000000..863b8fa42 --- /dev/null +++ b/src/Client/LanguageClient.cs @@ -0,0 +1,464 @@ +using Newtonsoft.Json.Linq; +using Microsoft.Extensions.Logging; +using OmniSharp.Extensions.LanguageServer.Capabilities.Client; +using OmniSharp.Extensions.LanguageServer.Capabilities.Server; +using OmniSharp.Extensions.LanguageServer.Models; +using OmniSharp.Extensions.LanguageServerProtocol.Client.Dispatcher; +using OmniSharp.Extensions.LanguageServerProtocol.Client.Handlers; +using OmniSharp.Extensions.LanguageServerProtocol.Client.Processes; +using OmniSharp.Extensions.LanguageServerProtocol.Client.Protocol; +using OmniSharp.Extensions.LanguageServerProtocol.Client.Logging; +using OmniSharp.Extensions.LanguageServerProtocol.Client.Clients; +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client +{ + /// + /// A client for the Language Server Protocol. + /// + /// + /// Note - at this stage, a cannot be reused once has been called; instead, create a new one. + /// + public sealed class LanguageClient + : IDisposable + { + /// + /// The dispatcher for incoming requests, notifications, and responses. + /// + readonly LspDispatcher _dispatcher = new LspDispatcher(); + + /// + /// The handler for dynamic registration of server capabilities. + /// + /// + /// We don't actually support this yet but some server implementations (e.g. OmniSharp) will freak out if we don't respond to the message, even if we've indicated that we don't support dynamic registrations of server capabilities. + /// + readonly DynamicRegistrationHandler _dynamicRegistrationHandler = new DynamicRegistrationHandler(); + + /// + /// The language server process. + /// + ServerProcess _process; + + /// + /// The underlying LSP connection to the language server process. + /// + LspConnection _connection; + + /// + /// Completion source that callers can await to determine when the language server is ready to use (i.e. initialised). + /// + TaskCompletionSource _readyCompletion = new TaskCompletionSource(); + + /// + /// Create a new . + /// + /// + /// The factory for loggers used by the client and its components. + /// + /// + /// A describing how to start the server process. + /// + public LanguageClient(ILoggerFactory loggerFactory, ProcessStartInfo serverStartInfo) + : this(loggerFactory, new StdioServerProcess(loggerFactory, serverStartInfo)) + { + } + + /// + /// Create a new . + /// + /// + /// The factory for loggers used by the client and its components. + /// + /// + /// A used to start or connect to the server process. + /// + public LanguageClient(ILoggerFactory loggerFactory, ServerProcess process) + : this(loggerFactory) + { + if (process == null) + throw new ArgumentNullException(nameof(process)); + + _process = process; + _process.Exited += ServerProcess_Exit; + } + + /// + /// Create a new . + /// + /// + /// The logger to use. + /// + LanguageClient(ILoggerFactory loggerFactory) + { + if (loggerFactory == null) + throw new ArgumentNullException(nameof(loggerFactory)); + + LoggerFactory = loggerFactory; + Log = LoggerFactory.CreateLogger(); + Workspace = new WorkspaceClient(this); + Window = new WindowClient(this); + TextDocument = new TextDocumentClient(this); + + _dispatcher.RegisterHandler(_dynamicRegistrationHandler); + } + + /// + /// Dispose of resources being used by the client. + /// + public void Dispose() + { + LspConnection connection = Interlocked.Exchange(ref _connection, null); + connection?.Dispose(); + + ServerProcess serverProcess = Interlocked.Exchange(ref _process, null); + serverProcess?.Dispose(); + } + + /// + /// The factory for loggers used by the client and its components. + /// + ILoggerFactory LoggerFactory { get; } + + /// + /// The client's logger. + /// + ILogger Log { get; } + + /// + /// The LSP Text Document API. + /// + public TextDocumentClient TextDocument { get; } + + /// + /// The LSP Window API. + /// + public WindowClient Window { get; } + + /// + /// The LSP Workspace API. + /// + public WorkspaceClient Workspace { get; } + + /// + /// The client's capabilities. + /// + public ClientCapabilities ClientCapabilities { get; } = new ClientCapabilities + { + Workspace = new WorkspaceClientCapabilites + { + DidChangeConfiguration = new DidChangeConfigurationCapability + { + DynamicRegistration = false + } + }, + TextDocument = new TextDocumentClientCapabilities + { + Synchronization = new SynchronizationCapability + { + DidSave = true, + DynamicRegistration = false + }, + Hover = new HoverCapability + { + DynamicRegistration = false + }, + Completion = new CompletionCapability + { + CompletionItem = new CompletionItemCapability + { + SnippetSupport = false + }, + DynamicRegistration = false + } + } + }; + + /// + /// The server's capabilities. + /// + public ServerCapabilities ServerCapabilities => _dynamicRegistrationHandler.ServerCapabilities; + + /// + /// Has the language client been initialised? + /// + public bool IsInitialized { get; private set; } + + /// + /// Is the connection to the language server open? + /// + public bool IsConnected => _connection != null && _connection.IsOpen; + + /// + /// A that completes when the client is ready to handle requests. + /// + public Task IsReady => _readyCompletion.Task; + + /// + /// A that completes when the underlying connection has closed and the server has stopped. + /// + public Task HasShutdown + { + get + { + return Task.WhenAll( + _connection.HasHasDisconnected, + _process?.HasExited ?? Task.CompletedTask + ); + } + } + + /// + /// Initialise the language server. + /// + /// + /// The workspace root. + /// + /// + /// An optional representing additional options to send to the server. + /// + /// + /// An optional that can be used to cancel the operation. + /// + /// + /// A representing initialisation. + /// + /// + /// has already been called. + /// + /// can only be called once per ; if you have called , you will need to use a new . + /// + public async Task Initialize(string workspaceRoot, object initializationOptions = null, CancellationToken cancellationToken = default(CancellationToken)) + { + if (IsInitialized) + throw new InvalidOperationException("Client has already been initialised."); + + try + { + await Start(); + + InitializeParams initializeParams = new InitializeParams + { + RootPath = workspaceRoot, + Capabilities = ClientCapabilities, + ProcessId = Process.GetCurrentProcess().Id, + InitializationOptions = initializationOptions + }; + + Log.LogDebug("Sending 'initialize' message to language server..."); + + InitializeResult result = await SendRequest("initialize", initializeParams, cancellationToken).ConfigureAwait(false); + if (result == null) + throw new LspException("Server replied to 'initialize' request with a null response."); + + _dynamicRegistrationHandler.ServerCapabilities = result.Capabilities; + + Log.LogDebug("Sent 'initialize' message to language server."); + + Log.LogDebug("Sending 'initialized' notification to language server..."); + + SendNotification("initialized"); + + Log.LogDebug("Sent 'initialized' notification to language server."); + + IsInitialized = true; + _readyCompletion.TrySetResult(null); + } + catch (Exception initializationError) + { + // Capture the initialisation error so anyone awaiting IsReady will also see it. + _readyCompletion.TrySetException(initializationError); + + throw; + } + } + + /// + /// Stop the language server. + /// + /// + /// A representing the shutdown operation. + /// + public async Task Shutdown() + { + LspConnection connection = _connection; + if (connection != null) + { + if (connection.IsOpen) + { + connection.SendEmptyNotification("shutdown"); + connection.SendEmptyNotification("exit"); + connection.Disconnect(flushOutgoing: true); + } + + await connection.HasHasDisconnected; + } + + ServerProcess serverProcess = _process; + if (serverProcess != null) + { + if (serverProcess.IsRunning) + await serverProcess.Stop(); + } + + IsInitialized = false; + _readyCompletion = new TaskCompletionSource(); + } + + /// + /// Register a message handler. + /// + /// + /// The message handler. + /// + /// + /// An representing the registration. + /// + public IDisposable RegisterHandler(IHandler handler) => _dispatcher.RegisterHandler(handler); + + /// + /// Send an empty notification to the language server. + /// + /// + /// The notification method name. + /// + public void SendNotification(string method) + { + LspConnection connection = _connection; + if (connection == null || !connection.IsOpen) + throw new InvalidOperationException("Not connected to the language server."); + + connection.SendEmptyNotification(method); + } + + /// + /// Send a notification message to the language server. + /// + /// + /// The notification method name. + /// + /// + /// The notification message. + /// + public void SendNotification(string method, object notification) + { + LspConnection connection = _connection; + if (connection == null || !connection.IsOpen) + throw new InvalidOperationException("Not connected to the language server."); + + connection.SendNotification(method, notification); + } + + /// + /// Send a request to the language server. + /// + /// + /// The request method name. + /// + /// + /// The request message. + /// + /// + /// An optional cancellation token that can be used to cancel the request. + /// + /// + /// A representing the request. + /// + public Task SendRequest(string method, object request, CancellationToken cancellationToken = default(CancellationToken)) + { + LspConnection connection = _connection; + if (connection == null || !connection.IsOpen) + throw new InvalidOperationException("Not connected to the language server."); + + return connection.SendRequest(method, request, cancellationToken); + } + + /// + /// Send a request to the language server. + /// + /// + /// The response message type. + /// + /// + /// The request method name. + /// + /// + /// The request message. + /// + /// + /// An optional cancellation token that can be used to cancel the request. + /// + /// + /// A representing the response. + /// + public Task SendRequest(string method, object request, CancellationToken cancellation = default(CancellationToken)) + { + LspConnection connection = _connection; + if (connection == null || !connection.IsOpen) + throw new InvalidOperationException("Not connected to the language server."); + + return connection.SendRequest(method, request, cancellation); + } + + /// + /// Start the language server. + /// + /// + /// A representing the operation. + /// + async Task Start() + { + if (_process == null) + throw new ObjectDisposedException(GetType().Name); + + if (!_process.IsRunning) + { + Log.LogDebug("Starting language server..."); + + await _process.Start(); + + Log.LogDebug("Language server is running."); + } + + Log.LogDebug("Opening connection to language server..."); + + if (_connection == null) + _connection = new LspConnection(LoggerFactory, input: _process.OutputStream, output: _process.InputStream); + + _connection.Connect(_dispatcher); + + Log.LogDebug("Connection to language server is open."); + } + + /// + /// Called when the server process has exited. + /// + /// + /// The event sender. + /// + /// + /// The event arguments. + /// + async void ServerProcess_Exit(object sender, EventArgs args) + { + Log.LogDebug("Server process has exited; language client is shutting down..."); + + LspConnection connection = Interlocked.Exchange(ref _connection, null); + if (connection != null) + { + using (connection) + { + connection.Disconnect(); + await connection.HasHasDisconnected; + } + } + + await Shutdown(); + + Log.LogDebug("Language client shutdown complete."); + } + } +} diff --git a/src/Client/LanguageClientRegistration.cs b/src/Client/LanguageClientRegistration.cs new file mode 100644 index 000000000..8e8804d51 --- /dev/null +++ b/src/Client/LanguageClientRegistration.cs @@ -0,0 +1,214 @@ +using OmniSharp.Extensions.JsonRpc; +using OmniSharp.Extensions.LanguageServerProtocol.Client.Handlers; +using System; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client +{ + /// + /// Extension methods for enabling various styles of handler registration. + /// + public static class LanguageRegistration + { + /// + /// Register a handler for empty notifications. + /// + /// + /// The . + /// + /// + /// The name of the notification method to handle. + /// + /// + /// A delegate that implements the handler. + /// + /// + /// An representing the registration. + /// + public static IDisposable HandleNotification(this LanguageClient languageClient, string method, NotificationHandler handler) + { + if (languageClient == null) + throw new ArgumentNullException(nameof(languageClient)); + + if (String.IsNullOrWhiteSpace(method)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(method)}.", nameof(method)); + + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + return languageClient.RegisterHandler( + new DelegateEmptyNotificationHandler(method, handler) + ); + } + + /// + /// Register a handler for empty notifications. + /// + /// + /// The . + /// + /// + /// The name of the notification method to handle. + /// + /// + /// A JSON-RPC that implements the handler. + /// + /// + /// An representing the registration. + /// + public static IDisposable HandleNotification(this LanguageClient languageClient, string method, INotificationHandler handler) + { + if (languageClient == null) + throw new ArgumentNullException(nameof(languageClient)); + + if (String.IsNullOrWhiteSpace(method)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(method)}.", nameof(method)); + + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + return languageClient.RegisterHandler( + new JsonRpcEmptyNotificationHandler(method, handler) + ); + } + + + /// + /// Register a handler for notifications. + /// + /// + /// The notification message type. + /// + /// + /// The . + /// + /// + /// The name of the notification method to handle. + /// + /// + /// A delegate that implements the handler. + /// + /// + /// An representing the registration. + /// + public static IDisposable HandleNotification(this LanguageClient languageClient, string method, NotificationHandler handler) + { + if (languageClient == null) + throw new ArgumentNullException(nameof(languageClient)); + + if (String.IsNullOrWhiteSpace(method)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(method)}.", nameof(method)); + + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + return languageClient.RegisterHandler( + new DelegateNotificationHandler(method, handler) + ); + } + + /// + /// Register a handler for notifications. + /// + /// + /// The notification message type. + /// + /// + /// The . + /// + /// + /// The name of the notification method to handle. + /// + /// + /// A JSON-RPC that implements the handler. + /// + /// + /// An representing the registration. + /// + public static IDisposable HandleNotification(this LanguageClient languageClient, string method, INotificationHandler handler) + { + if (languageClient == null) + throw new ArgumentNullException(nameof(languageClient)); + + if (String.IsNullOrWhiteSpace(method)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(method)}.", nameof(method)); + + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + return languageClient.RegisterHandler( + new JsonRpcNotificationHandler(method, handler) + ); + } + + /// + /// Register a handler for requests. + /// + /// + /// The request message type. + /// + /// + /// The . + /// + /// + /// The name of the request method to handle. + /// + /// + /// A delegate that implements the handler. + /// + /// + /// An representing the registration. + /// + public static IDisposable HandleRequest(this LanguageClient languageClient, string method, RequestHandler handler) + { + if (languageClient == null) + throw new ArgumentNullException(nameof(languageClient)); + + if (String.IsNullOrWhiteSpace(method)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(method)}.", nameof(method)); + + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + return languageClient.RegisterHandler( + new DelegateRequestHandler(method, handler) + ); + } + + /// + /// Register a handler for requests. + /// + /// + /// The request message type. + /// + /// + /// The response message type. + /// + /// + /// The . + /// + /// + /// The name of the request method to handle. + /// + /// + /// A delegate that implements the handler. + /// + /// + /// An representing the registration. + /// + public static IDisposable HandleRequest(this LanguageClient languageClient, string method, RequestHandler handler) + { + if (languageClient == null) + throw new ArgumentNullException(nameof(languageClient)); + + if (String.IsNullOrWhiteSpace(method)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(method)}.", nameof(method)); + + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + return languageClient.RegisterHandler( + new DelegateRequestResponseHandler(method, handler) + ); + } + } +} diff --git a/src/Client/Logging/LoggerExtensions.cs b/src/Client/Logging/LoggerExtensions.cs new file mode 100644 index 000000000..18cdb96b1 --- /dev/null +++ b/src/Client/Logging/LoggerExtensions.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.Extensions.Logging; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Logging +{ + /// + /// Extension methods for . + /// + public static class LoggerExtensions + { + /// + /// representing a generic error event. + /// + public static EventId GenericErrorEventId = new EventId(500); + + /// + /// Log an error. + /// + /// + /// The . + /// + /// + /// The exception (if any) associated with the error. + /// + /// + /// The log message. + /// + /// + /// The message format arguments (if any). + /// + public static void LogError(this ILogger logger, Exception exception, string message, params object[] args) + { + if (logger == null) + throw new ArgumentNullException(nameof(logger)); + + logger.LogError(GenericErrorEventId, exception, message, args); + } + } +} diff --git a/src/Client/LspErrorCodes.cs b/src/Client/LspErrorCodes.cs new file mode 100644 index 000000000..bbe3e20fc --- /dev/null +++ b/src/Client/LspErrorCodes.cs @@ -0,0 +1,48 @@ +namespace OmniSharp.Extensions.LanguageServerProtocol.Client +{ + /// + /// Well-known LSP error codes. + /// + public static class LspErrorCodes + { + /// + /// No error code was supplied. + /// + public static readonly int None = -32001; + + /// + /// Server has not been initialised. + /// + public const int ServerNotInitialized = -32002; + + /// + /// Method not found. + /// + public const int MethodNotSupported = -32601; + + /// + /// Invalid request. + /// + public const int InvalidRequest = -32600; + + /// + /// Invalid request parameters. + /// + public const int InvalidParameters = -32602; + + /// + /// Internal error. + /// + public const int InternalError = -32603; + + /// + /// Unable to parse request. + /// + public const int ParseError = -32700; + + /// + /// Request was cancelled. + /// + public const int RequestCancelled = -32800; + } +} diff --git a/src/Client/Processes/NamedPipeServerProcess.cs b/src/Client/Processes/NamedPipeServerProcess.cs new file mode 100644 index 000000000..dee5885d2 --- /dev/null +++ b/src/Client/Processes/NamedPipeServerProcess.cs @@ -0,0 +1,145 @@ +using Microsoft.Extensions.Logging; +using System.Collections.Generic; +using System.IO; +using System.IO.Pipes; +using System.Threading.Tasks; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Processes +{ + /// + /// A is a that creates named pipe streams to connect a language client to a language server in the same process. + /// + public class NamedPipeServerProcess + : ServerProcess + { + /// + /// Create a new . + /// + /// + /// The base name (prefix) used to create the named pipes. + /// + /// + /// The factory for loggers used by the process and its components. + /// + public NamedPipeServerProcess(string baseName, ILoggerFactory loggerFactory) + : base(loggerFactory) + { + BaseName = baseName; + } + + /// + /// Dispose of resources being used by the . + /// + /// + /// Explicit disposal? + /// + protected override void Dispose(bool disposing) + { + if (disposing) + CloseStreams(); + + base.Dispose(disposing); + } + + /// + /// The base name (prefix) used to create the named pipes. + /// + public string BaseName { get; } + + /// + /// Is the server running? + /// + public override bool IsRunning => ServerStartCompletion.Task.IsCompleted; + + /// + /// A that the client reads messages from. + /// + public NamedPipeClientStream ClientInputStream { get; protected set; } + + /// + /// A that the client writes messages to. + /// + public NamedPipeClientStream ClientOutputStream { get; protected set; } + + /// + /// A that the server reads messages from. + /// + public NamedPipeServerStream ServerInputStream { get; protected set; } + + /// + /// A that the server writes messages to. + /// + public NamedPipeServerStream ServerOutputStream { get; protected set; } + + /// + /// The server's input stream. + /// + public override Stream InputStream => ServerInputStream; + + /// + /// The server's output stream. + /// + public override Stream OutputStream => ServerOutputStream; + + /// + /// Start or connect to the server. + /// + /// + /// A representing the operation. + /// + public override async Task Start() + { + ServerExitCompletion = new TaskCompletionSource(); + + ServerInputStream = new NamedPipeServerStream(BaseName + "/in", PipeDirection.Out, 2, PipeTransmissionMode.Byte, PipeOptions.Asynchronous, inBufferSize: 1024, outBufferSize: 1024); + ServerOutputStream = new NamedPipeServerStream(BaseName + "/out", PipeDirection.In, 2, PipeTransmissionMode.Byte, PipeOptions.Asynchronous, inBufferSize: 1024, outBufferSize: 1024); + ClientInputStream = new NamedPipeClientStream(".", BaseName + "/out", PipeDirection.Out, PipeOptions.Asynchronous); + ClientOutputStream = new NamedPipeClientStream(".", BaseName + "/in", PipeDirection.In, PipeOptions.Asynchronous); + + // Ensure all pipes are connected before proceeding. + await Task.WhenAll( + ServerInputStream.WaitForConnectionAsync(), + ServerOutputStream.WaitForConnectionAsync(), + ClientInputStream.ConnectAsync(), + ClientOutputStream.ConnectAsync() + ); + + ServerStartCompletion.TrySetResult(null); + } + + /// + /// Stop the server. + /// + /// + /// A representing the operation. + /// + public override Task Stop() + { + ServerStartCompletion = new TaskCompletionSource(); + + CloseStreams(); + + ServerExitCompletion.TrySetResult(null); + + return Task.CompletedTask; + } + + /// + /// Close the underlying streams. + /// + void CloseStreams() + { + ClientInputStream?.Dispose(); + ClientInputStream = null; + + ClientOutputStream?.Dispose(); + ClientOutputStream = null; + + ServerInputStream?.Dispose(); + ServerInputStream = null; + + ServerOutputStream?.Dispose(); + ServerOutputStream = null; + } + } +} diff --git a/src/Client/Processes/ServerProcess.cs b/src/Client/Processes/ServerProcess.cs new file mode 100644 index 000000000..c476926aa --- /dev/null +++ b/src/Client/Processes/ServerProcess.cs @@ -0,0 +1,137 @@ +using Microsoft.Extensions.Logging; +using OmniSharp.Extensions.LanguageServerProtocol.Client.Logging; +using System; +using System.IO; +using System.Threading.Tasks; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Processes +{ + /// + /// A is responsible for launching or attaching to a language server, providing access to its input and output streams, and tracking its lifetime. + /// + public abstract class ServerProcess + : IDisposable + { + /// + /// Create a new . + /// + /// + /// The factory for loggers used by the process and its components. + /// + protected ServerProcess(ILoggerFactory loggerFactory) + { + if (loggerFactory == null) + throw new ArgumentNullException(nameof(loggerFactory)); + + LoggerFactory = loggerFactory; + Log = LoggerFactory.CreateLogger( + categoryName: GetType().FullName + ); + + ServerStartCompletion = new TaskCompletionSource(); + + ServerExitCompletion = new TaskCompletionSource(); + ServerExitCompletion.SetResult(null); // Start out as if the server has already exited. + } + + /// + /// Finaliser for . + /// + ~ServerProcess() + { + Dispose(false); + } + + /// + /// Dispose of resources being used by the launcher. + /// + public void Dispose() + { + Dispose(true); + } + + /// + /// Dispose of resources being used by the launcher. + /// + /// + /// Explicit disposal? + /// + protected virtual void Dispose(bool disposing) + { + } + + /// + /// The factory for loggers used by the process and its components. + /// + protected ILoggerFactory LoggerFactory { get; } + + /// + /// The process's logger. + /// + protected ILogger Log { get; } + + /// + /// The used to signal server startup. + /// + protected TaskCompletionSource ServerStartCompletion { get; set; } + + /// + /// The used to signal server exit. + /// + protected TaskCompletionSource ServerExitCompletion { get; set; } + + /// + /// Event raised when the server has exited. + /// + public event EventHandler Exited; + + /// + /// Is the server running? + /// + public abstract bool IsRunning { get; } + + /// + /// A that completes when the server has started. + /// + public Task HasStarted => ServerStartCompletion.Task; + + /// + /// A that completes when the server has exited. + /// + public Task HasExited => ServerExitCompletion.Task; + + /// + /// The server's input stream. + /// + /// + /// The connection will write to the server's input stream, and read from its output stream. + /// + public abstract Stream InputStream { get; } + + /// + /// The server's output stream. + /// + /// + /// The connection will read from the server's output stream, and write to its input stream. + /// + public abstract Stream OutputStream { get; } + + /// + /// Start or connect to the server. + /// + public abstract Task Start(); + + /// + /// Stop or disconnect from the server. + /// + public abstract Task Stop(); + + /// + /// Raise the event. + /// + protected virtual void OnExited() + { + Exited?.Invoke(this, EventArgs.Empty); + } + } +} diff --git a/src/Client/Processes/StdioServerProcess.cs b/src/Client/Processes/StdioServerProcess.cs new file mode 100644 index 000000000..7ca36d44e --- /dev/null +++ b/src/Client/Processes/StdioServerProcess.cs @@ -0,0 +1,137 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Processes +{ + /// + /// A is a that launches its server as an external process and communicates with it over STDIN / STDOUT. + /// + public class StdioServerProcess + : ServerProcess + { + /// + /// A that describes how to start the server. + /// + readonly ProcessStartInfo _serverStartInfo; + + /// + /// The current server process (if any). + /// + Process _serverProcess; + + /// + /// Create a new . + /// + /// + /// The factory for loggers used by the process and its components. + /// + /// + /// A that describes how to start the server. + /// + public StdioServerProcess(ILoggerFactory loggerFactory, ProcessStartInfo serverStartInfo) + : base(loggerFactory) + { + if (serverStartInfo == null) + throw new ArgumentNullException(nameof(serverStartInfo)); + + _serverStartInfo = serverStartInfo; + } + + /// + /// Dispose of resources being used by the launcher. + /// + /// + /// Explicit disposal? + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + Process serverProcess = Interlocked.Exchange(ref _serverProcess, null); + if (serverProcess != null) + { + if (!serverProcess.HasExited) + serverProcess.Kill(); + + serverProcess.Dispose(); + } + } + } + + /// + /// Is the server running? + /// + public override bool IsRunning => !ServerExitCompletion.Task.IsCompleted; + + /// + /// The server's input stream. + /// + public override Stream InputStream => _serverProcess?.StandardInput?.BaseStream; + + /// + /// The server's output stream. + /// + public override Stream OutputStream => _serverProcess?.StandardOutput?.BaseStream; + + /// + /// Start or connect to the server. + /// + public override Task Start() + { + ServerExitCompletion = new TaskCompletionSource(); + + _serverStartInfo.CreateNoWindow = true; + _serverStartInfo.UseShellExecute = false; + _serverStartInfo.RedirectStandardInput = true; + _serverStartInfo.RedirectStandardOutput = true; + + Process serverProcess = _serverProcess = new Process + { + StartInfo = _serverStartInfo, + EnableRaisingEvents = true + }; + serverProcess.Exited += ServerProcess_Exit; + + if (!serverProcess.Start()) + throw new InvalidOperationException("Failed to launch language server ."); + + ServerStartCompletion.TrySetResult(null); + + return Task.CompletedTask; + } + + /// + /// Stop or disconnect from the server. + /// + public override async Task Stop() + { + Process serverProcess = Interlocked.Exchange(ref _serverProcess, null); + if (serverProcess != null && !serverProcess.HasExited) + serverProcess.Kill(); + + await ServerExitCompletion.Task; + } + + /// + /// Called when the server process has exited. + /// + /// + /// The event sender. + /// + /// + /// The event arguments. + /// + void ServerProcess_Exit(object sender, EventArgs args) + { + Log.LogDebug("Server process has exited."); + + OnExited(); + ServerExitCompletion.TrySetResult(null); + ServerStartCompletion = new TaskCompletionSource(); + } + } +} diff --git a/src/Client/Protocol/ClientMessage.cs b/src/Client/Protocol/ClientMessage.cs new file mode 100644 index 000000000..0c6594a31 --- /dev/null +++ b/src/Client/Protocol/ClientMessage.cs @@ -0,0 +1,42 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Protocol +{ + /// + /// The client-side representation of an LSP message. + /// + [JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] + public class ClientMessage + { + /// + /// The JSON-RPC protocol version. + /// + [JsonProperty("jsonrpc")] + public string ProtocolVersion => "2.0"; + + /// + /// The request / response Id, if the message represents a request or a response. + /// + [JsonProperty(DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate, NullValueHandling = NullValueHandling.Ignore)] + public object Id { get; set; } + + /// + /// The JSON-RPC method name. + /// + public string Method { get; set; } + + /// + /// The request / notification message, if the message represents a request or a notification. + /// + [JsonProperty(DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate, NullValueHandling = NullValueHandling.Ignore)] + public JObject Params { get; set; } + + /// + /// The response message, if the message represents a response. + /// + [JsonProperty(DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate, NullValueHandling = NullValueHandling.Ignore)] + public JObject Result { get; set; } + } +} diff --git a/src/Client/Protocol/ErrorMessage.cs b/src/Client/Protocol/ErrorMessage.cs new file mode 100644 index 000000000..c0bfe6a9f --- /dev/null +++ b/src/Client/Protocol/ErrorMessage.cs @@ -0,0 +1,29 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Protocol +{ + /// + /// A JSON-RPC error message. + /// + [JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] + public class ErrorMessage + { + /// + /// The error code. + /// + public int Code { get; set; } + + /// + /// The error message. + /// + public string Message { get; set; } + + /// + /// Optional data associated with the message. + /// + [JsonProperty(DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate)] + public JToken Data { get; set; } + } +} diff --git a/src/Client/Protocol/LspConnection.cs b/src/Client/Protocol/LspConnection.cs new file mode 100644 index 000000000..db0df77b9 --- /dev/null +++ b/src/Client/Protocol/LspConnection.cs @@ -0,0 +1,1012 @@ +using Microsoft.Extensions.Logging; +using OmniSharp.Extensions.JsonRpc; +using OmniSharp.Extensions.LanguageServerProtocol.Client.Dispatcher; +using OmniSharp.Extensions.LanguageServerProtocol.Client.Logging; +using OmniSharp.Extensions.LanguageServerProtocol.Client.Handlers; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using JsonRpcMessages = OmniSharp.Extensions.JsonRpc.Server.Messages; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Protocol +{ + /// + /// An asynchronous connection using the LSP protocol over s. + /// + public sealed class LspConnection + : IDisposable + { + /// + /// The buffer size to use when receiving headers. + /// + const short HeaderBufferSize = 300; + + /// + /// Minimum size of the buffer for receiving headers ("Content-Length: 1\r\n\r\n"). + /// + const short MinimumHeaderLength = 21; + + /// + /// The length of time to wait for the outgoing message queue to drain. + /// + public static TimeSpan FlushTimeout { get; set; } = TimeSpan.FromSeconds(5); + + /// + /// The encoding used for message headers. + /// + public static Encoding HeaderEncoding = Encoding.ASCII; + + /// + /// The encoding used for message payloads. + /// + public static Encoding PayloadEncoding = Encoding.UTF8; + + /// + /// The queue of outgoing requests. + /// + readonly BlockingCollection _outgoing = new BlockingCollection(new ConcurrentQueue()); + + /// + /// The queue of incoming responses. + /// + readonly BlockingCollection _incoming = new BlockingCollection(new ConcurrentQueue()); + + /// + /// s representing cancellation of requests from the language server (keyed by request Id). + /// + readonly ConcurrentDictionary _requestCancellations = new ConcurrentDictionary(); + + /// + /// s representing completion of responses from the language server (keyed by request Id). + /// + readonly ConcurrentDictionary> _responseCompletions = new ConcurrentDictionary>(); + + /// + /// The input stream. + /// + readonly Stream _input; + + /// + /// The output stream. + /// + readonly Stream _output; + + /// + /// The next available request Id. + /// + int _nextRequestId = 0; + + /// + /// The cancellation source for the read and write loops. + /// + CancellationTokenSource _cancellationSource; + + /// + /// Cancellation for the read and write loops. + /// + CancellationToken _cancellation; + + /// + /// A representing the stopping of the connection's send, receive, and dispatch loops. + /// + Task _hasDisconnectedTask = Task.CompletedTask; + + /// + /// The used to dispatch messages to handlers. + /// + LspDispatcher _dispatcher; + + /// + /// A representing the connection's receive loop. + /// + Task _sendLoop; + + /// + /// A representing the connection's send loop. + /// + Task _receiveLoop; + + /// + /// A representing the connection's dispatch loop. + /// + Task _dispatchLoop; + + /// + /// Create a new . + /// + /// + /// The factory for loggers used by the connection and its components. + /// + /// + /// The input stream. + /// + /// + /// The output stream. + /// + public LspConnection(ILoggerFactory loggerFactory, Stream input, Stream output) + { + if (loggerFactory == null) + throw new ArgumentNullException(nameof(loggerFactory)); + + if (input == null) + throw new ArgumentNullException(nameof(input)); + + if (!input.CanRead) + throw new ArgumentException("Input stream does not support reading.", nameof(input)); + + if (output == null) + throw new ArgumentNullException(nameof(output)); + + if (!output.CanWrite) + throw new ArgumentException("Output stream does not support reading.", nameof(output)); + + Log = loggerFactory.CreateLogger(); + _input = input; + _output = output; + } + + /// + /// Dispose of resources being used by the connection. + /// + public void Dispose() + { + Disconnect(); + + _cancellationSource?.Dispose(); + } + + /// + /// The connection's logger. + /// + ILogger Log { get; } + + /// + /// Is the connection open? + /// + public bool IsOpen => _sendLoop != null || _receiveLoop != null || _dispatchLoop != null; + + /// + /// A task that completes when the connection is closed. + /// + public Task HasHasDisconnected => _hasDisconnectedTask; + + /// + /// Register a message handler. + /// + /// + /// The message handler. + /// + /// + /// An representing the registration. + /// + public IDisposable RegisterHandler(IHandler handler) + { + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + LspDispatcher dispatcher = _dispatcher; + if (dispatcher == null) + throw new InvalidOperationException("The connection has not been opened."); + + return dispatcher.RegisterHandler(handler); + } + + /// + /// Open the connection. + /// + /// + /// The used to dispatch messages to handlers. + /// + public void Connect(LspDispatcher dispatcher) + { + if (dispatcher == null) + throw new ArgumentNullException(nameof(dispatcher)); + + if (IsOpen) + throw new InvalidOperationException("Connection is already open."); + + _cancellationSource = new CancellationTokenSource(); + _cancellation = _cancellationSource.Token; + + _dispatcher = dispatcher; + _sendLoop = SendLoop(); + _receiveLoop = ReceiveLoop(); + _dispatchLoop = DispatchLoop(); + + _hasDisconnectedTask = Task.WhenAll(_sendLoop, _receiveLoop, _dispatchLoop); + } + + /// + /// Close the connection. + /// + /// + /// If true, stop receiving and block until all outgoing messages have been sent. + /// + public void Disconnect(bool flushOutgoing = false) + { + if (flushOutgoing) + { + // Stop receiving. + _incoming.CompleteAdding(); + + // Wait for the outgoing message queue to drain. + int remainingMessageCount = 0; + DateTime then = DateTime.Now; + while (DateTime.Now - then < FlushTimeout) + { + remainingMessageCount = _outgoing.Count; + if (remainingMessageCount == 0) + break; + + Thread.Sleep( + TimeSpan.FromMilliseconds(200) + ); + } + + if (remainingMessageCount > 0) + Log.LogWarning("Failed to flush outgoing messages ({RemainingMessageCount} messages remaining).", _outgoing.Count); + } + + // Cancel all outstanding requests. + // This should not be necessary because request cancellation tokens should be linked to _cancellationSource, but better to be sure we won't leave a caller hanging. + foreach (TaskCompletionSource responseCompletion in _responseCompletions.Values) + { + responseCompletion.TrySetException( + new OperationCanceledException("The request was canceled because the underlying connection was closed.") + ); + } + + _cancellationSource?.Cancel(); + _sendLoop = null; + _receiveLoop = null; + _dispatchLoop = null; + _dispatcher = null; + } + + /// + /// Send an empty notification to the language server. + /// + /// + /// The notification method name. + /// + public void SendEmptyNotification(string method) + { + if (String.IsNullOrWhiteSpace(method)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(method)}.", nameof(method)); + + if (!IsOpen) + throw new LspException("Not connected to the language server."); + + _outgoing.TryAdd(new ClientMessage + { + // No Id means it's a notification. + Method = method + }); + } + + /// + /// Send a notification message to the language server. + /// + /// + /// The notification method name. + /// + /// + /// The notification message. + /// + public void SendNotification(string method, object notification) + { + if (String.IsNullOrWhiteSpace(method)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(method)}.", nameof(method)); + + if (notification == null) + throw new ArgumentNullException(nameof(notification)); + + if (!IsOpen) + throw new LspException("Not connected to the language server."); + + _outgoing.TryAdd(new ClientMessage + { + // No Id means it's a notification. + Method = method, + Params = JObject.FromObject(notification) + }); + } + + /// + /// Send a request to the language server. + /// + /// + /// The request method name. + /// + /// + /// The request message. + /// + /// + /// An optional cancellation token that can be used to cancel the request. + /// + /// + /// A representing the request. + /// + public async Task SendRequest(string method, object request, CancellationToken cancellationToken = default(CancellationToken)) + { + if (String.IsNullOrWhiteSpace(method)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(method)}.", nameof(method)); + + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (!IsOpen) + throw new LspException("Not connected to the language server."); + + string requestId = Interlocked.Increment(ref _nextRequestId).ToString(); + + TaskCompletionSource responseCompletion = new TaskCompletionSource(state: requestId); + cancellationToken.Register(() => + { + responseCompletion.TrySetException( + new OperationCanceledException("The request was canceled via the supplied cancellation token.", cancellationToken) + ); + + // Send notification telling server to cancel the request, if possible. + if (!_outgoing.IsAddingCompleted) + { + _outgoing.TryAdd(new ClientMessage + { + Method = "$/cancelRequest", + Params = new JObject( + new JProperty("id", requestId) + ) + }); + } + }); + + _responseCompletions.TryAdd(requestId, responseCompletion); + + _outgoing.TryAdd(new ClientMessage + { + Id = requestId, + Method = method, + Params = request != null ? JObject.FromObject(request) : null + }); + + await responseCompletion.Task; + } + + /// + /// Send a request to the language server. + /// + /// + /// The response message type. + /// + /// + /// The request method name. + /// + /// + /// The request message. + /// + /// + /// An optional cancellation token that can be used to cancel the request. + /// + /// + /// A representing the response. + /// + public async Task SendRequest(string method, object request, CancellationToken cancellationToken = default(CancellationToken)) + { + if (String.IsNullOrWhiteSpace(method)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(method)}.", nameof(method)); + + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (!IsOpen) + throw new LspException("Not connected to the language server."); + + string requestId = Interlocked.Increment(ref _nextRequestId).ToString(); + + TaskCompletionSource responseCompletion = new TaskCompletionSource(state: requestId); + cancellationToken.Register(() => + { + responseCompletion.TrySetException( + new OperationCanceledException("The request was canceled via the supplied cancellation token.", cancellationToken) + ); + + // Send notification telling server to cancel the request, if possible. + if (!_outgoing.IsAddingCompleted) + { + _outgoing.TryAdd(new ClientMessage + { + Method = "$/cancelRequest", + Params = new JObject( + new JProperty("id", requestId) + ) + }); + } + }); + + _responseCompletions.TryAdd(requestId, responseCompletion); + + _outgoing.TryAdd(new ClientMessage + { + Id = requestId, + Method = method, + Params = request != null ? JObject.FromObject(request) : null + }); + + ServerMessage response = await responseCompletion.Task; + + if (response.Result != null) + return response.Result.ToObject(); + else + return default(TResponse); + } + + /// + /// The connection's message-send loop. + /// + /// + /// A representing the loop's activity. + /// + async Task SendLoop() + { + await Task.Yield(); + + Log.LogInformation("Send loop started."); + + try + { + while (_outgoing.TryTake(out object outgoing, -1, _cancellation)) + { + try + { + if (outgoing is ClientMessage message) + { + if (message.Id != null) + Log.LogDebug("Sending outgoing {RequestMethod} request {RequestId}...", message.Method, message.Id); + else + Log.LogDebug("Sending outgoing {RequestMethod} notification...", message.Method); + + await SendMessage(message); + + if (message.Id != null) + Log.LogDebug("Sent outgoing {RequestMethod} request {RequestId}.", message.Method, message.Id); + else + Log.LogDebug("Sent outgoing {RequestMethod} notification.", message.Method); + } + else if (outgoing is RpcError errorResponse) + { + Log.LogDebug("Sending outgoing error response {RequestId} ({ErrorMessage})...", errorResponse.Id, errorResponse.Error?.Message); + + await SendMessage(errorResponse); + + Log.LogDebug("Sent outgoing error response {RequestId}.", errorResponse.Id); + } + else + Log.LogError("Unexpected outgoing message type '{0}'.", outgoing.GetType().AssemblyQualifiedName); + } + catch (Exception sendError) + { + Log.LogError(sendError, "Unexpected error sending outgoing message {@Message}.", outgoing); + } + } + } + catch (OperationCanceledException operationCanceled) + { + // Like tears in rain + if (operationCanceled.CancellationToken != _cancellation) + throw; // time to die + } + finally + { + Log.LogInformation("Send loop terminated."); + } + } + + /// + /// The connection's message-receive loop. + /// + /// + /// A representing the loop's activity. + /// + async Task ReceiveLoop() + { + await Task.Yield(); + + Log.LogInformation("Receive loop started."); + + try + { + while (!_cancellation.IsCancellationRequested && !_incoming.IsAddingCompleted) + { + ServerMessage message = await ReceiveMessage(); + if (message == null) + continue; + + _cancellation.ThrowIfCancellationRequested(); + + try + { + if (message.Id != null) + { + // Request or response. + if (message.Params != null) + { + // Request. + Log.LogDebug("Received {RequestMethod} request {RequestId} from language server: {RequestParameters}", + message.Method, + message.Id, + message.Params?.ToString(Formatting.None) + ); + + // Publish. + if (!_incoming.IsAddingCompleted) + _incoming.TryAdd(message); + } + else + { + // Response. + string requestId = message.Id.ToString(); + TaskCompletionSource completion; + if (_responseCompletions.TryGetValue(requestId, out completion)) + { + if (message.Error != null) + { + Log.LogDebug("Received error response {RequestId} from language server: {@ErrorMessage}", + requestId, + message.Error + ); + + Log.LogDebug("Faulting request {RequestId}.", requestId); + + completion.TrySetException( + CreateLspException(message) + ); + } + else + { + Log.LogDebug("Received response {RequestId} from language server: {ResponseResult}", + requestId, + message.Result?.ToString(Formatting.None) + ); + + Log.LogDebug("Completing request {RequestId}.", requestId); + + completion.TrySetResult(message); + } + } + else + { + Log.LogDebug("Received unexpected response {RequestId} from language server: {ResponseResult}", + requestId, + message.Result?.ToString(Formatting.None) + ); + } + } + } + else + { + // Notification. + Log.LogDebug("Received {NotificationMethod} notification from language server: {NotificationParameters}", + message.Method, + message.Params?.ToString(Formatting.None) + ); + + // Publish. + if (!_incoming.IsAddingCompleted) + _incoming.TryAdd(message); + } + } + catch (Exception dispatchError) + { + Log.LogError(dispatchError, "Unexpected error processing incoming message {@Message}.", message); + } + } + } + catch (OperationCanceledException operationCanceled) + { + // Like tears in rain + if (operationCanceled.CancellationToken != _cancellation) + throw; // time to die + } + finally + { + Log.LogInformation("Receive loop terminated."); + } + } + + /// + /// Send a message to the language server. + /// + /// + /// The type of message to send. + /// + /// + /// The message to send. + /// + /// + /// A representing the operation. + /// + async Task SendMessage(TMessage message) + where TMessage : class + { + if (message == null) + throw new ArgumentNullException(nameof(message)); + + string payload = JsonConvert.SerializeObject(message); + byte[] payloadBuffer = PayloadEncoding.GetBytes(payload); + + byte[] headerBuffer = HeaderEncoding.GetBytes( + $"Content-Length: {payloadBuffer.Length}\r\n\r\n" + ); + + Log.LogDebug("Sending outgoing header ({HeaderSize} bytes)...", headerBuffer.Length); + await _output.WriteAsync(headerBuffer, 0, headerBuffer.Length, _cancellation); + Log.LogDebug("Sent outgoing header ({HeaderSize} bytes).", headerBuffer.Length); + + Log.LogDebug("Sending outgoing payload ({PayloadSize} bytes)...", payloadBuffer.Length); + await _output.WriteAsync(payloadBuffer, 0, payloadBuffer.Length, _cancellation); + Log.LogDebug("Sent outgoing payload ({PayloadSize} bytes).", payloadBuffer.Length); + + Log.LogDebug("Flushing output stream..."); + await _output.FlushAsync(_cancellation); + Log.LogDebug("Flushed output stream."); + } + + /// + /// Receive a message from the language server. + /// + /// + /// A representing the message, + /// + async Task ReceiveMessage() + { + Log.LogDebug("Reading response headers..."); + + byte[] headerBuffer = new byte[HeaderBufferSize]; + int bytesRead = await _input.ReadAsync(headerBuffer, 0, MinimumHeaderLength, _cancellation); + + Log.LogDebug("Read {ByteCount} bytes from input stream.", bytesRead); + + if (bytesRead == 0) + return null; // Stream closed. + + const byte CR = (byte)'\r'; + const byte LF = (byte)'\n'; + + while (bytesRead < MinimumHeaderLength || + headerBuffer[bytesRead - 4] != CR || headerBuffer[bytesRead - 3] != LF || + headerBuffer[bytesRead - 2] != CR || headerBuffer[bytesRead - 1] != LF) + { + Log.LogDebug("Reading additional data from input stream..."); + + // Read single bytes until we've got a valid end-of-header sequence. + var additionalBytesRead = await _input.ReadAsync(headerBuffer, bytesRead, 1, _cancellation); + if (additionalBytesRead == 0) + return null; // no more _input, mitigates endless loop here. + + Log.LogDebug("Read {ByteCount} bytes of additional data from input stream.", additionalBytesRead); + + bytesRead += additionalBytesRead; + } + + string headers = HeaderEncoding.GetString(headerBuffer, 0, bytesRead); + Log.LogDebug("Got raw headers: {Headers}", headers); + + if (String.IsNullOrWhiteSpace(headers)) + return null; // Stream closed. + + Log.LogDebug("Read response headers {Headers}.", headers); + + Dictionary parsedHeaders = ParseHeaders(headers); + + string contentLengthHeader; + if (!parsedHeaders.TryGetValue("Content-Length", out contentLengthHeader)) + { + Log.LogDebug("Invalid request headers (missing 'Content-Length' header)."); + + return null; + } + + int contentLength = Int32.Parse(contentLengthHeader); + + Log.LogDebug("Reading response body ({ExpectedByteCount} bytes expected).", contentLength); + + var requestBuffer = new byte[contentLength]; + var received = 0; + while (received < contentLength) + { + Log.LogDebug("Reading segment of incoming request body ({ReceivedByteCount} of {TotalByteCount} bytes so far)...", received, contentLength); + + var payloadBytesRead = await _input.ReadAsync(requestBuffer, received, requestBuffer.Length - received, _cancellation); + if (payloadBytesRead == 0) + { + Log.LogWarning("Bailing out of reading payload (no_more_input after {ByteCount} bytes)...", received); + + return null; + } + received += payloadBytesRead; + + Log.LogDebug("Read segment of incoming request body ({ReceivedByteCount} of {TotalByteCount} bytes so far).", received, contentLength); + } + + Log.LogDebug("Received entire payload ({ReceivedByteCount} bytes).", received); + + string responseBody = PayloadEncoding.GetString(requestBuffer); + ServerMessage message = JsonConvert.DeserializeObject(responseBody); + + Log.LogDebug("Read response body {ResponseBody}.", responseBody); + + return message; + } + + /// + /// Parse request headers. + /// + /// + /// + /// + /// A containing the header names and values. + /// + private Dictionary ParseHeaders(string rawHeaders) + { + if (rawHeaders == null) + throw new ArgumentNullException(nameof(rawHeaders)); + + Dictionary headers = new Dictionary(StringComparer.OrdinalIgnoreCase); // Header names are case-insensitive. + string[] rawHeaderEntries = rawHeaders.Split(new string[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries); + foreach (string rawHeaderEntry in rawHeaderEntries) + { + string[] nameAndValue = rawHeaderEntry.Split(new char[] { ':' }, count: 2); + if (nameAndValue.Length != 2) + continue; + + headers[nameAndValue[0].Trim()] = nameAndValue[1].Trim(); + } + + return headers; + } + + /// + /// The connection's message-dispatch loop. + /// + /// + /// A representing the loop's activity. + /// + async Task DispatchLoop() + { + await Task.Yield(); + + Log.LogInformation("Dispatch loop started."); + + try + { + while (_incoming.TryTake(out ServerMessage message, -1, _cancellation)) + { + if (message.Id != null) + { + // Request. + if (message.Method == "$/cancelRequest") + CancelRequest(message); + else + DispatchRequest(message); + } + else + { + // Notification. + DispatchNotification(message); + } + } + } + catch (OperationCanceledException operationCanceled) + { + // Like tears in rain + if (operationCanceled.CancellationToken != _cancellation) + throw; // time to die + } + finally + { + Log.LogInformation("Dispatch loop terminated."); + } + } + + /// + /// Dispatch a request. + /// + /// + /// The request message. + /// + private void DispatchRequest(ServerMessage requestMessage) + { + if (requestMessage == null) + throw new ArgumentNullException(nameof(requestMessage)); + + string requestId = requestMessage.Id.ToString(); + Log.LogDebug("Dispatching incoming {RequestMethod} request {RequestId}...", requestMessage.Method, requestId); + + CancellationTokenSource requestCancellation = CancellationTokenSource.CreateLinkedTokenSource(_cancellation); + _requestCancellations.TryAdd(requestId, requestCancellation); + + Task handlerTask = _dispatcher.TryHandleRequest(requestMessage.Method, requestMessage.Params, requestCancellation.Token); + if (handlerTask == null) + { + Log.LogWarning("Unable to dispatch incoming {RequestMethod} request {RequestId} (no handler registered).", requestMessage.Method, requestId); + + _outgoing.TryAdd( + new JsonRpcMessages.MethodNotFound(requestMessage.Id, requestMessage.Method) + ); + + return; + } + +#pragma warning disable CS4014 // Continuation does the work we need; no need to await it as this would tie up the dispatch loop. + handlerTask.ContinueWith(_ => + { + if (handlerTask.IsCanceled) + Log.LogDebug("{RequestMethod} request {RequestId} canceled.", requestMessage.Method, requestId); + else if (handlerTask.IsFaulted) + { + Exception handlerError = handlerTask.Exception.Flatten().InnerExceptions[0]; + + Log.LogError(handlerError, "{RequestMethod} request {RequestId} failed (unexpected error raised by handler).", requestMessage.Method, requestId); + + _outgoing.TryAdd(new RpcError(requestId, + new JsonRpcMessages.ErrorMessage( + code: 500, + message: "Error processing request: " + handlerError.Message, + data: handlerError.ToString() + ) + )); + } + else if (handlerTask.IsCompleted) + { + Log.LogDebug("{RequestMethod} request {RequestId} complete (Result = {@Result}).", requestMessage.Method, requestId, handlerTask.Result); + + _outgoing.TryAdd(new ClientMessage + { + Id = requestMessage.Id, + Method = requestMessage.Method, + Result = handlerTask.Result != null ? JObject.FromObject(handlerTask.Result) : null + }); + } + + _requestCancellations.TryRemove(requestId, out CancellationTokenSource cancellation); + cancellation.Dispose(); + }); +#pragma warning restore CS4014 // Continuation does the work we need; no need to await it as this would tie up the dispatch loop. + + Log.LogDebug("Dispatched incoming {RequestMethod} request {RequestId}.", requestMessage.Method, requestMessage.Id); + } + + /// + /// Cancel a request. + /// + /// + /// The request message. + /// + void CancelRequest(ServerMessage requestMessage) + { + if (requestMessage == null) + throw new ArgumentNullException(nameof(requestMessage)); + + string cancelRequestId = requestMessage.Params?.Value("id")?.ToString(); + if (cancelRequestId != null) + { + if (_requestCancellations.TryRemove(cancelRequestId, out CancellationTokenSource requestCancellation)) + { + Log.LogDebug("Cancel request {RequestId}", requestMessage.Id); + requestCancellation.Cancel(); + requestCancellation.Dispose(); + } + else + Log.LogDebug("Received cancellation message for non-existent (or already-completed) request "); + } + else + { + Log.LogWarning("Received invalid request cancellation message {MessageId} (missing 'id' parameter).", requestMessage.Id); + + _outgoing.TryAdd( + new JsonRpcMessages.InvalidParams(requestMessage.Id) + ); + } + } + + /// + /// Dispatch a notification. + /// + /// + /// The notification message. + /// + void DispatchNotification(ServerMessage notificationMessage) + { + if (notificationMessage == null) + throw new ArgumentNullException(nameof(notificationMessage)); + + Log.LogDebug("Dispatching incoming {NotificationMethod} notification...", notificationMessage.Method); + + Task handlerTask; + if (notificationMessage.Params != null) + handlerTask = _dispatcher.TryHandleNotification(notificationMessage.Method, notificationMessage.Params); + else + handlerTask = _dispatcher.TryHandleEmptyNotification(notificationMessage.Method); + +#pragma warning disable CS4014 // Continuation does the work we need; no need to await it as this would tie up the dispatch loop. + handlerTask.ContinueWith(completedHandler => + { + if (handlerTask.IsCanceled) + Log.LogDebug("{NotificationMethod} notification canceled.", notificationMessage.Method); + else if (handlerTask.IsFaulted) + { + Exception handlerError = handlerTask.Exception.Flatten().InnerExceptions[0]; + + Log.LogError(handlerError, "Failed to dispatch {NotificationMethod} notification (unexpected error raised by handler).", notificationMessage.Method); + } + else if (handlerTask.IsCompleted) + { + Log.LogDebug("{NotificationMethod} notification complete.", notificationMessage.Method); + + if (completedHandler.Result) + Log.LogDebug("Dispatched incoming {NotificationMethod} notification.", notificationMessage.Method); + else + Log.LogDebug("Ignored incoming {NotificationMethod} notification (no handler registered).", notificationMessage.Method); + } + }); +#pragma warning restore CS4014 // Continuation does the work we need; no need to await it as this would tie up the dispatch loop. + } + + /// + /// Create an to represent the specified message. + /// + /// + /// The ( must be populated). + /// + /// + /// The new . + /// + static LspException CreateLspException(ServerMessage message) + { + if (message == null) + throw new ArgumentNullException(nameof(message)); + + Trace.Assert(message.Error != null, "message.Error != null"); + + string requestId = message.Id?.ToString(); + + switch (message.Error.Code) + { + case LspErrorCodes.InvalidRequest: + { + return new LspInvalidRequestException(requestId); + } + case LspErrorCodes.InvalidParameters: + { + return new LspInvalidParametersException(requestId); + } + case LspErrorCodes.InternalError: + { + return new LspInternalErrorException(requestId); + } + case LspErrorCodes.MethodNotSupported: + { + return new LspMethodNotSupportedException(requestId, message.Method); + } + case LspErrorCodes.RequestCancelled: + { + return new LspRequestCancelledException(requestId); + } + default: + { + string exceptionMessage = $"Error processing request '{message.Id}' ({message.Error.Code}): {message.Error.Message}"; + + return new LspRequestException(exceptionMessage, requestId, message.Error.Code); + } + } + } + } +} diff --git a/src/Client/Protocol/LspConnectionExtensions.cs b/src/Client/Protocol/LspConnectionExtensions.cs new file mode 100644 index 000000000..b97192cae --- /dev/null +++ b/src/Client/Protocol/LspConnectionExtensions.cs @@ -0,0 +1,147 @@ +using OmniSharp.Extensions.LanguageServerProtocol.Client.Handlers; +using System; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Protocol +{ + /// + /// Extension methods for enabling various styles of handler registration. + /// + public static class LspConnectionExtensions + { + /// + /// Register a handler for empty notifications. + /// + /// + /// The . + /// + /// + /// The name of the notification method to handle. + /// + /// + /// A delegate that implements the handler. + /// + /// + /// An representing the registration. + /// + public static IDisposable HandleEmptyNotification(this LspConnection clientConnection, string method, NotificationHandler handler) + { + if (clientConnection == null) + throw new ArgumentNullException(nameof(clientConnection)); + + if (String.IsNullOrWhiteSpace(method)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(method)}.", nameof(method)); + + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + return clientConnection.RegisterHandler( + new DelegateEmptyNotificationHandler(method, handler) + ); + } + + /// + /// Register a handler for notifications. + /// + /// + /// The notification message type. + /// + /// + /// The . + /// + /// + /// The name of the notification method to handle. + /// + /// + /// A delegate that implements the handler. + /// + /// + /// An representing the registration. + /// + public static IDisposable HandleNotification(this LspConnection clientConnection, string method, NotificationHandler handler) + { + if (clientConnection == null) + throw new ArgumentNullException(nameof(clientConnection)); + + if (String.IsNullOrWhiteSpace(method)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(method)}.", nameof(method)); + + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + return clientConnection.RegisterHandler( + new DelegateNotificationHandler(method, handler) + ); + } + + /// + /// Register a handler for requests. + /// + /// + /// The request message type. + /// + /// + /// The . + /// + /// + /// The name of the request method to handle. + /// + /// + /// A delegate that implements the handler. + /// + /// + /// An representing the registration. + /// + public static IDisposable HandleRequest(this LspConnection clientConnection, string method, RequestHandler handler) + { + if (clientConnection == null) + throw new ArgumentNullException(nameof(clientConnection)); + + if (String.IsNullOrWhiteSpace(method)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(method)}.", nameof(method)); + + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + return clientConnection.RegisterHandler( + new DelegateRequestHandler(method, handler) + ); + } + + /// + /// Register a handler for requests. + /// + /// + /// The request message type. + /// + /// + /// The response message type. + /// + /// + /// The . + /// + /// + /// The name of the request method to handle. + /// + /// + /// A delegate that implements the handler. + /// + /// + /// An representing the registration. + /// + public static IDisposable HandleRequest(this LspConnection clientConnection, string method, RequestHandler handler) + { + if (clientConnection == null) + throw new ArgumentNullException(nameof(clientConnection)); + + if (String.IsNullOrWhiteSpace(method)) + throw new ArgumentException($"Argument cannot be null, empty, or entirely composed of whitespace: {nameof(method)}.", nameof(method)); + + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + return clientConnection.RegisterHandler( + new DelegateRequestResponseHandler(method, handler) + ); + } + } +} diff --git a/src/Client/Protocol/ServerMessage.cs b/src/Client/Protocol/ServerMessage.cs new file mode 100644 index 000000000..b79a4098a --- /dev/null +++ b/src/Client/Protocol/ServerMessage.cs @@ -0,0 +1,47 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Protocol +{ + /// + /// The server-side representation of an LSP message. + /// + [JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] + public class ServerMessage + { + /// + /// The JSON-RPC protocol version. + /// + public string ProtocolVersion { get; set; } = "2.0"; + + /// + /// The request / response Id, if the message represents a request or a response. + /// + [JsonProperty(DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate)] + public object Id { get; set; } + + /// + /// The JSON-RPC method name. + /// + public string Method { get; set; } + + /// + /// The request / notification message, if the message represents a request or a notification. + /// + [JsonProperty(DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate)] + public JObject Params { get; set; } + + /// + /// The response message, if the message represents a response. + /// + [JsonProperty(DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate)] + public JObject Result { get; set; } + + /// + /// The response error (if any). + /// + [JsonProperty("error", DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate)] + public ErrorMessage Error { get; set; } + } +} diff --git a/src/Client/Utilities/DocumentUri.cs b/src/Client/Utilities/DocumentUri.cs new file mode 100644 index 000000000..c7e86480d --- /dev/null +++ b/src/Client/Utilities/DocumentUri.cs @@ -0,0 +1,64 @@ +using System; +using System.IO; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Utilities +{ + /// + /// Helper methods for working with LSP document URIs. + /// + public static class DocumentUri + { + /// + /// Get the local file-system path for the specified document URI. + /// + /// + /// The LSP document URI. + /// + /// + /// The file-system path, or null if the URI does not represent a file-system path. + /// + public static string GetFileSystemPath(Uri documentUri) + { + if (documentUri == null) + throw new ArgumentNullException(nameof(documentUri)); + + if (documentUri.Scheme != "file") + return null; + + // The language server protocol represents "C:\Foo\Bar" as "file:///c:/foo/bar". + string fileSystemPath = Uri.UnescapeDataString(documentUri.AbsolutePath); + if (Path.DirectorySeparatorChar == '\\') + { + if (fileSystemPath.StartsWith("/")) + fileSystemPath = fileSystemPath.Substring(1); + + fileSystemPath = fileSystemPath.Replace('/', '\\'); + } + + return fileSystemPath; + } + + /// + /// Convert a file-system path to an LSP document URI. + /// + /// + /// The file-system path. + /// + /// + /// The LSP document URI. + /// + public static Uri FromFileSystemPath(string fileSystemPath) + { + if (String.IsNullOrWhiteSpace(fileSystemPath)) + throw new ArgumentException("Argument cannot be null, empty, or entirely composed of whitespace: 'fileSystemPath'.", nameof(fileSystemPath)); + + if (!Path.IsPathRooted(fileSystemPath)) + throw new ArgumentException($"Path '{fileSystemPath}' is not an absolute path.", nameof(fileSystemPath)); + + if (Path.DirectorySeparatorChar == '\\') + return new Uri("file:///" + fileSystemPath.Replace('\\', '/')); + + return new Uri("file://" + fileSystemPath); + } + } +} diff --git a/test/Client.Tests/Client.Tests.csproj b/test/Client.Tests/Client.Tests.csproj new file mode 100644 index 000000000..5e9d24010 --- /dev/null +++ b/test/Client.Tests/Client.Tests.csproj @@ -0,0 +1,37 @@ + + + + netcoreapp2.0 + + OmniSharp.Extensions.LanguageClient.Tests + OmniSharp.Extensions.LanguageServerProtocol.Client.Tests + + false + + + + 1701;1702;1705;IDE007 + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/Client.Tests/ConnectionTests.cs b/test/Client.Tests/ConnectionTests.cs new file mode 100644 index 000000000..9a13b2673 --- /dev/null +++ b/test/Client.Tests/ConnectionTests.cs @@ -0,0 +1,89 @@ +using Microsoft.Extensions.Logging; +using OmniSharp.Extensions.LanguageServerProtocol.Client.Dispatcher; +using OmniSharp.Extensions.LanguageServerProtocol.Client.Protocol; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Tests +{ + /// + /// Tests for . + /// + public class ConnectionTests + : PipeServerTestBase + { + /// + /// Create a new test suite. + /// + /// + /// Output for the current test. + /// + public ConnectionTests(ITestOutputHelper testOutput) + : base(testOutput) + { + } + + /// + /// Verify that a server can handle an empty notification from a client . + /// + [Fact(DisplayName = "Server connection can handle empty notification from client")] + public async Task Client_HandleEmptyNotification_Success() + { + var testCompletion = new TaskCompletionSource(); + + LspConnection serverConnection = await CreateServerConnection(); + LspConnection clientConnection = await CreateClientConnection(); + + var serverDispatcher = new LspDispatcher(); + serverDispatcher.HandleEmptyNotification("test", () => + { + Log.LogInformation("Got notification."); + + testCompletion.SetResult(null); + }); + serverConnection.Connect(serverDispatcher); + + clientConnection.Connect(new LspDispatcher()); + clientConnection.SendEmptyNotification("test"); + + await testCompletion.Task; + + clientConnection.Disconnect(flushOutgoing: true); + serverConnection.Disconnect(); + + await Task.WhenAll(clientConnection.HasHasDisconnected, serverConnection.HasHasDisconnected); + } + + /// + /// Verify that a client can handle an empty notification from a server . + /// + [Fact(DisplayName = "Client connection can handle empty notification from server")] + public async Task Server_HandleEmptyNotification_Success() + { + var testCompletion = new TaskCompletionSource(); + + LspConnection clientConnection = await CreateClientConnection(); + LspConnection serverConnection = await CreateServerConnection(); + + var clientDispatcher = new LspDispatcher(); + clientDispatcher.HandleEmptyNotification("test", () => + { + Log.LogInformation("Got notification."); + + testCompletion.SetResult(null); + }); + clientConnection.Connect(clientDispatcher); + + serverConnection.Connect(new LspDispatcher()); + serverConnection.SendEmptyNotification("test"); + + await testCompletion.Task; + + serverConnection.Disconnect(flushOutgoing: true); + clientConnection.Disconnect(); + + await Task.WhenAll(clientConnection.HasHasDisconnected, serverConnection.HasHasDisconnected); + } + } +} diff --git a/test/Client.Tests/Logging/TestOutputLogScope.cs b/test/Client.Tests/Logging/TestOutputLogScope.cs new file mode 100644 index 000000000..3c7e569a1 --- /dev/null +++ b/test/Client.Tests/Logging/TestOutputLogScope.cs @@ -0,0 +1,110 @@ +using System; +using System.Threading; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Tests.Logging +{ + /// + /// Log scope for . + /// + internal class TestOutputLogScope + { + /// + /// Storage for the current . + /// + static readonly AsyncLocal _currentScope = new AsyncLocal(); + + /// + /// The current (if any). + /// + public static TestOutputLogScope Current + { + get => _currentScope.Value; + set => _currentScope.Value = value; + } + + /// + /// The scope name. + /// + readonly string _name; + + /// + /// State associated with the scope. + /// + readonly object _state; + + /// + /// Create a new . + /// + /// + /// The scope name. + /// + /// + /// State associated with the scope. + /// + public TestOutputLogScope(string name, object state) + { + _name = name; + _state = state; + } + + /// + /// The scope's parent scope (if any). + /// + public TestOutputLogScope Parent { get; private set; } + + /// + /// Create a new and make it the current . + /// + /// + /// The scope name. + /// + /// + /// State associated with the scope. + /// + /// + /// An representing the scope. + /// + public static IDisposable Push(string name, object state) + { + TestOutputLogScope parent = Current; + Current = new TestOutputLogScope(name, state) + { + Parent = parent + }; + + return new ScopeDisposal(); + } + + /// + /// Get a string representation of the scope. + /// + /// + /// The scope's string representation. + /// + public override string ToString() => _state?.ToString(); + + /// + /// Wrapper for disposal of log scope. + /// + class ScopeDisposal + : IDisposable + { + /// + /// Has the scope been disposed? + /// + bool _disposed; + + /// + /// Revert to the previous scope (if any). + /// + public void Dispose() + { + if (_disposed) + return; + + Current = Current?.Parent; + _disposed = true; + } + } +} +} diff --git a/test/Client.Tests/Logging/TestOutputLogger.cs b/test/Client.Tests/Logging/TestOutputLogger.cs new file mode 100644 index 000000000..1a37004d8 --- /dev/null +++ b/test/Client.Tests/Logging/TestOutputLogger.cs @@ -0,0 +1,119 @@ +using Microsoft.Extensions.Logging; +using System; +using Xunit.Abstractions; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Tests.Logging +{ + /// + /// A logger that writes to Xunit test output. + /// + public class TestOutputLogger + : ILogger + { + /// + /// The Xunit test output. + /// + readonly ITestOutputHelper _testOutput; + + /// + /// The logger name. + /// + readonly string _name; + + /// + /// The minimum level to log at. + /// + readonly LogLevel _minimumLevel; + + /// + /// Create a new . + /// + /// + /// The Xunit test output. + /// + /// + /// The logger name. + /// + /// + /// The minimum level to log at. + /// + public TestOutputLogger(ITestOutputHelper testOutput, string name, LogLevel minimumLevel) + { + if (testOutput == null) + throw new ArgumentNullException(nameof(testOutput)); + + if (name == null) + throw new ArgumentNullException(nameof(name)); + + _testOutput = testOutput; + + // Trim off namespace, if possible. + string[] nameSegments = name.Split('.'); + _name = nameSegments[nameSegments.Length - 1]; + + _minimumLevel = minimumLevel; + } + + /// + /// Begin a new log scope. + /// + /// + /// The type used as state for the scope. + /// + /// + /// The scope state. + /// + /// + /// An representing the scope. + /// + public IDisposable BeginScope(TState state) => TestOutputLogScope.Push(_name, state); + + /// + /// Determine whether logging is enabled at the specified level. + /// + /// + /// The target log level. + /// + /// + /// true, if logging is enabled; otherwise, false. + /// + public bool IsEnabled(LogLevel logLevel) => logLevel >= _minimumLevel; + + /// + /// Write a log message to the test output. + /// + /// + /// The type used as state for the log entry. + /// + /// + /// The log entry's associated logging level. + /// + /// + /// An identifying the log entry type. + /// + /// + /// The log entry state. + /// + /// + /// The (if any) associated with the log entry. + /// + /// + /// A delegate that formats the log message. + /// + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + { + if (logLevel < _minimumLevel) + return; + + string prefix = !string.IsNullOrWhiteSpace(_name) + ? $"[{_name}/{logLevel}] " + : $"[{logLevel}] "; + + string message = prefix + formatter(state, exception); + if (exception != null) + message += "\n" + exception.ToString(); + + _testOutput.WriteLine(message); + } + } +} diff --git a/test/Client.Tests/Logging/TestOutputLoggingExtensions.cs b/test/Client.Tests/Logging/TestOutputLoggingExtensions.cs new file mode 100644 index 000000000..bf3ff51cf --- /dev/null +++ b/test/Client.Tests/Logging/TestOutputLoggingExtensions.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.Logging; +using System; +using Xunit.Abstractions; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Tests.Logging +{ + /// + /// Extension methods for configuring logging to Xunit test output. + /// + public static class TestOutputLoggingExtensions + { + /// + /// Write log events to Xunit test output. + /// + /// + /// The logger factory to configure. + /// + /// + /// The test output to which events will be logged. + /// + /// + /// The minimum level to log at. + /// + public static void AddTestOutput(this ILoggerFactory loggerFactory, ITestOutputHelper testOutput, LogLevel minimumLevel = LogLevel.Information) + { + if (loggerFactory == null) + throw new ArgumentNullException(nameof(loggerFactory)); + + if (testOutput == null) + throw new ArgumentNullException(nameof(testOutput)); + + loggerFactory.AddProvider( + new TestOutputLoggingProvider(testOutput, minimumLevel) + ); + } + } +} diff --git a/test/Client.Tests/Logging/TestOutputLoggingProvider.cs b/test/Client.Tests/Logging/TestOutputLoggingProvider.cs new file mode 100644 index 000000000..ee107345e --- /dev/null +++ b/test/Client.Tests/Logging/TestOutputLoggingProvider.cs @@ -0,0 +1,74 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Linq; +using Xunit.Abstractions; +using System.Collections.Concurrent; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Tests.Logging +{ + /// + /// A provider for loggers that send log events to Xunit test output. + /// + public sealed class TestOutputLoggingProvider + : ILoggerProvider + { + /// + /// Loggers created by the provider. + /// + static readonly ConcurrentDictionary _loggers = new ConcurrentDictionary(); + + /// + /// The test output to which events will be logged. + /// + readonly ITestOutputHelper _testOutput; + + /// + /// The minimum level to log at. + /// + readonly LogLevel _minimumLevel; + + /// + /// Create a new test output event sink. + /// + /// + /// The test output to which events will be logged. + /// + /// + /// The minimum level to log at. + /// + public TestOutputLoggingProvider(ITestOutputHelper testOutput, LogLevel minimumLevel) + { + if (testOutput == null) + throw new ArgumentNullException(nameof(testOutput)); + + _testOutput = testOutput; + _minimumLevel = minimumLevel; + } + + /// + /// Create a new logger. + /// + /// + /// The logger name. + /// + /// + /// The logger. + /// + public ILogger CreateLogger(string name) + { + if (name == null) + name = string.Empty; + + return _loggers.GetOrAdd(name, + new TestOutputLogger(_testOutput, name, _minimumLevel) + ); + } + + /// + /// Dispose of resources being used by the logger provider and its loggers. + /// + public void Dispose() + { + } + } +} diff --git a/test/Client.Tests/PipeServerTestBase.cs b/test/Client.Tests/PipeServerTestBase.cs new file mode 100644 index 000000000..d0fa401e2 --- /dev/null +++ b/test/Client.Tests/PipeServerTestBase.cs @@ -0,0 +1,141 @@ +using OmniSharp.Extensions.LanguageServerProtocol.Client.Dispatcher; +using OmniSharp.Extensions.LanguageServerProtocol.Client.Protocol; +using OmniSharp.Extensions.LanguageServerProtocol.Client.Processes; +using System; +using System.IO; +using System.Threading.Tasks; +using Xunit.Abstractions; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Tests +{ + /// + /// The base class for test suites that use a . + /// + public abstract class PipeServerTestBase + : TestBase + { + /// + /// The used to connect client and server streams. + /// + readonly NamedPipeServerProcess _serverProcess; + + /// + /// Create a new . + /// + /// + /// Output for the current test. + /// + protected PipeServerTestBase(ITestOutputHelper testOutput) + : base(testOutput) + { + _serverProcess = new NamedPipeServerProcess(Guid.NewGuid().ToString("N"), LoggerFactory); + Disposal.Add(_serverProcess); + } + + /// + /// The workspace root path. + /// + protected virtual string WorkspaceRoot => Path.GetDirectoryName(GetType().Assembly.Location); + + /// + /// The client's output stream (server reads from this). + /// + protected Stream ClientOutputStream => _serverProcess.ClientOutputStream; + + /// + /// The client's input stream (server writes to this). + /// + protected Stream ClientInputStream => _serverProcess.ClientInputStream; + + /// + /// The server's output stream (client reads from this). + /// + protected Stream ServerOutputStream => _serverProcess.ServerOutputStream; + + /// + /// The server's input stream (client writes to this). + /// + protected Stream ServerInputStream => _serverProcess.ServerInputStream; + + /// + /// Create a connected to the test's . + /// + /// + /// Automatically initialise the client? + /// + /// Default is true. + /// + /// + /// The . + /// + protected async Task CreateClient(bool initialize = true) + { + if (!_serverProcess.IsRunning) + await StartServer(); + + await _serverProcess.HasStarted; + + LanguageClient client = new LanguageClient(LoggerFactory, _serverProcess); + Disposal.Add(client); + + if (initialize) + await client.Initialize(WorkspaceRoot); + + return client; + } + + /// + /// Create a that uses the client ends of the the test's streams. + /// + /// + /// The . + /// + protected async Task CreateClientConnection() + { + if (!_serverProcess.IsRunning) + await StartServer(); + + await _serverProcess.HasStarted; + + LspConnection connection = new LspConnection(LoggerFactory, input: ServerOutputStream, output: ServerInputStream); + Disposal.Add(connection); + + return connection; + } + + /// + /// Create a that uses the server ends of the the test's streams. + /// + /// + /// The . + /// + protected async Task CreateServerConnection() + { + if (!_serverProcess.IsRunning) + await StartServer(); + + await _serverProcess.HasStarted; + + LspConnection connection = new LspConnection(LoggerFactory, input: ClientOutputStream, output: ClientInputStream); + Disposal.Add(connection); + + return connection; + } + + /// + /// Called to start the server process. + /// + /// + /// A representing the operation. + /// + protected virtual Task StartServer() => _serverProcess.Start(); + + /// + /// Called to stop the server process. + /// + /// + /// A representing the operation. + /// + protected virtual Task StopServer() => _serverProcess.Stop(); + } +} diff --git a/test/Client.Tests/PipeTests.cs b/test/Client.Tests/PipeTests.cs new file mode 100644 index 000000000..8559fc06c --- /dev/null +++ b/test/Client.Tests/PipeTests.cs @@ -0,0 +1,18 @@ +using System; +using System.IO.Pipes; +using System.Threading.Tasks; +using Xunit; +using System.Threading; +using Xunit.Abstractions; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Tests +{ + public class PipeTests + : TestBase + { + public PipeTests(ITestOutputHelper testOutput) + : base(testOutput) + { + } + } +} diff --git a/test/Client.Tests/TestBase.cs b/test/Client.Tests/TestBase.cs new file mode 100644 index 000000000..d7c641359 --- /dev/null +++ b/test/Client.Tests/TestBase.cs @@ -0,0 +1,128 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Reactive.Disposables; +using System.Reflection; +using System.Threading; +using Xunit; +using Xunit.Abstractions; + +namespace OmniSharp.Extensions.LanguageServerProtocol.Client.Tests +{ + using Logging; + + /// + /// The base class for test suites. + /// + public abstract class TestBase + : IDisposable + { + /// + /// Create a new test-suite. + /// + /// + /// Output for the current test. + /// + protected TestBase(ITestOutputHelper testOutput) + { + if (testOutput == null) + throw new ArgumentNullException(nameof(testOutput)); + + // We *must* have a synchronisation context for the test, or we'll see random deadlocks. + SynchronizationContext.SetSynchronizationContext( + new SynchronizationContext() + ); + + TestOutput = testOutput; + + // Redirect component logging to Serilog. + LoggerFactory = new LoggerFactory(); + Disposal.Add(LoggerFactory); + + LoggerFactory.AddDebug(LogLevel); + LoggerFactory.AddTestOutput(TestOutput, LogLevel); + + // Ugly hack to get access to the current test. + CurrentTest = (ITest) + TestOutput.GetType() + .GetField("test", BindingFlags.NonPublic | BindingFlags.Instance) + .GetValue(TestOutput); + + Assert.True(CurrentTest != null, "Cannot retrieve current test from ITestOutputHelper."); + + Log = LoggerFactory.CreateLogger("CurrentTest"); + + Disposal.Add( + Log.BeginScope("TestDisplayName='{TestName}'", CurrentTest.DisplayName) + ); + } + + /// + /// Finaliser for . + /// + ~TestBase() + { + Dispose(false); + } + + /// + /// Dispose of resources being used by the test suite. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Dispose of resources being used by the test suite. + /// + /// + /// Explicit disposal? + /// + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + try + { + Disposal.Dispose(); + } + finally + { + if (Log is IDisposable logDisposal) + logDisposal.Dispose(); + } + } + } + + /// + /// A representing resources used by the test. + /// + protected CompositeDisposable Disposal { get; } = new CompositeDisposable(); + + /// + /// Output for the current test. + /// + protected ITestOutputHelper TestOutput { get; } + + /// + /// A representing the current test. + /// + protected ITest CurrentTest { get; } + + /// + /// The Serilog logger for the current test. + /// + protected ILoggerFactory LoggerFactory { get; } + + /// + /// The Serilog logger for the current test. + /// + protected ILogger Log { get; } + + /// + /// The logging level for the current test. + /// + protected virtual LogLevel LogLevel => LogLevel.Information; + } +}