diff --git a/modules/nextflow/src/main/groovy/nextflow/script/BaseScript.groovy b/modules/nextflow/src/main/groovy/nextflow/script/BaseScript.groovy index 72b8e85bcc..088b7b22a8 100644 --- a/modules/nextflow/src/main/groovy/nextflow/script/BaseScript.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/script/BaseScript.groovy @@ -277,14 +277,19 @@ abstract class BaseScript extends Script implements ExecutionContext { } if( !entryFlow ) { - if( meta.getLocalWorkflowNames() ) - throw new AbortOperationException("No entry workflow specified") - // Check if we have standalone processes that can be executed automatically - if( meta.hasExecutableProcesses() ) { + if( meta.hasExecutableWorkflows() ) { + // Create an entry workflow that calls the single named workflow automatically + final handler = new WorkflowEntryHandler(this, session, meta) + this.entryFlow = handler.createEntryWorkflow() + } + else if( meta.hasExecutableProcesses() ) { // Create a workflow to execute the process (single process or first of multiple) final handler = new ProcessEntryHandler(this, session, meta) this.entryFlow = handler.createEntryWorkflow() } + else if( meta.getLocalWorkflowNames() ) { + throw new AbortOperationException("No entry workflow specified") + } else { return result } diff --git a/modules/nextflow/src/main/groovy/nextflow/script/ScriptMeta.groovy b/modules/nextflow/src/main/groovy/nextflow/script/ScriptMeta.groovy index 49b4c3deb4..f710910d9f 100644 --- a/modules/nextflow/src/main/groovy/nextflow/script/ScriptMeta.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/script/ScriptMeta.groovy @@ -303,6 +303,21 @@ class ScriptMeta { return result } + /** + * Check if this script has a named workflow that can be executed + * automatically without an explicit entry workflow. + * + * @return true if the script has exactly one named workflow + */ + boolean hasExecutableWorkflows() { + // Don't allow execution of true modules (those are meant for inclusion) + if( isModule() ) + return false + + // Must have exactly one workflow + return getLocalWorkflowNames().size() == 1 + } + /** * Check if this script has standalone processes that can be executed * automatically without requiring workflows diff --git a/modules/nextflow/src/main/groovy/nextflow/script/WorkflowDef.groovy b/modules/nextflow/src/main/groovy/nextflow/script/WorkflowDef.groovy index 22d052af44..6643845e73 100644 --- a/modules/nextflow/src/main/groovy/nextflow/script/WorkflowDef.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/script/WorkflowDef.groovy @@ -40,6 +40,8 @@ class WorkflowDef extends BindableDef implements ChainableDef, IterableDef, Exec private List declaredInputs + private Map declaredInputTypes + private List declaredOutputs private Set variableNames @@ -64,6 +66,7 @@ class WorkflowDef extends BindableDef implements ChainableDef, IterableDef, Exec this.body = copy.call() // now it can access the parameters this.declaredInputs = new ArrayList<>(resolver.getTakes()) + this.declaredInputTypes = new HashMap<>(resolver.getTakeTypes()) this.declaredOutputs = new ArrayList<>(resolver.getEmits()) this.variableNames = getVarNames0() } @@ -102,6 +105,8 @@ class WorkflowDef extends BindableDef implements ChainableDef, IterableDef, Exec @PackageScope List getDeclaredInputs() { declaredInputs } + @PackageScope Map getDeclaredInputTypes() { declaredInputTypes } + @PackageScope List getDeclaredOutputs() { declaredOutputs } @PackageScope Map getDeclaredPublish() { declaredPublish } @@ -222,14 +227,20 @@ class WorkflowDef extends BindableDef implements ChainableDef, IterableDef, Exec @CompileStatic class WorkflowParamsDsl { - private static final String TAKE = '_take_' - private static final String EMIT = '_emit_' - List takes = new ArrayList<>(10) + Map takeTypes = new HashMap<>() List emits = new ArrayList<>(10) - void _take_(String name) { + /** + * Called by generated code for each workflow take parameter. + * + * @param name the parameter name + * @param type the parameter type (may be Object for untyped workflows) + */ + void _take_(String name, Class type = null) { takes.add(name) + if( type != null && type != Object ) + takeTypes.put(name, type) } void _emit_(String name) { diff --git a/modules/nextflow/src/main/groovy/nextflow/script/WorkflowEntryHandler.groovy b/modules/nextflow/src/main/groovy/nextflow/script/WorkflowEntryHandler.groovy new file mode 100644 index 0000000000..3df8582b7c --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/script/WorkflowEntryHandler.groovy @@ -0,0 +1,245 @@ +/* + * Copyright 2013-2026, 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.script + +import java.nio.file.Path + +import groovy.json.JsonSlurper +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import groovy.yaml.YamlSlurper +import nextflow.Session +import nextflow.dataflow.ChannelNamespace +import nextflow.exception.ScriptRuntimeException +import nextflow.file.FileHelper +import nextflow.script.types.Channel +import nextflow.script.types.Value +import nextflow.splitter.CsvSplitter +import nextflow.util.TypeHelper + +/** + * Helper class for named workflow execution. + * + * This feature enables direct execution of a named workflow without + * an explicit entry workflow: + * - Scripts with a single named workflow run it automatically: + * {@code nextflow run script.nf --param value} + * - Command-line parameters are mapped directly to workflow inputs ({@code take:}) + * - Inputs of collection type are loaded from samplesheet files (CSV, JSON, YAML) + * - Non-collection inputs are passed through as values + * + * @author Ben Sherman + */ +@Slf4j +@CompileStatic +class WorkflowEntryHandler { + + private final BaseScript script + private final Session session + private final WorkflowDef workflowDef + + WorkflowEntryHandler(BaseScript script, Session session, ScriptMeta meta) { + this.script = script + this.session = session + + final workflowNames = meta.getLocalWorkflowNames() + if( workflowNames.size() != 1 ) + throw new IllegalStateException("Direct execution of named workflows is only supported for scripts with exactly one named workflow") + + if( !script.isTypingEnabled() ) + throw new IllegalStateException("Direct execution of named workflows is only supported when static typing is enabled") + + final workflowName = workflowNames.first() + this.workflowDef = meta.getWorkflow(workflowName) + } + + /** + * Creates an entry workflow that calls the selected named workflow. + * + * Parameters are automatically mapped to workflow inputs, with + * collection-typed inputs loaded from samplesheet files. + * + * Workflow emits are published as pipeline outputs, without creating + * an output directory. + */ + WorkflowDef createEntryWorkflow() { + final workflowName = workflowDef.name + final entryBody = { -> + final entryExecutionClosure = { -> + // Map parameters to workflow inputs + final inputs = getWorkflowArguments(workflowDef, session.params) + // Execute the named workflow + final output = workflowDef.run(inputs as Object[]) as ChannelOut + // Publish workflow emits as pipeline outputs + assignOutputs(output) + publishOutputs() + return output + } + final sourceCode = " // Auto-generated workflow entry\n ${workflowName}(...)" + return new BodyDef(entryExecutionClosure, sourceCode, 'workflow') + } + return new WorkflowDef(script, entryBody) + } + + private void assignOutputs(ChannelOut output) { + final outputNames = workflowDef.getDeclaredOutputs() + final dsl = script.getBinding() + if( output.size() == 1 && outputNames.size() == 1 ) { + dsl._publish_(outputNames.first(), output[0]) + } + else { + for( final name : outputNames ) + dsl._publish_(name, output.getProperty(name)) + } + } + + // TODO: disable output directory so that workflow output is produced without + // actually copying files to output directory + private void publishOutputs() { + final outputNames = workflowDef.getDeclaredOutputs() + final dsl = new OutputDsl() + for( final name : outputNames ) + dsl.declare(name, { -> }) + dsl.apply(session) + } + + /** + * Resolves the workflow input arguments from the current session params. + * + * For each declared input ({@code take:} parameter) of the named workflow: + * - If the input is typed as {@code Channel}, the param value is resolved + * as a samplesheet path or collection and loaded into a channel + * - If the param value is a collection, it is loaded into a channel via + * {@code channel.fromList()} + * - If the param value is a string path to a samplesheet (CSV, JSON, YAML), + * the file is loaded and its contents are emitted as a channel + * - Otherwise the value is passed directly as a workflow variable + * + * @param workflowDef + * @param params + */ + private List getWorkflowArguments(WorkflowDef workflowDef, Map params) { + final inputs = workflowDef.getDeclaredInputs() + final inputTypes = workflowDef.getDeclaredInputTypes() + + log.debug "Getting input arguments for workflow: ${workflowDef.name}" + log.debug "Session params: ${params}" + + final arguments = [] + for( final name : inputs ) { + final value = params.get(name) + if( value == null && !params.containsKey(name) ) + throw new ScriptRuntimeException("Workflow `${workflowDef.name}` requires input `${name}` but no parameter `--${name}` was provided") + + final type = inputTypes.get(name) + arguments.add(resolveInput(name, type, value)) + } + + log.debug "Final input arguments: ${arguments}" + return arguments + } + + /** + * Resolves a single workflow input value. + * + * When the declared type is {@code Channel} (or a subtype), the value is + * always resolved to a channel — either by loading a samplesheet file or by + * wrapping an existing collection with {@code channel.fromList()}. + * + * For other (or unknown) types the value is passed through as-is, unless it + * happens to be a collection or a samplesheet path, in which case a channel + * is also created (heuristic fallback for untyped workflows). + * + * @param name the input name (for error messages) + * @param type the declared type of the input, or {@code null} if untyped + * @param value the raw param value + * @return a {@code ChannelImpl} if the value resolves to a channel, or the + * raw value for scalar inputs + */ + protected Object resolveInput(String name, Class type, Object value) { + if( value == null ) + return value + + // TODO: need to generate __Params class to preserve input types + if( type == Channel ) { + if( value instanceof Collection ) { + return ChannelNamespace.fromList((Collection)value) + } + if( value instanceof String ) { + final path = FileHelper.asPath(value) + return ChannelNamespace.fromList(loadFromFile(name, path)) + } + throw new ScriptRuntimeException("Workflow input `${name}` expects a Channel but received: ${value} [${value.class.simpleName}]") + } + + if( type == Value ) { + return ChannelNamespace.value(value) + } + + if( value !instanceof String ) + return TypeHelper.asType(value, type) + + final str = (String) value + + if( type == Boolean ) { + if( str.toLowerCase() == 'true' ) return Boolean.TRUE + if( str.toLowerCase() == 'false' ) return Boolean.FALSE + } + + if( type == Integer || type == Float ) { + if( str.isInteger() ) return str.toInteger() + if( str.isLong() ) return str.toLong() + } + + if( type == Float ) { + if( str.isFloat() ) return str.toFloat() + if( str.isDouble() ) return str.toDouble() + } + + if( type == Path ) { + return TypeHelper.asPathType(str) + } + + return value + } + + /** + * Loads the contents of a samplesheet file as a list of records. + * + * Supported formats: + * - CSV: header row required, comma-separated + * - JSON: must be a top-level array + * - YAML / YML: must be a top-level sequence + * + * @param name the input name (for error messages) + * @param file the samplesheet file to load + * @return a list of raw records (maps) + */ + protected List loadFromFile(String name, Path file) { + final ext = file.getExtension() + final value = switch( ext ) { + case 'csv' -> new CsvSplitter().options(header: true, sep: ',').target(file).list() + case 'json' -> new JsonSlurper().parse(file) + case 'yaml', 'yml' -> new YamlSlurper().parse(file) + default -> throw new ScriptRuntimeException("Unrecognized file format '${ext}' for input file '${file}' for workflow input `${name}` -- should be CSV, JSON, or YAML") + } + if( value !instanceof List ) + throw new ScriptRuntimeException("Input file '${file}' for workflow input `${name}` must contain a list of records, but got: ${value.class.simpleName}") + return (List)value + } + +} diff --git a/modules/nextflow/src/test/groovy/nextflow/script/WorkflowEntryHandlerTest.groovy b/modules/nextflow/src/test/groovy/nextflow/script/WorkflowEntryHandlerTest.groovy new file mode 100644 index 0000000000..15799060e3 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/script/WorkflowEntryHandlerTest.groovy @@ -0,0 +1,362 @@ +/* + * Copyright 2013-2026, 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.script + +import java.nio.file.Files + +import nextflow.Session +import nextflow.exception.ScriptRuntimeException +import spock.lang.Timeout +import test.Dsl2Spec + +import static test.ScriptHelper.* + +/** + * Tests for {@link WorkflowEntryHandler}. + * + * @author Ben Sherman + */ +@Timeout(10) +class WorkflowEntryHandlerTest extends Dsl2Spec { + + // ── unit: loadFromFile ──────────────────────────────────────────────────── + + private WorkflowEntryHandler makeHandler(List inputs = []) { + def workflowDef = Mock(WorkflowDef) { + getName() >> 'HELLO' + getDeclaredInputs() >> inputs + getDeclaredInputTypes() >> [:] + } + def session = Mock(Session) { getParams() >> [:] } + def script = Mock(BaseScript) { + isTypingEnabled() >> true + } + def meta = Mock(ScriptMeta) { + getLocalWorkflowNames() >> ['HELLO'] + getWorkflow('HELLO') >> workflowDef + } + return new WorkflowEntryHandler(script, session, meta) + } + + def 'should load records from a CSV file'() { + given: + def csvFile = Files.createTempFile('test', '.csv') + csvFile.text = '''\ + id,name + 1,sample1 + 2,sample2 + '''.stripIndent() + + when: + def result = makeHandler().loadFromFile('samples', csvFile.toAbsolutePath()) + + then: + result instanceof List + result.size() == 2 + result[0].id == '1' + result[0].name == 'sample1' + + cleanup: + csvFile?.delete() + } + + def 'should load records from a JSON file'() { + given: + def jsonFile = Files.createTempFile('test', '.json') + jsonFile.text = '[{"id":1,"name":"s1"},{"id":2,"name":"s2"}]' + + when: + def result = makeHandler().loadFromFile('samples', jsonFile.toAbsolutePath()) + + then: + result instanceof List + result.size() == 2 + result[0].id == 1 + result[1].name == 's2' + + cleanup: + jsonFile?.delete() + } + + def 'should load records from a YAML file'() { + given: + def yamlFile = Files.createTempFile('test', '.yml') + yamlFile.text = '''\ + - id: 1 + name: s1 + - id: 2 + name: s2 + '''.stripIndent() + + when: + def result = makeHandler().loadFromFile('samples', yamlFile.toAbsolutePath()) + + then: + result instanceof List + result.size() == 2 + result[0].id == 1 + result[1].name == 's2' + + cleanup: + yamlFile?.delete() + } + + def 'should throw for unrecognized samplesheet format'() { + given: + def txtFile = Files.createTempFile('test', '.txt') + txtFile.text = 'some text' + + when: + makeHandler().loadFromFile('items', txtFile.toAbsolutePath()) + + then: + def e = thrown(ScriptRuntimeException) + e.message.contains("Unrecognized file format 'txt'") + + cleanup: + txtFile?.delete() + } + + def 'should throw for a JSON file whose top level is not a list'() { + given: + def jsonFile = Files.createTempFile('test', '.json') + jsonFile.text = '{"key":"value"}' // object, not array + + when: + makeHandler().loadFromFile('samples', jsonFile.toAbsolutePath()) + + then: + def e = thrown(ScriptRuntimeException) + e.message.contains('must contain a list of records') + + cleanup: + jsonFile?.delete() + } + + // ── unit: getWorkflowArguments / error cases ────────────────────────────── + + def 'should throw for a missing required workflow input'() { + given: + def workflowDef = Mock(WorkflowDef) { + getName() >> 'HELLO' + getDeclaredInputs() >> ['samples'] + getDeclaredInputTypes() >> [:] + } + def session = Mock(Session) + def script = Mock(BaseScript) { + isTypingEnabled() >> true + } + def meta = Mock(ScriptMeta) { + getLocalWorkflowNames() >> ['HELLO'] + getWorkflow('HELLO') >> workflowDef + } + def handler = new WorkflowEntryHandler(script, session, meta) + + when: + handler.getWorkflowArguments(workflowDef, [:]) + + then: + def e = thrown(ScriptRuntimeException) + e.message.contains('requires input `samples`') + } + + def 'should throw error when multiple workflows are defined'() { + given: + def workflow1 = Mock(WorkflowDef) { + getName() >> 'FIRST' + getDeclaredInputs() >> [] + getDeclaredInputTypes() >> [:] + } + def session = Mock(Session) { getParams() >> [:] } + def script = Mock(BaseScript) { + isTypingEnabled() >> true + } + def meta = Mock(ScriptMeta) { + getLocalWorkflowNames() >> ['FIRST', 'SECOND'] + getWorkflow('FIRST') >> workflow1 + } + + when: + def handler = new WorkflowEntryHandler(script, session, meta) + + then: + def e = thrown(IllegalStateException) + e.message.contains('exactly one named workflow') + } + + // ── integration tests ───────────────────────────────────────────────────── + + def 'should auto-run a named workflow with a scalar input'() { + when: + def result = runScript( + '''\ + nextflow.enable.types = true + + workflow GREET { + take: + name: String + + emit: + greeting = "Hello, ${name}!" + } + ''', + config: [params: [name: 'World']] + ) + + then: + result != null + } + + def 'should auto-run a named workflow with a CSV samplesheet input'() { + given: + def csvFile = Files.createTempFile('samples', '.csv') + csvFile.text = '''\ + id,value + 1,alpha + 2,beta + '''.stripIndent() + + when: + def result = runScript( + '''\ + nextflow.enable.types = true + + workflow PROCESS_SAMPLES { + take: + samples: Channel + + emit: + out = samples + } + ''', + config: [params: [samples: csvFile.toString()]] + ) + + then: + result != null + + cleanup: + csvFile?.delete() + } + + def 'should auto-run a named workflow with a JSON samplesheet input'() { + given: + def jsonFile = Files.createTempFile('samples', '.json') + jsonFile.text = '[{"id":1,"name":"s1"},{"id":2,"name":"s2"}]' + + when: + def result = runScript( + '''\ + nextflow.enable.types = true + + workflow PROCESS_SAMPLES { + take: + samples: Channel + + emit: + out = samples + } + ''', + config: [params: [samples: jsonFile.toString()]] + ) + + then: + result != null + + cleanup: + jsonFile?.delete() + } + + def 'should auto-run a named workflow with multiple inputs'() { + given: + def csvFile = Files.createTempFile('samples', '.csv') + csvFile.text = '''\ + id,value + 1,alpha + '''.stripIndent() + + when: + def result = runScript( + '''\ + nextflow.enable.types = true + + workflow PIPELINE { + take: + samples: Channel + outdir: String + + emit: + out = samples + } + ''', + config: [params: [samples: csvFile.toString(), outdir: 'results']] + ) + + then: + result != null + + cleanup: + csvFile?.delete() + } + + def 'should throw for a missing workflow input'() { + when: + runScript( + '''\ + nextflow.enable.types = true + + workflow GREET { + take: + name: String + + emit: + greeting = "Hello!" + } + ''', + params: [:] + ) + + then: + def e = thrown(ScriptRuntimeException) + e.message.contains('requires input `name`') + } + + def 'should prefer explicit entry workflow over named workflow'() { + when: + // An explicit (unnamed) entry workflow takes priority over WorkflowEntryHandler + def result = runScript( + '''\ + workflow { + "explicit entry" + } + + workflow NAMED { + take: + x + emit: + out = x + } + ''', + params: [x: 'ignored'] + ) + + then: + // The explicit entry workflow ran + result != null + } + +} diff --git a/modules/nf-lang/src/main/java/nextflow/script/control/ScriptToGroovyVisitor.java b/modules/nf-lang/src/main/java/nextflow/script/control/ScriptToGroovyVisitor.java index 1cef61dd7e..54c33c0dfd 100644 --- a/modules/nf-lang/src/main/java/nextflow/script/control/ScriptToGroovyVisitor.java +++ b/modules/nf-lang/src/main/java/nextflow/script/control/ScriptToGroovyVisitor.java @@ -236,7 +236,7 @@ public void visitWorkflow(WorkflowNode node) { private Statement workflowTakes(Parameter[] takes) { var statements = Arrays.stream(takes) .map((take) -> - stmt(callThisX("_take_", args(constX(take.getName())))) + stmt(callThisX("_take_", args(constX(take.getName()), classX(take.getType())))) ) .toList(); return block(null, statements);