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/NextflowLanguageServer.java b/src/main/java/nextflow/lsp/NextflowLanguageServer.java index 8b2ed21..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(); @@ -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()) ); @@ -497,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(); @@ -516,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()); } } @@ -585,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/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..3cdc62c 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 SchemaNode.Scope schema = ConfigSchemaFactory.load(); + 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 ConfigSchemaVisitor(sourceUnit, schema, configuration.typeChecking()).visit(); + new ConfigSchemaVisitor(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 de63554..472f59e 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; @@ -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 schema = ast.getConfigNode(uri).getSchema(); if( nodeStack.isEmpty() ) - return Either.forLeft(TOPLEVEL_ITEMS); + return Either.forLeft(topLevelItems(schema)); 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(schema)); + addConfigOptions(names, schema); } return ch.isIncomplete() @@ -179,8 +177,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) -> { @@ -191,9 +189,9 @@ private void addConfigOptions(List names) { }); } - private static List topLevelItems() { + private static List topLevelItems(SchemaNode.Scope schema) { var result = new ArrayList(); - SchemaNode.ROOT.children().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/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 deleted file mode 100644 index c24e783..0000000 --- a/src/main/java/nextflow/lsp/services/config/ConfigSchemaFactory.java +++ /dev/null @@ -1,127 +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.lsp.services.config; - -import java.io.IOException; -import java.util.Collections; -import java.util.List; -import java.util.Map; - -import groovy.json.JsonSlurper; -import groovy.lang.Closure; -import nextflow.config.schema.SchemaNode; -import nextflow.script.types.Duration; -import nextflow.script.types.MemoryUnit; -import org.codehaus.groovy.runtime.IOGroovyMethods; - -/** - * Load the config schema from the compiler as well as - * the index file. - * - * @author Ben Sherman - */ -public class ConfigSchemaFactory { - - public static SchemaNode.Scope load() { - var scope = SchemaNode.ROOT; - scope.children().putAll(fromIndex()); - return scope; - } - - private static Map fromIndex() { - try { - var classLoader = ConfigSchemaFactory.class.getClassLoader(); - var resource = classLoader.getResourceAsStream("schema/index.json"); - var text = IOGroovyMethods.getText(resource); - var json = new JsonSlurper().parseText(text); - return fromChildren((Map) json); - } - catch( IOException e ) { - System.err.println("Failed to read index file: " + 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(); - return Map.entry(name, fromNode(node)); - }) - .toArray(Map.Entry[]::new); - return Map.ofEntries(entries); - } - - private static SchemaNode fromNode(Map node) { - var type = (String) node.get("type"); - var spec = (Map) node.get("spec"); - - if( "Option".equals(type) ) - return fromOption(spec); - - if( "Placeholder".equals(type) ) - return fromPlaceholder(spec); - - if( "Scope".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")); - 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")); - 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")); - return new SchemaNode.Scope(description, children); - } - - private static final Map STANDARD_TYPES = Map.ofEntries( - Map.entry("Boolean", Boolean.class), - Map.entry("Closure", Closure.class), - Map.entry("Duration", Duration.class), - Map.entry("Float", Float.class), - Map.entry("Integer", Integer.class), - Map.entry("List", List.class), - Map.entry("MemoryUnit", MemoryUnit.class), - Map.entry("String", String.class) - ); - - private static Class fromType(Object type) { - if( type instanceof String s ) { - return STANDARD_TYPES.getOrDefault(s, Object.class); - } - if( type instanceof Map m ) { - var name = (String) m.get("name"); - // TODO: type arguments - return STANDARD_TYPES.getOrDefault(name, Object.class); - } - throw new IllegalStateException(); - } - -} diff --git a/src/main/java/nextflow/lsp/services/config/ConfigSchemaVisitor.java b/src/main/java/nextflow/lsp/services/config/ConfigSchemaVisitor.java index 3aa3d35..9ed3f0c 100644 --- a/src/main/java/nextflow/lsp/services/config/ConfigSchemaVisitor.java +++ b/src/main/java/nextflow/lsp/services/config/ConfigSchemaVisitor.java @@ -16,17 +16,24 @@ package nextflow.lsp.services.config; import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +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.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; 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; @@ -34,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 schema. + * + * Config scopes from third-party plugins are inferred + * from the `plugins` block, if specified. * * @author Ben Sherman */ @@ -42,15 +55,15 @@ 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, boolean typeChecking) { + public ConfigSchemaVisitor(SourceUnit sourceUnit, PluginSpecCache pluginSpecCache, boolean typeChecking) { this.sourceUnit = sourceUnit; - this.schema = schema; + this.pluginSpecCache = pluginSpecCache; this.typeChecking = typeChecking; } @@ -59,10 +72,56 @@ protected SourceUnit getSourceUnit() { return sourceUnit; } + private SchemaNode.Scope schema; + public void visit() { var moduleNode = sourceUnit.getAST(); - if( moduleNode instanceof ConfigNode cn ) + if( moduleNode instanceof ConfigNode cn ) { + this.schema = getPluginScopes(cn); + cn.setSchema(schema); super.visit(cn); + this.schema = null; + } + } + + private SchemaNode.Scope getPluginScopes(ConfigNode cn) { + var defaultScopes = ConfigSpecFactory.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) { + 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/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/spec/ConfigSpecFactory.java b/src/main/java/nextflow/lsp/spec/ConfigSpecFactory.java new file mode 100644 index 0000000..2d4d47c --- /dev/null +++ b/src/main/java/nextflow/lsp/spec/ConfigSpecFactory.java @@ -0,0 +1,157 @@ +/* + * 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.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import groovy.json.JsonSlurper; +import groovy.lang.Closure; +import nextflow.config.schema.SchemaNode; +import nextflow.script.types.Duration; +import nextflow.script.types.MemoryUnit; +import org.codehaus.groovy.runtime.IOGroovyMethods; + +/** + * Load config scopes from core definitions and plugin specs. + * + * @author Ben Sherman + */ +public class ConfigSpecFactory { + + private static Map defaultScopes = null; + + /** + * Load config scopes from core definitions. + */ + public static Map defaultScopes() { + if( defaultScopes == null ) { + var scope = SchemaNode.ROOT; + scope.children().putAll(fromCoreDefinitions()); + defaultScopes = scope.children(); + } + return defaultScopes; + } + + private static Map fromCoreDefinitions() { + try { + var classLoader = ConfigSpecFactory.class.getClassLoader(); + var resource = classLoader.getResourceAsStream("spec/definitions.json"); + var text = IOGroovyMethods.getText(resource); + var definitions = (List) new JsonSlurper().parseText(text); + return fromChildren(definitions); + } + catch( IOException e ) { + System.err.println("Failed to read core definitions: " + e.toString()); + return Collections.emptyMap(); + } + } + + /** + * 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); + return Map.ofEntries(entries); + } + + private static SchemaNode fromNode(Map node) { + var type = (String) node.get("type"); + var spec = (Map) node.get("spec"); + + if( "ConfigOption".equals(type) ) + return fromOption(spec); + + if( "ConfigPlaceholderScope".equals(type) ) + return fromPlaceholder(spec); + + if( "ConfigScope".equals(type) ) + return fromScope(spec); + + throw new IllegalStateException(); + } + + 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 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 spec) { + var description = (String) spec.get("description"); + var children = fromChildren((List) spec.get("children")); + return new SchemaNode.Scope(description, children); + } + + 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("Set", Set.class), + Map.entry("String", String.class) + ); + + private static Class fromType(Object type) { + if( type instanceof String s ) { + return STANDARD_TYPES.getOrDefault(s, Object.class); + } + if( type instanceof Map m ) { + var name = (String) m.get("name"); + // TODO: type arguments + return STANDARD_TYPES.getOrDefault(name, Object.class); + } + throw new IllegalStateException(); + } + +} 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/spec/PluginSpecCache.java b/src/main/java/nextflow/lsp/spec/PluginSpecCache.java new file mode 100644 index 0000000..f12a141 --- /dev/null +++ b/src/main/java/nextflow/lsp/spec/PluginSpecCache.java @@ -0,0 +1,148 @@ +/* + * 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.config.schema.SchemaNode; +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 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(); + } + +} 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 + } +}