From 4f3848adb1e4a382ce06fecf4a09b1d584e15183 Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Thu, 23 Oct 2025 09:09:36 -0500 Subject: [PATCH 1/8] Resolve plugin definitions from registry --- .../nextflow/lsp/NextflowLanguageServer.java | 25 +-- .../services/LanguageServerConfiguration.java | 2 + .../lsp/services/config/ConfigAstCache.java | 13 +- .../config/ConfigCompletionProvider.java | 22 ++- .../services/config/ConfigHoverProvider.java | 13 +- .../lsp/services/config/ConfigService.java | 10 +- .../services/config/ConfigSpecVisitor.java | 65 +++++++- .../script/ResolvePluginIncludeVisitor.java | 109 +++++++++++++ .../lsp/services/script/ScriptAstCache.java | 8 +- .../lsp/services/script/ScriptService.java | 6 +- .../config => spec}/ConfigSpecFactory.java | 22 ++- .../java/nextflow/lsp/spec/PluginSpec.java | 29 ++++ .../nextflow/lsp/spec/PluginSpecCache.java | 147 ++++++++++++++++++ .../nextflow/lsp/spec/ScriptSpecFactory.java | 105 +++++++++++++ 14 files changed, 531 insertions(+), 45 deletions(-) create mode 100644 src/main/java/nextflow/lsp/services/script/ResolvePluginIncludeVisitor.java rename src/main/java/nextflow/lsp/{services/config => spec}/ConfigSpecFactory.java (87%) create mode 100644 src/main/java/nextflow/lsp/spec/PluginSpec.java create mode 100644 src/main/java/nextflow/lsp/spec/PluginSpecCache.java create mode 100644 src/main/java/nextflow/lsp/spec/ScriptSpecFactory.java diff --git a/src/main/java/nextflow/lsp/NextflowLanguageServer.java b/src/main/java/nextflow/lsp/NextflowLanguageServer.java index 5c128b27..01df7e01 100644 --- a/src/main/java/nextflow/lsp/NextflowLanguageServer.java +++ b/src/main/java/nextflow/lsp/NextflowLanguageServer.java @@ -121,8 +121,8 @@ public static void main(String[] args) { private LanguageClient client = null; private Map workspaceRoots = new HashMap<>(); - private Map scriptServices = new HashMap<>(); - private Map configServices = new HashMap<>(); + private Map configServices = new HashMap<>(); + private Map scriptServices = new HashMap<>(); private LanguageServerConfiguration configuration = LanguageServerConfiguration.defaults(); @@ -461,6 +461,7 @@ public void didChangeConfiguration(DidChangeConfigurationParams params) { withDefault(JsonUtils.getBoolean(settings, "nextflow.formatting.harshilAlignment"), configuration.harshilAlignment()), withDefault(JsonUtils.getBoolean(settings, "nextflow.formatting.maheshForm"), configuration.maheshForm()), withDefault(JsonUtils.getInteger(settings, "nextflow.completion.maxItems"), configuration.maxCompletionItems()), + withDefault(JsonUtils.getString(settings, "nextflow.pluginRegistryUrl"), configuration.pluginRegistryUrl()), withDefault(JsonUtils.getBoolean(settings, "nextflow.formatting.sortDeclarations"), configuration.sortDeclarations()), withDefault(JsonUtils.getBoolean(settings, "nextflow.typeChecking"), configuration.typeChecking()) ); @@ -500,8 +501,8 @@ private void initializeWorkspaces() { progress.update(progressMessage, count * 100 / total); count++; - scriptServices.get(name).initialize(configuration); configServices.get(name).initialize(configuration); + scriptServices.get(name).initialize(configuration, configServices.get(name).getPluginSpecCache()); } progress.end(); @@ -519,16 +520,16 @@ public void didChangeWorkspaceFolders(DidChangeWorkspaceFoldersParams params) { var name = workspaceFolder.getName(); log.debug("workspace/didChangeWorkspaceFolders remove " + name); workspaceRoots.remove(name); - scriptServices.remove(name).clearDiagnostics(); configServices.remove(name).clearDiagnostics(); + scriptServices.remove(name).clearDiagnostics(); } for( var workspaceFolder : event.getAdded() ) { var name = workspaceFolder.getName(); var uri = workspaceFolder.getUri(); log.debug("workspace/didChangeWorkspaceFolders add " + name + " " + uri); addWorkspaceFolder(name, uri); - scriptServices.get(name).initialize(configuration); configServices.get(name).initialize(configuration); + scriptServices.get(name).initialize(configuration, configServices.get(name).getPluginSpecCache()); } } @@ -620,13 +621,13 @@ public CompletableFuture, List services) { + private LanguageService getLanguageService0(String uri, Map services) { var service = workspaceRoots.entrySet().stream() .filter((entry) -> entry.getValue() != null && uri.startsWith(entry.getValue())) .findFirst() - .map((entry) -> services.get(entry.getKey())) - .orElse(services.get(DEFAULT_WORKSPACE_FOLDER_NAME)); + .map((entry) -> (LanguageService) services.get(entry.getKey())) + .orElse((LanguageService) services.get(DEFAULT_WORKSPACE_FOLDER_NAME)); if( service == null || !service.matchesFile(uri) ) return null; return service; diff --git a/src/main/java/nextflow/lsp/services/LanguageServerConfiguration.java b/src/main/java/nextflow/lsp/services/LanguageServerConfiguration.java index be9ad2cb..641db146 100644 --- a/src/main/java/nextflow/lsp/services/LanguageServerConfiguration.java +++ b/src/main/java/nextflow/lsp/services/LanguageServerConfiguration.java @@ -27,6 +27,7 @@ public record LanguageServerConfiguration( boolean harshilAlignment, boolean maheshForm, int maxCompletionItems, + String pluginRegistryUrl, boolean sortDeclarations, boolean typeChecking ) { @@ -41,6 +42,7 @@ public static LanguageServerConfiguration defaults() { false, false, 100, + "https://registry.nextflow.io/api/", false, false ); diff --git a/src/main/java/nextflow/lsp/services/config/ConfigAstCache.java b/src/main/java/nextflow/lsp/services/config/ConfigAstCache.java index fafe9467..9c1cebd1 100644 --- a/src/main/java/nextflow/lsp/services/config/ConfigAstCache.java +++ b/src/main/java/nextflow/lsp/services/config/ConfigAstCache.java @@ -21,6 +21,7 @@ import java.util.Set; import groovy.lang.GroovyClassLoader; +import nextflow.config.ast.ConfigNode; import nextflow.config.control.ConfigResolveVisitor; import nextflow.config.control.ResolveIncludeVisitor; import nextflow.config.parser.ConfigParserPluginFactory; @@ -30,6 +31,7 @@ import nextflow.lsp.compiler.LanguageServerErrorCollector; import nextflow.lsp.file.FileCache; import nextflow.lsp.services.LanguageServerConfiguration; +import nextflow.lsp.spec.PluginSpecCache; import nextflow.script.control.PhaseAware; import nextflow.script.control.Phases; import nextflow.script.types.Types; @@ -46,7 +48,7 @@ public class ConfigAstCache extends ASTNodeCache { private LanguageServerConfiguration configuration; - private Map spec = ConfigSpecFactory.defaultScopes(); + private PluginSpecCache pluginSpecCache; public ConfigAstCache() { super(createCompiler()); @@ -65,8 +67,9 @@ private static CompilerConfiguration createConfiguration() { return config; } - public void initialize(LanguageServerConfiguration configuration) { + public void initialize(LanguageServerConfiguration configuration, PluginSpecCache pluginSpecCache) { this.configuration = configuration; + this.pluginSpecCache = pluginSpecCache; } @Override @@ -92,7 +95,7 @@ protected Set analyze(Set uris, FileCache fileCache) { continue; // phase 3: name checking new ConfigResolveVisitor(sourceUnit, compiler().compilationUnit(), Types.DEFAULT_CONFIG_IMPORTS).visit(); - new ConfigSpecVisitor(sourceUnit, spec, configuration.typeChecking()).visit(); + new ConfigSpecVisitor(sourceUnit, pluginSpecCache, configuration.typeChecking()).visit(); if( sourceUnit.getErrorCollector().hasErrors() ) continue; // phase 4: type checking @@ -121,4 +124,8 @@ public boolean hasSyntaxErrors(URI uri) { .isPresent(); } + public ConfigNode getConfigNode(URI uri) { + return (ConfigNode) getSourceUnit(uri).getAST(); + } + } diff --git a/src/main/java/nextflow/lsp/services/config/ConfigCompletionProvider.java b/src/main/java/nextflow/lsp/services/config/ConfigCompletionProvider.java index a7e29ec9..c7bfec19 100644 --- a/src/main/java/nextflow/lsp/services/config/ConfigCompletionProvider.java +++ b/src/main/java/nextflow/lsp/services/config/ConfigCompletionProvider.java @@ -25,7 +25,6 @@ import nextflow.config.ast.ConfigIncompleteNode; import nextflow.config.dsl.ConfigDsl; import nextflow.config.spec.SpecNode; -import nextflow.lsp.ast.ASTNodeCache; import nextflow.lsp.ast.CompletionHelper; import nextflow.lsp.services.CompletionProvider; import nextflow.lsp.util.Logger; @@ -62,14 +61,12 @@ */ public class ConfigCompletionProvider implements CompletionProvider { - private static final List TOPLEVEL_ITEMS = topLevelItems(); - private static Logger log = Logger.getInstance(); - private ASTNodeCache ast; + private ConfigAstCache ast; private CompletionHelper ch; - public ConfigCompletionProvider(ASTNodeCache ast, int maxItems) { + public ConfigCompletionProvider(ConfigAstCache ast, int maxItems) { this.ast = ast; this.ch = new CompletionHelper(maxItems); } @@ -86,8 +83,9 @@ public Either, CompletionList> completion(TextDocumentIdent return Either.forLeft(Collections.emptyList()); var nodeStack = ast.getNodesAtPosition(uri, position); + var spec = ast.getConfigNode(uri).getSpec(); if( nodeStack.isEmpty() ) - return Either.forLeft(TOPLEVEL_ITEMS); + return Either.forLeft(topLevelItems(spec)); if( isConfigExpression(nodeStack) ) { addCompletionItems(nodeStack); @@ -95,8 +93,8 @@ public Either, CompletionList> completion(TextDocumentIdent else { var names = currentConfigScope(nodeStack); if( names.isEmpty() ) - return Either.forLeft(TOPLEVEL_ITEMS); - addConfigOptions(names); + return Either.forLeft(topLevelItems(spec)); + addConfigOptions(names, spec); } return ch.isIncomplete() @@ -179,8 +177,8 @@ private static List currentConfigScope(List nodeStack) { return names; } - private void addConfigOptions(List names) { - var scope = SpecNode.ROOT.getScope(names); + private void addConfigOptions(List names, SpecNode.Scope spec) { + var scope = spec.getScope(names); if( scope == null ) return; scope.children().forEach((name, child) -> { @@ -191,9 +189,9 @@ private void addConfigOptions(List names) { }); } - private static List topLevelItems() { + private static List topLevelItems(SpecNode.Scope spec) { var result = new ArrayList(); - SpecNode.ROOT.children().forEach((name, child) -> { + spec.children().forEach((name, child) -> { if( child instanceof SpecNode.Option option ) { result.add(configOption(name, option.description(), option.type())); } diff --git a/src/main/java/nextflow/lsp/services/config/ConfigHoverProvider.java b/src/main/java/nextflow/lsp/services/config/ConfigHoverProvider.java index 13ab162a..2dc78ada 100644 --- a/src/main/java/nextflow/lsp/services/config/ConfigHoverProvider.java +++ b/src/main/java/nextflow/lsp/services/config/ConfigHoverProvider.java @@ -23,7 +23,6 @@ import nextflow.config.ast.ConfigAssignNode; import nextflow.config.ast.ConfigBlockNode; import nextflow.config.spec.SpecNode; -import nextflow.lsp.ast.ASTNodeCache; import nextflow.lsp.ast.ASTNodeStringUtils; import nextflow.lsp.ast.LanguageServerASTUtils; import nextflow.lsp.services.HoverProvider; @@ -49,9 +48,9 @@ public class ConfigHoverProvider implements HoverProvider { private static Logger log = Logger.getInstance(); - private ASTNodeCache ast; + private ConfigAstCache ast; - public ConfigHoverProvider(ASTNodeCache ast) { + public ConfigHoverProvider(ConfigAstCache ast) { this.ast = ast; } @@ -69,7 +68,7 @@ public Hover hover(TextDocumentIdentifier textDocument, Position position) { var builder = new StringBuilder(); - var content = getHoverContent(nodeStack); + var content = getHoverContent(nodeStack, ast.getConfigNode(uri).getSpec()); if( content != null ) { builder.append(content); builder.append('\n'); @@ -94,14 +93,14 @@ public Hover hover(TextDocumentIdentifier textDocument, Position position) { return new Hover(new MarkupContent(MarkupKind.MARKDOWN, value)); } - protected String getHoverContent(List nodeStack) { + protected String getHoverContent(List nodeStack, SpecNode.Scope spec) { var offsetNode = nodeStack.get(0); if( offsetNode instanceof ConfigAssignNode assign ) { var names = getCurrentScope(nodeStack); names.addAll(assign.names); var fqName = String.join(".", names); - var option = SpecNode.ROOT.getOption(names); + var option = spec.getOption(names); if( option != null ) { var description = StringGroovyMethods.stripIndent(option.description(), true).trim(); var builder = new StringBuilder(); @@ -120,7 +119,7 @@ else if( Logger.isDebugEnabled() ) { if( names.isEmpty() ) return null; - var scope = SpecNode.ROOT.getChild(names); + var scope = spec.getChild(names); if( scope != null ) { return StringGroovyMethods.stripIndent(scope.description(), true).trim(); } diff --git a/src/main/java/nextflow/lsp/services/config/ConfigService.java b/src/main/java/nextflow/lsp/services/config/ConfigService.java index 4d9f98c7..bf10ca1e 100644 --- a/src/main/java/nextflow/lsp/services/config/ConfigService.java +++ b/src/main/java/nextflow/lsp/services/config/ConfigService.java @@ -23,6 +23,7 @@ import nextflow.lsp.services.LanguageService; import nextflow.lsp.services.LinkProvider; import nextflow.lsp.services.SemanticTokensProvider; +import nextflow.lsp.spec.PluginSpecCache; /** * Implementation of language services for Nextflow config files. @@ -31,6 +32,8 @@ */ public class ConfigService extends LanguageService { + private PluginSpecCache pluginSpecCache; + private ConfigAstCache astCache; public ConfigService(String rootUri) { @@ -46,11 +49,16 @@ public boolean matchesFile(String uri) { @Override public void initialize(LanguageServerConfiguration configuration) { synchronized (this) { - astCache.initialize(configuration); + pluginSpecCache = new PluginSpecCache(configuration.pluginRegistryUrl()); + astCache.initialize(configuration, pluginSpecCache); } super.initialize(configuration); } + public PluginSpecCache getPluginSpecCache() { + return pluginSpecCache; + } + @Override protected ASTNodeCache getAstCache() { return astCache; diff --git a/src/main/java/nextflow/lsp/services/config/ConfigSpecVisitor.java b/src/main/java/nextflow/lsp/services/config/ConfigSpecVisitor.java index ae2b0c83..244b1049 100644 --- a/src/main/java/nextflow/lsp/services/config/ConfigSpecVisitor.java +++ b/src/main/java/nextflow/lsp/services/config/ConfigSpecVisitor.java @@ -16,19 +16,24 @@ package nextflow.lsp.services.config; import java.util.ArrayList; +import java.util.HashMap; import java.util.Map; import java.util.Stack; +import nextflow.config.ast.ConfigApplyBlockNode; import nextflow.config.ast.ConfigAssignNode; import nextflow.config.ast.ConfigBlockNode; import nextflow.config.ast.ConfigNode; import nextflow.config.ast.ConfigVisitorSupport; import nextflow.config.spec.SpecNode; +import nextflow.lsp.spec.ConfigSpecFactory; +import nextflow.lsp.spec.PluginSpecCache; import nextflow.script.control.PhaseAware; import nextflow.script.control.Phases; import nextflow.script.types.TypesEx; import org.codehaus.groovy.ast.ASTNode; import org.codehaus.groovy.ast.ClassHelper; +import org.codehaus.groovy.ast.expr.ConstantExpression; import org.codehaus.groovy.control.SourceUnit; import org.codehaus.groovy.control.messages.SyntaxErrorMessage; import org.codehaus.groovy.control.messages.WarningMessage; @@ -36,7 +41,13 @@ import org.codehaus.groovy.syntax.SyntaxException; import org.codehaus.groovy.syntax.Token; +import static nextflow.script.ast.ASTUtils.*; + /** + * Validate config options against the config spec. + * + * Config scopes from third-party plugins are inferred + * from the `plugins` block, if specified. * * @author Ben Sherman */ @@ -44,15 +55,15 @@ public class ConfigSpecVisitor extends ConfigVisitorSupport { private SourceUnit sourceUnit; - private SpecNode.Scope spec; + private PluginSpecCache pluginSpecCache; private boolean typeChecking; private Stack scopes = new Stack<>(); - public ConfigSpecVisitor(SourceUnit sourceUnit, Map scopes, boolean typeChecking) { + public ConfigSpecVisitor(SourceUnit sourceUnit, PluginSpecCache pluginSpecCache, boolean typeChecking) { this.sourceUnit = sourceUnit; - this.spec = new SpecNode.Scope("", scopes); + this.pluginSpecCache = pluginSpecCache; this.typeChecking = typeChecking; } @@ -61,10 +72,56 @@ protected SourceUnit getSourceUnit() { return sourceUnit; } + private SpecNode.Scope spec; + public void visit() { var moduleNode = sourceUnit.getAST(); - if( moduleNode instanceof ConfigNode cn ) + if( moduleNode instanceof ConfigNode cn ) { + this.spec = getPluginScopes(cn); + cn.setSpec(spec); super.visit(cn); + this.spec = null; + } + } + + private SpecNode.Scope getPluginScopes(ConfigNode cn) { + var defaultScopes = ConfigSpecFactory.defaultScopes(); + var pluginScopes = pluginConfigScopes(cn); + var children = new HashMap(); + children.putAll(defaultScopes); + children.putAll(pluginScopes); + return new SpecNode.Scope("", children); + } + + private Map pluginConfigScopes(ConfigNode cn) { + var entries = cn.getConfigStatements().stream() + // get plugin refs from `plugins` block + .map(stmt -> + stmt instanceof ConfigApplyBlockNode node && "plugins".equals(node.name) ? node : null + ) + .filter(node -> node != null) + .flatMap(node -> node.statements.stream()) + .map((call) -> { + var arguments = asMethodCallArguments(call); + var firstArg = arguments.get(0); + return firstArg instanceof ConstantExpression ce ? ce.getText() : null; + }) + // fetch plugin specs from plugin registry + .filter(ref -> ref != null) + .map((ref) -> { + var tokens = ref.split("@"); + var name = tokens[0]; + var version = tokens.length == 2 ? tokens[1] : null; + return pluginSpecCache.get(name, version); + }) + .filter(spec -> spec != null) + .map(spec -> spec.configScopes()) + .toList(); + + var result = new HashMap(); + for( var entry : entries ) + result.putAll(entry); + return result; } @Override diff --git a/src/main/java/nextflow/lsp/services/script/ResolvePluginIncludeVisitor.java b/src/main/java/nextflow/lsp/services/script/ResolvePluginIncludeVisitor.java new file mode 100644 index 00000000..054b3858 --- /dev/null +++ b/src/main/java/nextflow/lsp/services/script/ResolvePluginIncludeVisitor.java @@ -0,0 +1,109 @@ +/* + * Copyright 2024-2025, 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.util.List; + +import nextflow.lsp.spec.PluginSpecCache; +import nextflow.script.ast.IncludeNode; +import nextflow.script.ast.ScriptNode; +import nextflow.script.ast.ScriptVisitorSupport; +import nextflow.script.control.PhaseAware; +import nextflow.script.control.Phases; +import org.codehaus.groovy.ast.ASTNode; +import org.codehaus.groovy.ast.MethodNode; +import org.codehaus.groovy.control.SourceUnit; +import org.codehaus.groovy.control.messages.SyntaxErrorMessage; +import org.codehaus.groovy.syntax.SyntaxException; + +/** + * Resolve plugin includes against plugin specs. + * + * @author Ben Sherman + */ +public class ResolvePluginIncludeVisitor extends ScriptVisitorSupport { + + private SourceUnit sourceUnit; + + private PluginSpecCache pluginSpecCache; + + public ResolvePluginIncludeVisitor(SourceUnit sourceUnit, PluginSpecCache pluginSpecCache) { + this.sourceUnit = sourceUnit; + this.pluginSpecCache = pluginSpecCache; + } + + @Override + protected SourceUnit getSourceUnit() { + return sourceUnit; + } + + public void visit() { + var moduleNode = sourceUnit.getAST(); + if( moduleNode instanceof ScriptNode sn ) + super.visit(sn); + } + + @Override + public void visitInclude(IncludeNode node) { + var source = node.source.getText(); + if( !source.startsWith("plugin/") ) + return; + var pluginName = source.split("/")[1]; + var spec = pluginSpecCache.get(pluginName, null); + if( spec == null ) { + addError("Plugin '" + pluginName + "' does not exist or is not specified in the configuration file", node); + return; + } + for( var entry : node.entries ) { + var entryName = entry.name; + var mn = findMethod(spec.functions(), entryName); + if( mn != null ) { + entry.setTarget(mn); + continue; + } + if( findMethod(spec.factories(), entryName) != null ) + continue; + if( findMethod(spec.operators(), entryName) != null ) + continue; + addError("Included name '" + entryName + "' is not defined in plugin '" + pluginName + "'", node); + } + } + + private static MethodNode findMethod(List methods, String name) { + return methods.stream() + .filter(mn -> mn.getName().equals(name)) + .findFirst().orElse(null); + } + + @Override + public void addError(String message, ASTNode node) { + var cause = new ResolveIncludeError(message, node); + var errorMessage = new SyntaxErrorMessage(cause, sourceUnit); + sourceUnit.getErrorCollector().addErrorAndContinue(errorMessage); + } + + private class ResolveIncludeError extends SyntaxException implements PhaseAware { + + public ResolveIncludeError(String message, ASTNode node) { + super(message, node); + } + + @Override + public int getPhase() { + return Phases.INCLUDE_RESOLUTION; + } + } +} diff --git a/src/main/java/nextflow/lsp/services/script/ScriptAstCache.java b/src/main/java/nextflow/lsp/services/script/ScriptAstCache.java index 6db9d297..b4fae599 100644 --- a/src/main/java/nextflow/lsp/services/script/ScriptAstCache.java +++ b/src/main/java/nextflow/lsp/services/script/ScriptAstCache.java @@ -30,6 +30,7 @@ import nextflow.lsp.compiler.LanguageServerErrorCollector; import nextflow.lsp.file.FileCache; import nextflow.lsp.services.LanguageServerConfiguration; +import nextflow.lsp.spec.PluginSpecCache; import nextflow.script.ast.FunctionNode; import nextflow.script.ast.IncludeNode; import nextflow.script.ast.ProcessNode; @@ -60,6 +61,8 @@ public class ScriptAstCache extends ASTNodeCache { private LanguageServerConfiguration configuration; + private PluginSpecCache pluginSpecCache; + public ScriptAstCache(String rootUri) { super(createCompiler()); this.libCache = createLibCache(rootUri); @@ -89,8 +92,9 @@ private static CompilerConfiguration createConfiguration() { return config; } - public void initialize(LanguageServerConfiguration configuration) { + public void initialize(LanguageServerConfiguration configuration, PluginSpecCache pluginSpecCache) { this.configuration = configuration; + this.pluginSpecCache = pluginSpecCache; } @Override @@ -111,6 +115,8 @@ protected Set analyze(Set uris, FileCache fileCache) { var visitor = new ResolveIncludeVisitor(sourceUnit, compiler(), uris); visitor.visit(); + new ResolvePluginIncludeVisitor(sourceUnit, pluginSpecCache).visit(); + var uri = sourceUnit.getSource().getURI(); if( visitor.isChanged() ) { var errorCollector = (LanguageServerErrorCollector) sourceUnit.getErrorCollector(); diff --git a/src/main/java/nextflow/lsp/services/script/ScriptService.java b/src/main/java/nextflow/lsp/services/script/ScriptService.java index 5a0c2877..6c8356d8 100644 --- a/src/main/java/nextflow/lsp/services/script/ScriptService.java +++ b/src/main/java/nextflow/lsp/services/script/ScriptService.java @@ -33,6 +33,7 @@ import nextflow.lsp.services.SemanticTokensProvider; import nextflow.lsp.services.SymbolProvider; import nextflow.script.formatter.FormattingOptions; +import nextflow.lsp.spec.PluginSpecCache; /** * Implementation of language services for Nextflow scripts. @@ -53,10 +54,9 @@ public boolean matchesFile(String uri) { return uri.endsWith(".nf"); } - @Override - public void initialize(LanguageServerConfiguration configuration) { + public void initialize(LanguageServerConfiguration configuration, PluginSpecCache pluginSpecCache) { synchronized (this) { - astCache.initialize(configuration); + astCache.initialize(configuration, pluginSpecCache); } super.initialize(configuration); } diff --git a/src/main/java/nextflow/lsp/services/config/ConfigSpecFactory.java b/src/main/java/nextflow/lsp/spec/ConfigSpecFactory.java similarity index 87% rename from src/main/java/nextflow/lsp/services/config/ConfigSpecFactory.java rename to src/main/java/nextflow/lsp/spec/ConfigSpecFactory.java index 91d5a33c..936aaea2 100644 --- a/src/main/java/nextflow/lsp/services/config/ConfigSpecFactory.java +++ b/src/main/java/nextflow/lsp/spec/ConfigSpecFactory.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package nextflow.lsp.services.config; +package nextflow.lsp.spec; import java.io.IOException; import java.util.Collections; @@ -29,7 +29,7 @@ import org.codehaus.groovy.runtime.IOGroovyMethods; /** - * Load config scopes from core definitions. + * Load config scopes from core definitions and plugin specs. * * @author Ben Sherman */ @@ -63,6 +63,24 @@ private static Map fromCoreDefinitions() { } } + /** + * Load config scopes from a plugin spec. + * + * @param definitions + */ + public static Map fromDefinitions(List definitions) { + var entries = definitions.stream() + .filter(node -> "ConfigScope".equals(node.get("type"))) + .map((node) -> { + var spec = (Map) node.get("spec"); + var name = (String) spec.get("name"); + var scope = fromScope(spec); + return Map.entry(name, scope); + }) + .toArray(Map.Entry[]::new); + return Map.ofEntries(entries); + } + private static Map fromChildren(List children) { var entries = children.stream() .map((node) -> { diff --git a/src/main/java/nextflow/lsp/spec/PluginSpec.java b/src/main/java/nextflow/lsp/spec/PluginSpec.java new file mode 100644 index 00000000..0b713c0a --- /dev/null +++ b/src/main/java/nextflow/lsp/spec/PluginSpec.java @@ -0,0 +1,29 @@ +/* + * Copyright 2024-2025, 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.spec; + +import java.util.List; +import java.util.Map; + +import nextflow.config.spec.SpecNode; +import org.codehaus.groovy.ast.MethodNode; + +public record PluginSpec( + Map configScopes, + List factories, + List functions, + List operators +) {} diff --git a/src/main/java/nextflow/lsp/spec/PluginSpecCache.java b/src/main/java/nextflow/lsp/spec/PluginSpecCache.java new file mode 100644 index 00000000..55a72f17 --- /dev/null +++ b/src/main/java/nextflow/lsp/spec/PluginSpecCache.java @@ -0,0 +1,147 @@ +/* + * Copyright 2024-2025, 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.spec; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import groovy.json.JsonSlurper; +import nextflow.lsp.util.Logger; +import org.codehaus.groovy.ast.MethodNode; + +import static java.net.http.HttpResponse.BodyHandlers; + +/** + * Cache plugin specs that are fetched from the plugin registry. + * + * @author Ben Sherman + */ +public class PluginSpecCache { + + private static Logger log = Logger.getInstance(); + + private URI registryUri; + + private HttpClient client = HttpClient.newBuilder().build(); + + private Map cache = new HashMap<>(); + + public PluginSpecCache(String registryUrl) { + this.registryUri = URI.create(registryUrl); + } + + /** + * Get the plugin spec for a given plugin release. + * + * If the version is not specified, the latest version is used instead. + * + * Results are cached to minimize registry API calls. + * + * @param name + * @param version + */ + public PluginSpec get(String name, String version) { + var ref = new PluginRef(name, version); + if( !cache.containsKey(ref) ) + cache.put(ref, compute(name, version)); + return cache.get(ref); + } + + private PluginSpec compute(String name, String version) { + try { + return compute0(name, version); + } + catch( Exception e ) { + log.error(e.toString()); + return null; + } + } + + private PluginSpec compute0(String name, String version) { + // fetch plugin spec from registry + var response = fetch(name, version); + if( response == null ) + return null; + + // select plugin release (or latest if not specified) + var release = pluginRelease(response); + if( release == null ) + return null; + + // get spec from plugin release + return pluginSpec(release); + } + + private Map fetch(String name, String version) { + var path = version != null + ? String.format("v1/plugins/%s/%s", name, version) + : String.format("v1/plugins/%s", name); + var uri = registryUri.resolve(path); + + log.debug("fetch plugin " + uri); + + try { + var request = HttpRequest.newBuilder() + .uri(uri) + .GET() + .header("Accept", "application/json") + .build(); + var httpResponse = client.send(request, BodyHandlers.ofString()); + var response = new JsonSlurper().parseText(httpResponse.body()); + return response instanceof Map m ? m : null; + } + catch( IOException | InterruptedException e ) { + log.error(e.toString()); + return null; + } + } + + private static Map pluginRelease(Map response) { + if( response.containsKey("plugin") ) { + var plugin = (Map) response.get("plugin"); + var releases = (List) plugin.get("releases"); + return releases.get(0); + } + if( response.containsKey("pluginRelease") ) { + return (Map) response.get("pluginRelease"); + } + return null; + } + + private static PluginSpec pluginSpec(Map release) { + var specJson = (String) release.get("spec"); + var spec = (Map) new JsonSlurper().parseText(specJson); + var definitions = (List) spec.get("definitions"); + return new PluginSpec( + ConfigSpecFactory.fromDefinitions(definitions), + ScriptSpecFactory.fromDefinitions(definitions, "Factory"), + ScriptSpecFactory.fromDefinitions(definitions, "Function"), + ScriptSpecFactory.fromDefinitions(definitions, "Operator") + ); + } + +} + + +record PluginRef( + String name, + String version +) {} diff --git a/src/main/java/nextflow/lsp/spec/ScriptSpecFactory.java b/src/main/java/nextflow/lsp/spec/ScriptSpecFactory.java new file mode 100644 index 00000000..a480ca22 --- /dev/null +++ b/src/main/java/nextflow/lsp/spec/ScriptSpecFactory.java @@ -0,0 +1,105 @@ +/* + * Copyright 2024-2025, 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.spec; + +import java.lang.reflect.Modifier; +import java.util.List; +import java.util.Map; + +import nextflow.script.dsl.Description; +import nextflow.script.types.Duration; +import nextflow.script.types.MemoryUnit; +import org.codehaus.groovy.ast.AnnotationNode; +import org.codehaus.groovy.ast.ClassHelper; +import org.codehaus.groovy.ast.ClassNode; +import org.codehaus.groovy.ast.MethodNode; +import org.codehaus.groovy.ast.Parameter; +import org.codehaus.groovy.ast.expr.ConstantExpression; +import org.codehaus.groovy.ast.stmt.EmptyStatement; + +/** + * Load script definitions from plugin specs. + * + * @author Ben Sherman + */ +public class ScriptSpecFactory { + + public static List fromDefinitions(List definitions, String type) { + return definitions.stream() + .filter(node -> type.equals(node.get("type"))) + .map((node) -> { + var spec = (Map) node.get("spec"); + var name = (String) spec.get("name"); + return fromMethod(spec); + }) + .toList(); + } + + private static MethodNode fromMethod(Map spec) { + var name = (String) spec.get("name"); + var description = (String) spec.get("description"); + var returnType = fromType(spec.get("returnType")); + var parameters = fromParameters((List) spec.get("parameters")); + var method = new MethodNode(name, Modifier.PUBLIC, returnType, parameters, ClassNode.EMPTY_ARRAY, EmptyStatement.INSTANCE); + method.setHasNoRealSourcePosition(true); + method.setDeclaringClass(ClassHelper.dynamicType()); + method.setSynthetic(true); + if( description != null ) { + var an = new AnnotationNode(ClassHelper.makeCached(Description.class)); + an.addMember("value", new ConstantExpression(description)); + method.addAnnotation(an); + } + return method; + } + + private static Parameter[] fromParameters(List parameters) { + return parameters.stream() + .map((param) -> { + var name = (String) param.get("name"); + var type = fromType(param.get("type")); + return new Parameter(type, name); + }) + .toArray(Parameter[]::new); + } + + private static final Map STANDARD_TYPES = Map.ofEntries( + Map.entry("Boolean", ClassHelper.Boolean_TYPE), + Map.entry("boolean", ClassHelper.Boolean_TYPE), + Map.entry("Closure", ClassHelper.CLOSURE_TYPE), + Map.entry("Duration", ClassHelper.makeCached(Duration.class)), + Map.entry("Float", ClassHelper.Float_TYPE), + Map.entry("float", ClassHelper.Float_TYPE), + Map.entry("Integer", ClassHelper.Integer_TYPE), + Map.entry("int", ClassHelper.Integer_TYPE), + Map.entry("List", ClassHelper.LIST_TYPE), + Map.entry("MemoryUnit", ClassHelper.makeCached(MemoryUnit.class)), + Map.entry("Set", ClassHelper.SET_TYPE), + Map.entry("String", ClassHelper.STRING_TYPE) + ); + + private static ClassNode fromType(Object type) { + if( type instanceof String s ) { + return STANDARD_TYPES.getOrDefault(s, ClassHelper.dynamicType()); + } + if( type instanceof Map m ) { + var name = (String) m.get("name"); + // TODO: type arguments + return STANDARD_TYPES.getOrDefault(name, ClassHelper.dynamicType()); + } + throw new IllegalStateException(); + } + +} From 337b9a568df295b254b9f1b87bb0def915da4839 Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Thu, 23 Oct 2025 10:30:10 -0500 Subject: [PATCH 2/8] Use only currently loaded plugins when resolving script includes --- .../services/config/ConfigSpecVisitor.java | 17 +++++++--- .../script/ResolvePluginIncludeVisitor.java | 2 +- .../java/nextflow/lsp/spec/PluginRef.java | 21 +++++++++++++ .../nextflow/lsp/spec/PluginSpecCache.java | 31 ++++++++++++++++--- 4 files changed, 61 insertions(+), 10 deletions(-) create mode 100644 src/main/java/nextflow/lsp/spec/PluginRef.java diff --git a/src/main/java/nextflow/lsp/services/config/ConfigSpecVisitor.java b/src/main/java/nextflow/lsp/services/config/ConfigSpecVisitor.java index 244b1049..3cb6bf1f 100644 --- a/src/main/java/nextflow/lsp/services/config/ConfigSpecVisitor.java +++ b/src/main/java/nextflow/lsp/services/config/ConfigSpecVisitor.java @@ -27,6 +27,7 @@ import nextflow.config.ast.ConfigVisitorSupport; import nextflow.config.spec.SpecNode; import nextflow.lsp.spec.ConfigSpecFactory; +import nextflow.lsp.spec.PluginRef; import nextflow.lsp.spec.PluginSpecCache; import nextflow.script.control.PhaseAware; import nextflow.script.control.Phases; @@ -94,8 +95,8 @@ private SpecNode.Scope getPluginScopes(ConfigNode cn) { } private Map pluginConfigScopes(ConfigNode cn) { - var entries = cn.getConfigStatements().stream() - // get plugin refs from `plugins` block + // get plugin refs from `plugins` block + var refs = cn.getConfigStatements().stream() .map(stmt -> stmt instanceof ConfigApplyBlockNode node && "plugins".equals(node.name) ? node : null ) @@ -106,18 +107,26 @@ private Map pluginConfigScopes(ConfigNode cn) { var firstArg = arguments.get(0); return firstArg instanceof ConstantExpression ce ? ce.getText() : null; }) - // fetch plugin specs from plugin registry .filter(ref -> ref != null) .map((ref) -> { var tokens = ref.split("@"); var name = tokens[0]; var version = tokens.length == 2 ? tokens[1] : null; - return pluginSpecCache.get(name, version); + return new PluginRef(name, version); }) + .toList(); + + // fetch plugin specs from plugin registry + var entries = refs.stream() + .map((ref) -> pluginSpecCache.get(ref.name(), ref.version())) .filter(spec -> spec != null) .map(spec -> spec.configScopes()) .toList(); + // set current versions in plugin spec cache + pluginSpecCache.setCurrentVersions(refs); + + // collect config scopes from plugin specs var result = new HashMap(); for( var entry : entries ) result.putAll(entry); diff --git a/src/main/java/nextflow/lsp/services/script/ResolvePluginIncludeVisitor.java b/src/main/java/nextflow/lsp/services/script/ResolvePluginIncludeVisitor.java index 054b3858..187d8e34 100644 --- a/src/main/java/nextflow/lsp/services/script/ResolvePluginIncludeVisitor.java +++ b/src/main/java/nextflow/lsp/services/script/ResolvePluginIncludeVisitor.java @@ -62,7 +62,7 @@ public void visitInclude(IncludeNode node) { if( !source.startsWith("plugin/") ) return; var pluginName = source.split("/")[1]; - var spec = pluginSpecCache.get(pluginName, null); + var spec = pluginSpecCache.getCurrent(pluginName); if( spec == null ) { addError("Plugin '" + pluginName + "' does not exist or is not specified in the configuration file", node); return; diff --git a/src/main/java/nextflow/lsp/spec/PluginRef.java b/src/main/java/nextflow/lsp/spec/PluginRef.java new file mode 100644 index 00000000..4719a6c8 --- /dev/null +++ b/src/main/java/nextflow/lsp/spec/PluginRef.java @@ -0,0 +1,21 @@ +/* + * Copyright 2024-2025, 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.spec; + +public record PluginRef( + String name, + String version +) {} diff --git a/src/main/java/nextflow/lsp/spec/PluginSpecCache.java b/src/main/java/nextflow/lsp/spec/PluginSpecCache.java index 55a72f17..1785ce0b 100644 --- a/src/main/java/nextflow/lsp/spec/PluginSpecCache.java +++ b/src/main/java/nextflow/lsp/spec/PluginSpecCache.java @@ -44,6 +44,8 @@ public class PluginSpecCache { private Map cache = new HashMap<>(); + private List currentVersions; + public PluginSpecCache(String registryUrl) { this.registryUri = URI.create(registryUrl); } @@ -138,10 +140,29 @@ private static PluginSpec pluginSpec(Map release) { ); } -} + /** + * Set the plugin versions currently specified by the config. + * + * @param currentVersions + */ + public void setCurrentVersions(List currentVersions) { + this.currentVersions = currentVersions; + } + /** + * Get the currently loaded spec for a plugin. + * + * @param name + */ + public PluginSpec getCurrent(String name) { + if( currentVersions == null ) + return null; + var ref = currentVersions.stream() + .filter(r -> r.name().equals(name)) + .findFirst().orElse(null); + if( ref == null ) + return null; + return cache.get(ref); + } -record PluginRef( - String name, - String version -) {} +} From ada2765fae47c51390d3fd49131d5f367ef2517e Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Thu, 23 Oct 2025 13:20:56 -0500 Subject: [PATCH 3/8] minor edits, add unit tests --- .../nextflow/lsp/NextflowLanguageServer.java | 1 + .../nextflow/lsp/spec/PluginSpecCache.java | 77 +++++++++---------- src/test/groovy/nextflow/lsp/TestUtils.groovy | 15 ++++ .../services/config/ConfigHoverTest.groovy | 72 +++++++++++++++++ .../lsp/spec/PluginSpecCacheTest.groovy | 37 +++++++++ 5 files changed, 161 insertions(+), 41 deletions(-) create mode 100644 src/test/groovy/nextflow/lsp/services/config/ConfigHoverTest.groovy create mode 100644 src/test/groovy/nextflow/lsp/spec/PluginSpecCacheTest.groovy diff --git a/src/main/java/nextflow/lsp/NextflowLanguageServer.java b/src/main/java/nextflow/lsp/NextflowLanguageServer.java index 01df7e01..2a5b99d0 100644 --- a/src/main/java/nextflow/lsp/NextflowLanguageServer.java +++ b/src/main/java/nextflow/lsp/NextflowLanguageServer.java @@ -484,6 +484,7 @@ private T withDefault(T value, T defaultValue) { private boolean shouldInitialize(LanguageServerConfiguration previous, LanguageServerConfiguration current) { return previous.errorReportingMode() != current.errorReportingMode() || !DefaultGroovyMethods.equals(previous.excludePatterns(), current.excludePatterns()) + || previous.pluginRegistryUrl() != current.pluginRegistryUrl() || previous.typeChecking() != current.typeChecking(); } diff --git a/src/main/java/nextflow/lsp/spec/PluginSpecCache.java b/src/main/java/nextflow/lsp/spec/PluginSpecCache.java index 1785ce0b..a16e2e85 100644 --- a/src/main/java/nextflow/lsp/spec/PluginSpecCache.java +++ b/src/main/java/nextflow/lsp/spec/PluginSpecCache.java @@ -19,6 +19,7 @@ import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -40,8 +41,6 @@ public class PluginSpecCache { private URI registryUri; - private HttpClient client = HttpClient.newBuilder().build(); - private Map cache = new HashMap<>(); private List currentVersions; @@ -63,36 +62,31 @@ public PluginSpecCache(String registryUrl) { public PluginSpec get(String name, String version) { var ref = new PluginRef(name, version); if( !cache.containsKey(ref) ) - cache.put(ref, compute(name, version)); + updateCache(ref); return cache.get(ref); } - private PluginSpec compute(String name, String version) { + private void updateCache(PluginRef ref) { try { - return compute0(name, version); + // fetch plugin spec from registry + var response = fetch(ref.name(), ref.version()); + if( response == null ) + return; + + // select plugin release (or latest if not specified) + var release = pluginRelease(response); + if( release == null ) + return; + + // save plugin spec to cache + cache.put(ref, pluginSpec(release)); } - catch( Exception e ) { - log.error(e.toString()); - return null; + catch( IOException | InterruptedException e ) { + e.printStackTrace(System.err); } } - private PluginSpec compute0(String name, String version) { - // fetch plugin spec from registry - var response = fetch(name, version); - if( response == null ) - return null; - - // select plugin release (or latest if not specified) - var release = pluginRelease(response); - if( release == null ) - return null; - - // get spec from plugin release - return pluginSpec(release); - } - - private Map fetch(String name, String version) { + private Map fetch(String name, String version) throws IOException, InterruptedException { var path = version != null ? String.format("v1/plugins/%s/%s", name, version) : String.format("v1/plugins/%s", name); @@ -100,20 +94,15 @@ private Map fetch(String name, String version) { log.debug("fetch plugin " + uri); - try { - var request = HttpRequest.newBuilder() - .uri(uri) - .GET() - .header("Accept", "application/json") - .build(); - var httpResponse = client.send(request, BodyHandlers.ofString()); - var response = new JsonSlurper().parseText(httpResponse.body()); - return response instanceof Map m ? m : null; - } - catch( IOException | InterruptedException e ) { - log.error(e.toString()); - return null; - } + var client = HttpClient.newBuilder().build(); + var request = HttpRequest.newBuilder() + .uri(uri) + .GET() + .header("Accept", "application/json") + .build(); + var httpResponse = client.send(request, BodyHandlers.ofString()); + var response = new JsonSlurper().parseText(httpResponse.body()); + return response instanceof Map m ? m : null; } private static Map pluginRelease(Map response) { @@ -129,9 +118,7 @@ private static Map pluginRelease(Map response) { } private static PluginSpec pluginSpec(Map release) { - var specJson = (String) release.get("spec"); - var spec = (Map) new JsonSlurper().parseText(specJson); - var definitions = (List) spec.get("definitions"); + var definitions = pluginDefinitions(release); return new PluginSpec( ConfigSpecFactory.fromDefinitions(definitions), ScriptSpecFactory.fromDefinitions(definitions, "Factory"), @@ -140,6 +127,14 @@ private static PluginSpec pluginSpec(Map release) { ); } + private static List pluginDefinitions(Map release) { + var specJson = (String) release.get("spec"); + if( specJson == null ) + return Collections.emptyList(); + var spec = (Map) new JsonSlurper().parseText(specJson); + return (List) spec.get("definitions"); + } + /** * Set the plugin versions currently specified by the config. * diff --git a/src/test/groovy/nextflow/lsp/TestUtils.groovy b/src/test/groovy/nextflow/lsp/TestUtils.groovy index c3e5ad47..cc64ccc3 100644 --- a/src/test/groovy/nextflow/lsp/TestUtils.groovy +++ b/src/test/groovy/nextflow/lsp/TestUtils.groovy @@ -21,6 +21,7 @@ import java.nio.file.Path import nextflow.lsp.services.LanguageServerConfiguration import nextflow.lsp.services.LanguageService +import nextflow.lsp.services.config.ConfigService import nextflow.lsp.services.script.ScriptService import org.eclipse.lsp4j.DidOpenTextDocumentParams import org.eclipse.lsp4j.TextDocumentItem @@ -40,6 +41,20 @@ class TestUtils { return workspaceRoot } + /** + * Get a language service instance for Nextflow config files. + */ + static ConfigService getConfigService() { + def service = new ConfigService(workspaceRoot.toUri().toString()) + def configuration = LanguageServerConfiguration.defaults() + service.connect(new TestLanguageClient()) + service.initialize(configuration) + // skip workspace scan + open(service, getUri('nextflow.config'), '') + service.updateNow() + return service + } + /** * Get a language service instance for Nextflow scripts. */ diff --git a/src/test/groovy/nextflow/lsp/services/config/ConfigHoverTest.groovy b/src/test/groovy/nextflow/lsp/services/config/ConfigHoverTest.groovy new file mode 100644 index 00000000..6a29db54 --- /dev/null +++ b/src/test/groovy/nextflow/lsp/services/config/ConfigHoverTest.groovy @@ -0,0 +1,72 @@ +/* + * Copyright 2024-2025, 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.config + +import org.eclipse.lsp4j.Hover +import org.eclipse.lsp4j.HoverParams +import org.eclipse.lsp4j.Position +import org.eclipse.lsp4j.TextDocumentIdentifier +import spock.lang.Specification + +import static nextflow.lsp.TestUtils.* + +/** + * + * @author Ben Sherman + */ +class ConfigHoverTest extends Specification { + + String getHoverHint(ConfigService service, String uri, Position position) { + return service + .hover(new HoverParams(new TextDocumentIdentifier(uri), position)) + .getContents() + .getRight() + .getValue() + } + + def 'should get hover hint for a config scope' () { + given: + def service = getConfigService() + def uri = getUri('nextflow.config') + def value + + when: + open(service, uri, '''\ + executor { + } + ''') + service.updateNow() + value = getHoverHint(service, uri, new Position(1, 0)) + then: + value == 'The `executor` scope controls various executor behaviors.\n' + + when: + open(service, uri, '''\ + plugins { + id 'nf-prov@1.6.0' + } + + prov { + } + ''') + service.updateNow() + value = getHoverHint(service, uri, new Position(4, 0)) + then: + value == 'The `prov` scope allows you to configure the `nf-prov` plugin.\n' + } + +} diff --git a/src/test/groovy/nextflow/lsp/spec/PluginSpecCacheTest.groovy b/src/test/groovy/nextflow/lsp/spec/PluginSpecCacheTest.groovy new file mode 100644 index 00000000..4399060c --- /dev/null +++ b/src/test/groovy/nextflow/lsp/spec/PluginSpecCacheTest.groovy @@ -0,0 +1,37 @@ +/* + * Copyright 2024-2025, 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.spec + +import spock.lang.Specification + +/** + * + * @author Ben Sherman + */ +class PluginSpecCacheTest extends Specification { + + def 'should fetch a plugin spec' () { + given: + def pluginSpecCache = new PluginSpecCache('https://registry.nextflow.io/api/') + + when: + def spec = pluginSpecCache.get('nf-prov', '1.6.0') + then: + spec.configScopes().containsKey('prov') + } + +} From 64abcac8d0915b730887702154940863ab24f1f2 Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Thu, 23 Oct 2025 16:14:43 -0500 Subject: [PATCH 4/8] Fix test harness --- src/test/groovy/nextflow/lsp/TestUtils.groovy | 4 +++- .../nextflow/lsp/services/config/ConfigHoverTest.groovy | 9 ++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/test/groovy/nextflow/lsp/TestUtils.groovy b/src/test/groovy/nextflow/lsp/TestUtils.groovy index cc64ccc3..8d2e0250 100644 --- a/src/test/groovy/nextflow/lsp/TestUtils.groovy +++ b/src/test/groovy/nextflow/lsp/TestUtils.groovy @@ -23,6 +23,7 @@ import nextflow.lsp.services.LanguageServerConfiguration import nextflow.lsp.services.LanguageService import nextflow.lsp.services.config.ConfigService import nextflow.lsp.services.script.ScriptService +import nextflow.lsp.spec.PluginSpecCache import org.eclipse.lsp4j.DidOpenTextDocumentParams import org.eclipse.lsp4j.TextDocumentItem @@ -61,8 +62,9 @@ class TestUtils { static ScriptService getScriptService() { def service = new ScriptService(workspaceRoot.toUri().toString()) def configuration = LanguageServerConfiguration.defaults() + def pluginSpecCache = new PluginSpecCache(configuration.pluginRegistryUrl()) service.connect(new TestLanguageClient()) - service.initialize(configuration) + service.initialize(configuration, pluginSpecCache) // skip workspace scan open(service, getUri('main.nf'), '') service.updateNow() diff --git a/src/test/groovy/nextflow/lsp/services/config/ConfigHoverTest.groovy b/src/test/groovy/nextflow/lsp/services/config/ConfigHoverTest.groovy index 6a29db54..54f98e11 100644 --- a/src/test/groovy/nextflow/lsp/services/config/ConfigHoverTest.groovy +++ b/src/test/groovy/nextflow/lsp/services/config/ConfigHoverTest.groovy @@ -31,11 +31,10 @@ import static nextflow.lsp.TestUtils.* class ConfigHoverTest extends Specification { String getHoverHint(ConfigService service, String uri, Position position) { - return service - .hover(new HoverParams(new TextDocumentIdentifier(uri), position)) - .getContents() - .getRight() - .getValue() + def hover = service.hover(new HoverParams(new TextDocumentIdentifier(uri), position)) + return hover + ? hover.getContents().getRight().getValue() + : null } def 'should get hover hint for a config scope' () { From 9b056eabdda1b8a82e04afa9bb440d62416be2b6 Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Thu, 23 Oct 2025 16:32:11 -0500 Subject: [PATCH 5/8] update test --- .../lsp/services/config/ConfigHoverTest.groovy | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/test/groovy/nextflow/lsp/services/config/ConfigHoverTest.groovy b/src/test/groovy/nextflow/lsp/services/config/ConfigHoverTest.groovy index 54f98e11..8526ba8c 100644 --- a/src/test/groovy/nextflow/lsp/services/config/ConfigHoverTest.groovy +++ b/src/test/groovy/nextflow/lsp/services/config/ConfigHoverTest.groovy @@ -41,7 +41,6 @@ class ConfigHoverTest extends Specification { given: def service = getConfigService() def uri = getUri('nextflow.config') - def value when: open(service, uri, '''\ @@ -49,9 +48,15 @@ class ConfigHoverTest extends Specification { } ''') service.updateNow() - value = getHoverHint(service, uri, new Position(1, 0)) + def value = getHoverHint(service, uri, new Position(1, 0)) then: value == 'The `executor` scope controls various executor behaviors.\n' + } + + def 'should get hover hint for a plugin config scope' () { + given: + def service = getConfigService() + def uri = getUri('nextflow.config') when: open(service, uri, '''\ @@ -63,7 +68,7 @@ class ConfigHoverTest extends Specification { } ''') service.updateNow() - value = getHoverHint(service, uri, new Position(4, 0)) + def value = getHoverHint(service, uri, new Position(4, 0)) then: value == 'The `prov` scope allows you to configure the `nf-prov` plugin.\n' } From a6da27d37b73e7f71727bce6ad3571d02b072cd3 Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Thu, 23 Oct 2025 16:36:22 -0500 Subject: [PATCH 6/8] Upgrade to Gradle 9 --- build.gradle | 6 +++++- gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 3a4817a1..27aa2d01 100644 --- a/build.gradle +++ b/build.gradle @@ -30,7 +30,6 @@ test { configurations { nextflowRuntime - specImplementation.extendsFrom(nextflowRuntime) } dependencies { @@ -57,6 +56,7 @@ dependencies { testImplementation ('org.objenesis:objenesis:3.4') testImplementation ('net.bytebuddy:byte-buddy:1.14.17') testImplementation ('org.spockframework:spock-core:2.3-groovy-4.0') { exclude group: 'org.apache.groovy' } + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } sourceSets { @@ -67,6 +67,10 @@ sourceSets { } } +configurations { + specImplementation.extendsFrom(nextflowRuntime) +} + task buildSpec(type: JavaExec) { description = 'Build spec file of core definitions' group = 'build' diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e18bc253..2e111328 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 3ad61068a781354aba304b0c248adc0ae58b7e8d Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Thu, 23 Oct 2025 17:08:33 -0500 Subject: [PATCH 7/8] update test harness --- src/test/groovy/nextflow/lsp/TestLanguageClient.groovy | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/groovy/nextflow/lsp/TestLanguageClient.groovy b/src/test/groovy/nextflow/lsp/TestLanguageClient.groovy index 1721001b..8383057d 100644 --- a/src/test/groovy/nextflow/lsp/TestLanguageClient.groovy +++ b/src/test/groovy/nextflow/lsp/TestLanguageClient.groovy @@ -49,5 +49,6 @@ class TestLanguageClient implements LanguageClient { @Override public void logMessage(MessageParams message) { + System.err.println(message.getMessage()) } } From 4f50e0f0f7e67fc06febb9434c33da92ae72b570 Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Thu, 23 Oct 2025 17:28:31 -0500 Subject: [PATCH 8/8] Mock plugin spec cache in ConfigHoverTest --- .../lsp/services/config/ConfigService.java | 6 +++++- .../lsp/services/config/ConfigHoverTest.groovy | 17 ++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/main/java/nextflow/lsp/services/config/ConfigService.java b/src/main/java/nextflow/lsp/services/config/ConfigService.java index bf10ca1e..1eedb131 100644 --- a/src/main/java/nextflow/lsp/services/config/ConfigService.java +++ b/src/main/java/nextflow/lsp/services/config/ConfigService.java @@ -48,8 +48,12 @@ public boolean matchesFile(String uri) { @Override public void initialize(LanguageServerConfiguration configuration) { + initialize(configuration, new PluginSpecCache(configuration.pluginRegistryUrl())); + } + + public void initialize(LanguageServerConfiguration configuration, PluginSpecCache pluginSpecCache) { synchronized (this) { - pluginSpecCache = new PluginSpecCache(configuration.pluginRegistryUrl()); + this.pluginSpecCache = pluginSpecCache; astCache.initialize(configuration, pluginSpecCache); } super.initialize(configuration); diff --git a/src/test/groovy/nextflow/lsp/services/config/ConfigHoverTest.groovy b/src/test/groovy/nextflow/lsp/services/config/ConfigHoverTest.groovy index 8526ba8c..dce7890c 100644 --- a/src/test/groovy/nextflow/lsp/services/config/ConfigHoverTest.groovy +++ b/src/test/groovy/nextflow/lsp/services/config/ConfigHoverTest.groovy @@ -16,6 +16,11 @@ package nextflow.lsp.services.config +import nextflow.config.spec.SpecNode +import nextflow.lsp.TestLanguageClient +import nextflow.lsp.services.LanguageServerConfiguration +import nextflow.lsp.spec.PluginSpec +import nextflow.lsp.spec.PluginSpecCache import org.eclipse.lsp4j.Hover import org.eclipse.lsp4j.HoverParams import org.eclipse.lsp4j.Position @@ -55,8 +60,18 @@ class ConfigHoverTest extends Specification { def 'should get hover hint for a plugin config scope' () { given: - def service = getConfigService() def uri = getUri('nextflow.config') + def service = new ConfigService(workspaceRoot().toUri().toString()) + def configuration = LanguageServerConfiguration.defaults() + def pluginSpecCache = Spy(new PluginSpecCache(configuration.pluginRegistryUrl())) + pluginSpecCache.get('nf-prov', '1.6.0') >> new PluginSpec( + [ + 'prov': new SpecNode.Scope('The `prov` scope allows you to configure the `nf-prov` plugin.', [:]) + ], + [], [], [] + ) + service.connect(new TestLanguageClient()) + service.initialize(configuration, pluginSpecCache) when: open(service, uri, '''\