// // 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.Diagnostics; using System.Linq; using System.Text; using System.Threading.Tasks; using Microsoft.SqlTools.Hosting.Protocol; using Microsoft.SqlTools.ServiceLayer.Hosting; using Microsoft.SqlTools.ServiceLayer.Workspace.Contracts; using Microsoft.SqlTools.Utility; using Range = Microsoft.SqlTools.ServiceLayer.Workspace.Contracts.Range; namespace Microsoft.SqlTools.ServiceLayer.Workspace { /// /// Class for handling requests/events that deal with the state of the workspace, including the /// opening and closing of files, the changing of configuration, etc. /// /// /// The type of the class used for serializing and deserializing the configuration. Must be the /// actual type of the instance otherwise deserialization will be incomplete. /// public class WorkspaceService where TConfig : class, new() { #region Singleton Instance Implementation private static Lazy> instance = new Lazy>(() => new WorkspaceService()); public static WorkspaceService Instance { get { return instance.Value; } } /// /// Default, parameterless constructor. /// TODO: Figure out how to make this truely singleton even with dependency injection for tests /// public WorkspaceService() { ConfigChangeCallbacks = new List(); TextDocChangeCallbacks = new List(); TextDocOpenCallbacks = new List(); TextDocCloseCallbacks = new List(); CurrentSettings = new TConfig(); } #endregion #region Properties /// /// Workspace object for the service. Virtual to allow for mocking /// public virtual Workspace Workspace { get; internal set; } /// /// Current settings for the workspace /// public TConfig CurrentSettings { get; internal set; } /// /// Delegate for callbacks that occur when the configuration for the workspace changes /// /// The settings that were just set /// The settings before they were changed /// Context of the event that triggered the callback /// public delegate Task ConfigChangeCallback(TConfig newSettings, TConfig oldSettings, EventContext eventContext); /// /// Delegate for callbacks that occur when the current text document changes /// /// Array of files that changed /// Context of the event raised for the changed files public delegate Task TextDocChangeCallback(ScriptFile[] changedFiles, EventContext eventContext); /// /// Delegate for callbacks that occur when a text document is opened /// /// Request uri /// File that was opened /// Context of the event raised for the changed files public delegate Task TextDocOpenCallback(string uri, ScriptFile openFile, EventContext eventContext); /// /// Delegate for callbacks that occur when a text document is closed /// /// Request uri /// File that was closed /// Context of the event raised for changed files public delegate Task TextDocCloseCallback(string uri, ScriptFile closedFile, EventContext eventContext); /// /// List of callbacks to call when the configuration of the workspace changes /// private List ConfigChangeCallbacks { get; set; } /// /// List of callbacks to call when the current text document changes /// private List TextDocChangeCallbacks { get; set; } /// /// List of callbacks to call when a text document is opened /// private List TextDocOpenCallbacks { get; set; } /// /// List of callbacks to call when a text document is closed /// private List TextDocCloseCallbacks { get; set; } #endregion #region Public Methods public void InitializeService(ServiceHost serviceHost) { // Create a workspace that will handle state for the session Workspace = new Workspace(); // Register the handlers for when changes to the workspae occur serviceHost.SetEventHandler(DidChangeTextDocumentNotification.Type, HandleDidChangeTextDocumentNotification); serviceHost.SetEventHandler(DidOpenTextDocumentNotification.Type, HandleDidOpenTextDocumentNotification); serviceHost.SetEventHandler(DidCloseTextDocumentNotification.Type, HandleDidCloseTextDocumentNotification); serviceHost.SetEventHandler(DidChangeConfigurationNotification.Type, HandleDidChangeConfigurationNotification); // Register an initialization handler that sets the workspace path serviceHost.RegisterInitializeTask(async (parameters, contect) => { Logger.Write(TraceEventType.Verbose, "Initializing workspace service"); if (Workspace != null) { Workspace.WorkspacePath = parameters.RootPath; } await Task.FromResult(0); }); // Register a shutdown request that disposes the workspace serviceHost.RegisterShutdownTask(async (parameters, context) => { Logger.Write(TraceEventType.Verbose, "Shutting down workspace service"); if (Workspace != null) { Workspace.Dispose(); Workspace = null; } await Task.FromResult(0); }); } /// /// Adds a new task to be called when the configuration has been changed. Use this to /// handle changing configuration and changing the current configuration. /// /// Task to handle the request public void RegisterConfigChangeCallback(ConfigChangeCallback task) { ConfigChangeCallbacks.Add(task); } /// /// Adds a new task to be called when the text of a document changes. /// /// Delegate to call when the document changes public void RegisterTextDocChangeCallback(TextDocChangeCallback task) { TextDocChangeCallbacks.Add(task); } /// /// Adds a new task to be called when a text document closes. /// /// Delegate to call when the document closes public void RegisterTextDocCloseCallback(TextDocCloseCallback task) { TextDocCloseCallbacks.Add(task); } /// /// Adds a new task to be called when a file is opened /// /// Delegate to call when a document is opened public void RegisterTextDocOpenCallback(TextDocOpenCallback task) { TextDocOpenCallbacks.Add(task); } #endregion #region Event Handlers /// /// Handles text document change events /// internal Task HandleDidChangeTextDocumentNotification( DidChangeTextDocumentParams textChangeParams, EventContext eventContext) { try { 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 ?? textChangeParams.TextDocument.Uri; msg.AppendLine(string.Format(" File: {0}", fileUri)); ScriptFile changedFile = Workspace.GetFile(fileUri); if (changedFile != null) { changedFile.ApplyChange( GetFileChangeDetails( textChange.Range.Value, textChange.Text)); changedFiles.Add(changedFile); } } Logger.Write(TraceEventType.Verbose, msg.ToString()); var handlers = TextDocChangeCallbacks.Select(t => t(changedFiles.ToArray(), eventContext)); return Task.WhenAll(handlers); } catch (Exception ex) { Logger.Write(TraceEventType.Error, "Unknown error " + ex.ToString()); // Swallow exceptions here to prevent us from crashing // TODO: this probably means the ScriptFile model is in a bad state or out of sync with the actual file; we should recover here return Task.FromResult(true); } } internal async Task HandleDidOpenTextDocumentNotification( DidOpenTextDocumentNotification openParams, EventContext eventContext) { try { Logger.Write(TraceEventType.Verbose, "HandleDidOpenTextDocumentNotification"); if (IsScmEvent(openParams.TextDocument.Uri)) { return; } // read the SQL file contents into the ScriptFile ScriptFile openedFile = Workspace.GetFileBuffer(openParams.TextDocument.Uri, openParams.TextDocument.Text); if (openedFile == null) { return; } // Propagate the changes to the event handlers var textDocOpenTasks = TextDocOpenCallbacks.Select( t => t(openParams.TextDocument.Uri, openedFile, eventContext)); await Task.WhenAll(textDocOpenTasks); } catch (Exception ex) { Logger.Write(TraceEventType.Error, "Unknown error " + ex.ToString()); // Swallow exceptions here to prevent us from crashing // TODO: this probably means the ScriptFile model is in a bad state or out of sync with the actual file; we should recover here return; } } internal async Task HandleDidCloseTextDocumentNotification( DidCloseTextDocumentParams closeParams, EventContext eventContext) { try { Logger.Write(TraceEventType.Verbose, "HandleDidCloseTextDocumentNotification"); if (IsScmEvent(closeParams.TextDocument.Uri)) { return; } // Skip closing this file if the file doesn't exist var closedFile = Workspace.GetFile(closeParams.TextDocument.Uri); if (closedFile == null) { return; } // Trash the existing document from our mapping Workspace.CloseFile(closedFile); // Send out a notification to other services that have subscribed to this event var textDocClosedTasks = TextDocCloseCallbacks.Select(t => t(closeParams.TextDocument.Uri, closedFile, eventContext)); await Task.WhenAll(textDocClosedTasks); } catch (Exception ex) { Logger.Write(TraceEventType.Error, "Unknown error " + ex.ToString()); // Swallow exceptions here to prevent us from crashing // TODO: this probably means the ScriptFile model is in a bad state or out of sync with the actual file; we should recover here return; } } /// /// Handles the configuration change event /// internal async Task HandleDidChangeConfigurationNotification( DidChangeConfigurationParams configChangeParams, EventContext eventContext) { try { Logger.Write(TraceEventType.Verbose, "HandleDidChangeConfigurationNotification"); // Propagate the changes to the event handlers var configUpdateTasks = ConfigChangeCallbacks.Select( t => t(configChangeParams.Settings, CurrentSettings, eventContext)); await Task.WhenAll(configUpdateTasks); } catch (Exception ex) { Logger.Write(TraceEventType.Error, "Unknown error " + ex.ToString()); // Swallow exceptions here to prevent us from crashing // TODO: this probably means the ScriptFile model is in a bad state or out of sync with the actual file; we should recover here return; } } #endregion #region Private Helpers /// /// 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 }; } internal static bool IsScmEvent(string filePath) { // if the URI is prefixed with git: then we want to skip processing that file return filePath.StartsWith("git:"); } #endregion } }