diff --git a/src/main/groovy/nextflow/lsp/NextflowLanguageServer.java b/src/main/groovy/nextflow/lsp/NextflowLanguageServer.java index 5bffd17..9d98a1c 100644 --- a/src/main/groovy/nextflow/lsp/NextflowLanguageServer.java +++ b/src/main/groovy/nextflow/lsp/NextflowLanguageServer.java @@ -78,6 +78,9 @@ import org.eclipse.lsp4j.RenameParams; import org.eclipse.lsp4j.ServerCapabilities; import org.eclipse.lsp4j.SetTraceParams; +import org.eclipse.lsp4j.SignatureHelp; +import org.eclipse.lsp4j.SignatureHelpOptions; +import org.eclipse.lsp4j.SignatureHelpParams; import org.eclipse.lsp4j.SymbolInformation; import org.eclipse.lsp4j.TextDocumentSyncKind; import org.eclipse.lsp4j.TextEdit; @@ -163,6 +166,8 @@ public CompletableFuture initialize(InitializeParams params) { serverCapabilities.setHoverProvider(true); serverCapabilities.setReferencesProvider(true); serverCapabilities.setRenameProvider(true); + var signatureHelpOptions = new SignatureHelpOptions(List.of("(", ",")); + serverCapabilities.setSignatureHelpProvider(signatureHelpOptions); serverCapabilities.setWorkspaceSymbolProvider(true); var initializeResult = new InitializeResult(serverCapabilities); @@ -406,6 +411,20 @@ public CompletableFuture rename(RenameParams params) { }); } + @Override + public CompletableFuture signatureHelp(SignatureHelpParams params) { + return CompletableFutures.computeAsync((cancelChecker) -> { + cancelChecker.checkCanceled(); + var uri = params.getTextDocument().getUri(); + var position = params.getPosition(); + log.debug(String.format("textDocument/signatureHelp %s [ %d, %d ]", relativePath(uri), position.getLine(), position.getCharacter())); + var service = getLanguageService(uri); + if( service == null ) + return null; + return service.signatureHelp(params); + }); + } + // --- WorkspaceService @Override diff --git a/src/main/groovy/nextflow/lsp/ast/ASTNodeStringUtils.java b/src/main/groovy/nextflow/lsp/ast/ASTNodeStringUtils.java index 97052b5..4120caa 100644 --- a/src/main/groovy/nextflow/lsp/ast/ASTNodeStringUtils.java +++ b/src/main/groovy/nextflow/lsp/ast/ASTNodeStringUtils.java @@ -22,6 +22,8 @@ import groovy.lang.groovydoc.Groovydoc; import nextflow.lsp.ast.ASTNodeCache; import nextflow.lsp.ast.ASTUtils; +import nextflow.lsp.services.util.CustomFormattingOptions; +import nextflow.lsp.services.util.Formatter; import nextflow.script.dsl.Constant; import nextflow.script.dsl.DslType; import nextflow.script.dsl.FeatureFlag; @@ -46,7 +48,7 @@ import org.codehaus.groovy.ast.Variable; import org.codehaus.groovy.runtime.StringGroovyMethods; -import static nextflow.script.v2.ASTHelpers.findAnnotation; +import static nextflow.script.v2.ASTHelpers.*; /** * Utility methods for retreiving text information for ast nodes. @@ -93,6 +95,13 @@ public static String toString(MethodNode node, ASTNodeCache ast) { var builder = new StringBuilder(); builder.append("workflow "); builder.append(wn.isEntry() ? "" : wn.getName()); + builder.append("("); + builder.append( + asBlockStatements(wn.takes).stream() + .map(take -> asVarX(take).getName()) + .collect(Collectors.joining(", ")) + ); + builder.append(")"); return builder.toString(); } @@ -100,6 +109,15 @@ public static String toString(MethodNode node, ASTNodeCache ast) { var builder = new StringBuilder(); builder.append("process "); builder.append(pn.getName()); + builder.append("\n\ninput:\n"); + asDirectives(pn.inputs).forEach((call) -> { + var fmt = new Formatter(new CustomFormattingOptions(0, false, false)); + fmt.append(call.getMethodAsString()); + fmt.append(' '); + fmt.visitArguments(asMethodCallArguments(call), hasNamedArgs(call), false); + builder.append(fmt.toString()); + builder.append('\n'); + }); return builder.toString(); } diff --git a/src/main/groovy/nextflow/lsp/services/LanguageService.java b/src/main/groovy/nextflow/lsp/services/LanguageService.java index e0aec4e..b77dc3d 100644 --- a/src/main/groovy/nextflow/lsp/services/LanguageService.java +++ b/src/main/groovy/nextflow/lsp/services/LanguageService.java @@ -72,6 +72,8 @@ import org.eclipse.lsp4j.PublishDiagnosticsParams; import org.eclipse.lsp4j.ReferenceParams; import org.eclipse.lsp4j.RenameParams; +import org.eclipse.lsp4j.SignatureHelp; +import org.eclipse.lsp4j.SignatureHelpParams; import org.eclipse.lsp4j.SymbolInformation; import org.eclipse.lsp4j.TextEdit; import org.eclipse.lsp4j.WorkspaceEdit; @@ -115,6 +117,7 @@ public LanguageService() { protected LinkProvider getLinkProvider() { return null; } protected ReferenceProvider getReferenceProvider() { return null; } protected RenameProvider getRenameProvider() { return null; } + protected SignatureHelpProvider getSignatureHelpProvider() { return null; } protected SymbolProvider getSymbolProvider() { return null; } private volatile boolean initialized; @@ -294,6 +297,15 @@ public WorkspaceEdit rename(RenameParams params) { return provider.rename(params.getTextDocument(), params.getPosition(), params.getNewName()); } + public SignatureHelp signatureHelp(SignatureHelpParams params) { + var provider = getSignatureHelpProvider(); + if( provider == null ) + return null; + + updateNow(); + return provider.signatureHelp(params.getTextDocument(), params.getPosition(), params.getContext()); + } + public List symbol(WorkspaceSymbolParams params) { var provider = getSymbolProvider(); if( provider == null ) diff --git a/src/main/groovy/nextflow/lsp/services/SignatureHelpProvider.java b/src/main/groovy/nextflow/lsp/services/SignatureHelpProvider.java new file mode 100644 index 0000000..6710875 --- /dev/null +++ b/src/main/groovy/nextflow/lsp/services/SignatureHelpProvider.java @@ -0,0 +1,27 @@ +/* + * Copyright 2024, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.lsp.services; + +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.SignatureHelp; +import org.eclipse.lsp4j.SignatureHelpContext; +import org.eclipse.lsp4j.TextDocumentIdentifier; + +public interface SignatureHelpProvider { + + SignatureHelp signatureHelp(TextDocumentIdentifier textDocument, Position position, SignatureHelpContext context); + +} diff --git a/src/main/groovy/nextflow/lsp/services/script/ScriptService.java b/src/main/groovy/nextflow/lsp/services/script/ScriptService.java index 15fb455..1bf9506 100644 --- a/src/main/groovy/nextflow/lsp/services/script/ScriptService.java +++ b/src/main/groovy/nextflow/lsp/services/script/ScriptService.java @@ -29,6 +29,7 @@ import nextflow.lsp.services.LinkProvider; import nextflow.lsp.services.ReferenceProvider; import nextflow.lsp.services.RenameProvider; +import nextflow.lsp.services.SignatureHelpProvider; import nextflow.lsp.services.SymbolProvider; /** @@ -95,6 +96,11 @@ protected RenameProvider getRenameProvider() { return new ScriptReferenceProvider(astCache); } + @Override + protected SignatureHelpProvider getSignatureHelpProvider() { + return new ScriptSignatureHelpProvider(astCache); + } + @Override protected SymbolProvider getSymbolProvider() { return new ScriptSymbolProvider(astCache); diff --git a/src/main/groovy/nextflow/lsp/services/script/ScriptSignatureHelpProvider.java b/src/main/groovy/nextflow/lsp/services/script/ScriptSignatureHelpProvider.java new file mode 100644 index 0000000..c693e25 --- /dev/null +++ b/src/main/groovy/nextflow/lsp/services/script/ScriptSignatureHelpProvider.java @@ -0,0 +1,173 @@ +/* + * Copyright 2024, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.lsp.services.script; + +import java.net.URI; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import nextflow.lsp.ast.ASTNodeStringUtils; +import nextflow.lsp.ast.ASTUtils; +import nextflow.lsp.services.SignatureHelpProvider; +import nextflow.lsp.services.util.CustomFormattingOptions; +import nextflow.lsp.services.util.Formatter; +import nextflow.lsp.util.Logger; +import nextflow.script.v2.ProcessNode; +import nextflow.script.v2.WorkflowNode; +import org.codehaus.groovy.ast.ASTNode; +import org.codehaus.groovy.ast.MethodNode; +import org.codehaus.groovy.ast.expr.ArgumentListExpression; +import org.codehaus.groovy.ast.expr.MethodCall; +import org.eclipse.lsp4j.MarkupContent; +import org.eclipse.lsp4j.MarkupKind; +import org.eclipse.lsp4j.ParameterInformation; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.SignatureHelp; +import org.eclipse.lsp4j.SignatureHelpContext; +import org.eclipse.lsp4j.SignatureInformation; +import org.eclipse.lsp4j.TextDocumentIdentifier; + +import static nextflow.script.v2.ASTHelpers.*; + +public class ScriptSignatureHelpProvider implements SignatureHelpProvider { + + private static Logger log = Logger.getInstance(); + + private ScriptAstCache ast; + + public ScriptSignatureHelpProvider(ScriptAstCache ast) { + this.ast = ast; + } + + @Override + public SignatureHelp signatureHelp(TextDocumentIdentifier textDocument, Position position, SignatureHelpContext context) { + if( ast == null ) { + log.error("ast cache is empty while providing signature help"); + return null; + } + + var uri = URI.create(textDocument.getUri()); + var offsetNode = ast.getNodeAtLineAndColumn(uri, position.getLine(), position.getCharacter()); + if( offsetNode == null ) + return null; + + var call = getMethodCall(offsetNode); + if( call == null ) + return null; + + var result = context.getActiveSignatureHelp(); + if( result == null ) + result = astNodeToSignatureHelp(call); + + var activeParameter = getActiveParameter(offsetNode, call); + var activeSignature = getActiveSignature(call, activeParameter); + result.setActiveSignature(activeSignature); + result.setActiveParameter(activeParameter); + return result; + } + + private MethodCall getMethodCall(ASTNode node) { + if( node instanceof MethodCall call ) { + return call; + } + + if( node instanceof ArgumentListExpression ) { + return getMethodCall(ast.getParent(node)); + } + + ASTNode current = node; + while( current != null ) { + if( current instanceof MethodCall call ) + return call; + current = ast.getParent(current); + } + return null; + } + + private SignatureHelp astNodeToSignatureHelp(MethodCall call) { + var methods = ASTUtils.getMethodOverloadsFromCallExpression(call, ast); + var signatures = methods.stream() + .map((mn) -> { + var documentation = ASTNodeStringUtils.getDocumentation(mn); + var si = new SignatureInformation( + ASTNodeStringUtils.getLabel(mn, ast), + documentation != null ? new MarkupContent(MarkupKind.MARKDOWN, documentation) : null, + getParameterInfo(mn) + ); + return si; + }) + .collect(Collectors.toList()); + return new SignatureHelp(signatures, 0, 0); + } + + private List getParameterInfo(MethodNode mn) { + if( mn instanceof ProcessNode pn ) { + return asDirectives(pn.inputs) + .map((call) -> { + var fmt = new Formatter(new CustomFormattingOptions(0, false, false)); + fmt.append(call.getMethodAsString()); + fmt.append(' '); + fmt.visitArguments(asMethodCallArguments(call), hasNamedArgs(call), false); + var label = fmt.toString(); + var documentation = new MarkupContent(MarkupKind.MARKDOWN, "```groovy\n" + label + "\n```"); + return new ParameterInformation(label, documentation); + }) + .collect(Collectors.toList()); + } + + if( mn instanceof WorkflowNode wn ) { + return asBlockStatements(wn.takes).stream() + .map((take) -> { + var varX = asVarX(take); + return new ParameterInformation(varX.getName(), varX.getName()); + }) + .collect(Collectors.toList()); + } + + return Arrays.stream(mn.getParameters()) + .map(p -> new ParameterInformation(ASTNodeStringUtils.toString(p, ast))) + .collect(Collectors.toList()); + } + + private int getActiveParameter(ASTNode node, MethodCall call) { + var args = asMethodCallArguments(call); + if( node == call || node == call.getArguments() ) { + return args.size(); + } + for( int i = 0; i < args.size(); i++ ) { + if( contains(args.get(i), node) ) + return i; + } + return -1; + } + + private static boolean contains(ASTNode a, ASTNode b) { + return a.getLineNumber() <= b.getLineNumber() + && a.getColumnNumber() <= b.getColumnNumber() + && b.getLastLineNumber() <= a.getLastLineNumber() + && b.getLastColumnNumber() <= a.getLastColumnNumber(); + } + + private int getActiveSignature(MethodCall call, int activeParameter) { + var methods = ASTUtils.getMethodOverloadsFromCallExpression(call, ast); + if( methods.size() == 1 ) + return 0; + var best = ASTUtils.getMethodFromCallExpression(call, ast, activeParameter); + return methods.indexOf(best); + } + +}