diff --git a/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/AutoCompleteHelper.cs b/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/AutoCompleteHelper.cs index a2c51c9a..9667c0e3 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/AutoCompleteHelper.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/AutoCompleteHelper.cs @@ -15,6 +15,7 @@ using Microsoft.SqlServer.Management.SqlParser.Parser; using Microsoft.SqlTools.ServiceLayer.Connection; using Microsoft.SqlTools.ServiceLayer.LanguageServices.Contracts; using Microsoft.SqlTools.ServiceLayer.SqlContext; +using Microsoft.SqlTools.ServiceLayer.Utility; using Microsoft.SqlTools.ServiceLayer.Workspace; using Microsoft.SqlTools.ServiceLayer.Workspace.Contracts; @@ -679,5 +680,77 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices return null; } } + + /// + /// Converts a SQL Parser List of MethodHelpText objects into a VS Code SignatureHelp object + /// + internal static SignatureHelp ConvertMethodHelpTextListToSignatureHelp(List methods, Babel.MethodNameAndParamLocations locations, int line, int column) + { + Validate.IsNotNull(nameof(methods), methods); + Validate.IsNotNull(nameof(locations), locations); + Validate.IsGreaterThan(nameof(line), line, 0); + Validate.IsGreaterThan(nameof(column), column, 0); + + SignatureHelp help = new SignatureHelp(); + + help.Signatures = methods.Select(method => + { + return new SignatureInformation() + { + // Signature label format: param1, param2, ..., paramn RETURNS + Label = method.Name + " " + method.Parameters.Select(parameter => parameter.Display).Aggregate((l, r) => l + "," + r) + " " + method.Type, + Documentation = method.Description, + Parameters = method.Parameters.Select(parameter => + { + return new ParameterInformation() + { + Label = parameter.Display, + Documentation = parameter.Description + }; + }).ToArray() + }; + }).Where(method => method.Label.Contains(locations.Name)).ToArray(); + + if (help.Signatures.Length == 0) + { + return null; + } + + // Find the matching method signature at the cursor's location + // For now, take the first match (since we've already filtered by name above) + help.ActiveSignature = 0; + + // Determine the current parameter at the cursor + int currentParameter = -1; // Default case: not on any particular parameter + if (locations.ParamStartLocation != null) + { + // Is the cursor past the function name? + var location = locations.ParamStartLocation.Value; + if (line > location.LineNumber || (line == location.LineNumber && line == location.LineNumber && column >= location.ColumnNumber)) + { + currentParameter = 0; + } + } + foreach (var location in locations.ParamSeperatorLocations) + { + // Is the cursor past a comma ',' and at least on the next parameter? + if (line > location.LineNumber || (line == location.LineNumber && column > location.ColumnNumber)) + { + currentParameter++; + } + } + if (locations.ParamEndLocation != null) + { + // Is the cursor past the end of the parameter list on a different token? + var location = locations.ParamEndLocation.Value; + if (line > location.LineNumber || (line == location.LineNumber && line == location.LineNumber && column > location.ColumnNumber)) + { + currentParameter = -1; + } + } + help.ActiveParameter = currentParameter; + + return help; + } } } diff --git a/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/LanguageService.cs b/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/LanguageService.cs index eb2713ef..8d164861 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/LanguageService.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/LanguageService.cs @@ -200,9 +200,9 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices // turn off until needed (10/28/2016) // serviceHost.SetRequestHandler(DefinitionRequest.Type, HandleDefinitionRequest); // serviceHost.SetRequestHandler(ReferencesRequest.Type, HandleReferencesRequest); - // serviceHost.SetRequestHandler(SignatureHelpRequest.Type, HandleSignatureHelpRequest); // serviceHost.SetRequestHandler(DocumentHighlightRequest.Type, HandleDocumentHighlightRequest); + serviceHost.SetRequestHandler(SignatureHelpRequest.Type, HandleSignatureHelpRequest); serviceHost.SetRequestHandler(CompletionResolveRequest.Type, HandleCompletionResolveRequest); serviceHost.SetRequestHandler(HoverRequest.Type, HandleHoverRequest); serviceHost.SetRequestHandler(CompletionRequest.Type, HandleCompletionRequest); @@ -309,13 +309,6 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices await Task.FromResult(true); } - private static async Task HandleSignatureHelpRequest( - TextDocumentPosition textDocumentPosition, - RequestContext requestContext) - { - await Task.FromResult(true); - } - private static async Task HandleDocumentHighlightRequest( TextDocumentPosition textDocumentPosition, RequestContext requestContext) @@ -324,6 +317,32 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices } #endif + private static async Task HandleSignatureHelpRequest( + TextDocumentPosition textDocumentPosition, + RequestContext requestContext) + { + // check if Intellisense suggestions are enabled + if (!WorkspaceService.Instance.CurrentSettings.IsSuggestionsEnabled) + { + await Task.FromResult(true); + } + else + { + ScriptFile scriptFile = WorkspaceService.Instance.Workspace.GetFile( + textDocumentPosition.TextDocument.Uri); + + SignatureHelp help = LanguageService.Instance.GetSignatureHelp(textDocumentPosition, scriptFile); + if (help != null) + { + await requestContext.SendResult(help); + } + else + { + await requestContext.SendResult(new SignatureHelp()); + } + } + } + private static async Task HandleHoverRequest( TextDocumentPosition textDocumentPosition, RequestContext requestContext) @@ -690,6 +709,76 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices return null; } + /// + /// Get function signature help for the current position + /// + internal SignatureHelp GetSignatureHelp(TextDocumentPosition textDocumentPosition, ScriptFile scriptFile) + { + int startLine = textDocumentPosition.Position.Line; + int startColumn = TextUtilities.PositionOfPrevDelimeter( + scriptFile.Contents, + textDocumentPosition.Position.Line, + textDocumentPosition.Position.Character); + int endColumn = textDocumentPosition.Position.Character; + + ScriptParseInfo scriptParseInfo = GetScriptParseInfo(textDocumentPosition.TextDocument.Uri); + + ConnectionInfo connInfo; + LanguageService.ConnectionServiceInstance.TryFindConnection( + scriptFile.ClientFilePath, + out connInfo); + + // reparse and bind the SQL statement if needed + if (RequiresReparse(scriptParseInfo, scriptFile)) + { + ParseAndBind(scriptFile, connInfo); + } + + if (scriptParseInfo != null && scriptParseInfo.ParseResult != null) + { + if (Monitor.TryEnter(scriptParseInfo.BuildingMetadataLock)) + { + try + { + QueueItem queueItem = this.BindingQueue.QueueBindingOperation( + key: scriptParseInfo.ConnectionKey, + bindingTimeout: LanguageService.BindingTimeout, + bindOperation: (bindingContext, cancelToken) => + { + // get the list of possible current methods for signature help + var methods = Resolver.FindMethods( + scriptParseInfo.ParseResult, + startLine + 1, + endColumn + 1, + bindingContext.MetadataDisplayInfoProvider); + + // get positional information on the current method + var methodLocations = Resolver.GetMethodNameAndParams(scriptParseInfo.ParseResult, + startLine + 1, + endColumn + 1, + bindingContext.MetadataDisplayInfoProvider); + + // convert from the parser format to the VS Code wire format + return AutoCompleteHelper.ConvertMethodHelpTextListToSignatureHelp(methods, + methodLocations, + startLine + 1, + endColumn + 1); + }); + + queueItem.ItemProcessed.WaitOne(); + return queueItem.GetResultAsT(); + } + finally + { + Monitor.Exit(scriptParseInfo.BuildingMetadataLock); + } + } + } + + // return null if there isn't a tooltip for the current location + return null; + } + /// /// Return the completion item list for the current text position. /// This method does not await cache builds since it expects to return quickly diff --git a/test/Microsoft.SqlTools.ServiceLayer.TestDriver/Tests/LanguageServiceTests.cs b/test/Microsoft.SqlTools.ServiceLayer.TestDriver/Tests/LanguageServiceTests.cs index 039b0b6f..dd93a8e6 100644 --- a/test/Microsoft.SqlTools.ServiceLayer.TestDriver/Tests/LanguageServiceTests.cs +++ b/test/Microsoft.SqlTools.ServiceLayer.TestDriver/Tests/LanguageServiceTests.cs @@ -3,6 +3,8 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. // +using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.SqlTools.ServiceLayer.LanguageServices.Contracts; @@ -255,5 +257,172 @@ namespace Microsoft.SqlTools.ServiceLayer.TestDriver.Tests await testHelper.Disconnect(queryTempFile.FilePath); } } + + [Fact] + public async Task FunctionSignatureCompletionReturnsEmptySignatureHelpObjectWhenThereAreNoMatches() + { + string sqlText = "EXEC sys.fn_not_a_real_function "; + + using (SelfCleaningTempFile tempFile = new SelfCleaningTempFile()) + using (TestHelper testHelper = new TestHelper()) + { + string ownerUri = tempFile.FilePath; + File.WriteAllText(ownerUri, sqlText); + + // Connect + await testHelper.Connect(ownerUri, ConnectionTestUtils.LocalhostConnection); + + // Wait for intellisense to be ready + var readyParams = await testHelper.Driver.WaitForEvent(IntelliSenseReadyNotification.Type, 30000); + Assert.NotNull(readyParams); + Assert.Equal(ownerUri, readyParams.OwnerUri); + + // Send a function signature help Request + var position = new TextDocumentPosition() + { + TextDocument = new TextDocumentIdentifier() + { + Uri = ownerUri + }, + Position = new Position() + { + Line = 0, + Character = sqlText.Length + } + }; + var signatureHelp = await testHelper.Driver.SendRequest(SignatureHelpRequest.Type, position); + + Assert.NotNull(signatureHelp); + Assert.False(signatureHelp.ActiveSignature.HasValue); + Assert.Null(signatureHelp.Signatures); + + await testHelper.Disconnect(ownerUri); + } + } + + [Fact] + public async Task FunctionSignatureCompletionReturnsCorrectFunction() + { + string sqlText = "EXEC sys.fn_isrolemember "; + + using (SelfCleaningTempFile tempFile = new SelfCleaningTempFile()) + using (TestHelper testHelper = new TestHelper()) + { + string ownerUri = tempFile.FilePath; + File.WriteAllText(ownerUri, sqlText); + + // Connect + await testHelper.Connect(ownerUri, ConnectionTestUtils.LocalhostConnection); + + // Wait for intellisense to be ready + var readyParams = await testHelper.Driver.WaitForEvent(IntelliSenseReadyNotification.Type, 30000); + Assert.NotNull(readyParams); + Assert.Equal(ownerUri, readyParams.OwnerUri); + + // Send a function signature help Request + var position = new TextDocumentPosition() + { + TextDocument = new TextDocumentIdentifier() + { + Uri = ownerUri + }, + Position = new Position() + { + Line = 0, + Character = sqlText.Length + } + }; + var signatureHelp = await testHelper.Driver.SendRequest(SignatureHelpRequest.Type, position); + + Assert.NotNull(signatureHelp); + Assert.True(signatureHelp.ActiveSignature.HasValue); + Assert.NotEmpty(signatureHelp.Signatures); + + var label = signatureHelp.Signatures[signatureHelp.ActiveSignature.Value].Label; + Assert.NotNull(label); + Assert.NotEmpty(label); + Assert.True(label.Contains("fn_isrolemember")); + + await testHelper.Disconnect(ownerUri); + } + } + + [Fact] + public async Task FunctionSignatureCompletionReturnsCorrectParametersAtEachPosition() + { + string sqlText = "EXEC sys.fn_isrolemember 1, 'testing', 2"; + + using (SelfCleaningTempFile tempFile = new SelfCleaningTempFile()) + using (TestHelper testHelper = new TestHelper()) + { + string ownerUri = tempFile.FilePath; + File.WriteAllText(ownerUri, sqlText); + + // Connect + await testHelper.Connect(ownerUri, ConnectionTestUtils.LocalhostConnection); + + // Wait for intellisense to be ready + var readyParams = await testHelper.Driver.WaitForEvent(IntelliSenseReadyNotification.Type, 30000); + Assert.NotNull(readyParams); + Assert.Equal(ownerUri, readyParams.OwnerUri); + + // Verify all parameters when the cursor is inside of parameters and at separator boundaries (,) + await VerifyFunctionSignatureHelpParameter(testHelper, ownerUri, 25, "fn_isrolemember", 0, "@mode int"); + await VerifyFunctionSignatureHelpParameter(testHelper, ownerUri, 26, "fn_isrolemember", 0, "@mode int"); + await VerifyFunctionSignatureHelpParameter(testHelper, ownerUri, 27, "fn_isrolemember", 1, "@login sysname"); + await VerifyFunctionSignatureHelpParameter(testHelper, ownerUri, 30, "fn_isrolemember", 1, "@login sysname"); + await VerifyFunctionSignatureHelpParameter(testHelper, ownerUri, 37, "fn_isrolemember", 1, "@login sysname"); + await VerifyFunctionSignatureHelpParameter(testHelper, ownerUri, 38, "fn_isrolemember", 2, "@tranpubid int"); + await VerifyFunctionSignatureHelpParameter(testHelper, ownerUri, 39, "fn_isrolemember", 2, "@tranpubid int"); + + await testHelper.Disconnect(ownerUri); + } + } + + public async Task VerifyFunctionSignatureHelpParameter( + TestHelper testHelper, + string ownerUri, + int character, + string expectedFunctionName, + int expectedParameterIndex, + string expectedParameterName) + { + var position = new TextDocumentPosition() + { + TextDocument = new TextDocumentIdentifier() + { + Uri = ownerUri + }, + Position = new Position() + { + Line = 0, + Character = character + } + }; + var signatureHelp = await testHelper.Driver.SendRequest(SignatureHelpRequest.Type, position); + + Assert.NotNull(signatureHelp); + Assert.NotNull(signatureHelp.ActiveSignature); + Assert.True(signatureHelp.ActiveSignature.HasValue); + Assert.NotEmpty(signatureHelp.Signatures); + + var activeSignature = signatureHelp.Signatures[signatureHelp.ActiveSignature.Value]; + Assert.NotNull(activeSignature); + + var label = activeSignature.Label; + Assert.NotNull(label); + Assert.NotEmpty(label); + Assert.True(label.Contains(expectedFunctionName)); + + Assert.NotNull(signatureHelp.ActiveParameter); + Assert.True(signatureHelp.ActiveParameter.HasValue); + Assert.Equal(expectedParameterIndex, signatureHelp.ActiveParameter.Value); + + var parameter = activeSignature.Parameters[signatureHelp.ActiveParameter.Value]; + Assert.NotNull(parameter); + Assert.NotNull(parameter.Label); + Assert.NotEmpty(parameter.Label); + Assert.Equal(expectedParameterName, parameter.Label); + } } }