diff --git a/global.json b/global.json new file mode 100644 index 00000000..db6ba19b --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "projects": [ "src", "test" ] +} + + diff --git a/src/ServiceHost/LanguageServer/ClientCapabilities.cs b/src/ServiceHost/LanguageServer/ClientCapabilities.cs new file mode 100644 index 00000000..70e2d068 --- /dev/null +++ b/src/ServiceHost/LanguageServer/ClientCapabilities.cs @@ -0,0 +1,18 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + + +namespace Microsoft.SqlTools.EditorServices.Protocol.LanguageServer +{ + /// + /// Defines a class that describes the capabilities of a language + /// client. At this time no specific capabilities are listed for + /// clients. + /// + public class ClientCapabilities + { + } +} + diff --git a/src/ServiceHost/LanguageServer/Completion.cs b/src/ServiceHost/LanguageServer/Completion.cs new file mode 100644 index 00000000..5f26ea96 --- /dev/null +++ b/src/ServiceHost/LanguageServer/Completion.cs @@ -0,0 +1,85 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Diagnostics; +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol; + +namespace Microsoft.SqlTools.EditorServices.Protocol.LanguageServer +{ + public class CompletionRequest + { + public static readonly + RequestType Type = + RequestType.Create("textDocument/completion"); + } + + public class CompletionResolveRequest + { + public static readonly + RequestType Type = + RequestType.Create("completionItem/resolve"); + } + + public enum CompletionItemKind + { + Text = 1, + Method = 2, + Function = 3, + Constructor = 4, + Field = 5, + Variable = 6, + Class = 7, + Interface = 8, + Module = 9, + Property = 10, + Unit = 11, + Value = 12, + Enum = 13, + Keyword = 14, + Snippet = 15, + Color = 16, + File = 17, + Reference = 18 + } + + [DebuggerDisplay("NewText = {NewText}, Range = {Range.Start.Line}:{Range.Start.Character} - {Range.End.Line}:{Range.End.Character}")] + public class TextEdit + { + public Range Range { get; set; } + + public string NewText { get; set; } + } + + [DebuggerDisplay("Kind = {Kind.ToString()}, Label = {Label}, Detail = {Detail}")] + public class CompletionItem + { + public string Label { get; set; } + + public CompletionItemKind? Kind { get; set; } + + public string Detail { get; set; } + + /// + /// Gets or sets the documentation string for the completion item. + /// + public string Documentation { get; set; } + + public string SortText { get; set; } + + public string FilterText { get; set; } + + public string InsertText { get; set; } + + public TextEdit TextEdit { get; set; } + + /// + /// Gets or sets a custom data field that allows the server to mark + /// each completion item with an identifier that will help correlate + /// the item to the previous completion request during a completion + /// resolve request. + /// + public object Data { get; set; } + } +} diff --git a/src/ServiceHost/LanguageServer/Configuration.cs b/src/ServiceHost/LanguageServer/Configuration.cs new file mode 100644 index 00000000..b9ad87db --- /dev/null +++ b/src/ServiceHost/LanguageServer/Configuration.cs @@ -0,0 +1,21 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol; + +namespace Microsoft.SqlTools.EditorServices.Protocol.LanguageServer +{ + public class DidChangeConfigurationNotification + { + public static readonly + EventType> Type = + EventType>.Create("workspace/didChangeConfiguration"); + } + + public class DidChangeConfigurationParams + { + public TConfig Settings { get; set; } + } +} diff --git a/src/ServiceHost/LanguageServer/Definition.cs b/src/ServiceHost/LanguageServer/Definition.cs new file mode 100644 index 00000000..b18845c3 --- /dev/null +++ b/src/ServiceHost/LanguageServer/Definition.cs @@ -0,0 +1,17 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol; + +namespace Microsoft.SqlTools.EditorServices.Protocol.LanguageServer +{ + public class DefinitionRequest + { + public static readonly + RequestType Type = + RequestType.Create("textDocument/definition"); + } +} + diff --git a/src/ServiceHost/LanguageServer/Diagnostics.cs b/src/ServiceHost/LanguageServer/Diagnostics.cs new file mode 100644 index 00000000..a5472607 --- /dev/null +++ b/src/ServiceHost/LanguageServer/Diagnostics.cs @@ -0,0 +1,71 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol; + +namespace Microsoft.SqlTools.EditorServices.Protocol.LanguageServer +{ + public class PublishDiagnosticsNotification + { + public static readonly + EventType Type = + EventType.Create("textDocument/publishDiagnostics"); + + /// + /// Gets or sets the URI for which diagnostic information is reported. + /// + public string Uri { get; set; } + + /// + /// Gets or sets the array of diagnostic information items. + /// + public Diagnostic[] Diagnostics { get; set; } + } + + public enum DiagnosticSeverity + { + /// + /// Indicates that the diagnostic represents an error. + /// + Error = 1, + + /// + /// Indicates that the diagnostic represents a warning. + /// + Warning = 2, + + /// + /// Indicates that the diagnostic represents an informational message. + /// + Information = 3, + + /// + /// Indicates that the diagnostic represents a hint. + /// + Hint = 4 + } + + public class Diagnostic + { + public Range Range { get; set; } + + /// + /// Gets or sets the severity of the diagnostic. If omitted, the + /// client should interpret the severity. + /// + public DiagnosticSeverity? Severity { get; set; } + + /// + /// Gets or sets the diagnostic's code (optional). + /// + public string Code { get; set; } + + /// + /// Gets or sets the diagnostic message. + /// + public string Message { get; set; } + } +} + diff --git a/src/ServiceHost/LanguageServer/DocumentHighlight.cs b/src/ServiceHost/LanguageServer/DocumentHighlight.cs new file mode 100644 index 00000000..6849ddfb --- /dev/null +++ b/src/ServiceHost/LanguageServer/DocumentHighlight.cs @@ -0,0 +1,31 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol; + +namespace Microsoft.SqlTools.EditorServices.Protocol.LanguageServer +{ + public enum DocumentHighlightKind + { + Text = 1, + Read = 2, + Write = 3 + } + + public class DocumentHighlight + { + public Range Range { get; set; } + + public DocumentHighlightKind Kind { get; set; } + } + + public class DocumentHighlightRequest + { + public static readonly + RequestType Type = + RequestType.Create("textDocument/documentHighlight"); + } +} + diff --git a/src/ServiceHost/LanguageServer/ExpandAliasRequest.cs b/src/ServiceHost/LanguageServer/ExpandAliasRequest.cs new file mode 100644 index 00000000..d7f9fde4 --- /dev/null +++ b/src/ServiceHost/LanguageServer/ExpandAliasRequest.cs @@ -0,0 +1,16 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol; + +namespace Microsoft.SqlTools.EditorServices.Protocol.LanguageServer +{ + public class ExpandAliasRequest + { + public static readonly + RequestType Type = + RequestType.Create("SqlTools/expandAlias"); + } +} diff --git a/src/ServiceHost/LanguageServer/FindModuleRequest.cs b/src/ServiceHost/LanguageServer/FindModuleRequest.cs new file mode 100644 index 00000000..ab78a158 --- /dev/null +++ b/src/ServiceHost/LanguageServer/FindModuleRequest.cs @@ -0,0 +1,24 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol; +using System.Collections.Generic; + +namespace Microsoft.SqlTools.EditorServices.Protocol.LanguageServer +{ + public class FindModuleRequest + { + public static readonly + RequestType, object> Type = + RequestType, object>.Create("SqlTools/findModule"); + } + + + public class PSModuleMessage + { + public string Name { get; set; } + public string Description { get; set; } + } +} diff --git a/src/ServiceHost/LanguageServer/Hover.cs b/src/ServiceHost/LanguageServer/Hover.cs new file mode 100644 index 00000000..2e196fba --- /dev/null +++ b/src/ServiceHost/LanguageServer/Hover.cs @@ -0,0 +1,32 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol; + +namespace Microsoft.SqlTools.EditorServices.Protocol.LanguageServer +{ + public class MarkedString + { + public string Language { get; set; } + + public string Value { get; set; } + } + + public class Hover + { + public MarkedString[] Contents { get; set; } + + public Range? Range { get; set; } + } + + public class HoverRequest + { + public static readonly + RequestType Type = + RequestType.Create("textDocument/hover"); + + } +} + diff --git a/src/ServiceHost/LanguageServer/Initialize.cs b/src/ServiceHost/LanguageServer/Initialize.cs new file mode 100644 index 00000000..7551835e --- /dev/null +++ b/src/ServiceHost/LanguageServer/Initialize.cs @@ -0,0 +1,46 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol; + +namespace Microsoft.SqlTools.EditorServices.Protocol.LanguageServer +{ + public class InitializeRequest + { + public static readonly + RequestType Type = + RequestType.Create("initialize"); + + /// + /// Gets or sets the root path of the editor's open workspace. + /// If null it is assumed that a file was opened without having + /// a workspace open. + /// + public string RootPath { get; set; } + + /// + /// Gets or sets the capabilities provided by the client (editor). + /// + public ClientCapabilities Capabilities { get; set; } + } + + public class InitializeResult + { + /// + /// Gets or sets the capabilities provided by the language server. + /// + public ServerCapabilities Capabilities { get; set; } + } + + public class InitializeError + { + /// + /// Gets or sets a boolean indicating whether the client should retry + /// sending the Initialize request after showing the error to the user. + /// + public bool Retry { get; set;} + } +} + diff --git a/src/ServiceHost/LanguageServer/InstallModuleRequest.cs b/src/ServiceHost/LanguageServer/InstallModuleRequest.cs new file mode 100644 index 00000000..b03b8864 --- /dev/null +++ b/src/ServiceHost/LanguageServer/InstallModuleRequest.cs @@ -0,0 +1,16 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol; + +namespace Microsoft.SqlTools.EditorServices.Protocol.LanguageServer +{ + class InstallModuleRequest + { + public static readonly + RequestType Type = + RequestType.Create("SqlTools/installModule"); + } +} diff --git a/src/ServiceHost/LanguageServer/References.cs b/src/ServiceHost/LanguageServer/References.cs new file mode 100644 index 00000000..25a92b12 --- /dev/null +++ b/src/ServiceHost/LanguageServer/References.cs @@ -0,0 +1,27 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol; + +namespace Microsoft.SqlTools.EditorServices.Protocol.LanguageServer +{ + public class ReferencesRequest + { + public static readonly + RequestType Type = + RequestType.Create("textDocument/references"); + } + + public class ReferencesParams : TextDocumentPosition + { + public ReferencesContext Context { get; set; } + } + + public class ReferencesContext + { + public bool IncludeDeclaration { get; set; } + } +} + diff --git a/src/ServiceHost/LanguageServer/ServerCapabilities.cs b/src/ServiceHost/LanguageServer/ServerCapabilities.cs new file mode 100644 index 00000000..2f7404d9 --- /dev/null +++ b/src/ServiceHost/LanguageServer/ServerCapabilities.cs @@ -0,0 +1,63 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.SqlTools.EditorServices.Protocol.LanguageServer +{ + public class ServerCapabilities + { + public TextDocumentSyncKind? TextDocumentSync { get; set; } + + public bool? HoverProvider { get; set; } + + public CompletionOptions CompletionProvider { get; set; } + + public SignatureHelpOptions SignatureHelpProvider { get; set; } + + public bool? DefinitionProvider { get; set; } + + public bool? ReferencesProvider { get; set; } + + public bool? DocumentHighlightProvider { get; set; } + + public bool? DocumentSymbolProvider { get; set; } + + public bool? WorkspaceSymbolProvider { get; set; } + } + + /// + /// Defines the document synchronization strategies that a server may support. + /// + public enum TextDocumentSyncKind + { + /// + /// Indicates that documents should not be synced at all. + /// + None = 0, + + /// + /// Indicates that document changes are always sent with the full content. + /// + Full, + + /// + /// Indicates that document changes are sent as incremental changes after + /// the initial document content has been sent. + /// + Incremental + } + + public class CompletionOptions + { + public bool? ResolveProvider { get; set; } + + public string[] TriggerCharacters { get; set; } + } + + public class SignatureHelpOptions + { + public string[] TriggerCharacters { get; set; } + } +} + diff --git a/src/ServiceHost/LanguageServer/ShowOnlineHelpRequest.cs b/src/ServiceHost/LanguageServer/ShowOnlineHelpRequest.cs new file mode 100644 index 00000000..8f21fb1b --- /dev/null +++ b/src/ServiceHost/LanguageServer/ShowOnlineHelpRequest.cs @@ -0,0 +1,16 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol; + +namespace Microsoft.SqlTools.EditorServices.Protocol.LanguageServer +{ + public class ShowOnlineHelpRequest + { + public static readonly + RequestType Type = + RequestType.Create("SqlTools/showOnlineHelp"); + } +} diff --git a/src/ServiceHost/LanguageServer/Shutdown.cs b/src/ServiceHost/LanguageServer/Shutdown.cs new file mode 100644 index 00000000..f0a7bbd2 --- /dev/null +++ b/src/ServiceHost/LanguageServer/Shutdown.cs @@ -0,0 +1,32 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol; + +namespace Microsoft.SqlTools.EditorServices.Protocol.LanguageServer +{ + /// + /// Defines a message that is sent from the client to request + /// that the server shut down. + /// + public class ShutdownRequest + { + public static readonly + RequestType Type = + RequestType.Create("shutdown"); + } + + /// + /// Defines an event that is sent from the client to notify that + /// the client is exiting and the server should as well. + /// + public class ExitNotification + { + public static readonly + EventType Type = + EventType.Create("exit"); + } +} + diff --git a/src/ServiceHost/LanguageServer/SignatureHelp.cs b/src/ServiceHost/LanguageServer/SignatureHelp.cs new file mode 100644 index 00000000..5d4233e3 --- /dev/null +++ b/src/ServiceHost/LanguageServer/SignatureHelp.cs @@ -0,0 +1,42 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol; + +namespace Microsoft.SqlTools.EditorServices.Protocol.LanguageServer +{ + public class SignatureHelpRequest + { + public static readonly + RequestType Type = + RequestType.Create("textDocument/signatureHelp"); + } + + public class ParameterInformation + { + public string Label { get; set; } + + public string Documentation { get; set; } + } + + public class SignatureInformation + { + public string Label { get; set; } + + public string Documentation { get; set; } + + public ParameterInformation[] Parameters { get; set; } + } + + public class SignatureHelp + { + public SignatureInformation[] Signatures { get; set; } + + public int? ActiveSignature { get; set; } + + public int? ActiveParameter { get; set; } + } +} + diff --git a/src/ServiceHost/LanguageServer/TextDocument.cs b/src/ServiceHost/LanguageServer/TextDocument.cs new file mode 100644 index 00000000..9f477374 --- /dev/null +++ b/src/ServiceHost/LanguageServer/TextDocument.cs @@ -0,0 +1,164 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Diagnostics; +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol; + +namespace Microsoft.SqlTools.EditorServices.Protocol.LanguageServer +{ + /// + /// Defines a base parameter class for identifying a text document. + /// + [DebuggerDisplay("TextDocumentIdentifier = {Uri}")] + public class TextDocumentIdentifier + { + /// + /// Gets or sets the URI which identifies the path of the + /// text document. + /// + public string Uri { get; set; } + } + + /// + /// Defines a position in a text document. + /// + [DebuggerDisplay("TextDocumentPosition = {Position.Line}:{Position.Character}")] + public class TextDocumentPosition : TextDocumentIdentifier + { + /// + /// Gets or sets the position in the document. + /// + public Position Position { get; set; } + } + + public class DidOpenTextDocumentNotification : TextDocumentIdentifier + { + public static readonly + EventType Type = + EventType.Create("textDocument/didOpen"); + + /// + /// Gets or sets the full content of the opened document. + /// + public string Text { get; set; } + } + + public class DidCloseTextDocumentNotification + { + public static readonly + EventType Type = + EventType.Create("textDocument/didClose"); + } + + public class DidChangeTextDocumentNotification + { + public static readonly + EventType Type = + EventType.Create("textDocument/didChange"); + } + + public class DidChangeTextDocumentParams : TextDocumentIdentifier + { + public TextDocumentUriChangeEvent TextDocument { get; set; } + + /// + /// Gets or sets the list of changes to the document content. + /// + public TextDocumentChangeEvent[] ContentChanges { get; set; } + } + + public class TextDocumentUriChangeEvent + { + /// + /// Gets or sets the Uri of the changed text document + /// + public string Uri { get; set; } + + /// + /// Gets or sets the Version of the changed text document + /// + public int Version { get; set; } + } + + public class TextDocumentChangeEvent + { + /// + /// Gets or sets the Range where the document was changed. Will + /// be null if the server's TextDocumentSyncKind is Full. + /// + public Range? Range { get; set; } + + /// + /// Gets or sets the length of the Range being replaced in the + /// document. Will be null if the server's TextDocumentSyncKind is + /// Full. + /// + public int? RangeLength { get; set; } + + /// + /// Gets or sets the new text of the document. + /// + public string Text { get; set; } + } + + [DebuggerDisplay("Position = {Line}:{Character}")] + public class Position + { + /// + /// Gets or sets the zero-based line number. + /// + public int Line { get; set; } + + /// + /// Gets or sets the zero-based column number. + /// + public int Character { get; set; } + } + + [DebuggerDisplay("Start = {Start.Line}:{Start.Character}, End = {End.Line}:{End.Character}")] + public struct Range + { + /// + /// Gets or sets the starting position of the range. + /// + public Position Start { get; set; } + + /// + /// Gets or sets the ending position of the range. + /// + public Position End { get; set; } + } + + [DebuggerDisplay("Range = {Range.Start.Line}:{Range.Start.Character} - {Range.End.Line}:{Range.End.Character}, Uri = {Uri}")] + public class Location + { + /// + /// Gets or sets the URI indicating the file in which the location refers. + /// + public string Uri { get; set; } + + /// + /// Gets or sets the Range indicating the range in which location refers. + /// + public Range Range { get; set; } + } + + public enum FileChangeType + { + Created = 1, + + Changed, + + Deleted + } + + public class FileEvent + { + public string Uri { get; set; } + + public FileChangeType Type { get; set; } + } +} + diff --git a/src/ServiceHost/LanguageServer/WorkspaceSymbols.cs b/src/ServiceHost/LanguageServer/WorkspaceSymbols.cs new file mode 100644 index 00000000..25a554b5 --- /dev/null +++ b/src/ServiceHost/LanguageServer/WorkspaceSymbols.cs @@ -0,0 +1,62 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol; + +namespace Microsoft.SqlTools.EditorServices.Protocol.LanguageServer +{ + public enum SymbolKind + { + File = 1, + Module = 2, + Namespace = 3, + Package = 4, + Class = 5, + Method = 6, + Property = 7, + Field = 8, + Constructor = 9, + Enum = 10, + Interface = 11, + Function = 12, + Variable = 13, + Constant = 14, + String = 15, + Number = 16, + Boolean = 17, + Array = 18, + } + + public class SymbolInformation + { + public string Name { get; set; } + + public SymbolKind Kind { get; set; } + + public Location Location { get; set; } + + public string ContainerName { get; set;} + } + + public class DocumentSymbolRequest + { + public static readonly + RequestType Type = + RequestType.Create("textDocument/documentSymbol"); + } + + public class WorkspaceSymbolRequest + { + public static readonly + RequestType Type = + RequestType.Create("workspace/symbol"); + } + + public class WorkspaceSymbolParams + { + public string Query { get; set;} + } +} + diff --git a/src/ServiceHost/LanguageSupport/LanguageService.cs b/src/ServiceHost/LanguageSupport/LanguageService.cs new file mode 100644 index 00000000..3ab77697 --- /dev/null +++ b/src/ServiceHost/LanguageSupport/LanguageService.cs @@ -0,0 +1,57 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.EditorServices; +using Microsoft.SqlTools.EditorServices.Session; + +namespace Microsoft.SqlTools.LanguageSupport +{ + /// + /// Main class for Language Service functionality + /// + public class LanguageService + { + /// + /// Gets or sets the current SQL Tools context + /// + /// + private SqlToolsContext Context { get; set; } + + /// + /// Constructor for the Language Service class + /// + /// + public LanguageService(SqlToolsContext context) + { + this.Context = context; + } + + /// + /// Gets a list of semantic diagnostic marks for the provided script file + /// + /// + public ScriptFileMarker[] GetSemanticMarkers(ScriptFile scriptFile) + { + // the commented out snippet is an example of how to create a error marker + // semanticMarkers = new ScriptFileMarker[1]; + // semanticMarkers[0] = new ScriptFileMarker() + // { + // Message = "Error message", + // Level = ScriptFileMarkerLevel.Error, + // ScriptRegion = new ScriptRegion() + // { + // File = scriptFile.FilePath, + // StartLineNumber = 2, + // StartColumnNumber = 2, + // StartOffset = 0, + // EndLineNumber = 4, + // EndColumnNumber = 10, + // EndOffset = 0 + // } + // }; + return new ScriptFileMarker[0]; + } + } +} diff --git a/src/ServiceHost/MessageProtocol/Channel/ChannelBase.cs b/src/ServiceHost/MessageProtocol/Channel/ChannelBase.cs new file mode 100644 index 00000000..848da39f --- /dev/null +++ b/src/ServiceHost/MessageProtocol/Channel/ChannelBase.cs @@ -0,0 +1,81 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol.Serializers; +using System.Threading.Tasks; + +namespace Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol.Channel +{ + /// + /// Defines a base implementation for servers and their clients over a + /// single kind of communication channel. + /// + public abstract class ChannelBase + { + /// + /// Gets a boolean that is true if the channel is connected or false if not. + /// + public bool IsConnected { get; protected set; } + + /// + /// Gets the MessageReader for reading messages from the channel. + /// + public MessageReader MessageReader { get; protected set; } + + /// + /// Gets the MessageWriter for writing messages to the channel. + /// + public MessageWriter MessageWriter { get; protected set; } + + /// + /// Starts the channel and initializes the MessageDispatcher. + /// + /// The type of message protocol used by the channel. + public void Start(MessageProtocolType messageProtocolType) + { + IMessageSerializer messageSerializer = null; + if (messageProtocolType == MessageProtocolType.LanguageServer) + { + messageSerializer = new JsonRpcMessageSerializer(); + } + else + { + messageSerializer = new V8MessageSerializer(); + } + + this.Initialize(messageSerializer); + } + + /// + /// Returns a Task that allows the consumer of the ChannelBase + /// implementation to wait until a connection has been made to + /// the opposite endpoint whether it's a client or server. + /// + /// A Task to be awaited until a connection is made. + public abstract Task WaitForConnection(); + + /// + /// Stops the channel. + /// + public void Stop() + { + this.Shutdown(); + } + + /// + /// A method to be implemented by subclasses to handle the + /// actual initialization of the channel and the creation and + /// assignment of the MessageReader and MessageWriter properties. + /// + /// The IMessageSerializer to use for message serialization. + protected abstract void Initialize(IMessageSerializer messageSerializer); + + /// + /// A method to be implemented by subclasses to handle shutdown + /// of the channel once Stop is called. + /// + protected abstract void Shutdown(); + } +} diff --git a/src/ServiceHost/MessageProtocol/Channel/StdioClientChannel.cs b/src/ServiceHost/MessageProtocol/Channel/StdioClientChannel.cs new file mode 100644 index 00000000..5390f52d --- /dev/null +++ b/src/ServiceHost/MessageProtocol/Channel/StdioClientChannel.cs @@ -0,0 +1,125 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol.Channel +{ + /// + /// Provides a client implementation for the standard I/O channel. + /// Launches the server process and then attaches to its console + /// streams. + /// + public class StdioClientChannel : ChannelBase + { + private string serviceProcessPath; + private string serviceProcessArguments; + + private Stream inputStream; + private Stream outputStream; + private Process serviceProcess; + + /// + /// Gets the process ID of the server process. + /// + public int ProcessId { get; private set; } + + /// + /// Initializes an instance of the StdioClient. + /// + /// The full path to the server process executable. + /// Optional arguments to pass to the service process executable. + public StdioClientChannel( + string serverProcessPath, + params string[] serverProcessArguments) + { + this.serviceProcessPath = serverProcessPath; + + if (serverProcessArguments != null) + { + this.serviceProcessArguments = + string.Join( + " ", + serverProcessArguments); + } + } + + protected override void Initialize(IMessageSerializer messageSerializer) + { + this.serviceProcess = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = this.serviceProcessPath, + Arguments = this.serviceProcessArguments, + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardOutputEncoding = Encoding.UTF8, + }, + EnableRaisingEvents = true, + }; + + // Start the process + this.serviceProcess.Start(); + this.ProcessId = this.serviceProcess.Id; + + // Open the standard input/output streams + this.inputStream = this.serviceProcess.StandardOutput.BaseStream; + this.outputStream = this.serviceProcess.StandardInput.BaseStream; + + // Set up the message reader and writer + this.MessageReader = + new MessageReader( + this.inputStream, + messageSerializer); + + this.MessageWriter = + new MessageWriter( + this.outputStream, + messageSerializer); + + this.IsConnected = true; + } + + public override Task WaitForConnection() + { + // We're always connected immediately in the stdio channel + return Task.FromResult(true); + } + + protected override void Shutdown() + { + if (this.inputStream != null) + { + this.inputStream.Dispose(); + this.inputStream = null; + } + + if (this.outputStream != null) + { + this.outputStream.Dispose(); + this.outputStream = null; + } + + if (this.MessageReader != null) + { + this.MessageReader = null; + } + + if (this.MessageWriter != null) + { + this.MessageWriter = null; + } + + this.serviceProcess.Kill(); + } + } +} diff --git a/src/ServiceHost/MessageProtocol/Channel/StdioServerChannel.cs b/src/ServiceHost/MessageProtocol/Channel/StdioServerChannel.cs new file mode 100644 index 00000000..0b9376d4 --- /dev/null +++ b/src/ServiceHost/MessageProtocol/Channel/StdioServerChannel.cs @@ -0,0 +1,60 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.IO; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol.Channel +{ + /// + /// Provides a server implementation for the standard I/O channel. + /// When started in a process, attaches to the console I/O streams + /// to communicate with the client that launched the process. + /// + public class StdioServerChannel : ChannelBase + { + private Stream inputStream; + private Stream outputStream; + + protected override void Initialize(IMessageSerializer messageSerializer) + { +#if !NanoServer + // Ensure that the console is using UTF-8 encoding + System.Console.InputEncoding = Encoding.UTF8; + System.Console.OutputEncoding = Encoding.UTF8; +#endif + + // Open the standard input/output streams + this.inputStream = System.Console.OpenStandardInput(); + this.outputStream = System.Console.OpenStandardOutput(); + + // Set up the reader and writer + this.MessageReader = + new MessageReader( + this.inputStream, + messageSerializer); + + this.MessageWriter = + new MessageWriter( + this.outputStream, + messageSerializer); + + this.IsConnected = true; + } + + public override Task WaitForConnection() + { + // We're always connected immediately in the stdio channel + return Task.FromResult(true); + } + + protected override void Shutdown() + { + // No default implementation needed, streams will be + // disposed on process shutdown. + } + } +} diff --git a/src/ServiceHost/MessageProtocol/Constants.cs b/src/ServiceHost/MessageProtocol/Constants.cs new file mode 100644 index 00000000..0fae5d8d --- /dev/null +++ b/src/ServiceHost/MessageProtocol/Constants.cs @@ -0,0 +1,25 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol +{ + public static class Constants + { + public const string ContentLengthFormatString = "Content-Length: {0}\r\n\r\n"; + public static readonly JsonSerializerSettings JsonSerializerSettings; + + static Constants() + { + JsonSerializerSettings = new JsonSerializerSettings(); + + // Camel case all object properties + JsonSerializerSettings.ContractResolver = + new CamelCasePropertyNamesContractResolver(); + } + } +} diff --git a/src/ServiceHost/MessageProtocol/EventContext.cs b/src/ServiceHost/MessageProtocol/EventContext.cs new file mode 100644 index 00000000..eb42ebbb --- /dev/null +++ b/src/ServiceHost/MessageProtocol/EventContext.cs @@ -0,0 +1,33 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Threading.Tasks; + +namespace Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol +{ + /// + /// Provides context for a received event so that handlers + /// can write events back to the channel. + /// + public class EventContext + { + private MessageWriter messageWriter; + + public EventContext(MessageWriter messageWriter) + { + this.messageWriter = messageWriter; + } + + public async Task SendEvent( + EventType eventType, + TParams eventParams) + { + await this.messageWriter.WriteEvent( + eventType, + eventParams); + } + } +} + diff --git a/src/ServiceHost/MessageProtocol/EventType.cs b/src/ServiceHost/MessageProtocol/EventType.cs new file mode 100644 index 00000000..dd460817 --- /dev/null +++ b/src/ServiceHost/MessageProtocol/EventType.cs @@ -0,0 +1,33 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol +{ + /// + /// Defines an event type with a particular method name. + /// + /// The parameter type for this event. + public class EventType + { + /// + /// Gets the method name for the event type. + /// + public string MethodName { get; private set; } + + /// + /// Creates an EventType instance with the given parameter type and method name. + /// + /// The method name of the event. + /// A new EventType instance for the defined type. + public static EventType Create(string methodName) + { + return new EventType() + { + MethodName = methodName + }; + } + } +} + diff --git a/src/ServiceHost/MessageProtocol/IMessageSender.cs b/src/ServiceHost/MessageProtocol/IMessageSender.cs new file mode 100644 index 00000000..7f331eed --- /dev/null +++ b/src/ServiceHost/MessageProtocol/IMessageSender.cs @@ -0,0 +1,22 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Threading.Tasks; + +namespace Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol +{ + internal interface IMessageSender + { + Task SendEvent( + EventType eventType, + TParams eventParams); + + Task SendRequest( + RequestType requestType, + TParams requestParams, + bool waitForResponse); + } +} + diff --git a/src/ServiceHost/MessageProtocol/IMessageSerializer.cs b/src/ServiceHost/MessageProtocol/IMessageSerializer.cs new file mode 100644 index 00000000..81b23fa6 --- /dev/null +++ b/src/ServiceHost/MessageProtocol/IMessageSerializer.cs @@ -0,0 +1,30 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Newtonsoft.Json.Linq; + +namespace Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol +{ + /// + /// Defines a common interface for message serializers. + /// + public interface IMessageSerializer + { + /// + /// Serializes a Message to a JObject. + /// + /// The message to be serialized. + /// A JObject which contains the JSON representation of the message. + JObject SerializeMessage(Message message); + + /// + /// Deserializes a JObject to a Messsage. + /// + /// The JObject containing the JSON representation of the message. + /// The Message that was represented by the JObject. + Message DeserializeMessage(JObject messageJson); + } +} + diff --git a/src/ServiceHost/MessageProtocol/Message.cs b/src/ServiceHost/MessageProtocol/Message.cs new file mode 100644 index 00000000..75dab5cd --- /dev/null +++ b/src/ServiceHost/MessageProtocol/Message.cs @@ -0,0 +1,136 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Diagnostics; +using Newtonsoft.Json.Linq; + +namespace Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol +{ + /// + /// Defines all possible message types. + /// + public enum MessageType + { + Unknown, + Request, + Response, + Event + } + + /// + /// Provides common details for protocol messages of any format. + /// + [DebuggerDisplay("MessageType = {MessageType.ToString()}, Method = {Method}, Id = {Id}")] + public class Message + { + /// + /// Gets or sets the message type. + /// + public MessageType MessageType { get; set; } + + /// + /// Gets or sets the message's sequence ID. + /// + public string Id { get; set; } + + /// + /// Gets or sets the message's method/command name. + /// + public string Method { get; set; } + + /// + /// Gets or sets a JToken containing the contents of the message. + /// + public JToken Contents { get; set; } + + /// + /// Gets or sets a JToken containing error details. + /// + public JToken Error { get; set; } + + /// + /// Creates a message with an Unknown type. + /// + /// A message with Unknown type. + public static Message Unknown() + { + return new Message + { + MessageType = MessageType.Unknown + }; + } + + /// + /// Creates a message with a Request type. + /// + /// The sequence ID of the request. + /// The method name of the request. + /// The contents of the request. + /// A message with a Request type. + public static Message Request(string id, string method, JToken contents) + { + return new Message + { + MessageType = MessageType.Request, + Id = id, + Method = method, + Contents = contents + }; + } + + /// + /// Creates a message with a Response type. + /// + /// The sequence ID of the original request. + /// The method name of the original request. + /// The contents of the response. + /// A message with a Response type. + public static Message Response(string id, string method, JToken contents) + { + return new Message + { + MessageType = MessageType.Response, + Id = id, + Method = method, + Contents = contents + }; + } + + /// + /// Creates a message with a Response type and error details. + /// + /// The sequence ID of the original request. + /// The method name of the original request. + /// The error details of the response. + /// A message with a Response type and error details. + public static Message ResponseError(string id, string method, JToken error) + { + return new Message + { + MessageType = MessageType.Response, + Id = id, + Method = method, + Error = error + }; + } + + /// + /// Creates a message with an Event type. + /// + /// The method name of the event. + /// The contents of the event. + /// A message with an Event type. + public static Message Event(string method, JToken contents) + { + return new Message + { + MessageType = MessageType.Event, + Method = method, + Contents = contents + }; + } + } +} + diff --git a/src/ServiceHost/MessageProtocol/MessageDispatcher.cs b/src/ServiceHost/MessageProtocol/MessageDispatcher.cs new file mode 100644 index 00000000..21c179e2 --- /dev/null +++ b/src/ServiceHost/MessageProtocol/MessageDispatcher.cs @@ -0,0 +1,325 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol.Channel; +using Microsoft.SqlTools.EditorServices.Utility; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol +{ + public class MessageDispatcher + { + #region Fields + + private ChannelBase protocolChannel; + + private AsyncContextThread messageLoopThread; + + private Dictionary> requestHandlers = + new Dictionary>(); + + private Dictionary> eventHandlers = + new Dictionary>(); + + private Action responseHandler; + + private CancellationTokenSource messageLoopCancellationToken = + new CancellationTokenSource(); + + #endregion + + #region Properties + + public SynchronizationContext SynchronizationContext { get; private set; } + + public bool InMessageLoopThread + { + get + { + // We're in the same thread as the message loop if the + // current synchronization context equals the one we + // know. + return SynchronizationContext.Current == this.SynchronizationContext; + } + } + + protected MessageReader MessageReader { get; private set; } + + protected MessageWriter MessageWriter { get; private set; } + + + #endregion + + #region Constructors + + public MessageDispatcher(ChannelBase protocolChannel) + { + this.protocolChannel = protocolChannel; + this.MessageReader = protocolChannel.MessageReader; + this.MessageWriter = protocolChannel.MessageWriter; + } + + #endregion + + #region Public Methods + + public void Start() + { + // Start the main message loop thread. The Task is + // not explicitly awaited because it is running on + // an independent background thread. + this.messageLoopThread = new AsyncContextThread("Message Dispatcher"); + this.messageLoopThread + .Run(() => this.ListenForMessages(this.messageLoopCancellationToken.Token)) + .ContinueWith(this.OnListenTaskCompleted); + } + + public void Stop() + { + // Stop the message loop thread + if (this.messageLoopThread != null) + { + this.messageLoopCancellationToken.Cancel(); + this.messageLoopThread.Stop(); + } + } + + public void SetRequestHandler( + RequestType requestType, + Func, Task> requestHandler) + { + this.SetRequestHandler( + requestType, + requestHandler, + false); + } + + public void SetRequestHandler( + RequestType requestType, + Func, Task> requestHandler, + bool overrideExisting) + { + if (overrideExisting) + { + // Remove the existing handler so a new one can be set + this.requestHandlers.Remove(requestType.MethodName); + } + + this.requestHandlers.Add( + requestType.MethodName, + (requestMessage, messageWriter) => + { + var requestContext = + new RequestContext( + requestMessage, + messageWriter); + + TParams typedParams = default(TParams); + if (requestMessage.Contents != null) + { + // TODO: Catch parse errors! + typedParams = requestMessage.Contents.ToObject(); + } + + return requestHandler(typedParams, requestContext); + }); + } + + public void SetEventHandler( + EventType eventType, + Func eventHandler) + { + this.SetEventHandler( + eventType, + eventHandler, + false); + } + + public void SetEventHandler( + EventType eventType, + Func eventHandler, + bool overrideExisting) + { + if (overrideExisting) + { + // Remove the existing handler so a new one can be set + this.eventHandlers.Remove(eventType.MethodName); + } + + this.eventHandlers.Add( + eventType.MethodName, + (eventMessage, messageWriter) => + { + var eventContext = new EventContext(messageWriter); + + TParams typedParams = default(TParams); + if (eventMessage.Contents != null) + { + // TODO: Catch parse errors! + typedParams = eventMessage.Contents.ToObject(); + } + + return eventHandler(typedParams, eventContext); + }); + } + + public void SetResponseHandler(Action responseHandler) + { + this.responseHandler = responseHandler; + } + + #endregion + + #region Events + + public event EventHandler UnhandledException; + + protected void OnUnhandledException(Exception unhandledException) + { + if (this.UnhandledException != null) + { + this.UnhandledException(this, unhandledException); + } + } + + #endregion + + #region Private Methods + + private async Task ListenForMessages(CancellationToken cancellationToken) + { + this.SynchronizationContext = SynchronizationContext.Current; + + // Run the message loop + bool isRunning = true; + while (isRunning && !cancellationToken.IsCancellationRequested) + { + Message newMessage = null; + + try + { + // Read a message from the channel + newMessage = await this.MessageReader.ReadMessage(); + } + catch (MessageParseException e) + { + // TODO: Write an error response + + Logger.Write( + LogLevel.Error, + "Could not parse a message that was received:\r\n\r\n" + + e.ToString()); + + // Continue the loop + continue; + } + catch (EndOfStreamException) + { + // The stream has ended, end the message loop + break; + } + catch (Exception e) + { + var b = e.Message; + newMessage = null; + } + + // The message could be null if there was an error parsing the + // previous message. In this case, do not try to dispatch it. + if (newMessage != null) + { + // Process the message + await this.DispatchMessage( + newMessage, + this.MessageWriter); + } + } + } + + protected async Task DispatchMessage( + Message messageToDispatch, + MessageWriter messageWriter) + { + Task handlerToAwait = null; + + if (messageToDispatch.MessageType == MessageType.Request) + { + Func requestHandler = null; + if (this.requestHandlers.TryGetValue(messageToDispatch.Method, out requestHandler)) + { + handlerToAwait = requestHandler(messageToDispatch, messageWriter); + } + else + { + // TODO: Message not supported error + } + } + else if (messageToDispatch.MessageType == MessageType.Response) + { + if (this.responseHandler != null) + { + this.responseHandler(messageToDispatch); + } + } + else if (messageToDispatch.MessageType == MessageType.Event) + { + Func eventHandler = null; + if (this.eventHandlers.TryGetValue(messageToDispatch.Method, out eventHandler)) + { + handlerToAwait = eventHandler(messageToDispatch, messageWriter); + } + else + { + // TODO: Message not supported error + } + } + else + { + // TODO: Return message not supported + } + + if (handlerToAwait != null) + { + try + { + await handlerToAwait; + } + catch (TaskCanceledException) + { + // Some tasks may be cancelled due to legitimate + // timeouts so don't let those exceptions go higher. + } + catch (AggregateException e) + { + if (!(e.InnerExceptions[0] is TaskCanceledException)) + { + // Cancelled tasks aren't a problem, so rethrow + // anything that isn't a TaskCanceledException + throw e; + } + } + } + } + + private void OnListenTaskCompleted(Task listenTask) + { + if (listenTask.IsFaulted) + { + this.OnUnhandledException(listenTask.Exception); + } + else if (listenTask.IsCompleted || listenTask.IsCanceled) + { + // TODO: Dispose of anything? + } + } + + #endregion + } +} + diff --git a/src/ServiceHost/MessageProtocol/MessageParseException.cs b/src/ServiceHost/MessageProtocol/MessageParseException.cs new file mode 100644 index 00000000..98a17c20 --- /dev/null +++ b/src/ServiceHost/MessageProtocol/MessageParseException.cs @@ -0,0 +1,23 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; + +namespace Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol +{ + public class MessageParseException : Exception + { + public string OriginalMessageText { get; private set; } + + public MessageParseException( + string originalMessageText, + string errorMessage, + params object[] errorMessageArgs) + : base(string.Format(errorMessage, errorMessageArgs)) + { + this.OriginalMessageText = originalMessageText; + } + } +} diff --git a/src/ServiceHost/MessageProtocol/MessageProtocolType.cs b/src/ServiceHost/MessageProtocol/MessageProtocolType.cs new file mode 100644 index 00000000..5484ae3c --- /dev/null +++ b/src/ServiceHost/MessageProtocol/MessageProtocolType.cs @@ -0,0 +1,23 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol +{ + /// + /// Defines the possible message protocol types. + /// + public enum MessageProtocolType + { + /// + /// Identifies the language server message protocol. + /// + LanguageServer, + + /// + /// Identifies the debug adapter message protocol. + /// + DebugAdapter + } +} diff --git a/src/ServiceHost/MessageProtocol/MessageReader.cs b/src/ServiceHost/MessageProtocol/MessageReader.cs new file mode 100644 index 00000000..a2df43ba --- /dev/null +++ b/src/ServiceHost/MessageProtocol/MessageReader.cs @@ -0,0 +1,262 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.EditorServices.Utility; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol +{ + public class MessageReader + { + #region Private Fields + + public const int DefaultBufferSize = 8192; + public const double BufferResizeTrigger = 0.25; + + private const int CR = 0x0D; + private const int LF = 0x0A; + private static string[] NewLineDelimiters = new string[] { Environment.NewLine }; + + private Stream inputStream; + private IMessageSerializer messageSerializer; + private Encoding messageEncoding; + + private ReadState readState; + private bool needsMoreData = true; + private int readOffset; + private int bufferEndOffset; + private byte[] messageBuffer = new byte[DefaultBufferSize]; + + private int expectedContentLength; + private Dictionary messageHeaders; + + enum ReadState + { + Headers, + Content + } + + #endregion + + #region Constructors + + public MessageReader( + Stream inputStream, + IMessageSerializer messageSerializer, + Encoding messageEncoding = null) + { + Validate.IsNotNull("streamReader", inputStream); + Validate.IsNotNull("messageSerializer", messageSerializer); + + this.inputStream = inputStream; + this.messageSerializer = messageSerializer; + + this.messageEncoding = messageEncoding; + if (messageEncoding == null) + { + this.messageEncoding = Encoding.UTF8; + } + + this.messageBuffer = new byte[DefaultBufferSize]; + } + + #endregion + + #region Public Methods + + public async Task ReadMessage() + { + string messageContent = null; + + // Do we need to read more data or can we process the existing buffer? + while (!this.needsMoreData || await this.ReadNextChunk()) + { + // Clear the flag since we should have what we need now + this.needsMoreData = false; + + // Do we need to look for message headers? + if (this.readState == ReadState.Headers && + !this.TryReadMessageHeaders()) + { + // If we don't have enough data to read headers yet, keep reading + this.needsMoreData = true; + continue; + } + + // Do we need to look for message content? + if (this.readState == ReadState.Content && + !this.TryReadMessageContent(out messageContent)) + { + // If we don't have enough data yet to construct the content, keep reading + this.needsMoreData = true; + continue; + } + + // We've read a message now, break out of the loop + break; + } + + // Get the JObject for the JSON content + JObject messageObject = JObject.Parse(messageContent); + + // Load the message + Logger.Write( + LogLevel.Verbose, + string.Format( + "READ MESSAGE:\r\n\r\n{0}", + messageObject.ToString(Formatting.Indented))); + + // Return the parsed message + return this.messageSerializer.DeserializeMessage(messageObject); + } + + #endregion + + #region Private Methods + + private async Task ReadNextChunk() + { + // Do we need to resize the buffer? See if less than 1/4 of the space is left. + if (((double)(this.messageBuffer.Length - this.bufferEndOffset) / this.messageBuffer.Length) < 0.25) + { + // Double the size of the buffer + Array.Resize( + ref this.messageBuffer, + this.messageBuffer.Length * 2); + } + + // Read the next chunk into the message buffer + int readLength = + await this.inputStream.ReadAsync( + this.messageBuffer, + this.bufferEndOffset, + this.messageBuffer.Length - this.bufferEndOffset); + + this.bufferEndOffset += readLength; + + if (readLength == 0) + { + // If ReadAsync returns 0 then it means that the stream was + // closed unexpectedly (usually due to the client application + // ending suddenly). For now, just terminate the language + // server immediately. + // TODO: Provide a more graceful shutdown path + throw new EndOfStreamException( + "MessageReader's input stream ended unexpectedly, terminating."); + } + + return true; + } + + private bool TryReadMessageHeaders() + { + int scanOffset = this.readOffset; + + // Scan for the final double-newline that marks the + // end of the header lines + while (scanOffset + 3 < this.bufferEndOffset && + (this.messageBuffer[scanOffset] != CR || + this.messageBuffer[scanOffset + 1] != LF || + this.messageBuffer[scanOffset + 2] != CR || + this.messageBuffer[scanOffset + 3] != LF)) + { + scanOffset++; + } + + // No header or body separator found (e.g CRLFCRLF) + if (scanOffset + 3 >= this.bufferEndOffset) + { + return false; + } + + this.messageHeaders = new Dictionary(); + + var headers = + Encoding.ASCII + .GetString(this.messageBuffer, this.readOffset, scanOffset) + .Split(NewLineDelimiters, StringSplitOptions.RemoveEmptyEntries); + + // Read each header and store it in the dictionary + foreach (var header in headers) + { + int currentLength = header.IndexOf(':'); + if (currentLength == -1) + { + throw new ArgumentException("Message header must separate key and value using :"); + } + + var key = header.Substring(0, currentLength); + var value = header.Substring(currentLength + 1).Trim(); + this.messageHeaders[key] = value; + } + + // Make sure a Content-Length header was present, otherwise it + // is a fatal error + string contentLengthString = null; + if (!this.messageHeaders.TryGetValue("Content-Length", out contentLengthString)) + { + throw new MessageParseException("", "Fatal error: Content-Length header must be provided."); + } + + // Parse the content length to an integer + if (!int.TryParse(contentLengthString, out this.expectedContentLength)) + { + throw new MessageParseException("", "Fatal error: Content-Length value is not an integer."); + } + + // Skip past the headers plus the newline characters + this.readOffset += scanOffset + 4; + + // Done reading headers, now read content + this.readState = ReadState.Content; + + return true; + } + + private bool TryReadMessageContent(out string messageContent) + { + messageContent = null; + + // Do we have enough bytes to reach the expected length? + if ((this.bufferEndOffset - this.readOffset) < this.expectedContentLength) + { + return false; + } + + // Convert the message contents to a string using the specified encoding + messageContent = + this.messageEncoding.GetString( + this.messageBuffer, + this.readOffset, + this.expectedContentLength); + + // Move the remaining bytes to the front of the buffer for the next message + var remainingByteCount = this.bufferEndOffset - (this.expectedContentLength + this.readOffset); + Buffer.BlockCopy( + this.messageBuffer, + this.expectedContentLength + this.readOffset, + this.messageBuffer, + 0, + remainingByteCount); + + // Reset the offsets for the next read + this.readOffset = 0; + this.bufferEndOffset = remainingByteCount; + + // Done reading content, now look for headers + this.readState = ReadState.Headers; + + return true; + } + + #endregion + } +} diff --git a/src/ServiceHost/MessageProtocol/MessageWriter.cs b/src/ServiceHost/MessageProtocol/MessageWriter.cs new file mode 100644 index 00000000..96e13bcd --- /dev/null +++ b/src/ServiceHost/MessageProtocol/MessageWriter.cs @@ -0,0 +1,140 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.EditorServices.Utility; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol +{ + public class MessageWriter + { + #region Private Fields + + private Stream outputStream; + private IMessageSerializer messageSerializer; + private AsyncLock writeLock = new AsyncLock(); + + private JsonSerializer contentSerializer = + JsonSerializer.Create( + Constants.JsonSerializerSettings); + + #endregion + + #region Constructors + + public MessageWriter( + Stream outputStream, + IMessageSerializer messageSerializer) + { + Validate.IsNotNull("streamWriter", outputStream); + Validate.IsNotNull("messageSerializer", messageSerializer); + + this.outputStream = outputStream; + this.messageSerializer = messageSerializer; + } + + #endregion + + #region Public Methods + + // TODO: This method should be made protected or private + + public async Task WriteMessage(Message messageToWrite) + { + Validate.IsNotNull("messageToWrite", messageToWrite); + + // Serialize the message + JObject messageObject = + this.messageSerializer.SerializeMessage( + messageToWrite); + + // Log the JSON representation of the message + Logger.Write( + LogLevel.Verbose, + string.Format( + "WRITE MESSAGE:\r\n\r\n{0}", + JsonConvert.SerializeObject( + messageObject, + Formatting.Indented, + Constants.JsonSerializerSettings))); + + string serializedMessage = + JsonConvert.SerializeObject( + messageObject, + Constants.JsonSerializerSettings); + + byte[] messageBytes = Encoding.UTF8.GetBytes(serializedMessage); + byte[] headerBytes = + Encoding.ASCII.GetBytes( + string.Format( + Constants.ContentLengthFormatString, + messageBytes.Length)); + + // Make sure only one call is writing at a time. You might be thinking + // "Why not use a normal lock?" We use an AsyncLock here so that the + // message loop doesn't get blocked while waiting for I/O to complete. + using (await this.writeLock.LockAsync()) + { + // Send the message + await this.outputStream.WriteAsync(headerBytes, 0, headerBytes.Length); + await this.outputStream.WriteAsync(messageBytes, 0, messageBytes.Length); + await this.outputStream.FlushAsync(); + } + } + + public async Task WriteRequest( + RequestType requestType, + TParams requestParams, + int requestId) + { + // Allow null content + JToken contentObject = + requestParams != null ? + JToken.FromObject(requestParams, contentSerializer) : + null; + + await this.WriteMessage( + Message.Request( + requestId.ToString(), + requestType.MethodName, + contentObject)); + } + + public async Task WriteResponse(TResult resultContent, string method, string requestId) + { + // Allow null content + JToken contentObject = + resultContent != null ? + JToken.FromObject(resultContent, contentSerializer) : + null; + + await this.WriteMessage( + Message.Response( + requestId, + method, + contentObject)); + } + + public async Task WriteEvent(EventType eventType, TParams eventParams) + { + // Allow null content + JToken contentObject = + eventParams != null ? + JToken.FromObject(eventParams, contentSerializer) : + null; + + await this.WriteMessage( + Message.Event( + eventType.MethodName, + contentObject)); + } + + #endregion + } +} diff --git a/src/ServiceHost/MessageProtocol/ProtocolEndpoint.cs b/src/ServiceHost/MessageProtocol/ProtocolEndpoint.cs new file mode 100644 index 00000000..daead186 --- /dev/null +++ b/src/ServiceHost/MessageProtocol/ProtocolEndpoint.cs @@ -0,0 +1,313 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol.Channel; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol +{ + /// + /// Provides behavior for a client or server endpoint that + /// communicates using the specified protocol. + /// + public class ProtocolEndpoint : IMessageSender + { + private bool isStarted; + private int currentMessageId; + private ChannelBase protocolChannel; + private MessageProtocolType messageProtocolType; + private TaskCompletionSource endpointExitedTask; + private SynchronizationContext originalSynchronizationContext; + + private Dictionary> pendingRequests = + new Dictionary>(); + + /// + /// Gets the MessageDispatcher which allows registration of + /// handlers for requests, responses, and events that are + /// transmitted through the channel. + /// + protected MessageDispatcher MessageDispatcher { get; set; } + + /// + /// Initializes an instance of the protocol server using the + /// specified channel for communication. + /// + /// + /// The channel to use for communication with the connected endpoint. + /// + /// + /// The type of message protocol used by the endpoint. + /// + public ProtocolEndpoint( + ChannelBase protocolChannel, + MessageProtocolType messageProtocolType) + { + this.protocolChannel = protocolChannel; + this.messageProtocolType = messageProtocolType; + this.originalSynchronizationContext = SynchronizationContext.Current; + } + + /// + /// Starts the language server client and sends the Initialize method. + /// + /// A Task that can be awaited for initialization to complete. + public async Task Start() + { + if (!this.isStarted) + { + // Start the provided protocol channel + this.protocolChannel.Start(this.messageProtocolType); + + // Start the message dispatcher + this.MessageDispatcher = new MessageDispatcher(this.protocolChannel); + + // Set the handler for any message responses that come back + this.MessageDispatcher.SetResponseHandler(this.HandleResponse); + + // Listen for unhandled exceptions from the dispatcher + this.MessageDispatcher.UnhandledException += MessageDispatcher_UnhandledException; + + // Notify implementation about endpoint start + await this.OnStart(); + + // Wait for connection and notify the implementor + // NOTE: This task is not meant to be awaited. + Task waitTask = + this.protocolChannel + .WaitForConnection() + .ContinueWith( + async (t) => + { + // Start the MessageDispatcher + this.MessageDispatcher.Start(); + await this.OnConnect(); + }); + + // Endpoint is now started + this.isStarted = true; + } + } + + public void WaitForExit() + { + this.endpointExitedTask = new TaskCompletionSource(); + this.endpointExitedTask.Task.Wait(); + } + + public async Task Stop() + { + if (this.isStarted) + { + // Make sure no future calls try to stop the endpoint during shutdown + this.isStarted = false; + + // Stop the implementation first + await this.OnStop(); + + // Stop the dispatcher and channel + this.MessageDispatcher.Stop(); + this.protocolChannel.Stop(); + + // Notify anyone waiting for exit + if (this.endpointExitedTask != null) + { + this.endpointExitedTask.SetResult(true); + } + } + } + + #region Message Sending + + /// + /// Sends a request to the server + /// + /// + /// + /// + /// + /// + public Task SendRequest( + RequestType requestType, + TParams requestParams) + { + return this.SendRequest(requestType, requestParams, true); + } + + public async Task SendRequest( + RequestType requestType, + TParams requestParams, + bool waitForResponse) + { + if (!this.protocolChannel.IsConnected) + { + throw new InvalidOperationException("SendRequest called when ProtocolChannel was not yet connected"); + } + + this.currentMessageId++; + + TaskCompletionSource responseTask = null; + + if (waitForResponse) + { + responseTask = new TaskCompletionSource(); + this.pendingRequests.Add( + this.currentMessageId.ToString(), + responseTask); + } + + await this.protocolChannel.MessageWriter.WriteRequest( + requestType, + requestParams, + this.currentMessageId); + + if (responseTask != null) + { + var responseMessage = await responseTask.Task; + + return + responseMessage.Contents != null ? + responseMessage.Contents.ToObject() : + default(TResult); + } + else + { + // TODO: Better default value here? + return default(TResult); + } + } + + /// + /// Sends an event to the channel's endpoint. + /// + /// The event parameter type. + /// The type of event being sent. + /// The event parameters being sent. + /// A Task that tracks completion of the send operation. + public Task SendEvent( + EventType eventType, + TParams eventParams) + { + if (!this.protocolChannel.IsConnected) + { + throw new InvalidOperationException("SendEvent called when ProtocolChannel was not yet connected"); + } + + // Some events could be raised from a different thread. + // To ensure that messages are written serially, dispatch + // dispatch the SendEvent call to the message loop thread. + + if (!this.MessageDispatcher.InMessageLoopThread) + { + TaskCompletionSource writeTask = new TaskCompletionSource(); + + this.MessageDispatcher.SynchronizationContext.Post( + async (obj) => + { + await this.protocolChannel.MessageWriter.WriteEvent( + eventType, + eventParams); + + writeTask.SetResult(true); + }, null); + + return writeTask.Task; + } + else + { + return this.protocolChannel.MessageWriter.WriteEvent( + eventType, + eventParams); + } + } + + #endregion + + #region Message Handling + + public void SetRequestHandler( + RequestType requestType, + Func, Task> requestHandler) + { + this.MessageDispatcher.SetRequestHandler( + requestType, + requestHandler); + } + + public void SetEventHandler( + EventType eventType, + Func eventHandler) + { + this.MessageDispatcher.SetEventHandler( + eventType, + eventHandler, + false); + } + + public void SetEventHandler( + EventType eventType, + Func eventHandler, + bool overrideExisting) + { + this.MessageDispatcher.SetEventHandler( + eventType, + eventHandler, + overrideExisting); + } + + private void HandleResponse(Message responseMessage) + { + TaskCompletionSource pendingRequestTask = null; + + if (this.pendingRequests.TryGetValue(responseMessage.Id, out pendingRequestTask)) + { + pendingRequestTask.SetResult(responseMessage); + this.pendingRequests.Remove(responseMessage.Id); + } + } + + #endregion + + #region Subclass Lifetime Methods + + protected virtual Task OnStart() + { + return Task.FromResult(true); + } + + protected virtual Task OnConnect() + { + return Task.FromResult(true); + } + + protected virtual Task OnStop() + { + return Task.FromResult(true); + } + + #endregion + + #region Event Handlers + + private void MessageDispatcher_UnhandledException(object sender, Exception e) + { + if (this.endpointExitedTask != null) + { + this.endpointExitedTask.SetException(e); + } + + else if (this.originalSynchronizationContext != null) + { + this.originalSynchronizationContext.Post(o => { throw e; }, null); + } + } + + #endregion + } +} + diff --git a/src/ServiceHost/MessageProtocol/RequestContext.cs b/src/ServiceHost/MessageProtocol/RequestContext.cs new file mode 100644 index 00000000..a35bb136 --- /dev/null +++ b/src/ServiceHost/MessageProtocol/RequestContext.cs @@ -0,0 +1,47 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Newtonsoft.Json.Linq; +using System.Threading.Tasks; + +namespace Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol +{ + public class RequestContext + { + private Message requestMessage; + private MessageWriter messageWriter; + + public RequestContext(Message requestMessage, MessageWriter messageWriter) + { + this.requestMessage = requestMessage; + this.messageWriter = messageWriter; + } + + public async Task SendResult(TResult resultDetails) + { + await this.messageWriter.WriteResponse( + resultDetails, + requestMessage.Method, + requestMessage.Id); + } + + public async Task SendEvent(EventType eventType, TParams eventParams) + { + await this.messageWriter.WriteEvent( + eventType, + eventParams); + } + + public async Task SendError(object errorDetails) + { + await this.messageWriter.WriteMessage( + Message.ResponseError( + requestMessage.Id, + requestMessage.Method, + JToken.FromObject(errorDetails))); + } + } +} + diff --git a/src/ServiceHost/MessageProtocol/RequestType.cs b/src/ServiceHost/MessageProtocol/RequestType.cs new file mode 100644 index 00000000..29fc11c5 --- /dev/null +++ b/src/ServiceHost/MessageProtocol/RequestType.cs @@ -0,0 +1,24 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Diagnostics; + +namespace Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol +{ + [DebuggerDisplay("RequestType MethodName = {MethodName}")] + public class RequestType + { + public string MethodName { get; private set; } + + public static RequestType Create(string typeName) + { + return new RequestType() + { + MethodName = typeName + }; + } + } +} + diff --git a/src/ServiceHost/MessageProtocol/Serializers/JsonRpcMessageSerializer.cs b/src/ServiceHost/MessageProtocol/Serializers/JsonRpcMessageSerializer.cs new file mode 100644 index 00000000..fa1d1518 --- /dev/null +++ b/src/ServiceHost/MessageProtocol/Serializers/JsonRpcMessageSerializer.cs @@ -0,0 +1,99 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Newtonsoft.Json.Linq; + +namespace Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol.Serializers +{ + /// + /// Serializes messages in the JSON RPC format. Used primarily + /// for language servers. + /// + public class JsonRpcMessageSerializer : IMessageSerializer + { + public JObject SerializeMessage(Message message) + { + JObject messageObject = new JObject(); + + messageObject.Add("jsonrpc", JToken.FromObject("2.0")); + + if (message.MessageType == MessageType.Request) + { + messageObject.Add("id", JToken.FromObject(message.Id)); + messageObject.Add("method", message.Method); + messageObject.Add("params", message.Contents); + } + else if (message.MessageType == MessageType.Event) + { + messageObject.Add("method", message.Method); + messageObject.Add("params", message.Contents); + } + else if (message.MessageType == MessageType.Response) + { + messageObject.Add("id", JToken.FromObject(message.Id)); + + if (message.Error != null) + { + // Write error + messageObject.Add("error", message.Error); + } + else + { + // Write result + messageObject.Add("result", message.Contents); + } + } + + return messageObject; + } + + public Message DeserializeMessage(JObject messageJson) + { + // TODO: Check for jsonrpc version + + JToken token = null; + if (messageJson.TryGetValue("id", out token)) + { + // Message is a Request or Response + string messageId = token.ToString(); + + if (messageJson.TryGetValue("result", out token)) + { + return Message.Response(messageId, null, token); + } + else if (messageJson.TryGetValue("error", out token)) + { + return Message.ResponseError(messageId, null, token); + } + else + { + JToken messageParams = null; + messageJson.TryGetValue("params", out messageParams); + + if (!messageJson.TryGetValue("method", out token)) + { + // TODO: Throw parse error + } + + return Message.Request(messageId, token.ToString(), messageParams); + } + } + else + { + // Messages without an id are events + JToken messageParams = token; + messageJson.TryGetValue("params", out messageParams); + + if (!messageJson.TryGetValue("method", out token)) + { + // TODO: Throw parse error + } + + return Message.Event(token.ToString(), messageParams); + } + } + } +} + diff --git a/src/ServiceHost/MessageProtocol/Serializers/V8MessageSerializer.cs b/src/ServiceHost/MessageProtocol/Serializers/V8MessageSerializer.cs new file mode 100644 index 00000000..941e249a --- /dev/null +++ b/src/ServiceHost/MessageProtocol/Serializers/V8MessageSerializer.cs @@ -0,0 +1,113 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Newtonsoft.Json.Linq; +using System; + +namespace Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol.Serializers +{ + /// + /// Serializes messages in the V8 format. Used primarily for debug adapters. + /// + public class V8MessageSerializer : IMessageSerializer + { + public JObject SerializeMessage(Message message) + { + JObject messageObject = new JObject(); + + if (message.MessageType == MessageType.Request) + { + messageObject.Add("type", JToken.FromObject("request")); + messageObject.Add("seq", JToken.FromObject(message.Id)); + messageObject.Add("command", message.Method); + messageObject.Add("arguments", message.Contents); + } + else if (message.MessageType == MessageType.Event) + { + messageObject.Add("type", JToken.FromObject("event")); + messageObject.Add("event", message.Method); + messageObject.Add("body", message.Contents); + } + else if (message.MessageType == MessageType.Response) + { + messageObject.Add("type", JToken.FromObject("response")); + messageObject.Add("request_seq", JToken.FromObject(message.Id)); + messageObject.Add("command", message.Method); + + if (message.Error != null) + { + // Write error + messageObject.Add("success", JToken.FromObject(false)); + messageObject.Add("message", message.Error); + } + else + { + // Write result + messageObject.Add("success", JToken.FromObject(true)); + messageObject.Add("body", message.Contents); + } + } + + return messageObject; + } + + public Message DeserializeMessage(JObject messageJson) + { + JToken token = null; + + if (messageJson.TryGetValue("type", out token)) + { + string messageType = token.ToString(); + + if (string.Equals("request", messageType, StringComparison.CurrentCultureIgnoreCase)) + { + return Message.Request( + messageJson.GetValue("seq").ToString(), + messageJson.GetValue("command").ToString(), + messageJson.GetValue("arguments")); + } + else if (string.Equals("response", messageType, StringComparison.CurrentCultureIgnoreCase)) + { + if (messageJson.TryGetValue("success", out token)) + { + // Was the response for a successful request? + if (token.ToObject() == true) + { + return Message.Response( + messageJson.GetValue("request_seq").ToString(), + messageJson.GetValue("command").ToString(), + messageJson.GetValue("body")); + } + else + { + return Message.ResponseError( + messageJson.GetValue("request_seq").ToString(), + messageJson.GetValue("command").ToString(), + messageJson.GetValue("message")); + } + } + else + { + // TODO: Parse error + } + + } + else if (string.Equals("event", messageType, StringComparison.CurrentCultureIgnoreCase)) + { + return Message.Event( + messageJson.GetValue("event").ToString(), + messageJson.GetValue("body")); + } + else + { + return Message.Unknown(); + } + } + + return Message.Unknown(); + } + } +} + diff --git a/src/ServiceHost/Program.cs b/src/ServiceHost/Program.cs new file mode 100644 index 00000000..6bfd0f24 --- /dev/null +++ b/src/ServiceHost/Program.cs @@ -0,0 +1,40 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System; +using Microsoft.SqlTools.EditorServices.Protocol.Server; +using Microsoft.SqlTools.EditorServices.Session; +using Microsoft.SqlTools.EditorServices.Utility; + +namespace Microsoft.SqlTools.ServiceHost +{ + /// + /// Main application class for SQL Tools API Service Host executable + /// + class Program + { + /// + /// Main entry point into the SQL Tools API Service Host + /// + static void Main(string[] args) + { + // turn on Verbose logging during early development + // we need to switch to Normal when preparing for public preview + Logger.Initialize(minimumLogLevel: LogLevel.Verbose); + Logger.Write(LogLevel.Normal, "Starting SQL Tools Service Host"); + + const string hostName = "SQL Tools Service Host"; + const string hostProfileId = "SQLToolsService"; + Version hostVersion = new Version(1,0); + + // set up the host details and profile paths + var hostDetails = new HostDetails(hostName, hostProfileId, hostVersion); + var profilePaths = new ProfilePaths(hostProfileId, "baseAllUsersPath", "baseCurrentUserPath"); + + // create and run the language server + var languageServer = new LanguageServer(hostDetails, profilePaths); + languageServer.Start().Wait(); + languageServer.WaitForExit(); + } + } +} diff --git a/src/ServiceHost/Properties/AssemblyInfo.cs b/src/ServiceHost/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..27c1daba --- /dev/null +++ b/src/ServiceHost/Properties/AssemblyInfo.cs @@ -0,0 +1,44 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("SqlTools Editor Services Host Protocol Library")] +[assembly: AssemblyDescription("Provides message types and client/server APIs for the SqlTools Editor Services JSON protocol.")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("Microsoft")] +[assembly: AssemblyProduct("SqlTools Editor Services")] +[assembly: AssemblyCopyright("� Microsoft Corporation. All rights reserved.")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("78caf6c3-5955-4b15-a302-2bd6b7871d5b")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] +[assembly: AssemblyInformationalVersion("1.0.0.0")] + +[assembly: InternalsVisibleTo("Microsoft.SqlTools.EditorServices.Test.Protocol")] diff --git a/src/ServiceHost/Server/LanguageServer.cs b/src/ServiceHost/Server/LanguageServer.cs new file mode 100644 index 00000000..d6719141 --- /dev/null +++ b/src/ServiceHost/Server/LanguageServer.cs @@ -0,0 +1,517 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// +using Microsoft.SqlTools.EditorServices.Protocol.LanguageServer; +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol; +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol.Channel; +using Microsoft.SqlTools.EditorServices.Session; +using System.Threading.Tasks; +using Microsoft.SqlTools.EditorServices.Utility; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Linq; +using System; + +namespace Microsoft.SqlTools.EditorServices.Protocol.Server +{ + /// + /// SQL Tools VS Code Language Server request handler + /// + public class LanguageServer : LanguageServerBase + { + private static CancellationTokenSource existingRequestCancellation; + + private LanguageServerSettings currentSettings = new LanguageServerSettings(); + + private EditorSession editorSession; + + /// + /// Provides details about the host application. + /// + public LanguageServer(HostDetails hostDetails, ProfilePaths profilePaths) + : base(new StdioServerChannel()) + { + this.editorSession = new EditorSession(); + this.editorSession.StartSession(hostDetails, profilePaths); + } + + /// + /// Initialize the VS Code request/response callbacks + /// + protected override void Initialize() + { + // Register all supported message types + this.SetRequestHandler(InitializeRequest.Type, this.HandleInitializeRequest); + this.SetEventHandler(DidChangeTextDocumentNotification.Type, this.HandleDidChangeTextDocumentNotification); + this.SetEventHandler(DidOpenTextDocumentNotification.Type, this.HandleDidOpenTextDocumentNotification); + this.SetEventHandler(DidCloseTextDocumentNotification.Type, this.HandleDidCloseTextDocumentNotification); + this.SetEventHandler(DidChangeConfigurationNotification.Type, this.HandleDidChangeConfigurationNotification); + + this.SetRequestHandler(DefinitionRequest.Type, this.HandleDefinitionRequest); + this.SetRequestHandler(ReferencesRequest.Type, this.HandleReferencesRequest); + this.SetRequestHandler(CompletionRequest.Type, this.HandleCompletionRequest); + this.SetRequestHandler(CompletionResolveRequest.Type, this.HandleCompletionResolveRequest); + this.SetRequestHandler(SignatureHelpRequest.Type, this.HandleSignatureHelpRequest); + this.SetRequestHandler(DocumentHighlightRequest.Type, this.HandleDocumentHighlightRequest); + this.SetRequestHandler(HoverRequest.Type, this.HandleHoverRequest); + this.SetRequestHandler(DocumentSymbolRequest.Type, this.HandleDocumentSymbolRequest); + this.SetRequestHandler(WorkspaceSymbolRequest.Type, this.HandleWorkspaceSymbolRequest); + } + + /// + /// Handles the shutdown event for the Language Server + /// + protected override async Task Shutdown() + { + Logger.Write(LogLevel.Normal, "Language service is shutting down..."); + + if (this.editorSession != null) + { + this.editorSession.Dispose(); + this.editorSession = null; + } + + await Task.FromResult(true); + } + + /// + /// Handles the initialization request + /// + /// + /// + /// + protected async Task HandleInitializeRequest( + InitializeRequest initializeParams, + RequestContext requestContext) + { + Logger.Write(LogLevel.Verbose, "HandleDidChangeTextDocumentNotification"); + + // Grab the workspace path from the parameters + editorSession.Workspace.WorkspacePath = initializeParams.RootPath; + + await requestContext.SendResult( + new InitializeResult + { + Capabilities = new ServerCapabilities + { + TextDocumentSync = TextDocumentSyncKind.Incremental, + DefinitionProvider = true, + ReferencesProvider = true, + DocumentHighlightProvider = true, + DocumentSymbolProvider = true, + WorkspaceSymbolProvider = true, + HoverProvider = true, + CompletionProvider = new CompletionOptions + { + ResolveProvider = true, + TriggerCharacters = new string[] { ".", "-", ":", "\\" } + }, + SignatureHelpProvider = new SignatureHelpOptions + { + TriggerCharacters = new string[] { " " } // TODO: Other characters here? + } + } + }); + } + + /// + /// Handles text document change events + /// + /// + /// + /// + protected Task HandleDidChangeTextDocumentNotification( + DidChangeTextDocumentParams textChangeParams, + EventContext eventContext) + { + StringBuilder msg = new StringBuilder(); + msg.Append("HandleDidChangeTextDocumentNotification"); + List changedFiles = new List(); + + // A text change notification can batch multiple change requests + foreach (var textChange in textChangeParams.ContentChanges) + { + string fileUri = textChangeParams.TextDocument.Uri; + msg.AppendLine(); + msg.Append(" File: "); + msg.Append(fileUri); + + ScriptFile changedFile = editorSession.Workspace.GetFile(fileUri); + + changedFile.ApplyChange( + GetFileChangeDetails( + textChange.Range.Value, + textChange.Text)); + + changedFiles.Add(changedFile); + } + + Logger.Write(LogLevel.Verbose, msg.ToString()); + + this.RunScriptDiagnostics( + changedFiles.ToArray(), + editorSession, + eventContext); + + return Task.FromResult(true); + } + + protected Task HandleDidOpenTextDocumentNotification( + DidOpenTextDocumentNotification openParams, + EventContext eventContext) + { + Logger.Write(LogLevel.Verbose, "HandleDidOpenTextDocumentNotification"); + return Task.FromResult(true); + } + + protected Task HandleDidCloseTextDocumentNotification( + TextDocumentIdentifier closeParams, + EventContext eventContext) + { + Logger.Write(LogLevel.Verbose, "HandleDidCloseTextDocumentNotification"); + return Task.FromResult(true); + } + + /// + /// Handles the configuration change event + /// + /// + /// + protected async Task HandleDidChangeConfigurationNotification( + DidChangeConfigurationParams configChangeParams, + EventContext eventContext) + { + Logger.Write(LogLevel.Verbose, "HandleDidChangeConfigurationNotification"); + + bool oldLoadProfiles = this.currentSettings.EnableProfileLoading; + bool oldScriptAnalysisEnabled = + this.currentSettings.ScriptAnalysis.Enable.HasValue; + string oldScriptAnalysisSettingsPath = + this.currentSettings.ScriptAnalysis.SettingsPath; + + this.currentSettings.Update( + configChangeParams.Settings.SqlTools, + this.editorSession.Workspace.WorkspacePath); + + // If script analysis settings have changed we need to clear & possibly update the current diagnostic records. + if ((oldScriptAnalysisEnabled != this.currentSettings.ScriptAnalysis.Enable)) + { + // If the user just turned off script analysis or changed the settings path, send a diagnostics + // event to clear the analysis markers that they already have. + if (!this.currentSettings.ScriptAnalysis.Enable.Value) + { + ScriptFileMarker[] emptyAnalysisDiagnostics = new ScriptFileMarker[0]; + + foreach (var scriptFile in editorSession.Workspace.GetOpenedFiles()) + { + await PublishScriptDiagnostics( + scriptFile, + emptyAnalysisDiagnostics, + eventContext); + } + } + else + { + await this.RunScriptDiagnostics( + this.editorSession.Workspace.GetOpenedFiles(), + this.editorSession, + eventContext); + } + } + + await Task.FromResult(true); + } + + protected async Task HandleDefinitionRequest( + TextDocumentPosition textDocumentPosition, + RequestContext requestContext) + { + Logger.Write(LogLevel.Verbose, "HandleDefinitionRequest"); + await Task.FromResult(true); + } + + protected async Task HandleReferencesRequest( + ReferencesParams referencesParams, + RequestContext requestContext) + { + Logger.Write(LogLevel.Verbose, "HandleReferencesRequest"); + await Task.FromResult(true); + } + + protected async Task HandleCompletionRequest( + TextDocumentPosition textDocumentPosition, + RequestContext requestContext) + { + Logger.Write(LogLevel.Verbose, "HandleCompletionRequest"); + await Task.FromResult(true); + } + + protected async Task HandleCompletionResolveRequest( + CompletionItem completionItem, + RequestContext requestContext) + { + Logger.Write(LogLevel.Verbose, "HandleCompletionResolveRequest"); + await Task.FromResult(true); + } + + protected async Task HandleSignatureHelpRequest( + TextDocumentPosition textDocumentPosition, + RequestContext requestContext) + { + Logger.Write(LogLevel.Verbose, "HandleSignatureHelpRequest"); + await Task.FromResult(true); + } + + protected async Task HandleDocumentHighlightRequest( + TextDocumentPosition textDocumentPosition, + RequestContext requestContext) + { + Logger.Write(LogLevel.Verbose, "HandleDocumentHighlightRequest"); + await Task.FromResult(true); + } + + protected async Task HandleHoverRequest( + TextDocumentPosition textDocumentPosition, + RequestContext requestContext) + { + Logger.Write(LogLevel.Verbose, "HandleHoverRequest"); + await Task.FromResult(true); + } + + protected async Task HandleDocumentSymbolRequest( + TextDocumentIdentifier textDocumentIdentifier, + RequestContext requestContext) + { + Logger.Write(LogLevel.Verbose, "HandleDocumentSymbolRequest"); + await Task.FromResult(true); + } + + protected async Task HandleWorkspaceSymbolRequest( + WorkspaceSymbolParams workspaceSymbolParams, + RequestContext requestContext) + { + Logger.Write(LogLevel.Verbose, "HandleWorkspaceSymbolRequest"); + await Task.FromResult(true); + } + + /// + /// Runs script diagnostics on changed files + /// + /// + /// + /// + private Task RunScriptDiagnostics( + ScriptFile[] filesToAnalyze, + EditorSession editorSession, + EventContext eventContext) + { + if (!this.currentSettings.ScriptAnalysis.Enable.Value) + { + // If the user has disabled script analysis, skip it entirely + return Task.FromResult(true); + } + + // If there's an existing task, attempt to cancel it + try + { + if (existingRequestCancellation != null) + { + // Try to cancel the request + existingRequestCancellation.Cancel(); + + // If cancellation didn't throw an exception, + // clean up the existing token + existingRequestCancellation.Dispose(); + existingRequestCancellation = null; + } + } + catch (Exception e) + { + Logger.Write( + LogLevel.Error, + string.Format( + "Exception while cancelling analysis task:\n\n{0}", + e.ToString())); + + TaskCompletionSource cancelTask = new TaskCompletionSource(); + cancelTask.SetCanceled(); + return cancelTask.Task; + } + + // Create a fresh cancellation token and then start the task. + // We create this on a different TaskScheduler so that we + // don't block the main message loop thread. + existingRequestCancellation = new CancellationTokenSource(); + Task.Factory.StartNew( + () => + DelayThenInvokeDiagnostics( + 750, + filesToAnalyze, + editorSession, + eventContext, + existingRequestCancellation.Token), + CancellationToken.None, + TaskCreationOptions.None, + TaskScheduler.Default); + + return Task.FromResult(true); + } + + /// + /// Actually run the script diagnostics after waiting for some small delay + /// + /// + /// + /// + /// + /// + private static async Task DelayThenInvokeDiagnostics( + int delayMilliseconds, + ScriptFile[] filesToAnalyze, + EditorSession editorSession, + EventContext eventContext, + CancellationToken cancellationToken) + { + // First of all, wait for the desired delay period before + // analyzing the provided list of files + try + { + await Task.Delay(delayMilliseconds, cancellationToken); + } + catch (TaskCanceledException) + { + // If the task is cancelled, exit directly + return; + } + + // If we've made it past the delay period then we don't care + // about the cancellation token anymore. This could happen + // when the user stops typing for long enough that the delay + // period ends but then starts typing while analysis is going + // on. It makes sense to send back the results from the first + // delay period while the second one is ticking away. + + // Get the requested files + foreach (ScriptFile scriptFile in filesToAnalyze) + { + ScriptFileMarker[] semanticMarkers = null; + if (editorSession.LanguageService != null) + { + Logger.Write(LogLevel.Verbose, "Analyzing script file: " + scriptFile.FilePath); + semanticMarkers = editorSession.LanguageService.GetSemanticMarkers(scriptFile); + Logger.Write(LogLevel.Verbose, "Analysis complete."); + } + else + { + // Semantic markers aren't available if the AnalysisService + // isn't available + semanticMarkers = new ScriptFileMarker[0]; + } + + await PublishScriptDiagnostics( + scriptFile, + semanticMarkers, + eventContext); + } + } + + /// + /// Send the diagnostic results back to the host application + /// + /// + /// + /// + private static async Task PublishScriptDiagnostics( + ScriptFile scriptFile, + ScriptFileMarker[] semanticMarkers, + EventContext eventContext) + { + var allMarkers = scriptFile.SyntaxMarkers != null + ? scriptFile.SyntaxMarkers.Concat(semanticMarkers) + : semanticMarkers; + + // Always send syntax and semantic errors. We want to + // make sure no out-of-date markers are being displayed. + await eventContext.SendEvent( + PublishDiagnosticsNotification.Type, + new PublishDiagnosticsNotification + { + Uri = scriptFile.ClientFilePath, + Diagnostics = + allMarkers + .Select(GetDiagnosticFromMarker) + .ToArray() + }); + } + + /// + /// Convert a ScriptFileMarker to a Diagnostic that is Language Service compatible + /// + /// + /// + private static Diagnostic GetDiagnosticFromMarker(ScriptFileMarker scriptFileMarker) + { + return new Diagnostic + { + Severity = MapDiagnosticSeverity(scriptFileMarker.Level), + Message = scriptFileMarker.Message, + Range = new Range + { + // TODO: What offsets should I use? + Start = new Position + { + Line = scriptFileMarker.ScriptRegion.StartLineNumber - 1, + Character = scriptFileMarker.ScriptRegion.StartColumnNumber - 1 + }, + End = new Position + { + Line = scriptFileMarker.ScriptRegion.EndLineNumber - 1, + Character = scriptFileMarker.ScriptRegion.EndColumnNumber - 1 + } + } + }; + } + + /// + /// Map ScriptFileMarker severity to Diagnostic severity + /// + /// + private static DiagnosticSeverity MapDiagnosticSeverity(ScriptFileMarkerLevel markerLevel) + { + switch (markerLevel) + { + case ScriptFileMarkerLevel.Error: + return DiagnosticSeverity.Error; + + case ScriptFileMarkerLevel.Warning: + return DiagnosticSeverity.Warning; + + case ScriptFileMarkerLevel.Information: + return DiagnosticSeverity.Information; + + default: + return DiagnosticSeverity.Error; + } + } + + /// + /// Switch from 0-based offsets to 1 based offsets + /// + /// + /// + private static FileChange GetFileChangeDetails(Range changeRange, string insertString) + { + // The protocol's positions are zero-based so add 1 to all offsets + return new FileChange + { + InsertString = insertString, + Line = changeRange.Start.Line + 1, + Offset = changeRange.Start.Character + 1, + EndLine = changeRange.End.Line + 1, + EndOffset = changeRange.End.Character + 1 + }; + } + } +} diff --git a/src/ServiceHost/Server/LanguageServerBase.cs b/src/ServiceHost/Server/LanguageServerBase.cs new file mode 100644 index 00000000..0128484b --- /dev/null +++ b/src/ServiceHost/Server/LanguageServerBase.cs @@ -0,0 +1,84 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.EditorServices.Protocol.LanguageServer; +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol; +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol.Channel; +using System.Threading.Tasks; + +namespace Microsoft.SqlTools.EditorServices.Protocol.Server +{ + public abstract class LanguageServerBase : ProtocolEndpoint + { + private bool isStarted; + private ChannelBase serverChannel; + private TaskCompletionSource serverExitedTask; + + public LanguageServerBase(ChannelBase serverChannel) : + base(serverChannel, MessageProtocolType.LanguageServer) + { + this.serverChannel = serverChannel; + } + + protected override Task OnStart() + { + // Register handlers for server lifetime messages + this.SetRequestHandler(ShutdownRequest.Type, this.HandleShutdownRequest); + this.SetEventHandler(ExitNotification.Type, this.HandleExitNotification); + + // Initialize the implementation class + this.Initialize(); + + return Task.FromResult(true); + } + + protected override async Task OnStop() + { + await this.Shutdown(); + } + + /// + /// Overridden by the subclass to provide initialization + /// logic after the server channel is started. + /// + protected abstract void Initialize(); + + /// + /// Can be overridden by the subclass to provide shutdown + /// logic before the server exits. Subclasses do not need + /// to invoke or return the value of the base implementation. + /// + protected virtual Task Shutdown() + { + // No default implementation yet. + return Task.FromResult(true); + } + + private async Task HandleShutdownRequest( + object shutdownParams, + RequestContext requestContext) + { + // Allow the implementor to shut down gracefully + await this.Shutdown(); + + await requestContext.SendResult(new object()); + } + + private async Task HandleExitNotification( + object exitParams, + EventContext eventContext) + { + // Stop the server channel + await this.Stop(); + + // Notify any waiter that the server has exited + if (this.serverExitedTask != null) + { + this.serverExitedTask.SetResult(true); + } + } + } +} + diff --git a/src/ServiceHost/Server/LanguageServerEditorOperations.cs b/src/ServiceHost/Server/LanguageServerEditorOperations.cs new file mode 100644 index 00000000..e5714b19 --- /dev/null +++ b/src/ServiceHost/Server/LanguageServerEditorOperations.cs @@ -0,0 +1,114 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// +#if false +using Microsoft.SqlTools.EditorServices.Extensions; +using Microsoft.SqlTools.EditorServices.Protocol.LanguageServer; +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol; +using System.Threading.Tasks; + +namespace Microsoft.SqlTools.EditorServices.Protocol.Server +{ + internal class LanguageServerEditorOperations : IEditorOperations + { + private EditorSession editorSession; + private IMessageSender messageSender; + + public LanguageServerEditorOperations( + EditorSession editorSession, + IMessageSender messageSender) + { + this.editorSession = editorSession; + this.messageSender = messageSender; + } + + public async Task GetEditorContext() + { + ClientEditorContext clientContext = + await this.messageSender.SendRequest( + GetEditorContextRequest.Type, + new GetEditorContextRequest(), + true); + + return this.ConvertClientEditorContext(clientContext); + } + + public async Task InsertText(string filePath, string text, BufferRange insertRange) + { + await this.messageSender.SendRequest( + InsertTextRequest.Type, + new InsertTextRequest + { + FilePath = filePath, + InsertText = text, + InsertRange = + new Range + { + Start = new Position + { + Line = insertRange.Start.Line - 1, + Character = insertRange.Start.Column - 1 + }, + End = new Position + { + Line = insertRange.End.Line - 1, + Character = insertRange.End.Column - 1 + } + } + }, false); + + // TODO: Set the last param back to true! + } + + public Task SetSelection(BufferRange selectionRange) + { + return this.messageSender.SendRequest( + SetSelectionRequest.Type, + new SetSelectionRequest + { + SelectionRange = + new Range + { + Start = new Position + { + Line = selectionRange.Start.Line - 1, + Character = selectionRange.Start.Column - 1 + }, + End = new Position + { + Line = selectionRange.End.Line - 1, + Character = selectionRange.End.Column - 1 + } + } + }, true); + } + + public EditorContext ConvertClientEditorContext( + ClientEditorContext clientContext) + { + return + new EditorContext( + this, + this.editorSession.Workspace.GetFile(clientContext.CurrentFilePath), + new BufferPosition( + clientContext.CursorPosition.Line + 1, + clientContext.CursorPosition.Character + 1), + new BufferRange( + clientContext.SelectionRange.Start.Line + 1, + clientContext.SelectionRange.Start.Character + 1, + clientContext.SelectionRange.End.Line + 1, + clientContext.SelectionRange.End.Character + 1)); + } + + public Task OpenFile(string filePath) + { + return + this.messageSender.SendRequest( + OpenFileRequest.Type, + filePath, + true); + } + } +} +#endif \ No newline at end of file diff --git a/src/ServiceHost/Server/LanguageServerSettings.cs b/src/ServiceHost/Server/LanguageServerSettings.cs new file mode 100644 index 00000000..be09984a --- /dev/null +++ b/src/ServiceHost/Server/LanguageServerSettings.cs @@ -0,0 +1,90 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.IO; +using Microsoft.SqlTools.EditorServices.Utility; + +namespace Microsoft.SqlTools.EditorServices.Protocol.Server +{ + public class LanguageServerSettings + { + public bool EnableProfileLoading { get; set; } + + public ScriptAnalysisSettings ScriptAnalysis { get; set; } + + public LanguageServerSettings() + { + this.ScriptAnalysis = new ScriptAnalysisSettings(); + } + + public void Update(LanguageServerSettings settings, string workspaceRootPath) + { + if (settings != null) + { + this.EnableProfileLoading = settings.EnableProfileLoading; + this.ScriptAnalysis.Update(settings.ScriptAnalysis, workspaceRootPath); + } + } + } + + + public class ScriptAnalysisSettings + { + public bool? Enable { get; set; } + + public string SettingsPath { get; set; } + + public ScriptAnalysisSettings() + { + this.Enable = true; + } + + public void Update(ScriptAnalysisSettings settings, string workspaceRootPath) + { + if (settings != null) + { + this.Enable = settings.Enable; + + string settingsPath = settings.SettingsPath; + + if (string.IsNullOrWhiteSpace(settingsPath)) + { + settingsPath = null; + } + else if (!Path.IsPathRooted(settingsPath)) + { + if (string.IsNullOrEmpty(workspaceRootPath)) + { + // The workspace root path could be an empty string + // when the user has opened a SqlTools script file + // without opening an entire folder (workspace) first. + // In this case we should just log an error and let + // the specified settings path go through even though + // it will fail to load. + Logger.Write( + LogLevel.Error, + "Could not resolve Script Analyzer settings path due to null or empty workspaceRootPath."); + } + else + { + settingsPath = Path.GetFullPath(Path.Combine(workspaceRootPath, settingsPath)); + } + } + + this.SettingsPath = settingsPath; + } + } + } + + + public class LanguageServerSettingsWrapper + { + // NOTE: This property is capitalized as 'SqlTools' because the + // mode name sent from the client is written as 'SqlTools' and + // JSON.net is using camelCasing. + + public LanguageServerSettings SqlTools { get; set; } + } +} diff --git a/src/ServiceHost/Session/EditorSession.cs b/src/ServiceHost/Session/EditorSession.cs new file mode 100644 index 00000000..3c592a8f --- /dev/null +++ b/src/ServiceHost/Session/EditorSession.cs @@ -0,0 +1,75 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using Microsoft.SqlTools.EditorServices.Session; +using Microsoft.SqlTools.LanguageSupport; + +namespace Microsoft.SqlTools.EditorServices +{ + /// + /// Manages a single session for all editor services. This + /// includes managing all open script files for the session. + /// + public class EditorSession : IDisposable + { + #region Properties + + /// + /// Gets the Workspace instance for this session. + /// + public Workspace Workspace { get; private set; } + + /// + /// Gets or sets the Language Service + /// + /// + public LanguageService LanguageService { get; set; } + + /// + /// Gets the SqlToolsContext instance for this session. + /// + public SqlToolsContext SqlToolsContext { get; private set; } + + #endregion + + #region Public Methods + + /// + /// Starts the session using the provided IConsoleHost implementation + /// for the ConsoleService. + /// + /// + /// Provides details about the host application. + /// + /// + /// An object containing the profile paths for the session. + /// + public void StartSession(HostDetails hostDetails, ProfilePaths profilePaths) + { + // Initialize all services + this.SqlToolsContext = new SqlToolsContext(hostDetails, profilePaths); + this.LanguageService = new LanguageService(this.SqlToolsContext); + + // Create a workspace to contain open files + this.Workspace = new Workspace(this.SqlToolsContext.SqlToolsVersion); + } + + #endregion + + #region IDisposable Implementation + + /// + /// Disposes of any Runspaces that were created for the + /// services used in this session. + /// + public void Dispose() + { + } + + #endregion + + } +} diff --git a/src/ServiceHost/Session/HostDetails.cs b/src/ServiceHost/Session/HostDetails.cs new file mode 100644 index 00000000..1a5fc80d --- /dev/null +++ b/src/ServiceHost/Session/HostDetails.cs @@ -0,0 +1,92 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; + +namespace Microsoft.SqlTools.EditorServices.Session +{ + /// + /// Contains details about the current host application (most + /// likely the editor which is using the host process). + /// + public class HostDetails + { + #region Constants + + /// + /// The default host name for SqlTools Editor Services. Used + /// if no host name is specified by the host application. + /// + public const string DefaultHostName = "SqlTools Editor Services Host"; + + /// + /// The default host ID for SqlTools Editor Services. Used + /// for the host-specific profile path if no host ID is specified. + /// + public const string DefaultHostProfileId = "Microsoft.SqlToolsEditorServices"; + + /// + /// The default host version for SqlTools Editor Services. If + /// no version is specified by the host application, we use 0.0.0 + /// to indicate a lack of version. + /// + public static readonly Version DefaultHostVersion = new Version("0.0.0"); + + /// + /// The default host details in a HostDetails object. + /// + public static readonly HostDetails Default = new HostDetails(null, null, null); + + #endregion + + #region Properties + + /// + /// Gets the name of the host. + /// + public string Name { get; private set; } + + /// + /// Gets the profile ID of the host, used to determine the + /// host-specific profile path. + /// + public string ProfileId { get; private set; } + + /// + /// Gets the version of the host. + /// + public Version Version { get; private set; } + + #endregion + + #region Constructors + + /// + /// Creates an instance of the HostDetails class. + /// + /// + /// The display name for the host, typically in the form of + /// "[Application Name] Host". + /// + /// + /// The identifier of the SqlTools host to use for its profile path. + /// loaded. Used to resolve a profile path of the form 'X_profile.ps1' + /// where 'X' represents the value of hostProfileId. If null, a default + /// will be used. + /// + /// The host application's version. + public HostDetails( + string name, + string profileId, + Version version) + { + this.Name = name ?? DefaultHostName; + this.ProfileId = profileId ?? DefaultHostProfileId; + this.Version = version ?? DefaultHostVersion; + } + + #endregion + } +} diff --git a/src/ServiceHost/Session/OutputType.cs b/src/ServiceHost/Session/OutputType.cs new file mode 100644 index 00000000..8ba866d7 --- /dev/null +++ b/src/ServiceHost/Session/OutputType.cs @@ -0,0 +1,41 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.SqlTools.EditorServices +{ + /// + /// Enumerates the types of output lines that will be sent + /// to an IConsoleHost implementation. + /// + public enum OutputType + { + /// + /// A normal output line, usually written with the or Write-Host or + /// Write-Output cmdlets. + /// + Normal, + + /// + /// A debug output line, written with the Write-Debug cmdlet. + /// + Debug, + + /// + /// A verbose output line, written with the Write-Verbose cmdlet. + /// + Verbose, + + /// + /// A warning output line, written with the Write-Warning cmdlet. + /// + Warning, + + /// + /// An error output line, written with the Write-Error cmdlet or + /// as a result of some error during SqlTools pipeline execution. + /// + Error + } +} diff --git a/src/ServiceHost/Session/OutputWrittenEventArgs.cs b/src/ServiceHost/Session/OutputWrittenEventArgs.cs new file mode 100644 index 00000000..4b1dbbe3 --- /dev/null +++ b/src/ServiceHost/Session/OutputWrittenEventArgs.cs @@ -0,0 +1,65 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; + +namespace Microsoft.SqlTools.EditorServices +{ + /// + /// Provides details about output that has been written to the + /// SqlTools host. + /// + public class OutputWrittenEventArgs + { + /// + /// Gets the text of the output. + /// + public string OutputText { get; private set; } + + /// + /// Gets the type of the output. + /// + public OutputType OutputType { get; private set; } + + /// + /// Gets a boolean which indicates whether a newline + /// should be written after the output. + /// + public bool IncludeNewLine { get; private set; } + + /// + /// Gets the foreground color of the output text. + /// + public ConsoleColor ForegroundColor { get; private set; } + + /// + /// Gets the background color of the output text. + /// + public ConsoleColor BackgroundColor { get; private set; } + + /// + /// Creates an instance of the OutputWrittenEventArgs class. + /// + /// The text of the output. + /// A boolean which indicates whether a newline should be written after the output. + /// The type of the output. + /// The foreground color of the output text. + /// The background color of the output text. + public OutputWrittenEventArgs( + string outputText, + bool includeNewLine, + OutputType outputType, + ConsoleColor foregroundColor, + ConsoleColor backgroundColor) + { + this.OutputText = outputText; + this.IncludeNewLine = includeNewLine; + this.OutputType = outputType; + this.ForegroundColor = foregroundColor; + this.BackgroundColor = backgroundColor; + } + } +} + diff --git a/src/ServiceHost/Session/ProfilePaths.cs b/src/ServiceHost/Session/ProfilePaths.cs new file mode 100644 index 00000000..4af38521 --- /dev/null +++ b/src/ServiceHost/Session/ProfilePaths.cs @@ -0,0 +1,109 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace Microsoft.SqlTools.EditorServices.Session +{ + /// + /// Provides profile path resolution behavior relative to the name + /// of a particular SqlTools host. + /// + public class ProfilePaths + { + #region Constants + + /// + /// The file name for the "all hosts" profile. Also used as the + /// suffix for the host-specific profile filenames. + /// + public const string AllHostsProfileName = "profile.ps1"; + + #endregion + + #region Properties + + /// + /// Gets the profile path for all users, all hosts. + /// + public string AllUsersAllHosts { get; private set; } + + /// + /// Gets the profile path for all users, current host. + /// + public string AllUsersCurrentHost { get; private set; } + + /// + /// Gets the profile path for the current user, all hosts. + /// + public string CurrentUserAllHosts { get; private set; } + + /// + /// Gets the profile path for the current user and host. + /// + public string CurrentUserCurrentHost { get; private set; } + + #endregion + + #region Public Methods + + /// + /// Creates a new instance of the ProfilePaths class. + /// + /// + /// The identifier of the host used in the host-specific X_profile.ps1 filename. + /// + /// The base path to use for constructing AllUsers profile paths. + /// The base path to use for constructing CurrentUser profile paths. + public ProfilePaths( + string hostProfileId, + string baseAllUsersPath, + string baseCurrentUserPath) + { + this.Initialize(hostProfileId, baseAllUsersPath, baseCurrentUserPath); + } + + private void Initialize( + string hostProfileId, + string baseAllUsersPath, + string baseCurrentUserPath) + { + string currentHostProfileName = + string.Format( + "{0}_{1}", + hostProfileId, + AllHostsProfileName); + + this.AllUsersCurrentHost = Path.Combine(baseAllUsersPath, currentHostProfileName); + this.CurrentUserCurrentHost = Path.Combine(baseCurrentUserPath, currentHostProfileName); + this.AllUsersAllHosts = Path.Combine(baseAllUsersPath, AllHostsProfileName); + this.CurrentUserAllHosts = Path.Combine(baseCurrentUserPath, AllHostsProfileName); + } + + /// + /// Gets the list of profile paths that exist on the filesystem. + /// + /// An IEnumerable of profile path strings to be loaded. + public IEnumerable GetLoadableProfilePaths() + { + var profilePaths = + new string[] + { + this.AllUsersAllHosts, + this.AllUsersCurrentHost, + this.CurrentUserAllHosts, + this.CurrentUserCurrentHost + }; + + return profilePaths.Where(p => File.Exists(p)); + } + + #endregion + } +} + diff --git a/src/ServiceHost/Session/SqlToolsContext.cs b/src/ServiceHost/Session/SqlToolsContext.cs new file mode 100644 index 00000000..d8016afd --- /dev/null +++ b/src/ServiceHost/Session/SqlToolsContext.cs @@ -0,0 +1,25 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; + +namespace Microsoft.SqlTools.EditorServices.Session +{ + public class SqlToolsContext + { + /// + /// Gets the PowerShell version of the current runspace. + /// + public Version SqlToolsVersion + { + get; private set; + } + + public SqlToolsContext(HostDetails hostDetails, ProfilePaths profilePaths) + { + + } + } +} diff --git a/src/ServiceHost/Utility/AsyncContext.cs b/src/ServiceHost/Utility/AsyncContext.cs new file mode 100644 index 00000000..c0baa889 --- /dev/null +++ b/src/ServiceHost/Utility/AsyncContext.cs @@ -0,0 +1,52 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.SqlTools.EditorServices.Utility +{ + /// + /// Simplifies the setup of a SynchronizationContext for the use + /// of async calls in the current thread. + /// + public static class AsyncContext + { + /// + /// Starts a new ThreadSynchronizationContext, attaches it to + /// the thread, and then runs the given async main function. + /// + /// + /// The Task-returning Func which represents the "main" function + /// for the thread. + /// + public static void Start(Func asyncMainFunc) + { + // Is there already a synchronization context? + if (SynchronizationContext.Current != null) + { + throw new InvalidOperationException( + "A SynchronizationContext is already assigned on this thread."); + } + + // Create and register a synchronization context for this thread + var threadSyncContext = new ThreadSynchronizationContext(); + SynchronizationContext.SetSynchronizationContext(threadSyncContext); + + // Get the main task and act on its completion + Task asyncMainTask = asyncMainFunc(); + asyncMainTask.ContinueWith( + t => threadSyncContext.EndLoop(), + TaskScheduler.Default); + + // Start the synchronization context's request loop and + // wait for the main task to complete + threadSyncContext.RunLoopOnCurrentThread(); + asyncMainTask.GetAwaiter().GetResult(); + } + } +} + diff --git a/src/ServiceHost/Utility/AsyncContextThread.cs b/src/ServiceHost/Utility/AsyncContextThread.cs new file mode 100644 index 00000000..7b7947a5 --- /dev/null +++ b/src/ServiceHost/Utility/AsyncContextThread.cs @@ -0,0 +1,85 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.SqlTools.EditorServices.Utility +{ + /// + /// Provides a simplified interface for creating a new thread + /// and establishing an AsyncContext in it. + /// + public class AsyncContextThread + { + #region Private Fields + + private Task threadTask; + private string threadName; + private CancellationTokenSource threadCancellationToken = + new CancellationTokenSource(); + + #endregion + + #region Constructors + + /// + /// Initializes a new instance of the AsyncContextThread class. + /// + /// + /// The name of the thread for debugging purposes. + /// + public AsyncContextThread(string threadName) + { + this.threadName = threadName; + } + + #endregion + + #region Public Methods + + /// + /// Runs a task on the AsyncContextThread. + /// + /// + /// A Func which returns the task to be run on the thread. + /// + /// + /// A Task which can be used to monitor the thread for completion. + /// + public Task Run(Func taskReturningFunc) + { + // Start up a long-running task with the action as the + // main entry point for the thread + this.threadTask = + Task.Factory.StartNew( + () => + { + // Set the thread's name to help with debugging + Thread.CurrentThread.Name = "AsyncContextThread: " + this.threadName; + + // Set up an AsyncContext to run the task + AsyncContext.Start(taskReturningFunc); + }, + this.threadCancellationToken.Token, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + + return this.threadTask; + } + + /// + /// Stops the thread task. + /// + public void Stop() + { + this.threadCancellationToken.Cancel(); + } + + #endregion + } +} + diff --git a/src/ServiceHost/Utility/AsyncLock.cs b/src/ServiceHost/Utility/AsyncLock.cs new file mode 100644 index 00000000..b12983ce --- /dev/null +++ b/src/ServiceHost/Utility/AsyncLock.cs @@ -0,0 +1,103 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.SqlTools.EditorServices.Utility +{ + /// + /// Provides a simple wrapper over a SemaphoreSlim to allow + /// synchronization locking inside of async calls. Cannot be + /// used recursively. + /// + public class AsyncLock + { + #region Fields + + private Task lockReleaseTask; + private SemaphoreSlim lockSemaphore = new SemaphoreSlim(1, 1); + + #endregion + + #region Constructors + + /// + /// Initializes a new instance of the AsyncLock class. + /// + public AsyncLock() + { + this.lockReleaseTask = + Task.FromResult( + (IDisposable)new LockReleaser(this)); + } + + #endregion + + #region Public Methods + + /// + /// Locks + /// + /// A task which has an IDisposable + public Task LockAsync() + { + return this.LockAsync(CancellationToken.None); + } + + /// + /// Obtains or waits for a lock which can be used to synchronize + /// access to a resource. The wait may be cancelled with the + /// given CancellationToken. + /// + /// + /// A CancellationToken which can be used to cancel the lock. + /// + /// + public Task LockAsync(CancellationToken cancellationToken) + { + Task waitTask = lockSemaphore.WaitAsync(cancellationToken); + + return waitTask.IsCompleted ? + this.lockReleaseTask : + waitTask.ContinueWith( + (t, releaser) => + { + return (IDisposable)releaser; + }, + this.lockReleaseTask.Result, + cancellationToken, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + #endregion + + #region Private Classes + + /// + /// Provides an IDisposable wrapper around an AsyncLock so + /// that it can easily be used inside of a 'using' block. + /// + private class LockReleaser : IDisposable + { + private AsyncLock lockToRelease; + + internal LockReleaser(AsyncLock lockToRelease) + { + this.lockToRelease = lockToRelease; + } + + public void Dispose() + { + this.lockToRelease.lockSemaphore.Release(); + } + } + + #endregion + } +} + diff --git a/src/ServiceHost/Utility/AsyncQueue.cs b/src/ServiceHost/Utility/AsyncQueue.cs new file mode 100644 index 00000000..c899ee78 --- /dev/null +++ b/src/ServiceHost/Utility/AsyncQueue.cs @@ -0,0 +1,155 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.SqlTools.EditorServices.Utility +{ + /// + /// Provides a synchronized queue which can be used from within async + /// operations. This is primarily used for producer/consumer scenarios. + /// + /// The type of item contained in the queue. + public class AsyncQueue + { + #region Private Fields + + private AsyncLock queueLock = new AsyncLock(); + private Queue itemQueue; + private Queue> requestQueue; + + #endregion + + #region Properties + + /// + /// Returns true if the queue is currently empty. + /// + public bool IsEmpty { get; private set; } + + #endregion + + #region Constructors + + /// + /// Initializes an empty instance of the AsyncQueue class. + /// + public AsyncQueue() : this(Enumerable.Empty()) + { + } + + /// + /// Initializes an instance of the AsyncQueue class, pre-populated + /// with the given collection of items. + /// + /// + /// An IEnumerable containing the initial items with which the queue will + /// be populated. + /// + public AsyncQueue(IEnumerable initialItems) + { + this.itemQueue = new Queue(initialItems); + this.requestQueue = new Queue>(); + } + + #endregion + + #region Public Methods + + /// + /// Enqueues an item onto the end of the queue. + /// + /// The item to be added to the queue. + /// + /// A Task which can be awaited until the synchronized enqueue + /// operation completes. + /// + public async Task EnqueueAsync(T item) + { + using (await queueLock.LockAsync()) + { + TaskCompletionSource requestTaskSource = null; + + // Are any requests waiting? + while (this.requestQueue.Count > 0) + { + // Is the next request cancelled already? + requestTaskSource = this.requestQueue.Dequeue(); + if (!requestTaskSource.Task.IsCanceled) + { + // Dispatch the item + requestTaskSource.SetResult(item); + return; + } + } + + // No more requests waiting, queue the item for a later request + this.itemQueue.Enqueue(item); + this.IsEmpty = false; + } + } + + /// + /// Dequeues an item from the queue or waits asynchronously + /// until an item is available. + /// + /// + /// A Task which can be awaited until a value can be dequeued. + /// + public Task DequeueAsync() + { + return this.DequeueAsync(CancellationToken.None); + } + + /// + /// Dequeues an item from the queue or waits asynchronously + /// until an item is available. The wait can be cancelled + /// using the given CancellationToken. + /// + /// + /// A CancellationToken with which a dequeue wait can be cancelled. + /// + /// + /// A Task which can be awaited until a value can be dequeued. + /// + public async Task DequeueAsync(CancellationToken cancellationToken) + { + Task requestTask; + + using (await queueLock.LockAsync(cancellationToken)) + { + if (this.itemQueue.Count > 0) + { + // Items are waiting to be taken so take one immediately + T item = this.itemQueue.Dequeue(); + this.IsEmpty = this.itemQueue.Count == 0; + + return item; + } + else + { + // Queue the request for the next item + var requestTaskSource = new TaskCompletionSource(); + this.requestQueue.Enqueue(requestTaskSource); + + // Register the wait task for cancel notifications + cancellationToken.Register( + () => requestTaskSource.TrySetCanceled()); + + requestTask = requestTaskSource.Task; + } + } + + // Wait for the request task to complete outside of the lock + return await requestTask; + } + + #endregion + } +} + diff --git a/src/ServiceHost/Utility/Extensions.cs b/src/ServiceHost/Utility/Extensions.cs new file mode 100644 index 00000000..c73bc03c --- /dev/null +++ b/src/ServiceHost/Utility/Extensions.cs @@ -0,0 +1,34 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; + +namespace Microsoft.SqlTools.EditorServices.Utility +{ + internal static class ObjectExtensions + { + /// + /// Extension to evaluate an object's ToString() method in an exception safe way. This will + /// extension method will not throw. + /// + /// The object on which to call ToString() + /// The ToString() return value or a suitable error message is that throws. + public static string SafeToString(this object obj) + { + string str; + + try + { + str = obj.ToString(); + } + catch (Exception ex) + { + str = $""; + } + + return str; + } + } +} diff --git a/src/ServiceHost/Utility/Logger.cs b/src/ServiceHost/Utility/Logger.cs new file mode 100644 index 00000000..6bdd39bd --- /dev/null +++ b/src/ServiceHost/Utility/Logger.cs @@ -0,0 +1,222 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.IO; +using System.Runtime.CompilerServices; +using System.Text; + +namespace Microsoft.SqlTools.EditorServices.Utility +{ + /// + /// Defines the level indicators for log messages. + /// + public enum LogLevel + { + /// + /// Indicates a verbose log message. + /// + Verbose, + + /// + /// Indicates a normal, non-verbose log message. + /// + Normal, + + /// + /// Indicates a warning message. + /// + Warning, + + /// + /// Indicates an error message. + /// + Error + } + + /// + /// Provides a simple logging interface. May be replaced with a + /// more robust solution at a later date. + /// + public static class Logger + { + private static LogWriter logWriter; + + /// + /// Initializes the Logger for the current session. + /// + /// + /// Optional. Specifies the path at which log messages will be written. + /// + /// + /// Optional. Specifies the minimum log message level to write to the log file. + /// + public static void Initialize( + string logFilePath = "SqlToolsService.log", + LogLevel minimumLogLevel = LogLevel.Normal) + { + if (logWriter != null) + { + logWriter.Dispose(); + } + + // TODO: Parameterize this + logWriter = + new LogWriter( + minimumLogLevel, + logFilePath, + true); + } + + /// + /// Closes the Logger. + /// + public static void Close() + { + if (logWriter != null) + { + logWriter.Dispose(); + } + } + + /// + /// Writes a message to the log file. + /// + /// The level at which the message will be written. + /// The message text to be written. + /// The name of the calling method. + /// The source file path where the calling method exists. + /// The line number of the calling method. + public static void Write( + LogLevel logLevel, + string logMessage, + [CallerMemberName] string callerName = null, + [CallerFilePath] string callerSourceFile = null, + [CallerLineNumber] int callerLineNumber = 0) + { + if (logWriter != null) + { + logWriter.Write( + logLevel, + logMessage, + callerName, + callerSourceFile, + callerLineNumber); + } + } + } + + internal class LogWriter : IDisposable + { + private TextWriter textWriter; + private LogLevel minimumLogLevel = LogLevel.Verbose; + + public LogWriter(LogLevel minimumLogLevel, string logFilePath, bool deleteExisting) + { + this.minimumLogLevel = minimumLogLevel; + + // Ensure that we have a usable log file path + if (!Path.IsPathRooted(logFilePath)) + { + logFilePath = + Path.Combine( + AppContext.BaseDirectory, + logFilePath); + } + + + if (!this.TryOpenLogFile(logFilePath, deleteExisting)) + { + // If the log file couldn't be opened at this location, + // try opening it in a more reliable path + this.TryOpenLogFile( + Path.Combine( + Environment.GetEnvironmentVariable("TEMP"), + Path.GetFileName(logFilePath)), + deleteExisting); + } + } + + public void Write( + LogLevel logLevel, + string logMessage, + string callerName = null, + string callerSourceFile = null, + int callerLineNumber = 0) + { + if (this.textWriter != null && + logLevel >= this.minimumLogLevel) + { + // Print the timestamp and log level + this.textWriter.WriteLine( + "{0} [{1}] - Method \"{2}\" at line {3} of {4}\r\n", + DateTime.Now, + logLevel.ToString().ToUpper(), + callerName, + callerLineNumber, + callerSourceFile); + + // Print out indented message lines + foreach (var messageLine in logMessage.Split('\n')) + { + this.textWriter.WriteLine(" " + messageLine.TrimEnd()); + } + + // Finish with a newline and flush the writer + this.textWriter.WriteLine(); + this.textWriter.Flush(); + } + } + + public void Dispose() + { + if (this.textWriter != null) + { + this.textWriter.Flush(); + this.textWriter.Dispose(); + this.textWriter = null; + } + } + + private bool TryOpenLogFile( + string logFilePath, + bool deleteExisting) + { + try + { + // Make sure the log directory exists + Directory.CreateDirectory( + Path.GetDirectoryName( + logFilePath)); + + // Open the log file for writing with UTF8 encoding + this.textWriter = + new StreamWriter( + new FileStream( + logFilePath, + deleteExisting ? + FileMode.Create : + FileMode.Append), + Encoding.UTF8); + + return true; + } + catch (Exception e) + { + if (e is UnauthorizedAccessException || + e is IOException) + { + // This exception is thrown when we can't open the file + // at the path in logFilePath. Return false to indicate + // that the log file couldn't be created. + return false; + } + + // Unexpected exception, rethrow it + throw; + } + } + } +} diff --git a/src/ServiceHost/Utility/ThreadSynchronizationContext.cs b/src/ServiceHost/Utility/ThreadSynchronizationContext.cs new file mode 100644 index 00000000..2389bbb0 --- /dev/null +++ b/src/ServiceHost/Utility/ThreadSynchronizationContext.cs @@ -0,0 +1,77 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Collections.Concurrent; +using System.Threading; + +namespace Microsoft.SqlTools.EditorServices.Utility +{ + /// + /// Provides a SynchronizationContext implementation that can be used + /// in console applications or any thread which doesn't have its + /// own SynchronizationContext. + /// + public class ThreadSynchronizationContext : SynchronizationContext + { + #region Private Fields + + private BlockingCollection> requestQueue = + new BlockingCollection>(); + + #endregion + + #region Constructors + + /// + /// Posts a request for execution to the SynchronizationContext. + /// This will be executed on the SynchronizationContext's thread. + /// + /// + /// The callback to be invoked on the SynchronizationContext's thread. + /// + /// + /// A state object to pass along to the callback when executed through + /// the SynchronizationContext. + /// + public override void Post(SendOrPostCallback callback, object state) + { + // Add the request to the queue + this.requestQueue.Add( + new Tuple( + callback, state)); + } + + #endregion + + #region Public Methods + + /// + /// Starts the SynchronizationContext message loop on the current thread. + /// + public void RunLoopOnCurrentThread() + { + Tuple request; + + while (this.requestQueue.TryTake(out request, Timeout.Infinite)) + { + // Invoke the request's callback + request.Item1(request.Item2); + } + } + + /// + /// Ends the SynchronizationContext message loop. + /// + public void EndLoop() + { + // Tell the blocking queue that we're done + this.requestQueue.CompleteAdding(); + } + + #endregion + } +} + diff --git a/src/ServiceHost/Utility/Validate.cs b/src/ServiceHost/Utility/Validate.cs new file mode 100644 index 00000000..c788e67b --- /dev/null +++ b/src/ServiceHost/Utility/Validate.cs @@ -0,0 +1,143 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Collections.Generic; + +namespace Microsoft.SqlTools.EditorServices.Utility +{ + /// + /// Provides common validation methods to simplify method + /// parameter checks. + /// + public static class Validate + { + /// + /// Throws ArgumentNullException if value is null. + /// + /// The name of the parameter being validated. + /// The value of the parameter being validated. + public static void IsNotNull(string parameterName, object valueToCheck) + { + if (valueToCheck == null) + { + throw new ArgumentNullException(parameterName); + } + } + + /// + /// Throws ArgumentOutOfRangeException if the value is outside + /// of the given lower and upper limits. + /// + /// The name of the parameter being validated. + /// The value of the parameter being validated. + /// The lower limit which the value should not be less than. + /// The upper limit which the value should not be greater than. + public static void IsWithinRange( + string parameterName, + int valueToCheck, + int lowerLimit, + int upperLimit) + { + // TODO: Debug assert here if lowerLimit >= upperLimit + + if (valueToCheck < lowerLimit || valueToCheck > upperLimit) + { + throw new ArgumentOutOfRangeException( + parameterName, + valueToCheck, + string.Format( + "Value is not between {0} and {1}", + lowerLimit, + upperLimit)); + } + } + + /// + /// Throws ArgumentOutOfRangeException if the value is greater than or equal + /// to the given upper limit. + /// + /// The name of the parameter being validated. + /// The value of the parameter being validated. + /// The upper limit which the value should be less than. + public static void IsLessThan( + string parameterName, + int valueToCheck, + int upperLimit) + { + if (valueToCheck >= upperLimit) + { + throw new ArgumentOutOfRangeException( + parameterName, + valueToCheck, + string.Format( + "Value is greater than or equal to {0}", + upperLimit)); + } + } + + /// + /// Throws ArgumentOutOfRangeException if the value is less than or equal + /// to the given lower limit. + /// + /// The name of the parameter being validated. + /// The value of the parameter being validated. + /// The lower limit which the value should be greater than. + public static void IsGreaterThan( + string parameterName, + int valueToCheck, + int lowerLimit) + { + if (valueToCheck < lowerLimit) + { + throw new ArgumentOutOfRangeException( + parameterName, + valueToCheck, + string.Format( + "Value is less than or equal to {0}", + lowerLimit)); + } + } + + /// + /// Throws ArgumentException if the value is equal to the undesired value. + /// + /// The type of value to be validated. + /// The name of the parameter being validated. + /// The value that valueToCheck should not equal. + /// The value of the parameter being validated. + public static void IsNotEqual( + string parameterName, + TValue valueToCheck, + TValue undesiredValue) + { + if (EqualityComparer.Default.Equals(valueToCheck, undesiredValue)) + { + throw new ArgumentException( + string.Format( + "The given value '{0}' should not equal '{1}'", + valueToCheck, + undesiredValue), + parameterName); + } + } + + /// + /// Throws ArgumentException if the value is null, an empty string, + /// or a string containing only whitespace. + /// + /// The name of the parameter being validated. + /// The value of the parameter being validated. + public static void IsNotNullOrEmptyString(string parameterName, string valueToCheck) + { + if (string.IsNullOrWhiteSpace(valueToCheck)) + { + throw new ArgumentException( + "Parameter contains a null, empty, or whitespace string.", + parameterName); + } + } + } +} diff --git a/src/ServiceHost/Workspace/BufferPosition.cs b/src/ServiceHost/Workspace/BufferPosition.cs new file mode 100644 index 00000000..8f790d85 --- /dev/null +++ b/src/ServiceHost/Workspace/BufferPosition.cs @@ -0,0 +1,110 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Diagnostics; + +namespace Microsoft.SqlTools.EditorServices +{ + /// + /// Provides details about a position in a file buffer. All + /// positions are expressed in 1-based positions (i.e. the + /// first line and column in the file is position 1,1). + /// + [DebuggerDisplay("Position = {Line}:{Column}")] + public class BufferPosition + { + #region Properties + + /// + /// Provides an instance that represents a position that has not been set. + /// + public static readonly BufferPosition None = new BufferPosition(-1, -1); + + /// + /// Gets the line number of the position in the buffer. + /// + public int Line { get; private set; } + + /// + /// Gets the column number of the position in the buffer. + /// + public int Column { get; private set; } + + #endregion + + #region Constructors + + /// + /// Creates a new instance of the BufferPosition class. + /// + /// The line number of the position. + /// The column number of the position. + public BufferPosition(int line, int column) + { + this.Line = line; + this.Column = column; + } + + #endregion + + #region Public Methods + + /// + /// Compares two instances of the BufferPosition class. + /// + /// The object to which this instance will be compared. + /// True if the positions are equal, false otherwise. + public override bool Equals(object obj) + { + if (!(obj is BufferPosition)) + { + return false; + } + + BufferPosition other = (BufferPosition)obj; + + return + this.Line == other.Line && + this.Column == other.Column; + } + + /// + /// Calculates a unique hash code that represents this instance. + /// + /// A hash code representing this instance. + public override int GetHashCode() + { + return this.Line.GetHashCode() ^ this.Column.GetHashCode(); + } + + /// + /// Compares two positions to check if one is greater than the other. + /// + /// The first position to compare. + /// The second position to compare. + /// True if positionOne is greater than positionTwo. + public static bool operator >(BufferPosition positionOne, BufferPosition positionTwo) + { + return + (positionOne != null && positionTwo == null) || + (positionOne.Line > positionTwo.Line) || + (positionOne.Line == positionTwo.Line && + positionOne.Column > positionTwo.Column); + } + + /// + /// Compares two positions to check if one is less than the other. + /// + /// The first position to compare. + /// The second position to compare. + /// True if positionOne is less than positionTwo. + public static bool operator <(BufferPosition positionOne, BufferPosition positionTwo) + { + return positionTwo > positionOne; + } + + #endregion + } +} diff --git a/src/ServiceHost/Workspace/BufferRange.cs b/src/ServiceHost/Workspace/BufferRange.cs new file mode 100644 index 00000000..5d20598f --- /dev/null +++ b/src/ServiceHost/Workspace/BufferRange.cs @@ -0,0 +1,123 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Diagnostics; + +namespace Microsoft.SqlTools.EditorServices +{ + /// + /// Provides details about a range between two positions in + /// a file buffer. + /// + [DebuggerDisplay("Start = {Start.Line}:{Start.Column}, End = {End.Line}:{End.Column}")] + public class BufferRange + { + #region Properties + + /// + /// Provides an instance that represents a range that has not been set. + /// + public static readonly BufferRange None = new BufferRange(0, 0, 0, 0); + + /// + /// Gets the start position of the range in the buffer. + /// + public BufferPosition Start { get; private set; } + + /// + /// Gets the end position of the range in the buffer. + /// + public BufferPosition End { get; private set; } + + /// + /// Returns true if the current range is non-zero, i.e. + /// contains valid start and end positions. + /// + public bool HasRange + { + get + { + return this.Equals(BufferRange.None); + } + } + + #endregion + + #region Constructors + + /// + /// Creates a new instance of the BufferRange class. + /// + /// The start position of the range. + /// The end position of the range. + public BufferRange(BufferPosition start, BufferPosition end) + { + if (start > end) + { + throw new ArgumentException( + string.Format( + "Start position ({0}, {1}) must come before or be equal to the end position ({2}, {3}).", + start.Line, start.Column, + end.Line, end.Column)); + } + + this.Start = start; + this.End = end; + } + + /// + /// Creates a new instance of the BufferRange class. + /// + /// The 1-based starting line number of the range. + /// The 1-based starting column number of the range. + /// The 1-based ending line number of the range. + /// The 1-based ending column number of the range. + public BufferRange( + int startLine, + int startColumn, + int endLine, + int endColumn) + { + this.Start = new BufferPosition(startLine, startColumn); + this.End = new BufferPosition(endLine, endColumn); + } + + #endregion + + #region Public Methods + + /// + /// Compares two instances of the BufferRange class. + /// + /// The object to which this instance will be compared. + /// True if the ranges are equal, false otherwise. + public override bool Equals(object obj) + { + if (!(obj is BufferRange)) + { + return false; + } + + BufferRange other = (BufferRange)obj; + + return + this.Start.Equals(other.Start) && + this.End.Equals(other.End); + } + + /// + /// Calculates a unique hash code that represents this instance. + /// + /// A hash code representing this instance. + public override int GetHashCode() + { + return this.Start.GetHashCode() ^ this.End.GetHashCode(); + } + + #endregion + } +} + diff --git a/src/ServiceHost/Workspace/FileChange.cs b/src/ServiceHost/Workspace/FileChange.cs new file mode 100644 index 00000000..2f6efdf8 --- /dev/null +++ b/src/ServiceHost/Workspace/FileChange.cs @@ -0,0 +1,38 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.SqlTools.EditorServices +{ + /// + /// Contains details relating to a content change in an open file. + /// + public class FileChange + { + /// + /// The string which is to be inserted in the file. + /// + public string InsertString { get; set; } + + /// + /// The 1-based line number where the change starts. + /// + public int Line { get; set; } + + /// + /// The 1-based column offset where the change starts. + /// + public int Offset { get; set; } + + /// + /// The 1-based line number where the change ends. + /// + public int EndLine { get; set; } + + /// + /// The 1-based column offset where the change ends. + /// + public int EndOffset { get; set; } + } +} diff --git a/src/ServiceHost/Workspace/FilePosition.cs b/src/ServiceHost/Workspace/FilePosition.cs new file mode 100644 index 00000000..2cb58745 --- /dev/null +++ b/src/ServiceHost/Workspace/FilePosition.cs @@ -0,0 +1,110 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.SqlTools.EditorServices +{ + /// + /// Provides details and operations for a buffer position in a + /// specific file. + /// + public class FilePosition : BufferPosition + { + #region Private Fields + + private ScriptFile scriptFile; + + #endregion + + #region Constructors + + /// + /// Creates a new FilePosition instance for the 1-based line and + /// column numbers in the specified file. + /// + /// The ScriptFile in which the position is located. + /// The 1-based line number in the file. + /// The 1-based column number in the file. + public FilePosition( + ScriptFile scriptFile, + int line, + int column) + : base(line, column) + { + this.scriptFile = scriptFile; + } + + /// + /// Creates a new FilePosition instance for the specified file by + /// copying the specified BufferPosition + /// + /// The ScriptFile in which the position is located. + /// The original BufferPosition from which the line and column will be copied. + public FilePosition( + ScriptFile scriptFile, + BufferPosition copiedPosition) + : this(scriptFile, copiedPosition.Line, copiedPosition.Column) + { + scriptFile.ValidatePosition(copiedPosition); + } + + #endregion + + #region Public Methods + + /// + /// Gets a FilePosition relative to this position by adding the + /// provided line and column offset relative to the contents of + /// the current file. + /// + /// The line offset to add to this position. + /// The column offset to add to this position. + /// A new FilePosition instance for the calculated position. + public FilePosition AddOffset(int lineOffset, int columnOffset) + { + return this.scriptFile.CalculatePosition( + this, + lineOffset, + columnOffset); + } + + /// + /// Gets a FilePosition for the line and column position + /// of the beginning of the current line after any initial + /// whitespace for indentation. + /// + /// A new FilePosition instance for the calculated position. + public FilePosition GetLineStart() + { + string scriptLine = scriptFile.FileLines[this.Line - 1]; + + int lineStartColumn = 1; + for (int i = 0; i < scriptLine.Length; i++) + { + if (!char.IsWhiteSpace(scriptLine[i])) + { + lineStartColumn = i + 1; + break; + } + } + + return new FilePosition(this.scriptFile, this.Line, lineStartColumn); + } + + /// + /// Gets a FilePosition for the line and column position + /// of the end of the current line. + /// + /// A new FilePosition instance for the calculated position. + public FilePosition GetLineEnd() + { + string scriptLine = scriptFile.FileLines[this.Line - 1]; + return new FilePosition(this.scriptFile, this.Line, scriptLine.Length + 1); + } + + #endregion + + } +} + diff --git a/src/ServiceHost/Workspace/ScriptFile.cs b/src/ServiceHost/Workspace/ScriptFile.cs new file mode 100644 index 00000000..90d66244 --- /dev/null +++ b/src/ServiceHost/Workspace/ScriptFile.cs @@ -0,0 +1,538 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.EditorServices.Utility; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace Microsoft.SqlTools.EditorServices +{ + /// + /// Contains the details and contents of an open script file. + /// + public class ScriptFile + { + #region Private Fields + + private Version SqlToolsVersion; + + #endregion + + #region Properties + + /// + /// Gets a unique string that identifies this file. At this time, + /// this property returns a normalized version of the value stored + /// in the FilePath property. + /// + public string Id + { + get { return this.FilePath.ToLower(); } + } + + /// + /// Gets the path at which this file resides. + /// + public string FilePath { get; private set; } + + /// + /// Gets the path which the editor client uses to identify this file. + /// + public string ClientFilePath { get; private set; } + + /// + /// Gets or sets a boolean that determines whether + /// semantic analysis should be enabled for this file. + /// For internal use only. + /// + internal bool IsAnalysisEnabled { get; set; } + + /// + /// Gets a boolean that determines whether this file is + /// in-memory or not (either unsaved or non-file content). + /// + public bool IsInMemory { get; private set; } + + /// + /// Gets a string containing the full contents of the file. + /// + public string Contents + { + get + { + return string.Join("\r\n", this.FileLines); + } + } + + /// + /// Gets a BufferRange that represents the entire content + /// range of the file. + /// + public BufferRange FileRange { get; private set; } + + /// + /// Gets the list of syntax markers found by parsing this + /// file's contents. + /// + public ScriptFileMarker[] SyntaxMarkers + { + get; + private set; + } + + /// + /// Gets the list of strings for each line of the file. + /// + internal IList FileLines + { + get; + private set; + } + + /// + /// Gets the array of filepaths dot sourced in this ScriptFile + /// + public string[] ReferencedFiles + { + get; + private set; + } + + #endregion + + #region Constructors + + /// + /// Creates a new ScriptFile instance by reading file contents from + /// the given TextReader. + /// + /// The path at which the script file resides. + /// The path which the client uses to identify the file. + /// The TextReader to use for reading the file's contents. + /// The version of SqlTools for which the script is being parsed. + public ScriptFile( + string filePath, + string clientFilePath, + TextReader textReader, + Version SqlToolsVersion) + { + this.FilePath = filePath; + this.ClientFilePath = clientFilePath; + this.IsAnalysisEnabled = true; + this.IsInMemory = Workspace.IsPathInMemory(filePath); + this.SqlToolsVersion = SqlToolsVersion; + + this.SetFileContents(textReader.ReadToEnd()); + } + + /// + /// Creates a new ScriptFile instance with the specified file contents. + /// + /// The path at which the script file resides. + /// The path which the client uses to identify the file. + /// The initial contents of the script file. + /// The version of SqlTools for which the script is being parsed. + public ScriptFile( + string filePath, + string clientFilePath, + string initialBuffer, + Version SqlToolsVersion) + { + this.FilePath = filePath; + this.ClientFilePath = clientFilePath; + this.IsAnalysisEnabled = true; + this.SqlToolsVersion = SqlToolsVersion; + + this.SetFileContents(initialBuffer); + } + + #endregion + + #region Public Methods + + /// + /// Gets a line from the file's contents. + /// + /// The 1-based line number in the file. + /// The complete line at the given line number. + public string GetLine(int lineNumber) + { + Validate.IsWithinRange( + "lineNumber", lineNumber, + 1, this.FileLines.Count + 1); + + return this.FileLines[lineNumber - 1]; + } + + /// + /// Gets a range of lines from the file's contents. + /// + /// The buffer range from which lines will be extracted. + /// An array of strings from the specified range of the file. + public string[] GetLinesInRange(BufferRange bufferRange) + { + this.ValidatePosition(bufferRange.Start); + this.ValidatePosition(bufferRange.End); + + List linesInRange = new List(); + + int startLine = bufferRange.Start.Line, + endLine = bufferRange.End.Line; + + for (int line = startLine; line <= endLine; line++) + { + string currentLine = this.FileLines[line - 1]; + int startColumn = + line == startLine + ? bufferRange.Start.Column + : 1; + int endColumn = + line == endLine + ? bufferRange.End.Column + : currentLine.Length + 1; + + currentLine = + currentLine.Substring( + startColumn - 1, + endColumn - startColumn); + + linesInRange.Add(currentLine); + } + + return linesInRange.ToArray(); + } + + /// + /// Throws ArgumentOutOfRangeException if the given position is outside + /// of the file's buffer extents. + /// + /// The position in the buffer to be validated. + public void ValidatePosition(BufferPosition bufferPosition) + { + this.ValidatePosition( + bufferPosition.Line, + bufferPosition.Column); + } + + /// + /// Throws ArgumentOutOfRangeException if the given position is outside + /// of the file's buffer extents. + /// + /// The 1-based line to be validated. + /// The 1-based column to be validated. + public void ValidatePosition(int line, int column) + { + if (line < 1 || line > this.FileLines.Count + 1) + { + throw new ArgumentOutOfRangeException("Position is outside of file line range."); + } + + // The maximum column is either one past the length of the string + // or 1 if the string is empty. + string lineString = this.FileLines[line - 1]; + int maxColumn = lineString.Length > 0 ? lineString.Length + 1 : 1; + + if (column < 1 || column > maxColumn) + { + throw new ArgumentOutOfRangeException( + string.Format( + "Position is outside of column range for line {0}.", + line)); + } + } + + /// + /// Applies the provided FileChange to the file's contents + /// + /// The FileChange to apply to the file's contents. + public void ApplyChange(FileChange fileChange) + { + this.ValidatePosition(fileChange.Line, fileChange.Offset); + this.ValidatePosition(fileChange.EndLine, fileChange.EndOffset); + + // Break up the change lines + string[] changeLines = fileChange.InsertString.Split('\n'); + + // Get the first fragment of the first line + string firstLineFragment = + this.FileLines[fileChange.Line - 1] + .Substring(0, fileChange.Offset - 1); + + // Get the last fragment of the last line + string endLine = this.FileLines[fileChange.EndLine - 1]; + string lastLineFragment = + endLine.Substring( + fileChange.EndOffset - 1, + (this.FileLines[fileChange.EndLine - 1].Length - fileChange.EndOffset) + 1); + + // Remove the old lines + for (int i = 0; i <= fileChange.EndLine - fileChange.Line; i++) + { + this.FileLines.RemoveAt(fileChange.Line - 1); + } + + // Build and insert the new lines + int currentLineNumber = fileChange.Line; + for (int changeIndex = 0; changeIndex < changeLines.Length; changeIndex++) + { + // Since we split the lines above using \n, make sure to + // trim the ending \r's off as well. + string finalLine = changeLines[changeIndex].TrimEnd('\r'); + + // Should we add first or last line fragments? + if (changeIndex == 0) + { + // Append the first line fragment + finalLine = firstLineFragment + finalLine; + } + if (changeIndex == changeLines.Length - 1) + { + // Append the last line fragment + finalLine = finalLine + lastLineFragment; + } + + this.FileLines.Insert(currentLineNumber - 1, finalLine); + currentLineNumber++; + } + + // Parse the script again to be up-to-date + this.ParseFileContents(); + } + + /// + /// Calculates the zero-based character offset of a given + /// line and column position in the file. + /// + /// The 1-based line number from which the offset is calculated. + /// The 1-based column number from which the offset is calculated. + /// The zero-based offset for the given file position. + public int GetOffsetAtPosition(int lineNumber, int columnNumber) + { + Validate.IsWithinRange("lineNumber", lineNumber, 1, this.FileLines.Count); + Validate.IsGreaterThan("columnNumber", columnNumber, 0); + + int offset = 0; + + for(int i = 0; i < lineNumber; i++) + { + if (i == lineNumber - 1) + { + // Subtract 1 to account for 1-based column numbering + offset += columnNumber - 1; + } + else + { + // Add an offset to account for the current platform's newline characters + offset += this.FileLines[i].Length + Environment.NewLine.Length; + } + } + + return offset; + } + + /// + /// Calculates a FilePosition relative to a starting BufferPosition + /// using the given 1-based line and column offset. + /// + /// The original BufferPosition from which an new position should be calculated. + /// The 1-based line offset added to the original position in this file. + /// The 1-based column offset added to the original position in this file. + /// A new FilePosition instance with the resulting line and column number. + public FilePosition CalculatePosition( + BufferPosition originalPosition, + int lineOffset, + int columnOffset) + { + int newLine = originalPosition.Line + lineOffset, + newColumn = originalPosition.Column + columnOffset; + + this.ValidatePosition(newLine, newColumn); + + string scriptLine = this.FileLines[newLine - 1]; + newColumn = Math.Min(scriptLine.Length + 1, newColumn); + + return new FilePosition(this, newLine, newColumn); + } + + /// + /// Calculates the 1-based line and column number position based + /// on the given buffer offset. + /// + /// The buffer offset to convert. + /// A new BufferPosition containing the position of the offset. + public BufferPosition GetPositionAtOffset(int bufferOffset) + { + BufferRange bufferRange = + GetRangeBetweenOffsets( + bufferOffset, bufferOffset); + + return bufferRange.Start; + } + + /// + /// Calculates the 1-based line and column number range based on + /// the given start and end buffer offsets. + /// + /// The start offset of the range. + /// The end offset of the range. + /// A new BufferRange containing the positions in the offset range. + public BufferRange GetRangeBetweenOffsets(int startOffset, int endOffset) + { + bool foundStart = false; + int currentOffset = 0; + int searchedOffset = startOffset; + + BufferPosition startPosition = new BufferPosition(0, 0); + BufferPosition endPosition = startPosition; + + int line = 0; + while (line < this.FileLines.Count) + { + if (searchedOffset <= currentOffset + this.FileLines[line].Length) + { + int column = searchedOffset - currentOffset; + + // Have we already found the start position? + if (foundStart) + { + // Assign the end position and end the search + endPosition = new BufferPosition(line + 1, column + 1); + break; + } + else + { + startPosition = new BufferPosition(line + 1, column + 1); + + // Do we only need to find the start position? + if (startOffset == endOffset) + { + endPosition = startPosition; + break; + } + else + { + // Since the end offset can be on the same line, + // skip the line increment and continue searching + // for the end position + foundStart = true; + searchedOffset = endOffset; + continue; + } + } + } + + // Increase the current offset and include newline length + currentOffset += this.FileLines[line].Length + Environment.NewLine.Length; + line++; + } + + return new BufferRange(startPosition, endPosition); + } + + #endregion + + #region Private Methods + + private void SetFileContents(string fileContents) + { + // Split the file contents into lines and trim + // any carriage returns from the strings. + this.FileLines = + fileContents + .Split('\n') + .Select(line => line.TrimEnd('\r')) + .ToList(); + + // Parse the contents to get syntax tree and errors + this.ParseFileContents(); + } + + /// + /// Parses the current file contents to get the AST, tokens, + /// and parse errors. + /// + private void ParseFileContents() + { +#if false + ParseError[] parseErrors = null; + + // First, get the updated file range + int lineCount = this.FileLines.Count; + if (lineCount > 0) + { + this.FileRange = + new BufferRange( + new BufferPosition(1, 1), + new BufferPosition( + lineCount + 1, + this.FileLines[lineCount - 1].Length + 1)); + } + else + { + this.FileRange = BufferRange.None; + } + + try + { +#if SqlToolsv5r2 + // This overload appeared with Windows 10 Update 1 + if (this.SqlToolsVersion.Major >= 5 && + this.SqlToolsVersion.Build >= 10586) + { + // Include the file path so that module relative + // paths are evaluated correctly + this.ScriptAst = + Parser.ParseInput( + this.Contents, + this.FilePath, + out this.scriptTokens, + out parseErrors); + } + else + { + this.ScriptAst = + Parser.ParseInput( + this.Contents, + out this.scriptTokens, + out parseErrors); + } +#else + this.ScriptAst = + Parser.ParseInput( + this.Contents, + out this.scriptTokens, + out parseErrors); +#endif + } + catch (RuntimeException ex) + { + var parseError = + new ParseError( + null, + ex.ErrorRecord.FullyQualifiedErrorId, + ex.Message); + + parseErrors = new[] { parseError }; + this.scriptTokens = new Token[0]; + this.ScriptAst = null; + } + + // Translate parse errors into syntax markers + this.SyntaxMarkers = + parseErrors + .Select(ScriptFileMarker.FromParseError) + .ToArray(); + + //Get all dot sourced referenced files and store them + this.ReferencedFiles = + AstOperations.FindDotSourcedIncludes(this.ScriptAst); +#endif + } + +#endregion + } +} diff --git a/src/ServiceHost/Workspace/ScriptFileMarker.cs b/src/ServiceHost/Workspace/ScriptFileMarker.cs new file mode 100644 index 00000000..87c2576c --- /dev/null +++ b/src/ServiceHost/Workspace/ScriptFileMarker.cs @@ -0,0 +1,56 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.SqlTools.EditorServices +{ + /// + /// Defines the message level of a script file marker. + /// + public enum ScriptFileMarkerLevel + { + /// + /// The marker represents an informational message. + /// + Information = 0, + + /// + /// The marker represents a warning message. + /// + Warning, + + /// + /// The marker represents an error message. + /// + Error + }; + + /// + /// Contains details about a marker that should be displayed + /// for the a script file. The marker information could come + /// from syntax parsing or semantic analysis of the script. + /// + public class ScriptFileMarker + { + #region Properties + + /// + /// Gets or sets the marker's message string. + /// + public string Message { get; set; } + + /// + /// Gets or sets the marker's message level. + /// + public ScriptFileMarkerLevel Level { get; set; } + + /// + /// Gets or sets the ScriptRegion where the marker should appear. + /// + public ScriptRegion ScriptRegion { get; set; } + + #endregion + } +} + diff --git a/src/ServiceHost/Workspace/ScriptRegion.cs b/src/ServiceHost/Workspace/ScriptRegion.cs new file mode 100644 index 00000000..f2fa4ac8 --- /dev/null +++ b/src/ServiceHost/Workspace/ScriptRegion.cs @@ -0,0 +1,89 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +//using System.Management.Automation.Language; + +namespace Microsoft.SqlTools.EditorServices +{ + /// + /// Contains details about a specific region of text in script file. + /// + public sealed class ScriptRegion + { + #region Properties + + /// + /// Gets the file path of the script file in which this region is contained. + /// + public string File { get; set; } + + /// + /// Gets or sets the text that is contained within the region. + /// + public string Text { get; set; } + + /// + /// Gets or sets the starting line number of the region. + /// + public int StartLineNumber { get; set; } + + /// + /// Gets or sets the starting column number of the region. + /// + public int StartColumnNumber { get; set; } + + /// + /// Gets or sets the starting file offset of the region. + /// + public int StartOffset { get; set; } + + /// + /// Gets or sets the ending line number of the region. + /// + public int EndLineNumber { get; set; } + + /// + /// Gets or sets the ending column number of the region. + /// + public int EndColumnNumber { get; set; } + + /// + /// Gets or sets the ending file offset of the region. + /// + public int EndOffset { get; set; } + + #endregion + + #region Constructors + +#if false + /// + /// Creates a new instance of the ScriptRegion class from an + /// instance of an IScriptExtent implementation. + /// + /// + /// The IScriptExtent to copy into the ScriptRegion. + /// + /// + /// A new ScriptRegion instance with the same details as the IScriptExtent. + /// + public static ScriptRegion Create(IScriptExtent scriptExtent) + { + return new ScriptRegion + { + File = scriptExtent.File, + Text = scriptExtent.Text, + StartLineNumber = scriptExtent.StartLineNumber, + StartColumnNumber = scriptExtent.StartColumnNumber, + StartOffset = scriptExtent.StartOffset, + EndLineNumber = scriptExtent.EndLineNumber, + EndColumnNumber = scriptExtent.EndColumnNumber, + EndOffset = scriptExtent.EndOffset + }; + } +#endif + #endregion + } +} diff --git a/src/ServiceHost/Workspace/Workspace.cs b/src/ServiceHost/Workspace/Workspace.cs new file mode 100644 index 00000000..39e1d70f --- /dev/null +++ b/src/ServiceHost/Workspace/Workspace.cs @@ -0,0 +1,248 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.EditorServices.Utility; +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; +using System.Linq; + +namespace Microsoft.SqlTools.EditorServices +{ + /// + /// Manages a "workspace" of script files that are open for a particular + /// editing session. Also helps to navigate references between ScriptFiles. + /// + public class Workspace + { + #region Private Fields + + private Version SqlToolsVersion; + private Dictionary workspaceFiles = new Dictionary(); + + #endregion + + #region Properties + + /// + /// Gets or sets the root path of the workspace. + /// + public string WorkspacePath { get; set; } + + #endregion + + #region Constructors + + /// + /// Creates a new instance of the Workspace class. + /// + /// The version of SqlTools for which scripts will be parsed. + public Workspace(Version SqlToolsVersion) + { + this.SqlToolsVersion = SqlToolsVersion; + } + + #endregion + + #region Public Methods + + /// + /// Gets an open file in the workspace. If the file isn't open but + /// exists on the filesystem, load and return it. + /// + /// The file path at which the script resides. + /// + /// is not found. + /// + /// + /// contains a null or empty string. + /// + public ScriptFile GetFile(string filePath) + { + Validate.IsNotNullOrEmptyString("filePath", filePath); + + // Resolve the full file path + string resolvedFilePath = this.ResolveFilePath(filePath); + string keyName = resolvedFilePath.ToLower(); + + // Make sure the file isn't already loaded into the workspace + ScriptFile scriptFile = null; + if (!this.workspaceFiles.TryGetValue(keyName, out scriptFile)) + { + // This method allows FileNotFoundException to bubble up + // if the file isn't found. + using (FileStream fileStream = new FileStream(resolvedFilePath, FileMode.Open, FileAccess.Read)) + using (StreamReader streamReader = new StreamReader(fileStream, Encoding.UTF8)) + { + scriptFile = + new ScriptFile( + resolvedFilePath, + filePath, + streamReader, + this.SqlToolsVersion); + + this.workspaceFiles.Add(keyName, scriptFile); + } + + Logger.Write(LogLevel.Verbose, "Opened file on disk: " + resolvedFilePath); + } + + return scriptFile; + } + + private string ResolveFilePath(string filePath) + { + if (!IsPathInMemory(filePath)) + { + if (filePath.StartsWith(@"file://")) + { + // Client sent the path in URI format, extract the local path and trim + // any extraneous slashes + Uri fileUri = new Uri(filePath); + filePath = fileUri.LocalPath.TrimStart('/'); + } + + // Some clients send paths with UNIX-style slashes, replace those if necessary + filePath = filePath.Replace('/', '\\'); + + // Clients could specify paths with escaped space, [ and ] characters which .NET APIs + // will not handle. These paths will get appropriately escaped just before being passed + // into the SqlTools engine. + filePath = UnescapePath(filePath); + + // Get the absolute file path + filePath = Path.GetFullPath(filePath); + } + + Logger.Write(LogLevel.Verbose, "Resolved path: " + filePath); + + return filePath; + } + + internal static bool IsPathInMemory(string filePath) + { + // When viewing SqlTools files in the Git diff viewer, VS Code + // sends the contents of the file at HEAD with a URI that starts + // with 'inmemory'. Untitled files which have been marked of + // type SqlTools have a path starting with 'untitled'. + return + filePath.StartsWith("inmemory") || + filePath.StartsWith("untitled"); + } + + /// + /// Unescapes any escaped [, ] or space characters. Typically use this before calling a + /// .NET API that doesn't understand PowerShell escaped chars. + /// + /// The path to unescape. + /// The path with the ` character before [, ] and spaces removed. + public static string UnescapePath(string path) + { + if (!path.Contains("`")) + { + return path; + } + + return Regex.Replace(path, @"`(?=[ \[\]])", ""); + } + + /// + /// Gets a new ScriptFile instance which is identified by the given file + /// path and initially contains the given buffer contents. + /// + /// + /// + /// + public ScriptFile GetFileBuffer(string filePath, string initialBuffer) + { + Validate.IsNotNullOrEmptyString("filePath", filePath); + + // Resolve the full file path + string resolvedFilePath = this.ResolveFilePath(filePath); + string keyName = resolvedFilePath.ToLower(); + + // Make sure the file isn't already loaded into the workspace + ScriptFile scriptFile = null; + if (!this.workspaceFiles.TryGetValue(keyName, out scriptFile)) + { + scriptFile = + new ScriptFile( + resolvedFilePath, + filePath, + initialBuffer, + this.SqlToolsVersion); + + this.workspaceFiles.Add(keyName, scriptFile); + + Logger.Write(LogLevel.Verbose, "Opened file as in-memory buffer: " + resolvedFilePath); + } + + return scriptFile; + } + + /// + /// Gets an array of all opened ScriptFiles in the workspace. + /// + /// An array of all opened ScriptFiles in the workspace. + public ScriptFile[] GetOpenedFiles() + { + return workspaceFiles.Values.ToArray(); + } + + /// + /// Closes a currently open script file with the given file path. + /// + /// The file path at which the script resides. + public void CloseFile(ScriptFile scriptFile) + { + Validate.IsNotNull("scriptFile", scriptFile); + + this.workspaceFiles.Remove(scriptFile.Id); + } + + private string GetBaseFilePath(string filePath) + { + if (IsPathInMemory(filePath)) + { + // If the file is in memory, use the workspace path + return this.WorkspacePath; + } + + if (!Path.IsPathRooted(filePath)) + { + // TODO: Assert instead? + throw new InvalidOperationException( + string.Format( + "Must provide a full path for originalScriptPath: {0}", + filePath)); + } + + // Get the directory of the file path + return Path.GetDirectoryName(filePath); + } + + private string ResolveRelativeScriptPath(string baseFilePath, string relativePath) + { + if (Path.IsPathRooted(relativePath)) + { + return relativePath; + } + + // Get the directory of the original script file, combine it + // with the given path and then resolve the absolute file path. + string combinedPath = + Path.GetFullPath( + Path.Combine( + baseFilePath, + relativePath)); + + return combinedPath; + } + + #endregion + } +} diff --git a/src/ServiceHost/project.json b/src/ServiceHost/project.json new file mode 100644 index 00000000..11340892 --- /dev/null +++ b/src/ServiceHost/project.json @@ -0,0 +1,21 @@ +{ + "version": "1.0.0-*", + "buildOptions": { + "debugType": "portable", + "emitEntryPoint": true + }, + "dependencies": { + "Newtonsoft.Json": "9.0.1" + }, + "frameworks": { + "netcoreapp1.0": { + "dependencies": { + "Microsoft.NETCore.App": { + "type": "platform", + "version": "1.0.0" + } + }, + "imports": "dnxcore50" + } + } +} diff --git a/test/ServiceHost.Test/App.config b/test/ServiceHost.Test/App.config new file mode 100644 index 00000000..570b96df --- /dev/null +++ b/test/ServiceHost.Test/App.config @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/test/ServiceHost.Test/LanguageServer/JsonRpcMessageSerializerTests.cs b/test/ServiceHost.Test/LanguageServer/JsonRpcMessageSerializerTests.cs new file mode 100644 index 00000000..9ec341c5 --- /dev/null +++ b/test/ServiceHost.Test/LanguageServer/JsonRpcMessageSerializerTests.cs @@ -0,0 +1,144 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol; +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol.Serializers; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace Microsoft.SqlTools.EditorServices.Test.Protocol.LanguageServer +{ + public class TestMessageContents + { + public const string SomeFieldValue = "Some value"; + public const int NumberValue = 42; + + public string SomeField { get; set; } + + public int Number { get; set; } + + public TestMessageContents() + { + this.SomeField = SomeFieldValue; + this.Number = NumberValue; + } + } + + public class JsonRpcMessageSerializerTests + { + private IMessageSerializer messageSerializer; + + private const string MessageId = "42"; + private const string MethodName = "testMethod"; + private static readonly JToken MessageContent = JToken.FromObject(new TestMessageContents()); + + public JsonRpcMessageSerializerTests() + { + this.messageSerializer = new JsonRpcMessageSerializer(); + } + + [Fact] + public void SerializesRequestMessages() + { + var messageObj = + this.messageSerializer.SerializeMessage( + Message.Request( + MessageId, + MethodName, + MessageContent)); + + AssertMessageFields( + messageObj, + checkId: true, + checkMethod: true, + checkParams: true); + } + + [Fact] + public void SerializesEventMessages() + { + var messageObj = + this.messageSerializer.SerializeMessage( + Message.Event( + MethodName, + MessageContent)); + + AssertMessageFields( + messageObj, + checkMethod: true, + checkParams: true); + } + + [Fact] + public void SerializesResponseMessages() + { + var messageObj = + this.messageSerializer.SerializeMessage( + Message.Response( + MessageId, + null, + MessageContent)); + + AssertMessageFields( + messageObj, + checkId: true, + checkResult: true); + } + + [Fact] + public void SerializesResponseWithErrorMessages() + { + var messageObj = + this.messageSerializer.SerializeMessage( + Message.ResponseError( + MessageId, + null, + MessageContent)); + + AssertMessageFields( + messageObj, + checkId: true, + checkError: true); + } + + private static void AssertMessageFields( + JObject messageObj, + bool checkId = false, + bool checkMethod = false, + bool checkParams = false, + bool checkResult = false, + bool checkError = false) + { + JToken token = null; + + Assert.True(messageObj.TryGetValue("jsonrpc", out token)); + Assert.Equal("2.0", token.ToString()); + + if (checkId) + { + Assert.True(messageObj.TryGetValue("id", out token)); + Assert.Equal(MessageId, token.ToString()); + } + + if (checkMethod) + { + Assert.True(messageObj.TryGetValue("method", out token)); + Assert.Equal(MethodName, token.ToString()); + } + + if (checkError) + { + // TODO + } + else + { + string contentField = checkParams ? "params" : "result"; + Assert.True(messageObj.TryGetValue(contentField, out token)); + Assert.True(JToken.DeepEquals(token, MessageContent)); + } + } + } +} + diff --git a/test/ServiceHost.Test/Message/MessageReaderWriterTests.cs b/test/ServiceHost.Test/Message/MessageReaderWriterTests.cs new file mode 100644 index 00000000..82e619f5 --- /dev/null +++ b/test/ServiceHost.Test/Message/MessageReaderWriterTests.cs @@ -0,0 +1,177 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol; +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol.Serializers; +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.SqlTools.EditorServices.Test.Protocol.MessageProtocol +{ + public class MessageReaderWriterTests + { + const string TestEventString = "{\"type\":\"event\",\"event\":\"testEvent\",\"body\":null}"; + const string TestEventFormatString = "{{\"event\":\"testEvent\",\"body\":{{\"someString\":\"{0}\"}},\"seq\":0,\"type\":\"event\"}}"; + readonly int ExpectedMessageByteCount = Encoding.UTF8.GetByteCount(TestEventString); + + private IMessageSerializer messageSerializer; + + public MessageReaderWriterTests() + { + this.messageSerializer = new V8MessageSerializer(); + } + + [Fact] + public async Task WritesMessage() + { + MemoryStream outputStream = new MemoryStream(); + + MessageWriter messageWriter = + new MessageWriter( + outputStream, + this.messageSerializer); + + // Write the message and then roll back the stream to be read + // TODO: This will need to be redone! + await messageWriter.WriteMessage(Message.Event("testEvent", null)); + outputStream.Seek(0, SeekOrigin.Begin); + + string expectedHeaderString = + string.Format( + Constants.ContentLengthFormatString, + ExpectedMessageByteCount); + + byte[] buffer = new byte[128]; + await outputStream.ReadAsync(buffer, 0, expectedHeaderString.Length); + + Assert.Equal( + expectedHeaderString, + Encoding.ASCII.GetString(buffer, 0, expectedHeaderString.Length)); + + // Read the message + await outputStream.ReadAsync(buffer, 0, ExpectedMessageByteCount); + + Assert.Equal( + TestEventString, + Encoding.UTF8.GetString(buffer, 0, ExpectedMessageByteCount)); + + outputStream.Dispose(); + } + + [Fact] + public void ReadsMessage() + { + MemoryStream inputStream = new MemoryStream(); + MessageReader messageReader = + new MessageReader( + inputStream, + this.messageSerializer); + + // Write a message to the stream + byte[] messageBuffer = this.GetMessageBytes(TestEventString); + inputStream.Write( + this.GetMessageBytes(TestEventString), + 0, + messageBuffer.Length); + + inputStream.Flush(); + inputStream.Seek(0, SeekOrigin.Begin); + + Message messageResult = messageReader.ReadMessage().Result; + Assert.Equal("testEvent", messageResult.Method); + + inputStream.Dispose(); + } + + [Fact] + public void ReadsManyBufferedMessages() + { + MemoryStream inputStream = new MemoryStream(); + MessageReader messageReader = + new MessageReader( + inputStream, + this.messageSerializer); + + // Get a message to use for writing to the stream + byte[] messageBuffer = this.GetMessageBytes(TestEventString); + + // How many messages of this size should we write to overflow the buffer? + int overflowMessageCount = + (int)Math.Ceiling( + (MessageReader.DefaultBufferSize * 1.5) / messageBuffer.Length); + + // Write the necessary number of messages to the stream + for (int i = 0; i < overflowMessageCount; i++) + { + inputStream.Write(messageBuffer, 0, messageBuffer.Length); + } + + inputStream.Flush(); + inputStream.Seek(0, SeekOrigin.Begin); + + // Read the written messages from the stream + for (int i = 0; i < overflowMessageCount; i++) + { + Message messageResult = messageReader.ReadMessage().Result; + Assert.Equal("testEvent", messageResult.Method); + } + + inputStream.Dispose(); + } + + [Fact] + public void ReaderResizesBufferForLargeMessages() + { + MemoryStream inputStream = new MemoryStream(); + MessageReader messageReader = + new MessageReader( + inputStream, + this.messageSerializer); + + // Get a message with content so large that the buffer will need + // to be resized to fit it all. + byte[] messageBuffer = + this.GetMessageBytes( + string.Format( + TestEventFormatString, + new String('X', (int)(MessageReader.DefaultBufferSize * 3)))); + + inputStream.Write(messageBuffer, 0, messageBuffer.Length); + inputStream.Flush(); + inputStream.Seek(0, SeekOrigin.Begin); + + Message messageResult = messageReader.ReadMessage().Result; + Assert.Equal("testEvent", messageResult.Method); + + inputStream.Dispose(); + } + + private byte[] GetMessageBytes(string messageString, Encoding encoding = null) + { + if (encoding == null) + { + encoding = Encoding.UTF8; + } + + byte[] messageBytes = Encoding.UTF8.GetBytes(messageString); + byte[] headerBytes = + Encoding.ASCII.GetBytes( + string.Format( + Constants.ContentLengthFormatString, + messageBytes.Length)); + + // Copy the bytes into a single buffer + byte[] finalBytes = new byte[headerBytes.Length + messageBytes.Length]; + Buffer.BlockCopy(headerBytes, 0, finalBytes, 0, headerBytes.Length); + Buffer.BlockCopy(messageBytes, 0, finalBytes, headerBytes.Length, messageBytes.Length); + + return finalBytes; + } + } +} + diff --git a/test/ServiceHost.Test/Message/TestMessageTypes.cs b/test/ServiceHost.Test/Message/TestMessageTypes.cs new file mode 100644 index 00000000..cc5981dc --- /dev/null +++ b/test/ServiceHost.Test/Message/TestMessageTypes.cs @@ -0,0 +1,56 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.SqlTools.EditorServices.Protocol.MessageProtocol; +using System; +using System.Threading.Tasks; + +namespace Microsoft.SqlTools.EditorServices.Test.Protocol.MessageProtocol +{ + #region Request Types + + internal class TestRequest + { + public Task ProcessMessage( + EditorSession editorSession, + MessageWriter messageWriter) + { + return Task.FromResult(false); + } + } + + internal class TestRequestArguments + { + public string SomeString { get; set; } + } + + #endregion + + #region Response Types + + internal class TestResponse + { + } + + internal class TestResponseBody + { + public string SomeString { get; set; } + } + + #endregion + + #region Event Types + + internal class TestEvent + { + } + + internal class TestEventBody + { + public string SomeString { get; set; } + } + + #endregion +} diff --git a/test/ServiceHost.Test/PowerShellEditorServices.Test.Protocol.csproj b/test/ServiceHost.Test/PowerShellEditorServices.Test.Protocol.csproj new file mode 100644 index 00000000..54e20896 --- /dev/null +++ b/test/ServiceHost.Test/PowerShellEditorServices.Test.Protocol.csproj @@ -0,0 +1,109 @@ + + + + + + + Debug + AnyCPU + {E3A5CF5D-6E41-44AC-AE0A-4C227E4BACD4} + Library + Properties + Microsoft.SqlTools.EditorServices.Test.Protocol + Microsoft.SqlTools.EditorServices.Test.Protocol + v4.6.1 + 512 + 69e9ba79 + ..\..\ + true + + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + ..\..\packages\Newtonsoft.Json.8.0.2\lib\net45\Newtonsoft.Json.dll + True + + + + + + + + + + ..\..\packages\xunit.abstractions.2.0.0\lib\net35\xunit.abstractions.dll + True + + + ..\..\packages\xunit.assert.2.1.0\lib\portable-net45+win8+wp8+wpa81\xunit.assert.dll + True + + + ..\..\packages\xunit.extensibility.core.2.1.0\lib\portable-net45+win8+wp8+wpa81\xunit.core.dll + True + + + ..\..\packages\xunit.extensibility.execution.2.1.0\lib\net45\xunit.execution.desktop.dll + True + + + + + + + + + + + + + + + + + {f8a0946a-5d25-4651-8079-b8d5776916fb} + SqlToolsEditorServices.Protocol + + + {81e8cbcd-6319-49e7-9662-0475bd0791f4} + SqlToolsEditorServices + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Enable NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + + + + + \ No newline at end of file diff --git a/test/ServiceHost.Test/Properties/AssemblyInfo.cs b/test/ServiceHost.Test/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..5cf54b90 --- /dev/null +++ b/test/ServiceHost.Test/Properties/AssemblyInfo.cs @@ -0,0 +1,42 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("SqlToolsEditorServices.Test.Transport.Stdio")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("SqlToolsEditorServices.Test.Transport.Stdio")] +[assembly: AssemblyCopyright("Copyright � 2015")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("07137FCA-76D0-4CE7-9764-C21DB7A57093")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] + diff --git a/test/ServiceHost.Test/packages.config b/test/ServiceHost.Test/packages.config new file mode 100644 index 00000000..d01e8969 --- /dev/null +++ b/test/ServiceHost.Test/packages.config @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/test/ServiceHost.Test/project.json b/test/ServiceHost.Test/project.json new file mode 100644 index 00000000..b7f00724 --- /dev/null +++ b/test/ServiceHost.Test/project.json @@ -0,0 +1,30 @@ +{ + "version": "1.0.0-*", + "buildOptions": { + "debugType": "portable" + }, + "dependencies": { + "Newtonsoft.Json": "9.0.1", + "System.Runtime.Serialization.Primitives": "4.1.1", + "xunit": "2.1.0", + "dotnet-test-xunit": "1.0.0-rc2-192208-24", + "ServiceHost": { + "target": "project" + } + }, + "testRunner": "xunit", + "frameworks": { + "netcoreapp1.0": { + "dependencies": { + "Microsoft.NETCore.App": { + "type": "platform", + "version": "1.0.0" + } + }, + "imports": [ + "dotnet5.4", + "portable-net451+win8" + ] + } + } +}