diff --git a/src/main/java/nextflow/lsp/NextflowLanguageServer.java b/src/main/java/nextflow/lsp/NextflowLanguageServer.java index 84562c1..8b2ed21 100644 --- a/src/main/java/nextflow/lsp/NextflowLanguageServer.java +++ b/src/main/java/nextflow/lsp/NextflowLanguageServer.java @@ -450,6 +450,8 @@ public void didChangeConfiguration(DidChangeConfigurationParams params) { var oldConfiguration = configuration; configuration = new LanguageServerConfiguration( + withDefault(JsonUtils.getString(settings, "nextflow.dag.direction"), configuration.dagDirection()), + withDefault(JsonUtils.getBoolean(settings, "nextflow.dag.verbose"), configuration.dagVerbose()), withDefault(errorReportingMode(settings), configuration.errorReportingMode()), withDefault(JsonUtils.getStringArray(settings, "nextflow.files.exclude"), configuration.excludePatterns()), withDefault(JsonUtils.getBoolean(settings, "nextflow.completion.extended"), configuration.extendedCompletion()), @@ -553,14 +555,14 @@ public CompletableFuture executeCommand(ExecuteCommandParams params) { var uri = JsonUtils.getString(arguments.get(0)); var service = getLanguageService(uri); if( service != null ) - return service.executeCommand(command, arguments); + return service.executeCommand(command, arguments, configuration); } if( "nextflow.server.previewWorkspace".equals(command) && arguments.size() == 1 ) { log.debug(String.format("textDocument/previewWorkspace %s", arguments.toString())); var name = JsonUtils.getString(arguments.get(0)); var service = scriptServices.get(name); if( service != null ) - return service.executeCommand(command, arguments); + return service.executeCommand(command, arguments, configuration); } return null; }); diff --git a/src/main/java/nextflow/lsp/services/LanguageServerConfiguration.java b/src/main/java/nextflow/lsp/services/LanguageServerConfiguration.java index 2c603ed..be9ad2c 100644 --- a/src/main/java/nextflow/lsp/services/LanguageServerConfiguration.java +++ b/src/main/java/nextflow/lsp/services/LanguageServerConfiguration.java @@ -19,6 +19,8 @@ import java.util.List; public record LanguageServerConfiguration( + String dagDirection, + boolean dagVerbose, ErrorReportingMode errorReportingMode, List excludePatterns, boolean extendedCompletion, @@ -31,6 +33,8 @@ public record LanguageServerConfiguration( public static LanguageServerConfiguration defaults() { return new LanguageServerConfiguration( + "TB", + false, ErrorReportingMode.WARNINGS, Collections.emptyList(), false, diff --git a/src/main/java/nextflow/lsp/services/LanguageService.java b/src/main/java/nextflow/lsp/services/LanguageService.java index bf352e0..3c8ffc9 100644 --- a/src/main/java/nextflow/lsp/services/LanguageService.java +++ b/src/main/java/nextflow/lsp/services/LanguageService.java @@ -230,7 +230,7 @@ public List> documentSymbol(DocumentSy return provider.documentSymbol(params.getTextDocument()); } - public Object executeCommand(String command, List arguments) { + public Object executeCommand(String command, List arguments, LanguageServerConfiguration configuration) { return null; } diff --git a/src/main/java/nextflow/lsp/services/script/ScriptCodeLensProvider.java b/src/main/java/nextflow/lsp/services/script/ScriptCodeLensProvider.java index 7073cf4..cf9c5b2 100644 --- a/src/main/java/nextflow/lsp/services/script/ScriptCodeLensProvider.java +++ b/src/main/java/nextflow/lsp/services/script/ScriptCodeLensProvider.java @@ -70,23 +70,23 @@ public List codeLens(TextDocumentIdentifier textDocument) { return result; } - public Map previewDag(String documentUri, String name) { + public Map previewDag(String documentUri, String name, String direction, boolean verbose) { var uri = URI.create(documentUri); if( !ast.hasAST(uri) || ast.hasErrors(uri) ) - return Map.ofEntries(Map.entry("error", "DAG preview cannot be shown because the script has errors.")); + return Map.of("error", "DAG preview cannot be shown because the script has errors."); var sourceUnit = ast.getSourceUnit(uri); return ast.getWorkflowNodes(uri).stream() .filter(wn -> wn.isEntry() ? name == null : wn.getName().equals(name)) .findFirst() .map((wn) -> { - var visitor = new DataflowVisitor(sourceUnit, ast); + var visitor = new DataflowVisitor(sourceUnit, ast, verbose); visitor.visit(); var graph = visitor.getGraph(wn.isEntry() ? "" : wn.getName()); - var result = new MermaidRenderer().render(wn.getName(), graph); + var result = new MermaidRenderer(direction, verbose).render(wn.getName(), graph); log.debug(result); - return Map.ofEntries(Map.entry("result", result)); + return Map.of("result", result); }) .orElse(null); } diff --git a/src/main/java/nextflow/lsp/services/script/ScriptService.java b/src/main/java/nextflow/lsp/services/script/ScriptService.java index a4ead65..bc79c80 100644 --- a/src/main/java/nextflow/lsp/services/script/ScriptService.java +++ b/src/main/java/nextflow/lsp/services/script/ScriptService.java @@ -121,13 +121,13 @@ protected SymbolProvider getSymbolProvider() { } @Override - public Object executeCommand(String command, List arguments) { + public Object executeCommand(String command, List arguments, LanguageServerConfiguration configuration) { updateNow(); if( "nextflow.server.previewDag".equals(command) && arguments.size() == 2 ) { var uri = getJsonString(arguments.get(0)); var name = getJsonString(arguments.get(1)); var provider = new ScriptCodeLensProvider(astCache); - return provider.previewDag(uri, name); + return provider.previewDag(uri, name, configuration.dagDirection(), configuration.dagVerbose()); } if( "nextflow.server.previewWorkspace".equals(command) ) { var provider = new WorkspacePreviewProvider(astCache); diff --git a/src/main/java/nextflow/lsp/services/script/dag/DataflowVisitor.java b/src/main/java/nextflow/lsp/services/script/dag/DataflowVisitor.java index 8b8c508..b971d84 100644 --- a/src/main/java/nextflow/lsp/services/script/dag/DataflowVisitor.java +++ b/src/main/java/nextflow/lsp/services/script/dag/DataflowVisitor.java @@ -15,13 +15,10 @@ */ package nextflow.lsp.services.script.dag; -import java.net.URI; -import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; -import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -29,7 +26,6 @@ import java.util.stream.Collectors; import groovy.lang.Tuple3; -import nextflow.lsp.ast.LanguageServerASTUtils; import nextflow.lsp.services.script.ScriptAstCache; import nextflow.script.ast.ASTNodeMarker; import nextflow.script.ast.AssignmentExpression; @@ -39,18 +35,28 @@ import nextflow.script.ast.WorkflowNode; import org.codehaus.groovy.ast.ASTNode; import org.codehaus.groovy.ast.MethodNode; -import org.codehaus.groovy.ast.expr.*; -import org.codehaus.groovy.ast.stmt.*; +import org.codehaus.groovy.ast.expr.BinaryExpression; +import org.codehaus.groovy.ast.expr.ClosureExpression; +import org.codehaus.groovy.ast.expr.DeclarationExpression; +import org.codehaus.groovy.ast.expr.Expression; +import org.codehaus.groovy.ast.expr.MethodCallExpression; +import org.codehaus.groovy.ast.expr.PropertyExpression; +import org.codehaus.groovy.ast.expr.TernaryExpression; +import org.codehaus.groovy.ast.expr.TupleExpression; +import org.codehaus.groovy.ast.expr.VariableExpression; +import org.codehaus.groovy.ast.stmt.ExpressionStatement; +import org.codehaus.groovy.ast.stmt.IfStatement; +import org.codehaus.groovy.ast.stmt.Statement; import org.codehaus.groovy.control.SourceUnit; import org.codehaus.groovy.syntax.Types; import static nextflow.script.ast.ASTUtils.*; import static org.codehaus.groovy.ast.tools.GeneralUtils.*; - /** * * @author Ben Sherman + * @author Erik Danielsson */ public class DataflowVisitor extends ScriptVisitorSupport { @@ -58,15 +64,20 @@ public class DataflowVisitor extends ScriptVisitorSupport { private ScriptAstCache ast; + private boolean verbose; + private Map graphs = new HashMap<>(); private Stack> stackPreds = new Stack<>(); - public DataflowVisitor(SourceUnit sourceUnit, ScriptAstCache ast) { + private VariableContext vc = new VariableContext(); + + public DataflowVisitor(SourceUnit sourceUnit, ScriptAstCache ast, boolean verbose) { this.sourceUnit = sourceUnit; this.ast = ast; + this.verbose = verbose; - stackPreds.add(new HashSet<>()); + stackPreds.push(new HashSet<>()); } @Override @@ -101,10 +112,8 @@ public void visitWorkflow(WorkflowNode node) { var name = node.isEntry() ? "" : node.getName(); visitWorkflowTakes(node, current.inputs); visit(node.main); - if( node.isEntry() ) - visitWorkflowPublishers(node, current.outputs); - else - visitWorkflowEmits(node, current.outputs); + var outputs = node.isEntry() ? node.publishers : node.emits; + visitWorkflowOutputs(outputs, current.outputs); graphs.put(name, current); inEntry = false; current = null; @@ -114,13 +123,14 @@ private void visitWorkflowTakes(WorkflowNode node, Map result) { for( var stmt : asBlockStatements(node.takes) ) { var name = asVarX(stmt).getName(); var dn = addNode(name, Node.Type.NAME, stmt); - current.putSymbol(name, dn); + dn.verbose = false; + vc.putSymbol(name, dn); result.put(name, dn); } } - private void visitWorkflowEmits(WorkflowNode node, Map result) { - for( var stmt : asBlockStatements(node.emits) ) { + private void visitWorkflowOutputs(Statement outputs, Map result) { + for( var stmt : asBlockStatements(outputs) ) { var emit = ((ExpressionStatement) stmt).getExpression(); String name; if( emit instanceof VariableExpression ve ) { @@ -135,22 +145,65 @@ else if( emit instanceof AssignmentExpression assign ) { name = "$out"; visit(new AssignmentExpression(varX(name), emit)); } - var dn = current.getSymbol(name); - if( dn == null ) - System.err.println("missing emit: " + name); + var dn = getSymbol(name); + if( dn != null ) + dn.verbose = false; + else + System.err.println("missing output: " + name); result.put(name, dn); } } - private void visitWorkflowPublishers(WorkflowNode node, Map result) { - for( var stmt : asBlockStatements(node.publishers) ) { - var es = (ExpressionStatement) stmt; - var publisher = (BinaryExpression) es.getExpression(); - var target = asVarX(publisher.getLeftExpression()); - var source = publisher.getRightExpression(); - visit(new AssignmentExpression(target, source)); - var name = target.getName(); - result.put(name, current.getSymbol(name)); + // statements + + @Override + public void visitIfElse(IfStatement node) { + // visit the conditional expression + var controlPreds = visitWithPreds(node.getBooleanExpression()); + var controlDn = current.addNode("", Node.Type.CONTROL, null, controlPreds); + + // visit the if branch + vc.pushScope(); + current.pushSubgraph(controlDn); + visitWithPreds(node.getIfBlock()); + var ifSubgraph = current.popSubgraph(); + var ifScope = vc.popScope(); + + // visit the else branch + Subgraph elseSubgraph; + Map elseScope; + + if( !node.getElseBlock().isEmpty() ) { + vc.pushScope(); + current.pushSubgraph(controlDn); + visitWithPreds(node.getElseBlock()); + elseSubgraph = current.popSubgraph(); + elseScope = vc.popScope(); + } + else { + // if there is no else branch, then the set of active symbols + // after the if statement is the union of the active symbols + // from before the if and the active symbols in the if + elseSubgraph = null; + elseScope = vc.peekScope(); + } + + // apply variables from if and else scopes to current scope + var outputs = vc.mergeConditionalScopes(ifScope, elseScope); + + for( var name : outputs ) { + var preds = vc.getSymbolPreds(name); + if( preds.size() > 1 ) { + var dn = current.addNode(name, Node.Type.NAME, null, preds); + vc.putSymbol(name, dn); + } + } + + // hide if-else statement if both subgraphs are empty + if( !verbose && ifSubgraph.isVerbose() && (elseSubgraph == null || elseSubgraph.isVerbose()) ) { + controlDn.verbose = true; + for( var name : outputs ) + getSymbol(name).preds.addAll(controlPreds); } } @@ -173,7 +226,8 @@ public void visitMethodCallExpression(MethodCallExpression node) { var defNode = (MethodNode) node.getNodeMetaData(ASTNodeMarker.METHOD_TARGET); if( defNode instanceof WorkflowNode || defNode instanceof ProcessNode ) { var preds = visitWithPreds(node.getArguments()); - current.putSymbol(name, addNode(name, Node.Type.OPERATOR, defNode, preds)); + var dn = addNode(name, Node.Type.OPERATOR, defNode, preds); + vc.putSymbol(name, dn); return; } @@ -183,7 +237,7 @@ public void visitMethodCallExpression(MethodCallExpression node) { @Override public void visitBinaryExpression(BinaryExpression node) { if( node instanceof AssignmentExpression ) { - visitAssignment(node); + visitAssignment(node, false); return; } if( node.getOperation().getType() == Types.PIPE ) { @@ -194,17 +248,17 @@ public void visitBinaryExpression(BinaryExpression node) { super.visitBinaryExpression(node); } - private void visitAssignment(BinaryExpression node) { + private void visitAssignment(BinaryExpression node, boolean isLocal) { var preds = visitWithPreds(node.getRightExpression()); var targets = getAssignmentTargets(node.getLeftExpression()); for( var name : targets ) { var dn = addNode(name, Node.Type.NAME, null, preds); - current.putSymbol(name, dn); + vc.putSymbol(name, dn, isLocal); } } private Set getAssignmentTargets(Expression node) { - // e.g. (x, y, z) = [1, 2, 3] + // e.g. (x, y, z) = xyz if( node instanceof TupleExpression te ) { return te.getExpressions().stream() .map(el -> getAssignmentTarget(el).getName()) @@ -238,7 +292,8 @@ private void visitPipeline(BinaryExpression node) { if( defNode instanceof WorkflowNode || defNode instanceof ProcessNode ) { var label = defNode.getName(); var preds = visitWithPreds(lhs); - current.putSymbol(label, addNode(label, Node.Type.OPERATOR, defNode, preds)); + var dn = addNode(label, Node.Type.OPERATOR, defNode, preds); + vc.putSymbol(label, dn); return; } } @@ -248,11 +303,34 @@ private void visitPipeline(BinaryExpression node) { @Override public void visitDeclarationExpression(DeclarationExpression node) { - visitAssignment(node); + visitAssignment(node, true); + } + + @Override + public void visitTernaryExpression(TernaryExpression node) { + var controlPreds = visitWithPreds(node.getBooleanExpression()); + var controlDn = current.addNode("", Node.Type.CONTROL, null, controlPreds); + + current.pushSubgraph(controlDn); + var truePreds = visitWithPreds(node.getTrueExpression()); + var trueSubgraph = current.popSubgraph(); + currentPreds().addAll(truePreds); + + current.pushSubgraph(controlDn); + var falsePreds = visitWithPreds(node.getFalseExpression()); + var falseSubgraph = current.popSubgraph(); + currentPreds().addAll(falsePreds); + + // hide ternary expression if both subgraphs are empty + if( trueSubgraph.isVerbose() && falseSubgraph.isVerbose() ) { + controlDn.verbose = true; + currentPreds().addAll(controlPreds); + } } @Override public void visitClosureExpression(ClosureExpression node) { + // skip closures since they can't contain dataflow logic } @Override @@ -262,6 +340,7 @@ public void visitPropertyExpression(PropertyExpression node) { if( !current.inputs.containsKey(name) ) current.inputs.put(name, addNode(name, Node.Type.NAME, null)); var dn = current.inputs.get(name); + dn.verbose = false; currentPreds().add(dn); return; } @@ -365,25 +444,34 @@ else if( emit instanceof AssignmentExpression assign ) { } private void addOperatorPred(String label, ASTNode an) { - var dn = current.getSymbol(label); + var dn = getSymbol(label); if( dn != null ) currentPreds().add(dn); else - current.putSymbol(label, addNode(label, Node.Type.OPERATOR, an)); + vc.putSymbol(label, addNode(label, Node.Type.OPERATOR, an)); } @Override public void visitVariableExpression(VariableExpression node) { var name = node.getName(); - var dn = current.getSymbol(name); + var dn = getSymbol(name); if( dn != null ) currentPreds().add(dn); } // helpers + private Node getSymbol(String name) { + var preds = vc.getSymbolPreds(name); + if( preds.isEmpty() ) + return null; + if( preds.size() > 1 ) + System.err.println("unmerged symbol " + name + " " + preds); + return preds.iterator().next(); + } + private Set currentPreds() { - return stackPreds.lastElement(); + return stackPreds.peek(); } private Set visitWithPreds(ASTNode... nodes) { @@ -392,7 +480,7 @@ private Set visitWithPreds(ASTNode... nodes) { private Set visitWithPreds(Collection nodes) { // traverse a set of nodes and extract predecessor nodes - stackPreds.add(new HashSet<>()); + stackPreds.push(new HashSet<>()); for( var node : nodes ) { if( node != null ) @@ -402,10 +490,6 @@ private Set visitWithPreds(Collection nodes) { return stackPreds.pop(); } - private Node visitWithPred(ASTNode node) { - return visitWithPreds(node).stream().findFirst().orElse(null); - } - private Node addNode(String label, Node.Type type, ASTNode an, Set preds) { var uri = ast.getURI(an); var dn = current.addNode(label, type, uri, preds); @@ -418,96 +502,3 @@ private Node addNode(String label, Node.Type type, ASTNode an) { } } - - -class Graph { - - public final Map inputs = new HashMap<>(); - - public final Map nodes = new HashMap<>(); - - public final Map outputs = new HashMap<>(); - - private List> scopes = new ArrayList<>(); - - public Graph() { - pushScope(); - } - - public void pushScope() { - scopes.add(0, new HashMap<>()); - } - - public void popScope() { - scopes.remove(0); - } - - public Node getSymbol(String name) { - // get a variable node from the name table - for( var scope : scopes ) - if( scope.containsKey(name) ) - return scope.get(name); - - return null; - } - - public void putSymbol(String name, Node dn) { - // put a variable node into the name table - for( var scope : scopes ) { - if( scope.containsKey(name) ) { - scope.put(name, dn); - return; - } - } - - scopes.get(0).put(name, dn); - } - - public Node addNode(String label, Node.Type type, URI uri, Set preds) { - var id = nodes.size(); - var dn = new Node(id, label, type, uri, preds); - nodes.put(id, dn); - return dn; - } -} - - -class Node { - public enum Type { - NAME, - OPERATOR - } - - public final int id; - public final String label; - public final Type type; - public final URI uri; - public final Set preds; - - public Node(int id, String label, Type type, URI uri, Set preds) { - this.id = id; - this.label = label; - this.type = type; - this.uri = uri; - this.preds = preds; - } - - public void addPredecessors(Set preds) { - this.preds.addAll(preds); - } - - @Override - public boolean equals(Object other) { - return other instanceof Node n && this.id == n.id; - } - - @Override - public int hashCode() { - return id; - } - - @Override - public String toString() { - return String.format("id=%s,label='%s',type=%s", id, label, type); - } -} diff --git a/src/main/java/nextflow/lsp/services/script/dag/Graph.java b/src/main/java/nextflow/lsp/services/script/dag/Graph.java new file mode 100644 index 0000000..f913281 --- /dev/null +++ b/src/main/java/nextflow/lsp/services/script/dag/Graph.java @@ -0,0 +1,153 @@ +/* + * 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.dag; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.Stack; + +/** + * + * @author Ben Sherman + * @author Erik Danielsson + */ +class Graph { + + public final Map inputs = new HashMap<>(); + + public final Map nodes = new HashMap<>(); + + public final Map outputs = new HashMap<>(); + + private Stack subgraphs = new Stack<>(); + + private int nextSubgraphId = 0; + + public Graph() { + pushSubgraph(); + } + + public Subgraph peekSubgraph() { + return subgraphs.peek(); + } + + public void pushSubgraph() { + subgraphs.push(new Subgraph(nextSubgraphId, null)); + nextSubgraphId += 1; + } + + public void pushSubgraph(Node dn) { + subgraphs.push(new Subgraph(nextSubgraphId, dn)); + nextSubgraphId += 1; + } + + public Subgraph popSubgraph() { + var result = subgraphs.pop(); + subgraphs.peek().subgraphs.add(result); + return result; + } + + public Node addNode(String label, Node.Type type, URI uri, Set preds) { + var id = nodes.size(); + var dn = new Node(id, label, type, uri, preds); + nodes.put(id, dn); + subgraphs.peek().nodes.add(dn); + return dn; + } +} + + +class Subgraph { + + public final int id; + + public final Node pred; + + public final List subgraphs = new ArrayList<>(); + + public final Set nodes = new HashSet<>(); + + public Subgraph(int id, Node pred) { + this.id = id; + this.pred = pred; + } + + public boolean isVerbose() { + return nodes.stream().allMatch(n -> n.verbose); + } + + @Override + public boolean equals(Object other) { + return other instanceof Subgraph s && this.id == s.id; + } + + @Override + public int hashCode() { + return id; + } +} + + +class Node { + public enum Type { + NAME, + OPERATOR, + CONTROL + } + + public final int id; + public final String label; + public final Type type; + public final URI uri; + public final Set preds; + + public boolean verbose; + + public Node(int id, String label, Type type, URI uri, Set preds) { + this.id = id; + this.label = label; + this.type = type; + this.uri = uri; + this.preds = preds; + this.verbose = (type == Type.NAME); + } + + public void addPredecessors(Set preds) { + this.preds.addAll(preds); + } + + @Override + public boolean equals(Object other) { + return other instanceof Node n && this.id == n.id; + } + + @Override + public int hashCode() { + return id; + } + + @Override + public String toString() { + var predIds = preds.stream().map(p -> p.id).toList(); + return String.format("id=%s,label='%s',type=%s,preds=%s", id, label, type, predIds); + } +} diff --git a/src/main/java/nextflow/lsp/services/script/dag/MermaidRenderer.java b/src/main/java/nextflow/lsp/services/script/dag/MermaidRenderer.java index b419cd6..5d2d2f9 100644 --- a/src/main/java/nextflow/lsp/services/script/dag/MermaidRenderer.java +++ b/src/main/java/nextflow/lsp/services/script/dag/MermaidRenderer.java @@ -17,104 +17,225 @@ import java.util.ArrayList; import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; /** * * @author Ben Sherman + * @author Erik Danielsson */ public class MermaidRenderer { - public String render(String name, Graph graph) { - var isEntry = name == null; - var lines = new ArrayList(); - lines.add("flowchart TB"); - lines.add(String.format(" subgraph %s", isEntry ? "\" \"" : name)); + private static final List VALID_DIRECTIONS = List.of("LR", "TB", "TD"); + + private final String direction; + + private final boolean verbose; + + private StringBuilder builder; + + private int indent; + + public MermaidRenderer(String direction, boolean verbose) { + this.direction = VALID_DIRECTIONS.contains(direction) ? direction : "TB"; + this.verbose = verbose; + } + + private void reset() { + builder = new StringBuilder(); + indent = 0; + } + + private void append(String format, Object... args) { + builder.append(" ".repeat(indent)); + builder.append(String.format(format, args)); + builder.append('\n'); + } + + private void incIndent() { + indent++; + } + private void decIndent() { + indent--; + } + + public String render(String name, Graph graph) { // prepare inputs and outputs + var isEntry = name == null; var inputs = graph.inputs.values(); var nodes = graph.nodes.values(); var outputs = graph.outputs.values(); + // render graph + reset(); + append("flowchart %s", direction); + incIndent(); + append("subgraph %s", isEntry ? "\" \"" : name); + incIndent(); + // render inputs if( inputs.size() > 0 ) { - lines.add(String.format(" subgraph %s", isEntry ? "params" : "take")); + append("subgraph %s", isEntry ? "params" : "take"); + incIndent(); for( var dn : inputs ) { if( dn == null ) continue; - lines.add(" " + renderNode(dn.id, dn.label, dn.type)); + append(renderNode(dn.id, dn.label, dn.type)); } - lines.add(" end"); + decIndent(); + append("end"); } - // render nodes - for( var dn : nodes ) { - if( dn.type == Node.Type.NAME ) - continue; - - var label = dn.label - .replaceAll("\n", "\\\\\n") - .replaceAll("\"", "\\\\\""); + // render nodes and subgraphs + var root = graph.peekSubgraph(); - lines.add(" " + renderNode(dn.id, label, dn.type)); + root.nodes.stream() + .filter(n -> !inputs.contains(n)) + .filter(n -> !outputs.contains(n)) + .forEach(this::renderNode); - if( dn.uri != null ) - lines.add(String.format(" click v%d href \"%s\" _blank", dn.id, dn.uri.toString())); - } + var allSubgraphs = new ArrayList(); + for( var s : root.subgraphs ) + renderSubgraph(s, allSubgraphs); // render outputs if( outputs.size() > 0 ) { - lines.add(String.format(" subgraph %s", isEntry ? "publish" : "emit")); + append("subgraph %s", isEntry ? "publish" : "emit"); + incIndent(); for( var dn : outputs ) - lines.add(" " + renderNode(dn.id, dn.label, dn.type)); - lines.add(" end"); + append(renderNode(dn.id, dn.label, dn.type)); + decIndent(); + append("end"); } // render edges + var visited = new HashMap>(); + for( var dn : nodes ) { - if( isHidden(dn, inputs, outputs) ) + if( isHidden(dn) ) continue; - var preds = dn.preds; - while( true ) { - var done = preds.stream().allMatch(p -> !isHidden(p, inputs, outputs)); - if( done ) - break; - preds = preds.stream() - .flatMap(p -> - isHidden(p, inputs, outputs) - ? p.preds.stream() - : Stream.of(p) - ) - .collect(Collectors.toSet()); - } + for( var dnPred : visiblePreds(dn, visited) ) + append("v%d --> v%d", dnPred.id, dn.id); + } - for( var dnPred : preds ) - lines.add(String.format(" v%d --> v%d", dnPred.id, dn.id)); + // render subgraph edges + for( var subgraph : allSubgraphs ) { + if( subgraph.pred != null ) + append("v%d --> s%d", subgraph.pred.id, subgraph.id); } - lines.add(" end"); + decIndent(); + append("end"); + + return builder.toString(); + } + + /** + * Render a subgraph and collect all child subgraphs. + * + * @param subgraph + * @param allSubgraphs + */ + private void renderSubgraph(Subgraph subgraph, List allSubgraphs) { + if( isHidden(subgraph) ) + return; + + allSubgraphs.add(subgraph); + + append("subgraph s%d[\" \"]", subgraph.id); + incIndent(); + + for( var dn : subgraph.nodes ) + renderNode(dn); - return String.join("\n", lines); + for( var s : subgraph.subgraphs ) + renderSubgraph(s, allSubgraphs); + + decIndent(); + append("end"); } /** - * Only inputs, outputs, and processes/workflows are currently shown. + * Render a node. * * @param dn - * @param inputs - * @param outputs */ - private static boolean isHidden(Node dn, Collection inputs, Collection outputs) { - return dn.type == Node.Type.NAME && !inputs.contains(dn) && !outputs.contains(dn); + private void renderNode(Node dn) { + if( isHidden(dn) ) + return; + + var label = dn.label + .replaceAll("\n", "\\\\\n") + .replaceAll("\"", "\\\\\""); + + append(renderNode(dn.id, label, dn.type)); + if( dn.uri != null ) + append("click v%d href \"%s\" _blank", dn.id, dn.uri.toString()); } private static String renderNode(int id, String label, Node.Type type) { return switch( type ) { case NAME -> String.format("v%d[\"%s\"]", id, label); case OPERATOR -> String.format("v%d([%s])", id, label); + case CONTROL -> String.format("v%d{ }", id); }; } + /** + * Get the set of visible predecessors for a node. + * + * @param dn + * @param visited + */ + private Set visiblePreds(Node dn, Map> visited) { + if( visited.containsKey(dn) ) + return visited.get(dn); + + var result = dn.preds.stream() + .flatMap(pred -> ( + isHidden(pred) + ? visiblePreds(pred, visited).stream() + : Stream.of(pred) + )) + .collect(Collectors.toSet()); + visited.put(dn, result); + return result; + } + + /** + * When verbose mode is disabled, all nodes marked as verbose are hidden. + * Otherwise, only control nodes marked as verbose are hidden (because they + * are disconnected). + * + * @param dn + */ + private boolean isHidden(Node dn) { + if( verbose ) + return dn.verbose && dn.type == Node.Type.CONTROL; + else + return dn.verbose; + } + + /** + * When verbose mode is disabled, subgraphs with no visible nodes + * are hidden. Otherwise, only subgraphs with no nodes are hidden + * (because they are disconnected). + * + * @param dn + */ + private boolean isHidden(Subgraph s) { + if( verbose ) + return s.nodes.isEmpty(); + else + return s.isVerbose(); + } + } diff --git a/src/main/java/nextflow/lsp/services/script/dag/VariableContext.java b/src/main/java/nextflow/lsp/services/script/dag/VariableContext.java new file mode 100644 index 0000000..0d6ca9b --- /dev/null +++ b/src/main/java/nextflow/lsp/services/script/dag/VariableContext.java @@ -0,0 +1,160 @@ +/* + * 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.dag; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.Stack; + +/** + * + * @author Ben Sherman + * @author Erik Danielsson + */ +class VariableContext { + + private Stack> scopes = new Stack<>(); + + public VariableContext() { + scopes.push(new HashMap<>()); + } + + /** + * Get the current scope. + */ + public Map peekScope() { + return scopes.peek(); + } + + /** + * Enter a new scope, inheriting all symbols defined + * in the parent scope. + */ + public void pushScope() { + var newScope = new HashMap(); + scopes.push(newScope); + } + + /** + * Exit the current scope. + */ + public Map popScope() { + return scopes.pop(); + } + + /** + * Get the active instance of a given symbol. + * + * @param name + */ + public Variable getSymbol(String name) { + for( var scope : scopes ) { + if( scope.containsKey(name) ) + return scope.get(name); + } + return null; + } + + /** + * Get the current predecessors of a given symbol. + * + * @param name + */ + public Set getSymbolPreds(String name) { + var variable = getSymbol(name); + return variable != null + ? variable.preds + : Collections.emptySet(); + } + + /** + * Put a symbol into the current scope. + * + * @param name + * @param dn + * @param isLocal + */ + public void putSymbol(String name, Node dn, boolean isLocal) { + var variable = getSymbol(name); + var depth = variable != null + ? variable.depth + : isLocal ? currentDepth() : 1; + var preds = Set.of(dn); + scopes.peek().put(name, new Variable(depth, preds)); + } + + public void putSymbol(String name, Node dn) { + putSymbol(name, dn, false); + } + + /** + * Merge two conditional scopes into the current scope. + * + * @param ifScope + * @param elseScope + */ + public Set mergeConditionalScopes(Map ifScope, Map elseScope) { + var allSymbols = new HashMap(); + + // add symbols from if scope + ifScope.forEach((name, variable) -> { + if( variable.depth > currentDepth() ) + return; + + // propagate symbols that are definitely assigned + var other = elseScope.get(name); + if( other != null ) + allSymbols.put(name, variable.union(other)); + + // TODO: variables assigned in if but not else (or outside scope) are not definitely assigned + }); + + // TODO: variables assigned in else but not if are not definitely assigned + + // add merged symbols to current scope + scopes.peek().putAll(allSymbols); + + // return the set of merged symbols + return allSymbols.keySet(); + } + + private int currentDepth() { + return scopes.size(); + } + +} + + +class Variable { + + public final int depth; + + public final Set preds; + + Variable(int depth, Set preds) { + this.depth = depth; + this.preds = preds; + } + + public Variable union(Variable other) { + var allPreds = new HashSet(preds); + allPreds.addAll(other.preds); + return new Variable(depth, allPreds); + } +} diff --git a/src/test/groovy/nextflow/lsp/services/script/dag/PreviewDagTest.groovy b/src/test/groovy/nextflow/lsp/services/script/dag/PreviewDagTest.groovy new file mode 100644 index 0000000..4ed81b1 --- /dev/null +++ b/src/test/groovy/nextflow/lsp/services/script/dag/PreviewDagTest.groovy @@ -0,0 +1,376 @@ +/* + * 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.dag + +import nextflow.lsp.services.LanguageServerConfiguration +import nextflow.lsp.services.script.ScriptService +import spock.lang.Specification + +import static nextflow.lsp.TestUtils.* +import static nextflow.lsp.util.JsonUtils.* + +/** + * + * @author Ben Sherman + */ +class PreviewDagTest extends Specification { + + boolean checkDagPreview(ScriptService service, String uri, String name, String source, String mmd) { + open(service, uri, source.stripIndent()) + def response = service.executeCommand('nextflow.server.previewDag', [asJson(uri), asJson(name)], LanguageServerConfiguration.defaults()) + assert response.result == mmd.stripIndent() + return true + } + + def 'should handle an if-else statement' () { + given: + def service = getScriptService() + def uri = getUri('main.nf') + + expect: + checkDagPreview(service, uri, null, + '''\ + workflow { + if (params.echo) { + echo = TOUCH(params.echo) + } else { + echo = DEFAULT() + } + APPEND(echo) + } + + process TOUCH { + input: + val x + + script: + true + } + + process DEFAULT { + true + } + + process APPEND { + input: + val x + + script: + true + } + ''', + """\ + flowchart TB + subgraph " " + subgraph params + v0["echo"] + end + v1{ } + v7([APPEND]) + click v7 href "$uri" _blank + subgraph s1[" "] + v2([TOUCH]) + click v2 href "$uri" _blank + end + subgraph s2[" "] + v4([DEFAULT]) + click v4 href "$uri" _blank + end + v0 --> v1 + v0 --> v2 + v2 --> v7 + v4 --> v7 + v1 --> s1 + v1 --> s2 + end + """ + ) + + checkDagPreview(service, uri, null, + '''\ + workflow { + main: + if (TEST(params.input)) { + intermed = 1 + } else { + intermed = 2 + } + publish: + result = APPLY(intermed) + } + + process TEST { + input: + val x + + script: + true + } + + process APPLY { + input: + val x + + script: + true + } + ''', + """\ + flowchart TB + subgraph " " + subgraph params + v0["input"] + end + v1([TEST]) + click v1 href "$uri" _blank + v6([APPLY]) + click v6 href "$uri" _blank + subgraph publish + v7["result"] + end + v0 --> v1 + v1 --> v6 + v6 --> v7 + end + """ + ) + } + + def 'should collapse empty if-else statement' () { + given: + def service = getScriptService() + def uri = getUri('main.nf') + + expect: + checkDagPreview(service, uri, null, + '''\ + workflow { + main: + if( params.echo ) + echo = 1 + else + echo = 0 + + publish: + echo = echo + } + ''', + """\ + flowchart TB + subgraph " " + subgraph params + v0["echo"] + end + subgraph publish + v5["echo"] + end + v0 --> v5 + end + """ + ) + + checkDagPreview(service, uri, 'ECHO', + '''\ + workflow ECHO { + take: + debug + + main: + if( debug ) + echo = 1 + else + echo = 0 + + emit: + echo = echo + } + ''', + """\ + flowchart TB + subgraph ECHO + subgraph take + v0["debug"] + end + subgraph emit + v5["echo"] + end + v0 --> v5 + end + """ + ) + } + + def 'should handle a ternary expression' () { + given: + def service = getScriptService() + def uri = getUri('main.nf') + + expect: + checkDagPreview(service, uri, null, + '''\ + workflow { + echo = params.echo + ? TOUCH(params.echo) + : DEFAULT() + APPEND(echo) + } + + process TOUCH { + input: + val x + + script: + true + } + + process DEFAULT { + true + } + + process APPEND { + input: + val x + + script: + true + } + ''', + """\ + flowchart TB + subgraph " " + subgraph params + v0["echo"] + end + v1{ } + v5([APPEND]) + click v5 href "$uri" _blank + subgraph s1[" "] + v2([TOUCH]) + click v2 href "$uri" _blank + end + subgraph s2[" "] + v3([DEFAULT]) + click v3 href "$uri" _blank + end + v0 --> v1 + v0 --> v2 + v2 --> v5 + v3 --> v5 + v1 --> s1 + v1 --> s2 + end + """ + ) + } + + def 'should collapse empty ternary expression' () { + given: + def service = getScriptService() + def uri = getUri('main.nf') + + expect: + checkDagPreview(service, uri, null, + '''\ + workflow { + main: + echo = params.echo ? 1 : 0 + + publish: + echo = echo + } + ''', + """\ + flowchart TB + subgraph " " + subgraph params + v0["echo"] + end + subgraph publish + v3["echo"] + end + v0 --> v3 + end + """ + ) + } + + def 'should handle variable reassignment in an if statement' () { + given: + def service = getScriptService() + def uri = getUri('main.nf') + + expect: + checkDagPreview(service, uri, null, + '''\ + workflow { + main: + ch_versions = channel.empty() + + FOO() + ch_versions = ch_versions.mix( FOO.out.versions ) + + if (params.bar) { + BAR() + ch_versions = ch_versions.mix( BAR.out.versions ) + } + + publish: + versions = ch_versions + } + + process FOO { + output: + val 'versions', emit: versions + + script: + true + } + + process BAR { + output: + val 'versions', emit: versions + + script: + true + } + ''', + """\ + flowchart TB + subgraph " " + subgraph params + v3["bar"] + end + v1([FOO]) + click v1 href "$uri" _blank + v4{ } + subgraph s1[" "] + v5([BAR]) + click v5 href "$uri" _blank + end + subgraph publish + v8["versions"] + end + v3 --> v4 + v1 --> v8 + v5 --> v8 + v4 --> s1 + end + """ + ) + } + +}