From 68d63353a0fa126a3d7610e2b56171f0e6321c86 Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Thu, 25 Sep 2025 14:39:57 -0500 Subject: [PATCH 1/5] Load plugin config scopes from plugin registry --- build.gradle | 48 ++++---- .../services/config/ConfigSchemaFactory.java | 72 +++++++---- .../services/config/ConfigSchemaVisitor.java | 115 +++++++++++++++++- .../groovy/nextflow/SchemaRenderer.groovy | 43 ------- src/spec/groovy/nextflow/SpecRenderer.groovy | 69 +++++++++++ 5 files changed, 253 insertions(+), 94 deletions(-) delete mode 100644 src/schema/groovy/nextflow/SchemaRenderer.groovy create mode 100644 src/spec/groovy/nextflow/SpecRenderer.groovy diff --git a/build.gradle b/build.gradle index 2195dae..2c63f67 100644 --- a/build.gradle +++ b/build.gradle @@ -30,11 +30,11 @@ test { configurations { nextflowRuntime - schemaImplementation.extendsFrom(nextflowRuntime) + coreSpecImplementation.extendsFrom(nextflowRuntime) } dependencies { - implementation 'io.nextflow:nf-lang:25.07.0-edge' + implementation 'io.nextflow:nf-lang:25.08.0-edge' implementation 'org.apache.groovy:groovy:4.0.28' implementation 'org.apache.groovy:groovy-json:4.0.28' implementation 'org.eclipse.lsp4j:org.eclipse.lsp4j:0.23.0' @@ -45,13 +45,13 @@ dependencies { runtimeOnly 'org.yaml:snakeyaml:2.2' // include Nextflow runtime at build-time to extract language definitions - nextflowRuntime 'io.nextflow:nextflow:25.07.0-edge' - nextflowRuntime 'io.nextflow:nf-amazon:3.1.0' - nextflowRuntime 'io.nextflow:nf-azure:1.19.0' - nextflowRuntime 'io.nextflow:nf-google:1.22.2' - nextflowRuntime 'io.nextflow:nf-k8s:1.1.1' - nextflowRuntime 'io.nextflow:nf-tower:1.14.0' - nextflowRuntime 'io.nextflow:nf-wave:1.14.1' + nextflowRuntime 'io.nextflow:nextflow:25.08.0-edge' + nextflowRuntime 'io.nextflow:nf-amazon:3.2.0' + nextflowRuntime 'io.nextflow:nf-azure:1.20.0' + nextflowRuntime 'io.nextflow:nf-google:1.23.0' + nextflowRuntime 'io.nextflow:nf-k8s:1.2.0' + nextflowRuntime 'io.nextflow:nf-tower:1.15.0' + nextflowRuntime 'io.nextflow:nf-wave:1.15.0' testImplementation ('org.objenesis:objenesis:3.4') testImplementation ('net.bytebuddy:byte-buddy:1.14.17') @@ -59,23 +59,23 @@ dependencies { } sourceSets { - schema { - groovy.srcDir 'src/schema/groovy' + coreSpec { + groovy.srcDir 'src/spec/groovy' compileClasspath += configurations.nextflowRuntime runtimeClasspath += configurations.nextflowRuntime } } -task buildSchema(type: JavaExec) { - description = 'Build JSON schema of Nextflow configuration' +task buildCoreSpec(type: JavaExec) { + description = 'Build spec of core definitions' group = 'build' - dependsOn compileSchemaGroovy - classpath = sourceSets.schema.runtimeClasspath - mainClass.set('nextflow.SchemaRenderer') - args "$buildDir/generated/index.json" + dependsOn compileCoreSpecGroovy + classpath = sourceSets.coreSpec.runtimeClasspath + mainClass.set('nextflow.SpecRenderer') + args "$buildDir/generated/definitions.json" - outputs.file("$buildDir/generated/index.json") + outputs.file("$buildDir/generated/definitions.json") outputs.cacheIf { true } doFirst { @@ -84,17 +84,17 @@ task buildSchema(type: JavaExec) { } processResources { - dependsOn buildSchema - from(buildSchema.outputs.files) { - into 'schema' + dependsOn buildCoreSpec + from(buildCoreSpec.outputs.files) { + into 'spec' } } jar { - dependsOn buildSchema + dependsOn buildCoreSpec from("$buildDir/generated") { - include 'index.json' - into 'schema' + include 'definitions.json' + into 'spec' } } diff --git a/src/main/java/nextflow/lsp/services/config/ConfigSchemaFactory.java b/src/main/java/nextflow/lsp/services/config/ConfigSchemaFactory.java index c24e783..2ed5714 100644 --- a/src/main/java/nextflow/lsp/services/config/ConfigSchemaFactory.java +++ b/src/main/java/nextflow/lsp/services/config/ConfigSchemaFactory.java @@ -28,38 +28,58 @@ import org.codehaus.groovy.runtime.IOGroovyMethods; /** - * Load the config schema from the compiler as well as - * the index file. + * Load config scopes from core definitions and plugin specs. * * @author Ben Sherman */ public class ConfigSchemaFactory { + /** + * Load config scopes from core definitions. + */ public static SchemaNode.Scope load() { var scope = SchemaNode.ROOT; - scope.children().putAll(fromIndex()); + scope.children().putAll(fromCoreDefinitions()); return scope; } - private static Map fromIndex() { + private static Map fromCoreDefinitions() { try { var classLoader = ConfigSchemaFactory.class.getClassLoader(); - var resource = classLoader.getResourceAsStream("schema/index.json"); + var resource = classLoader.getResourceAsStream("spec/definitions.json"); var text = IOGroovyMethods.getText(resource); var json = new JsonSlurper().parseText(text); - return fromChildren((Map) json); + return fromChildren((List) json); } catch( IOException e ) { - System.err.println("Failed to read index file: " + e.toString()); + System.err.println("Failed to read core definitions: " + e.toString()); return Collections.emptyMap(); } } - private static Map fromChildren(Map children) { - var entries = children.entrySet().stream() - .map((entry) -> { - var name = entry.getKey(); - var node = (Map) entry.getValue(); + /** + * 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) -> { + var spec = (Map) node.get("spec"); + var name = (String) spec.get("name"); return Map.entry(name, fromNode(node)); }) .toArray(Map.Entry[]::new); @@ -68,36 +88,36 @@ private static Map fromChildren(Map children) { private static SchemaNode fromNode(Map node) { var type = (String) node.get("type"); - var spec = (Map) node.get("spec"); + var spec = (Map) node.get("spec"); - if( "Option".equals(type) ) + if( "ConfigOption".equals(type) ) return fromOption(spec); - if( "Placeholder".equals(type) ) + if( "ConfigPlaceholderScope".equals(type) ) return fromPlaceholder(spec); - if( "Scope".equals(type) ) + if( "ConfigScope".equals(type) ) return fromScope(spec); throw new IllegalStateException(); } - private static SchemaNode.Option fromOption(Map node) { - var description = (String) node.get("description"); - var type = fromType(node.get("type")); + private static SchemaNode.Option fromOption(Map spec) { + var description = (String) spec.get("description"); + var type = fromType(spec.get("type")); return new SchemaNode.Option(description, type); } - private static SchemaNode.Placeholder fromPlaceholder(Map node) { - var description = (String) node.get("description"); - var placeholderName = (String) node.get("placeholderName"); - var scope = fromScope((Map) node.get("scope")); + private static SchemaNode.Placeholder fromPlaceholder(Map spec) { + var description = (String) spec.get("description"); + var placeholderName = (String) spec.get("placeholderName"); + var scope = fromScope((Map) spec.get("scope")); return new SchemaNode.Placeholder(description, placeholderName, scope); } - private static SchemaNode.Scope fromScope(Map node) { - var description = (String) node.get("description"); - var children = fromChildren((Map) node.get("children")); + private static SchemaNode.Scope fromScope(Map spec) { + var description = (String) spec.get("description"); + var children = fromChildren((List) spec.get("children")); return new SchemaNode.Scope(description, children); } diff --git a/src/main/java/nextflow/lsp/services/config/ConfigSchemaVisitor.java b/src/main/java/nextflow/lsp/services/config/ConfigSchemaVisitor.java index 3aa3d35..508861b 100644 --- a/src/main/java/nextflow/lsp/services/config/ConfigSchemaVisitor.java +++ b/src/main/java/nextflow/lsp/services/config/ConfigSchemaVisitor.java @@ -15,31 +15,52 @@ */ package nextflow.lsp.services.config; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Stack; +import groovy.json.JsonSlurper; +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.schema.SchemaNode; +import nextflow.lsp.util.Logger; 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.expr.ConstantExpression; import org.codehaus.groovy.control.SourceUnit; import org.codehaus.groovy.control.messages.SyntaxErrorMessage; import org.codehaus.groovy.control.messages.WarningMessage; import org.codehaus.groovy.runtime.DefaultGroovyMethods; +import org.codehaus.groovy.runtime.StringGroovyMethods; import org.codehaus.groovy.syntax.SyntaxException; import org.codehaus.groovy.syntax.Token; +import static java.net.http.HttpResponse.BodyHandlers; +import static nextflow.script.ast.ASTUtils.*; + /** + * Validate config options against the config schema. + * + * Config scopes from third-party plugins are inferred + * from the `plugins` block, if specified. * * @author Ben Sherman */ public class ConfigSchemaVisitor extends ConfigVisitorSupport { + private static Logger log = Logger.getInstance(); + private SourceUnit sourceUnit; private SchemaNode.Scope schema; @@ -61,8 +82,100 @@ protected SourceUnit getSourceUnit() { public void visit() { var moduleNode = sourceUnit.getAST(); - if( moduleNode instanceof ConfigNode cn ) + if( moduleNode instanceof ConfigNode cn ) { + loadPluginScopes(cn); super.visit(cn); + } + } + + private void loadPluginScopes(ConfigNode cn) { + try { + var defaultScopes = schema.children(); + var pluginScopes = pluginConfigScopes(cn); + var children = new HashMap(); + children.putAll(defaultScopes); + children.putAll(pluginScopes); + this.schema = new SchemaNode.Scope(schema.description(), children); + } + catch( Exception e ) { + System.err.println("Failed to load plugin config scopes: " + e.toString()); + } + } + + private static final String PLUGIN_REGITRY_URL = "http://localhost:8080/api/"; + + private Map pluginConfigScopes(ConfigNode cn) { + var client = HttpClient.newBuilder().build(); + var baseUri = URI.create(PLUGIN_REGITRY_URL); + + 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(spec -> spec != null) + .map((spec) -> { + var tokens = StringGroovyMethods.tokenize(spec, "@"); + var name = tokens.get(0); + var version = tokens.size() == 2 ? tokens.get(1) : null; + var path = version != null + ? String.format("v1/plugins/%s/%s", name, version) + : String.format("v1/plugins/%s", name); + var uri = baseUri.resolve(path); + log.debug("fetch plugin " + uri); + var request = HttpRequest.newBuilder() + .uri(uri) + .GET() + .header("Accept", "application/json") + .build(); + try { + var response = client.send(request, BodyHandlers.ofString()); + var json = new JsonSlurper().parseText(response.body()); + return json instanceof Map m ? m : null; + } + catch( IOException | InterruptedException e ) { + return null; + } + }) + + // select plugin release (or latest if not specified) + .filter(json -> json != null) + .map((json) -> { + if( json.containsKey("plugin") ) { + var plugin = (Map) json.get("plugin"); + var releases = (List) plugin.get("releases"); + return releases.get(0); + } + if( json.containsKey("pluginRelease") ) { + return (Map) json.get("pluginRelease"); + } + return null; + }) + + // get spec from plugin release + .filter(release -> release != null) + .map((release) -> { + var text = (String) release.get("spec"); + var spec = (Map) new JsonSlurper().parseText(text); + var definitions = (List) spec.get("definitions"); + return ConfigSchemaFactory.fromDefinitions(definitions); + }) + .toList(); + + var result = new HashMap(); + for( var entry : entries ) + result.putAll(entry); + return result; } @Override diff --git a/src/schema/groovy/nextflow/SchemaRenderer.groovy b/src/schema/groovy/nextflow/SchemaRenderer.groovy deleted file mode 100644 index 99a3ecb..0000000 --- a/src/schema/groovy/nextflow/SchemaRenderer.groovy +++ /dev/null @@ -1,43 +0,0 @@ -/* - * 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 - -import groovy.transform.TypeChecked -import nextflow.config.schema.JsonRenderer -import nextflow.config.schema.MarkdownRenderer - -@TypeChecked -class SchemaRenderer { - - static void main(String[] args) { - if (args.length == 0) { - System.err.println("Missing output path") - System.exit(1) - } - - final outputPath = args[0] - final file = new File(outputPath) - file.parentFile.mkdirs() - file.text = new JsonRenderer().render() - - println "Rendered JSON schema to $file" - - final docsFile = new File("${file.parent}/config.md") - docsFile.text = new MarkdownRenderer().render() - - println "Rendered Markdown docs to $docsFile" - } -} diff --git a/src/spec/groovy/nextflow/SpecRenderer.groovy b/src/spec/groovy/nextflow/SpecRenderer.groovy new file mode 100644 index 0000000..8def6e2 --- /dev/null +++ b/src/spec/groovy/nextflow/SpecRenderer.groovy @@ -0,0 +1,69 @@ +/* + * 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 + +import groovy.json.JsonOutput +import groovy.transform.TypeChecked +import nextflow.config.schema.ConfigScope +import nextflow.config.schema.MarkdownRenderer +import nextflow.config.schema.SchemaNode +import nextflow.config.schema.ScopeName +import nextflow.plugin.Plugins +import nextflow.plugin.spec.ConfigSpec +import nextflow.script.dsl.Description + +@TypeChecked +class SpecRenderer { + + static void main(String[] args) { + if (args.length == 0) { + System.err.println("Missing output path") + System.exit(1) + } + + final outputPath = args[0] + final file = new File(outputPath) + file.parentFile.mkdirs() + file.text = JsonOutput.toJson(getDefinitions()) + + println "Rendered core definitions to $file" + + final docsFile = new File("${file.parent}/config.md") + docsFile.text = new MarkdownRenderer().render() + + println "Rendered Markdown docs to $docsFile" + } + + private static List getDefinitions() { + final result = new ArrayList() + for( final scope : Plugins.getExtensions(ConfigScope) ) { + final clazz = scope.getClass() + final scopeName = clazz.getAnnotation(ScopeName)?.value() + final description = clazz.getAnnotation(Description)?.value() + if( scopeName == '' ) { + SchemaNode.Scope.of(clazz, '').children().each { name, node -> + result.add(ConfigSpec.of(node, name)) + } + continue + } + if( !scopeName ) + continue + final node = SchemaNode.Scope.of(clazz, description) + result.add(ConfigSpec.of(node, scopeName)) + } + return result + } +} From 553dcb98e3cd0fcf58f26d59169794e7690e9ff4 Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Thu, 25 Sep 2025 14:58:52 -0500 Subject: [PATCH 2/5] Refactor registry url as LSP config setting --- .../nextflow/lsp/NextflowLanguageServer.java | 1 + .../services/LanguageServerConfiguration.java | 2 ++ .../lsp/services/config/ConfigAstCache.java | 2 +- .../services/config/ConfigSchemaFactory.java | 7 +++++-- .../services/config/ConfigSchemaVisitor.java | 17 ++++++++--------- 5 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/main/java/nextflow/lsp/NextflowLanguageServer.java b/src/main/java/nextflow/lsp/NextflowLanguageServer.java index 8b2ed21..7e8fef1 100644 --- a/src/main/java/nextflow/lsp/NextflowLanguageServer.java +++ b/src/main/java/nextflow/lsp/NextflowLanguageServer.java @@ -458,6 +458,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()) ); diff --git a/src/main/java/nextflow/lsp/services/LanguageServerConfiguration.java b/src/main/java/nextflow/lsp/services/LanguageServerConfiguration.java index be9ad2c..641db14 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 5923e91..ed26cd8 100644 --- a/src/main/java/nextflow/lsp/services/config/ConfigAstCache.java +++ b/src/main/java/nextflow/lsp/services/config/ConfigAstCache.java @@ -92,7 +92,7 @@ protected Set analyze(Set uris, FileCache fileCache) { continue; // phase 3: name checking new ConfigResolveVisitor(sourceUnit, compiler().compilationUnit(), Types.DEFAULT_CONFIG_IMPORTS).visit(); - new ConfigSchemaVisitor(sourceUnit, schema, configuration.typeChecking()).visit(); + new ConfigSchemaVisitor(sourceUnit, schema, configuration.pluginRegistryUrl(), configuration.typeChecking()).visit(); if( sourceUnit.getErrorCollector().hasErrors() ) continue; // phase 4: type checking diff --git a/src/main/java/nextflow/lsp/services/config/ConfigSchemaFactory.java b/src/main/java/nextflow/lsp/services/config/ConfigSchemaFactory.java index 2ed5714..ff048bb 100644 --- a/src/main/java/nextflow/lsp/services/config/ConfigSchemaFactory.java +++ b/src/main/java/nextflow/lsp/services/config/ConfigSchemaFactory.java @@ -48,8 +48,8 @@ private static Map fromCoreDefinitions() { var classLoader = ConfigSchemaFactory.class.getClassLoader(); var resource = classLoader.getResourceAsStream("spec/definitions.json"); var text = IOGroovyMethods.getText(resource); - var json = new JsonSlurper().parseText(text); - return fromChildren((List) json); + var definitions = (List) new JsonSlurper().parseText(text); + return fromChildren(definitions); } catch( IOException e ) { System.err.println("Failed to read core definitions: " + e.toString()); @@ -123,10 +123,13 @@ private static SchemaNode.Scope fromScope(Map spec) { private static final Map STANDARD_TYPES = Map.ofEntries( Map.entry("Boolean", Boolean.class), + Map.entry("boolean", Boolean.class), Map.entry("Closure", Closure.class), Map.entry("Duration", Duration.class), Map.entry("Float", Float.class), + Map.entry("float", Float.class), Map.entry("Integer", Integer.class), + Map.entry("int", Integer.class), Map.entry("List", List.class), Map.entry("MemoryUnit", MemoryUnit.class), Map.entry("String", String.class) diff --git a/src/main/java/nextflow/lsp/services/config/ConfigSchemaVisitor.java b/src/main/java/nextflow/lsp/services/config/ConfigSchemaVisitor.java index 508861b..df537d9 100644 --- a/src/main/java/nextflow/lsp/services/config/ConfigSchemaVisitor.java +++ b/src/main/java/nextflow/lsp/services/config/ConfigSchemaVisitor.java @@ -42,7 +42,6 @@ import org.codehaus.groovy.control.messages.SyntaxErrorMessage; import org.codehaus.groovy.control.messages.WarningMessage; import org.codehaus.groovy.runtime.DefaultGroovyMethods; -import org.codehaus.groovy.runtime.StringGroovyMethods; import org.codehaus.groovy.syntax.SyntaxException; import org.codehaus.groovy.syntax.Token; @@ -65,13 +64,16 @@ public class ConfigSchemaVisitor extends ConfigVisitorSupport { private SchemaNode.Scope schema; + private URI pluginRegistryUri; + private boolean typeChecking; private Stack scopes = new Stack<>(); - public ConfigSchemaVisitor(SourceUnit sourceUnit, SchemaNode.Scope schema, boolean typeChecking) { + public ConfigSchemaVisitor(SourceUnit sourceUnit, SchemaNode.Scope schema, String pluginRegistryUrl, boolean typeChecking) { this.sourceUnit = sourceUnit; this.schema = schema; + this.pluginRegistryUri = URI.create(pluginRegistryUrl); this.typeChecking = typeChecking; } @@ -102,11 +104,8 @@ private void loadPluginScopes(ConfigNode cn) { } } - private static final String PLUGIN_REGITRY_URL = "http://localhost:8080/api/"; - private Map pluginConfigScopes(ConfigNode cn) { var client = HttpClient.newBuilder().build(); - var baseUri = URI.create(PLUGIN_REGITRY_URL); var entries = cn.getConfigStatements().stream() @@ -125,13 +124,13 @@ private Map pluginConfigScopes(ConfigNode cn) { // fetch plugin specs from plugin registry .filter(spec -> spec != null) .map((spec) -> { - var tokens = StringGroovyMethods.tokenize(spec, "@"); - var name = tokens.get(0); - var version = tokens.size() == 2 ? tokens.get(1) : null; + var tokens = spec.split("@"); + var name = tokens[0]; + var version = tokens.length == 2 ? tokens[1] : null; var path = version != null ? String.format("v1/plugins/%s/%s", name, version) : String.format("v1/plugins/%s", name); - var uri = baseUri.resolve(path); + var uri = pluginRegistryUri.resolve(path); log.debug("fetch plugin " + uri); var request = HttpRequest.newBuilder() .uri(uri) From 993e6f3d64f120325e3f5f19c3f7a81db2eebf9d Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Thu, 25 Sep 2025 15:46:48 -0500 Subject: [PATCH 3/5] Cache registry API requests --- .../lsp/services/config/ConfigAstCache.java | 5 +- .../services/config/ConfigSchemaVisitor.java | 68 +-------- .../lsp/services/config/PluginSpecCache.java | 138 ++++++++++++++++++ 3 files changed, 150 insertions(+), 61 deletions(-) create mode 100644 src/main/java/nextflow/lsp/services/config/PluginSpecCache.java diff --git a/src/main/java/nextflow/lsp/services/config/ConfigAstCache.java b/src/main/java/nextflow/lsp/services/config/ConfigAstCache.java index ed26cd8..5dfb965 100644 --- a/src/main/java/nextflow/lsp/services/config/ConfigAstCache.java +++ b/src/main/java/nextflow/lsp/services/config/ConfigAstCache.java @@ -48,6 +48,8 @@ public class ConfigAstCache extends ASTNodeCache { private SchemaNode.Scope schema = ConfigSchemaFactory.load(); + private PluginSpecCache pluginSpecCache; + public ConfigAstCache() { super(createCompiler()); } @@ -67,6 +69,7 @@ private static CompilerConfiguration createConfiguration() { public void initialize(LanguageServerConfiguration configuration) { this.configuration = configuration; + this.pluginSpecCache = new PluginSpecCache(configuration.pluginRegistryUrl()); } @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 ConfigSchemaVisitor(sourceUnit, schema, configuration.pluginRegistryUrl(), configuration.typeChecking()).visit(); + new ConfigSchemaVisitor(sourceUnit, schema, pluginSpecCache, configuration.typeChecking()).visit(); if( sourceUnit.getErrorCollector().hasErrors() ) continue; // phase 4: type checking diff --git a/src/main/java/nextflow/lsp/services/config/ConfigSchemaVisitor.java b/src/main/java/nextflow/lsp/services/config/ConfigSchemaVisitor.java index df537d9..01a344f 100644 --- a/src/main/java/nextflow/lsp/services/config/ConfigSchemaVisitor.java +++ b/src/main/java/nextflow/lsp/services/config/ConfigSchemaVisitor.java @@ -15,24 +15,18 @@ */ package nextflow.lsp.services.config; -import java.io.IOException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Stack; -import groovy.json.JsonSlurper; 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.schema.SchemaNode; -import nextflow.lsp.util.Logger; import nextflow.script.control.PhaseAware; import nextflow.script.control.Phases; import nextflow.script.types.TypesEx; @@ -45,7 +39,6 @@ import org.codehaus.groovy.syntax.SyntaxException; import org.codehaus.groovy.syntax.Token; -import static java.net.http.HttpResponse.BodyHandlers; import static nextflow.script.ast.ASTUtils.*; /** @@ -58,22 +51,20 @@ */ public class ConfigSchemaVisitor extends ConfigVisitorSupport { - private static Logger log = Logger.getInstance(); - private SourceUnit sourceUnit; private SchemaNode.Scope schema; - private URI pluginRegistryUri; + private PluginSpecCache pluginSpecCache; private boolean typeChecking; private Stack scopes = new Stack<>(); - public ConfigSchemaVisitor(SourceUnit sourceUnit, SchemaNode.Scope schema, String pluginRegistryUrl, boolean typeChecking) { + public ConfigSchemaVisitor(SourceUnit sourceUnit, SchemaNode.Scope schema, PluginSpecCache pluginSpecCache, boolean typeChecking) { this.sourceUnit = sourceUnit; this.schema = schema; - this.pluginRegistryUri = URI.create(pluginRegistryUrl); + this.pluginSpecCache = pluginSpecCache; this.typeChecking = typeChecking; } @@ -105,10 +96,7 @@ private void loadPluginScopes(ConfigNode cn) { } private Map pluginConfigScopes(ConfigNode cn) { - var client = HttpClient.newBuilder().build(); - var entries = cn.getConfigStatements().stream() - // get plugin refs from `plugins` block .map(stmt -> stmt instanceof ConfigApplyBlockNode node && "plugins".equals(node.name) ? node : null @@ -120,55 +108,15 @@ 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(spec -> spec != null) - .map((spec) -> { - var tokens = spec.split("@"); + .filter(ref -> ref != null) + .map((ref) -> { + var tokens = ref.split("@"); var name = tokens[0]; var version = tokens.length == 2 ? tokens[1] : null; - var path = version != null - ? String.format("v1/plugins/%s/%s", name, version) - : String.format("v1/plugins/%s", name); - var uri = pluginRegistryUri.resolve(path); - log.debug("fetch plugin " + uri); - var request = HttpRequest.newBuilder() - .uri(uri) - .GET() - .header("Accept", "application/json") - .build(); - try { - var response = client.send(request, BodyHandlers.ofString()); - var json = new JsonSlurper().parseText(response.body()); - return json instanceof Map m ? m : null; - } - catch( IOException | InterruptedException e ) { - return null; - } - }) - - // select plugin release (or latest if not specified) - .filter(json -> json != null) - .map((json) -> { - if( json.containsKey("plugin") ) { - var plugin = (Map) json.get("plugin"); - var releases = (List) plugin.get("releases"); - return releases.get(0); - } - if( json.containsKey("pluginRelease") ) { - return (Map) json.get("pluginRelease"); - } - return null; - }) - - // get spec from plugin release - .filter(release -> release != null) - .map((release) -> { - var text = (String) release.get("spec"); - var spec = (Map) new JsonSlurper().parseText(text); - var definitions = (List) spec.get("definitions"); - return ConfigSchemaFactory.fromDefinitions(definitions); + return pluginSpecCache.get(name, version); }) + .filter(spec -> spec != null) .toList(); var result = new HashMap(); diff --git a/src/main/java/nextflow/lsp/services/config/PluginSpecCache.java b/src/main/java/nextflow/lsp/services/config/PluginSpecCache.java new file mode 100644 index 0000000..c7b8b96 --- /dev/null +++ b/src/main/java/nextflow/lsp/services/config/PluginSpecCache.java @@ -0,0 +1,138 @@ +/* + * 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 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.config.schema.SchemaNode; +import nextflow.lsp.util.Logger; + +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 Map 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 Map compute(String name, String version) { + try { + return compute0(name, version); + } + catch( Exception e ) { + log.error(e.toString()); + return null; + } + } + + private Map 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 + var specJson = (String) release.get("spec"); + var spec = (Map) new JsonSlurper().parseText(specJson); + var definitions = (List) spec.get("definitions"); + return ConfigSchemaFactory.fromDefinitions(definitions); + } + + 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; + } + +} + + +record PluginRef( + String name, + String version +) {} From 7c99338b8065f510d9bb4e1a22b28353c82b84d9 Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Thu, 25 Sep 2025 16:21:47 -0500 Subject: [PATCH 4/5] Use augmented config schema in hover hints and code completions --- .../lsp/services/config/ConfigAstCache.java | 9 ++++-- .../config/ConfigCompletionProvider.java | 14 ++++----- .../services/config/ConfigHoverProvider.java | 13 ++++---- .../services/config/ConfigSchemaFactory.java | 13 +++++--- .../services/config/ConfigSchemaVisitor.java | 30 ++++++++----------- 5 files changed, 41 insertions(+), 38 deletions(-) diff --git a/src/main/java/nextflow/lsp/services/config/ConfigAstCache.java b/src/main/java/nextflow/lsp/services/config/ConfigAstCache.java index 5dfb965..49741a3 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; @@ -46,8 +47,6 @@ public class ConfigAstCache extends ASTNodeCache { private LanguageServerConfiguration configuration; - private SchemaNode.Scope schema = ConfigSchemaFactory.load(); - private PluginSpecCache pluginSpecCache; public ConfigAstCache() { @@ -95,7 +94,7 @@ protected Set analyze(Set uris, FileCache fileCache) { continue; // phase 3: name checking new ConfigResolveVisitor(sourceUnit, compiler().compilationUnit(), Types.DEFAULT_CONFIG_IMPORTS).visit(); - new ConfigSchemaVisitor(sourceUnit, schema, pluginSpecCache, configuration.typeChecking()).visit(); + new ConfigSchemaVisitor(sourceUnit, pluginSpecCache, configuration.typeChecking()).visit(); if( sourceUnit.getErrorCollector().hasErrors() ) continue; // phase 4: type checking @@ -124,4 +123,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 de63554..1274900 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.schema.SchemaNode; -import nextflow.lsp.ast.ASTNodeCache; import nextflow.lsp.ast.CompletionHelper; import nextflow.lsp.services.CompletionProvider; import nextflow.lsp.util.Logger; @@ -66,10 +65,10 @@ public class ConfigCompletionProvider implements CompletionProvider { 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); } @@ -96,7 +95,7 @@ public Either, CompletionList> completion(TextDocumentIdent var names = currentConfigScope(nodeStack); if( names.isEmpty() ) return Either.forLeft(TOPLEVEL_ITEMS); - addConfigOptions(names); + addConfigOptions(names, ast.getConfigNode(uri).getSchema()); } return ch.isIncomplete() @@ -179,8 +178,8 @@ private static List currentConfigScope(List nodeStack) { return names; } - private void addConfigOptions(List names) { - var scope = SchemaNode.ROOT.getScope(names); + private void addConfigOptions(List names, SchemaNode.Scope schema) { + var scope = schema.getScope(names); if( scope == null ) return; scope.children().forEach((name, child) -> { @@ -192,8 +191,9 @@ private void addConfigOptions(List names) { } private static List topLevelItems() { + var defaultScopes = ConfigSchemaFactory.defaultScopes(); var result = new ArrayList(); - SchemaNode.ROOT.children().forEach((name, child) -> { + defaultScopes.forEach((name, child) -> { if( child instanceof SchemaNode.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 841cf9e..cc5f681 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.schema.SchemaNode; -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).getSchema()); 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, SchemaNode.Scope schema) { 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 = SchemaNode.ROOT.getOption(names); + var option = schema.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 = SchemaNode.ROOT.getChild(names); + var scope = schema.getChild(names); if( scope != null ) { return StringGroovyMethods.stripIndent(scope.description(), true).trim(); } diff --git a/src/main/java/nextflow/lsp/services/config/ConfigSchemaFactory.java b/src/main/java/nextflow/lsp/services/config/ConfigSchemaFactory.java index ff048bb..015e310 100644 --- a/src/main/java/nextflow/lsp/services/config/ConfigSchemaFactory.java +++ b/src/main/java/nextflow/lsp/services/config/ConfigSchemaFactory.java @@ -34,13 +34,18 @@ */ public class ConfigSchemaFactory { + private static Map defaultScopes = null; + /** * Load config scopes from core definitions. */ - public static SchemaNode.Scope load() { - var scope = SchemaNode.ROOT; - scope.children().putAll(fromCoreDefinitions()); - return scope; + public static Map defaultScopes() { + if( defaultScopes == null ) { + var scope = SchemaNode.ROOT; + scope.children().putAll(fromCoreDefinitions()); + defaultScopes = scope.children(); + } + return defaultScopes; } private static Map fromCoreDefinitions() { diff --git a/src/main/java/nextflow/lsp/services/config/ConfigSchemaVisitor.java b/src/main/java/nextflow/lsp/services/config/ConfigSchemaVisitor.java index 01a344f..b105c8f 100644 --- a/src/main/java/nextflow/lsp/services/config/ConfigSchemaVisitor.java +++ b/src/main/java/nextflow/lsp/services/config/ConfigSchemaVisitor.java @@ -53,17 +53,14 @@ public class ConfigSchemaVisitor extends ConfigVisitorSupport { private SourceUnit sourceUnit; - private SchemaNode.Scope schema; - private PluginSpecCache pluginSpecCache; private boolean typeChecking; private Stack scopes = new Stack<>(); - public ConfigSchemaVisitor(SourceUnit sourceUnit, SchemaNode.Scope schema, PluginSpecCache pluginSpecCache, boolean typeChecking) { + public ConfigSchemaVisitor(SourceUnit sourceUnit, PluginSpecCache pluginSpecCache, boolean typeChecking) { this.sourceUnit = sourceUnit; - this.schema = schema; this.pluginSpecCache = pluginSpecCache; this.typeChecking = typeChecking; } @@ -73,26 +70,25 @@ protected SourceUnit getSourceUnit() { return sourceUnit; } + private SchemaNode.Scope schema; + public void visit() { var moduleNode = sourceUnit.getAST(); if( moduleNode instanceof ConfigNode cn ) { - loadPluginScopes(cn); + this.schema = getPluginScopes(cn); + cn.setSchema(schema); super.visit(cn); + this.schema = null; } } - private void loadPluginScopes(ConfigNode cn) { - try { - var defaultScopes = schema.children(); - var pluginScopes = pluginConfigScopes(cn); - var children = new HashMap(); - children.putAll(defaultScopes); - children.putAll(pluginScopes); - this.schema = new SchemaNode.Scope(schema.description(), children); - } - catch( Exception e ) { - System.err.println("Failed to load plugin config scopes: " + e.toString()); - } + private SchemaNode.Scope getPluginScopes(ConfigNode cn) { + var defaultScopes = ConfigSchemaFactory.defaultScopes(); + var pluginScopes = pluginConfigScopes(cn); + var children = new HashMap(); + children.putAll(defaultScopes); + children.putAll(pluginScopes); + return new SchemaNode.Scope("", children); } private Map pluginConfigScopes(ConfigNode cn) { From 9cd83033f9e15bb6a1966a3ce5a1c69a62d1c86d Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Fri, 26 Sep 2025 07:58:19 -0500 Subject: [PATCH 5/5] Resolve plugin includes in script --- .../nextflow/lsp/NextflowLanguageServer.java | 24 ++-- .../lsp/services/config/ConfigAstCache.java | 5 +- .../config/ConfigCompletionProvider.java | 14 +-- .../services/config/ConfigSchemaVisitor.java | 5 +- .../lsp/services/config/ConfigService.java | 10 +- .../script/ResolvePluginIncludeVisitor.java | 109 ++++++++++++++++++ .../lsp/services/script/ScriptAstCache.java | 8 +- .../lsp/services/script/ScriptService.java | 6 +- .../ConfigSpecFactory.java} | 8 +- .../java/nextflow/lsp/spec/PluginSpec.java | 29 +++++ .../config => spec}/PluginSpecCache.java | 28 +++-- .../nextflow/lsp/spec/ScriptSpecFactory.java | 105 +++++++++++++++++ 12 files changed, 311 insertions(+), 40 deletions(-) create mode 100644 src/main/java/nextflow/lsp/services/script/ResolvePluginIncludeVisitor.java rename src/main/java/nextflow/lsp/{services/config/ConfigSchemaFactory.java => spec/ConfigSpecFactory.java} (96%) create mode 100644 src/main/java/nextflow/lsp/spec/PluginSpec.java rename src/main/java/nextflow/lsp/{services/config => spec}/PluginSpecCache.java (84%) 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 7e8fef1..f32463c 100644 --- a/src/main/java/nextflow/lsp/NextflowLanguageServer.java +++ b/src/main/java/nextflow/lsp/NextflowLanguageServer.java @@ -120,8 +120,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(); @@ -498,8 +498,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(); @@ -517,16 +517,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()); } } @@ -586,13 +586,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/config/ConfigAstCache.java b/src/main/java/nextflow/lsp/services/config/ConfigAstCache.java index 49741a3..3cdc62c 100644 --- a/src/main/java/nextflow/lsp/services/config/ConfigAstCache.java +++ b/src/main/java/nextflow/lsp/services/config/ConfigAstCache.java @@ -31,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; @@ -66,9 +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 = new PluginSpecCache(configuration.pluginRegistryUrl()); + this.pluginSpecCache = pluginSpecCache; } @Override diff --git a/src/main/java/nextflow/lsp/services/config/ConfigCompletionProvider.java b/src/main/java/nextflow/lsp/services/config/ConfigCompletionProvider.java index 1274900..472f59e 100644 --- a/src/main/java/nextflow/lsp/services/config/ConfigCompletionProvider.java +++ b/src/main/java/nextflow/lsp/services/config/ConfigCompletionProvider.java @@ -61,8 +61,6 @@ */ public class ConfigCompletionProvider implements CompletionProvider { - private static final List TOPLEVEL_ITEMS = topLevelItems(); - private static Logger log = Logger.getInstance(); private ConfigAstCache ast; @@ -85,8 +83,9 @@ public Either, CompletionList> completion(TextDocumentIdent return Either.forLeft(Collections.emptyList()); var nodeStack = ast.getNodesAtPosition(uri, position); + var schema = ast.getConfigNode(uri).getSchema(); if( nodeStack.isEmpty() ) - return Either.forLeft(TOPLEVEL_ITEMS); + return Either.forLeft(topLevelItems(schema)); if( isConfigExpression(nodeStack) ) { addCompletionItems(nodeStack); @@ -94,8 +93,8 @@ public Either, CompletionList> completion(TextDocumentIdent else { var names = currentConfigScope(nodeStack); if( names.isEmpty() ) - return Either.forLeft(TOPLEVEL_ITEMS); - addConfigOptions(names, ast.getConfigNode(uri).getSchema()); + return Either.forLeft(topLevelItems(schema)); + addConfigOptions(names, schema); } return ch.isIncomplete() @@ -190,10 +189,9 @@ private void addConfigOptions(List names, SchemaNode.Scope schema) { }); } - private static List topLevelItems() { - var defaultScopes = ConfigSchemaFactory.defaultScopes(); + private static List topLevelItems(SchemaNode.Scope schema) { var result = new ArrayList(); - defaultScopes.forEach((name, child) -> { + schema.children().forEach((name, child) -> { if( child instanceof SchemaNode.Option option ) { result.add(configOption(name, option.description(), option.type())); } diff --git a/src/main/java/nextflow/lsp/services/config/ConfigSchemaVisitor.java b/src/main/java/nextflow/lsp/services/config/ConfigSchemaVisitor.java index b105c8f..9ed3f0c 100644 --- a/src/main/java/nextflow/lsp/services/config/ConfigSchemaVisitor.java +++ b/src/main/java/nextflow/lsp/services/config/ConfigSchemaVisitor.java @@ -27,6 +27,8 @@ import nextflow.config.ast.ConfigNode; import nextflow.config.ast.ConfigVisitorSupport; import nextflow.config.schema.SchemaNode; +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; @@ -83,7 +85,7 @@ public void visit() { } private SchemaNode.Scope getPluginScopes(ConfigNode cn) { - var defaultScopes = ConfigSchemaFactory.defaultScopes(); + var defaultScopes = ConfigSpecFactory.defaultScopes(); var pluginScopes = pluginConfigScopes(cn); var children = new HashMap(); children.putAll(defaultScopes); @@ -113,6 +115,7 @@ private Map pluginConfigScopes(ConfigNode cn) { return pluginSpecCache.get(name, version); }) .filter(spec -> spec != null) + .map(spec -> spec.configScopes()) .toList(); var result = new HashMap(); diff --git a/src/main/java/nextflow/lsp/services/config/ConfigService.java b/src/main/java/nextflow/lsp/services/config/ConfigService.java index 4d9f98c..bf10ca1 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/script/ResolvePluginIncludeVisitor.java b/src/main/java/nextflow/lsp/services/script/ResolvePluginIncludeVisitor.java new file mode 100644 index 0000000..054b385 --- /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 6db9d29..b4fae59 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 bc79c80..95af69b 100644 --- a/src/main/java/nextflow/lsp/services/script/ScriptService.java +++ b/src/main/java/nextflow/lsp/services/script/ScriptService.java @@ -32,6 +32,7 @@ import nextflow.lsp.services.RenameProvider; import nextflow.lsp.services.SemanticTokensProvider; import nextflow.lsp.services.SymbolProvider; +import nextflow.lsp.spec.PluginSpecCache; /** * Implementation of language services for Nextflow scripts. @@ -52,10 +53,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/ConfigSchemaFactory.java b/src/main/java/nextflow/lsp/spec/ConfigSpecFactory.java similarity index 96% rename from src/main/java/nextflow/lsp/services/config/ConfigSchemaFactory.java rename to src/main/java/nextflow/lsp/spec/ConfigSpecFactory.java index 015e310..2d4d47c 100644 --- a/src/main/java/nextflow/lsp/services/config/ConfigSchemaFactory.java +++ b/src/main/java/nextflow/lsp/spec/ConfigSpecFactory.java @@ -13,12 +13,13 @@ * 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; import java.util.List; import java.util.Map; +import java.util.Set; import groovy.json.JsonSlurper; import groovy.lang.Closure; @@ -32,7 +33,7 @@ * * @author Ben Sherman */ -public class ConfigSchemaFactory { +public class ConfigSpecFactory { private static Map defaultScopes = null; @@ -50,7 +51,7 @@ public static Map defaultScopes() { private static Map fromCoreDefinitions() { try { - var classLoader = ConfigSchemaFactory.class.getClassLoader(); + var classLoader = ConfigSpecFactory.class.getClassLoader(); var resource = classLoader.getResourceAsStream("spec/definitions.json"); var text = IOGroovyMethods.getText(resource); var definitions = (List) new JsonSlurper().parseText(text); @@ -137,6 +138,7 @@ private static SchemaNode.Scope fromScope(Map spec) { Map.entry("int", Integer.class), Map.entry("List", List.class), Map.entry("MemoryUnit", MemoryUnit.class), + Map.entry("Set", Set.class), Map.entry("String", String.class) ); 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 0000000..f318c7a --- /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.schema.SchemaNode; +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/services/config/PluginSpecCache.java b/src/main/java/nextflow/lsp/spec/PluginSpecCache.java similarity index 84% rename from src/main/java/nextflow/lsp/services/config/PluginSpecCache.java rename to src/main/java/nextflow/lsp/spec/PluginSpecCache.java index c7b8b96..f12a141 100644 --- a/src/main/java/nextflow/lsp/services/config/PluginSpecCache.java +++ b/src/main/java/nextflow/lsp/spec/PluginSpecCache.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.net.URI; @@ -26,6 +26,7 @@ import groovy.json.JsonSlurper; import nextflow.config.schema.SchemaNode; import nextflow.lsp.util.Logger; +import org.codehaus.groovy.ast.MethodNode; import static java.net.http.HttpResponse.BodyHandlers; @@ -42,7 +43,7 @@ public class PluginSpecCache { private HttpClient client = HttpClient.newBuilder().build(); - private Map> cache = new HashMap<>(); + private Map cache = new HashMap<>(); public PluginSpecCache(String registryUrl) { this.registryUri = URI.create(registryUrl); @@ -58,14 +59,14 @@ public PluginSpecCache(String registryUrl) { * @param name * @param version */ - public Map get(String name, String 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 Map compute(String name, String version) { + private PluginSpec compute(String name, String version) { try { return compute0(name, version); } @@ -75,7 +76,7 @@ private Map compute(String name, String version) { } } - private Map compute0(String name, String version) { + private PluginSpec compute0(String name, String version) { // fetch plugin spec from registry var response = fetch(name, version); if( response == null ) @@ -87,10 +88,7 @@ private Map compute0(String name, String version) { return null; // get spec from plugin release - var specJson = (String) release.get("spec"); - var spec = (Map) new JsonSlurper().parseText(specJson); - var definitions = (List) spec.get("definitions"); - return ConfigSchemaFactory.fromDefinitions(definitions); + return pluginSpec(release); } private Map fetch(String name, String version) { @@ -129,6 +127,18 @@ private static Map pluginRelease(Map response) { 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") + ); + } + } 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 0000000..a480ca2 --- /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(); + } + +}