From 2a7d18874d73625af3221aa55aaea137d1da6c4a Mon Sep 17 00:00:00 2001 From: Gary Ewan Park Date: Wed, 13 Feb 2019 09:26:34 +0000 Subject: [PATCH] (GH-70) Initial implementation of Language Server - This still needs some work, but it is at least working now - Single rule for templated values has been implemented - Question remains over how to get server into file system - Question remains over sending entire file to server --- .gitignore | 3 + package-lock.json | 32 +++ package.json | 9 +- .../BufferManager.cs | 40 +++ .../Chocolatey.Language.Server.csproj | 14 + .../DiagnosticsHandler.cs | 68 +++++ src/Chocolatey.Language.Server/Program.cs | 43 ++++ .../TextDocumentSyncHandler.cs | 103 ++++++++ .../TextPositions.cs | 241 ++++++++++++++++++ src/extension.ts | 40 ++- 10 files changed, 588 insertions(+), 5 deletions(-) create mode 100644 src/Chocolatey.Language.Server/BufferManager.cs create mode 100644 src/Chocolatey.Language.Server/Chocolatey.Language.Server.csproj create mode 100644 src/Chocolatey.Language.Server/DiagnosticsHandler.cs create mode 100644 src/Chocolatey.Language.Server/Program.cs create mode 100644 src/Chocolatey.Language.Server/TextDocumentSyncHandler.cs create mode 100644 src/Chocolatey.Language.Server/TextPositions.cs diff --git a/.gitignore b/.gitignore index e562dfd4..cdb8fabc 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ yarn.lock # License file copied to chocolatey directory chocolatey/tools/LICENSE.txt config.wyam.hash + +[Bb]in/ +[Oo]bj/ diff --git a/package-lock.json b/package-lock.json index f84d1751..80faeca4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3395,6 +3395,38 @@ } } }, + "vscode-jsonrpc": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-4.0.0.tgz", + "integrity": "sha512-perEnXQdQOJMTDFNv+UF3h1Y0z4iSiaN9jIlb0OqIYgosPCZGYh/MCUlkFtV2668PL69lRDO32hmvL2yiidUYg==", + "dev": true + }, + "vscode-languageclient": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-5.2.1.tgz", + "integrity": "sha512-7jrS/9WnV0ruqPamN1nE7qCxn0phkH5LjSgSp9h6qoJGoeAKzwKz/PF6M+iGA/aklx4GLZg1prddhEPQtuXI1Q==", + "dev": true, + "requires": { + "semver": "^5.5.0", + "vscode-languageserver-protocol": "3.14.1" + } + }, + "vscode-languageserver-protocol": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.14.1.tgz", + "integrity": "sha512-IL66BLb2g20uIKog5Y2dQ0IiigW0XKrvmWiOvc0yXw80z3tMEzEnHjaGAb3ENuU7MnQqgnYJ1Cl2l9RvNgDi4g==", + "dev": true, + "requires": { + "vscode-jsonrpc": "^4.0.0", + "vscode-languageserver-types": "3.14.0" + } + }, + "vscode-languageserver-types": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.14.0.tgz", + "integrity": "sha512-lTmS6AlAlMHOvPQemVwo3CezxBp0sNB95KNPkqp3Nxd5VFEnuG1ByM0zlRWos0zjO3ZWtkvhal0COgiV1xIA4A==", + "dev": true + }, "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 298936c7..081803c7 100644 --- a/package.json +++ b/package.json @@ -27,10 +27,11 @@ "onCommand:chocolatey.pack", "onCommand:chocolatey.delete", "onCommand:chocolatey.push", - "onCommand:chocolatey.installTemplates" + "onCommand:chocolatey.installTemplates", + "workspaceContains:**/*.nuspec" ], "engines": { - "vscode": "^1.24.0" + "vscode": "^1.30.0" }, "categories": [ "Other" @@ -127,7 +128,9 @@ "tslint": "^5.12.1", "typemoq": "^2.1.0", "typescript": "^3.3.3", - "vscode": "^1.1.29" + "vscode": "^1.1.29", + "vscode-languageclient": "^5.2.1", + "vscode-jsonrpc": "^4.0.0" }, "dependencies": { "xml2js": "^0.4.19" diff --git a/src/Chocolatey.Language.Server/BufferManager.cs b/src/Chocolatey.Language.Server/BufferManager.cs new file mode 100644 index 00000000..0f5e8ad2 --- /dev/null +++ b/src/Chocolatey.Language.Server/BufferManager.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Concurrent; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; +using Buffer = Microsoft.Language.Xml.Buffer; + +namespace Chocolatey.Language.Server +{ + public class BufferManager + { + public EventHandler BufferUpdated; + + public BufferManager() + { + } + + private ConcurrentDictionary _buffers = new ConcurrentDictionary(); + + public void UpdateBuffer(Uri uri, Buffer buffer) + { + _buffers.AddOrUpdate(uri, buffer, (k, v) => buffer); + + BufferUpdated?.Invoke(this, new DocumentUpdatedEventArgs(uri)); + } + + public Buffer GetBuffer(Uri uri) + { + return _buffers.TryGetValue(uri, out var buffer) ? buffer : null; + } + } + + public class DocumentUpdatedEventArgs : EventArgs + { + public Uri Uri { get; } + + public DocumentUpdatedEventArgs(Uri uri) + { + Uri = uri; + } + } +} diff --git a/src/Chocolatey.Language.Server/Chocolatey.Language.Server.csproj b/src/Chocolatey.Language.Server/Chocolatey.Language.Server.csproj new file mode 100644 index 00000000..bd269ece --- /dev/null +++ b/src/Chocolatey.Language.Server/Chocolatey.Language.Server.csproj @@ -0,0 +1,14 @@ + + + + Exe + netcoreapp2.1 + latest + + + + + + + + diff --git a/src/Chocolatey.Language.Server/DiagnosticsHandler.cs b/src/Chocolatey.Language.Server/DiagnosticsHandler.cs new file mode 100644 index 00000000..e0cfb027 --- /dev/null +++ b/src/Chocolatey.Language.Server/DiagnosticsHandler.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Xml; +using System.Xml.Schema; +using Microsoft.Language.Xml; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; +using Buffer = Microsoft.Language.Xml.Buffer; +using DiagnosticSeverity = OmniSharp.Extensions.LanguageServer.Protocol.Models.DiagnosticSeverity; + +namespace Chocolatey.Language.Server +{ + public class DiagnosticsHandler + { + private readonly ILanguageServer _router; + private readonly BufferManager _bufferManager; + private static readonly IReadOnlyCollection TemplatedValues = new [] + { + "__replace", + "space_separated", + "tag1" + }; + + public DiagnosticsHandler(ILanguageServer router, BufferManager bufferManager) + { + _router = router; + _bufferManager = bufferManager; + } + + public void PublishDiagnostics(Uri uri, Buffer buffer) + { + var text = buffer.GetText(0, buffer.Length); + var syntaxTree = Parser.Parse(buffer); + var textPositions = new TextPositions(text); + var diagnostics = new List(); + + diagnostics.AddRange(NuspecDoesNotContainTemplatedValuesRequirement(syntaxTree, textPositions)); + + _router.Document.PublishDiagnostics(new PublishDiagnosticsParams + { + Uri = uri, + Diagnostics = diagnostics + }); + } + + private IEnumerable NuspecDoesNotContainTemplatedValuesRequirement(XmlDocumentSyntax syntaxTree, TextPositions textPositions) + { + foreach (var node in syntaxTree.DescendantNodesAndSelf().OfType()) + { + if (!TemplatedValues.Any(x => node.Value.Contains(x, StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + var range = textPositions.GetRange(node.Start, node.End); + + yield return new Diagnostic { + Message = "Templated value which should be removed", + Severity = DiagnosticSeverity.Error, + Range = range + }; + } + } + } +} diff --git a/src/Chocolatey.Language.Server/Program.cs b/src/Chocolatey.Language.Server/Program.cs new file mode 100644 index 00000000..d4018d19 --- /dev/null +++ b/src/Chocolatey.Language.Server/Program.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OmniSharp.Extensions.LanguageServer.Server; + +namespace Chocolatey.Language.Server +{ + class Program + { + static async Task Main(string[] args) + { + var options = new LanguageServerOptions() + .WithInput(Console.OpenStandardInput()) + .WithOutput(Console.OpenStandardOutput()) + .WithLoggerFactory(new LoggerFactory()) + .AddDefaultLoggingProvider() + .WithMinimumLogLevel(LogLevel.Trace) + .WithServices(ConfigureServices) + .WithHandler() + .OnInitialize((s, _) => { + var serviceProvider = (s as LanguageServer).Services; + var bufferManager = serviceProvider.GetService(); + var diagnosticsHandler = serviceProvider.GetService(); + + // Hook up diagnostics + bufferManager.BufferUpdated += (__, x) => diagnosticsHandler.PublishDiagnostics(x.Uri, bufferManager.GetBuffer(x.Uri)); + + return Task.CompletedTask; + }); + + var server = await LanguageServer.From(options); + + await server.WaitForExit; + } + + static void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + } + } +} diff --git a/src/Chocolatey.Language.Server/TextDocumentSyncHandler.cs b/src/Chocolatey.Language.Server/TextDocumentSyncHandler.cs new file mode 100644 index 00000000..7b537e20 --- /dev/null +++ b/src/Chocolatey.Language.Server/TextDocumentSyncHandler.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Schema; +using Microsoft.Language.Xml; +using OmniSharp.Extensions.Embedded.MediatR; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; +using OmniSharp.Extensions.LanguageServer.Protocol.Server.Capabilities; +using DiagnosticSeverity = OmniSharp.Extensions.LanguageServer.Protocol.Models.DiagnosticSeverity; + +namespace Chocolatey.Language.Server +{ + public class TextDocumentSyncHandler : ITextDocumentSyncHandler + { + private readonly ILanguageServer _router; + private readonly BufferManager _bufferManager; + + private readonly DocumentSelector _documentSelector = new DocumentSelector( + new DocumentFilter() + { + Pattern = "**/*.nuspec" + } + ); + + private SynchronizationCapability _capability; + + public TextDocumentSyncHandler(ILanguageServer router, BufferManager bufferManager) + { + _router = router; + _bufferManager = bufferManager; + } + + public TextDocumentSyncKind Change { get; } = TextDocumentSyncKind.Full; + + public TextDocumentChangeRegistrationOptions GetRegistrationOptions() + { + return new TextDocumentChangeRegistrationOptions() + { + DocumentSelector = _documentSelector, + SyncKind = Change + }; + } + + public TextDocumentAttributes GetTextDocumentAttributes(Uri uri) + { + return new TextDocumentAttributes(uri, "xml"); + } + + public Task Handle(DidChangeTextDocumentParams request, CancellationToken cancellationToken) + { + var uri = request.TextDocument.Uri; + var text = request.ContentChanges.FirstOrDefault()?.Text; + + _bufferManager.UpdateBuffer(uri, new StringBuffer(text)); + return Unit.Task; + } + + public Task Handle(DidOpenTextDocumentParams request, CancellationToken cancellationToken) + { + _bufferManager.UpdateBuffer(request.TextDocument.Uri, new StringBuffer(request.TextDocument.Text)); + return Unit.Task; + } + + public Task Handle(DidCloseTextDocumentParams request, CancellationToken cancellationToken) + { + return Unit.Task; + } + + public Task Handle(DidSaveTextDocumentParams request, CancellationToken cancellationToken) + { + return Unit.Task; + } + + public void SetCapability(SynchronizationCapability capability) + { + _capability = capability; + } + + TextDocumentRegistrationOptions IRegistration.GetRegistrationOptions() + { + return new TextDocumentRegistrationOptions() + { + DocumentSelector = _documentSelector + }; + } + + TextDocumentSaveRegistrationOptions IRegistration.GetRegistrationOptions() + { + return new TextDocumentSaveRegistrationOptions() + { + DocumentSelector = _documentSelector, + IncludeText = default + }; + } + } +} diff --git a/src/Chocolatey.Language.Server/TextPositions.cs b/src/Chocolatey.Language.Server/TextPositions.cs new file mode 100644 index 00000000..9d88d6ed --- /dev/null +++ b/src/Chocolatey.Language.Server/TextPositions.cs @@ -0,0 +1,241 @@ +/* +Derived from https://github.com/tintoy/msbuild-project-tools-server/blob/37f635e4cd2ddcaebb32ad113dad1cbbc331a92e/src/LanguageServer.Common/Utilities/TextPositions.cs + +MIT License + +Copyright (c) 2017 Adam Friedman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +using System; +using System.Collections.Generic; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Chocolatey.Language.Server +{ + /// + /// A quick-and-dirty calculator for text positions. + /// + /// + /// This could easily be improved by also storing a character sub-total for each line. + /// + public sealed class TextPositions + { + /// + /// The absolution starting position, within the text, of each line. + /// + readonly long[] _lineStartPositions; + + /// + /// Create a new for the specified text. + /// + /// + /// The text. + /// + public TextPositions(string text) + { + if (text == null) + throw new ArgumentNullException(nameof(text)); + + _lineStartPositions = CalculateLineStartPositions(text); + } + + /// + /// The number of lines in the text. + /// + public int LineCount => _lineStartPositions.Length; + + /// + /// The absolution starting position, within the text, of each line. + /// + public IReadOnlyList LineStartPositions => _lineStartPositions; + + /// + /// Convert a to an absolute position within the text. + /// + /// + /// The target (0-based or 1-based). + /// + /// + /// The equivalent absolute position (0-based) within the text. + /// + public long GetAbsolutePosition(Position position) + { + if (position == null) + throw new ArgumentNullException(nameof(position)); + + return GetAbsolutePosition(position.Line, position.Character); + } + + /// + /// Convert line and column numbers to an absolute position within the text. + /// + /// + /// The target line (0-based). + /// + /// + /// The target column (0-based). + /// + /// + /// The equivalent absolute position within the text. + /// + public long GetAbsolutePosition(long line, long column) + { + if (line < 0) + throw new ArgumentOutOfRangeException(nameof(line), line, "Line cannot be less than 0."); + + if (line >= _lineStartPositions.Length) + throw new ArgumentOutOfRangeException(nameof(line), line, "Line is past the end of the text."); + + if (column < 0) + throw new ArgumentOutOfRangeException(nameof(column), column, "Column cannot be less than 0."); + + return _lineStartPositions[line] + column; + } + + /// + /// Convert an absolute position to a line and column in the text. + /// + /// + /// The absolute position (0-based). + /// + /// + /// The equivalent within the text. + /// + public Position GetPosition(int absolutePosition) + { + int targetLine = Array.BinarySearch(_lineStartPositions, absolutePosition); + if (targetLine < 0) + targetLine = ~targetLine - 1; // No match, so BinarySearch returns 2's complement of the following line index. + + // Internally, we're 0-based, but lines and columns are (by convention) 1-based. + return new Position( + targetLine, + absolutePosition - _lineStartPositions[targetLine] + ); + } + + /// + /// Get a representing the specified absolute positions. + /// + /// + /// The (0-based) absolute start position. + /// + /// + /// The (1-based) absolute end position. + /// + /// + /// The . + /// + public Range GetRange(int absoluteStartPosition, int absoluteEndPosition) + { + return new Range( + start: GetPosition(absoluteStartPosition), + end: GetPosition(absoluteEndPosition) + ); + } + + /// + /// Calculate the length of the specified in the text. + /// + /// + /// The range. + /// + /// + /// The range length. + /// + public long GetLength(Range range) + { + if (range == null) + throw new ArgumentNullException(nameof(range)); + + return GetDistance(range.Start, range.End); + } + + /// + /// Calculate the number of characters, in the text, between the specified positions. + /// + /// + /// The first position. + /// + /// + /// The second position. + /// + /// + /// The difference in offset between and (can be negative). + /// + public long GetDistance(Position position1, Position position2) + { + if (position1 == null) + throw new ArgumentNullException(nameof(position1)); + + if (position2 == null) + throw new ArgumentNullException(nameof(position2)); + + return GetAbsolutePosition(position2) - GetAbsolutePosition(position1); + } + + /// + /// Calculate the start position for each line in the text. + /// + /// + /// The text to scan. + /// + /// + /// An array of line starting positions. + /// + long[] CalculateLineStartPositions(string text) + { + if (text == null) + throw new ArgumentNullException(nameof(text)); + + List lineStarts = new List(); + + int currentPosition = 0; + int currentLineStart = 0; + while (currentPosition < text.Length) + { + char currentChar = text[currentPosition]; + currentPosition++; + + switch (currentChar) + { + case '\r': + { + if (currentPosition < text.Length && text[currentPosition] == '\n') + currentPosition++; + + goto case '\n'; + } + case '\n': + { + lineStarts.Add(currentLineStart); + currentLineStart = currentPosition; + + break; + } + } + } + lineStarts.Add(currentLineStart); + + return lineStarts.ToArray(); + } + } +} diff --git a/src/extension.ts b/src/extension.ts index a7b033e7..e829c734 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,4 +1,6 @@ -import {window, commands, workspace, QuickPickItem} from "vscode"; +import { window, commands, workspace, QuickPickItem, ExtensionContext } from "vscode"; +import { LanguageClient, LanguageClientOptions, ServerOptions } from 'vscode-languageclient'; +import { Trace } from 'vscode-jsonrpc'; import * as chocolateyCli from "./ChocolateyCliManager"; import * as chocolateyOps from "./ChocolateyOperation"; import * as path from "path"; @@ -7,13 +9,47 @@ import * as fs from "fs"; var chocolateyManager : chocolateyCli.ChocolateyCliManager; var installed : boolean = false; -export function activate(): void { +export function activate(context: ExtensionContext): void { // register Commands commands.registerCommand("chocolatey.new", () => execute("new")); commands.registerCommand("chocolatey.pack", () => execute("pack")); commands.registerCommand("chocolatey.delete", () => deleteNupkgs()); commands.registerCommand("chocolatey.push", () => execute("push")); commands.registerCommand("chocolatey.installTemplates", () => execute("installTemplates")); + + let serverExe = 'dotnet'; + + // If the extension is launched in debug mode then the debug server options are used + // Otherwise the run options are used + let serverOptions: ServerOptions = { + // TODO: For the time being, this path is hard-coded + // A decision has to be made about how the Language Server is going to be placed on the file server for execution + run: { command: serverExe, args: ['/Users/gep13/github/gep13/chocolatey-vscode/src/Chocolatey.Language.Server/bin/Debug/netcoreapp2.1/Chocolatey.Language.Server.dll'] }, + debug: { command: serverExe, args: ['/Users/gep13/github/gep13/chocolatey-vscode/src/Chocolatey.Language.Server/bin/Debug/netcoreapp2.1/Chocolatey.Language.Server.dll'] } + } + + // Options to control the language client + let clientOptions: LanguageClientOptions = { + // Register the server for plain text documents + documentSelector: [ + { + pattern: '**/*.nuspec', + } + ], + synchronize: { + configurationSection: 'nuspec', + fileEvents: workspace.createFileSystemWatcher('**/*.nuspec') + }, + } + + // Create the language client and start the client. + const client = new LanguageClient('nuspec', 'nuspec', serverOptions, clientOptions); + client.trace = Trace.Verbose; + let disposable = client.start(); + + // Push the disposable to the context's subscriptions so that the + // client can be deactivated on extension deactivation + context.subscriptions.push(disposable); } function deleteNupkgs():void {