diff --git a/pom.xml b/pom.xml index 848b3615..79763f1c 100644 --- a/pom.xml +++ b/pom.xml @@ -144,9 +144,9 @@ org.apache.maven.plugins maven-compiler-plugin - 3.11.0 + 3.12.1 - 17 + 21 diff --git a/praxiscore-script/pom.xml b/praxiscore-script/pom.xml index 106d1bbf..11629de7 100644 --- a/praxiscore-script/pom.xml +++ b/praxiscore-script/pom.xml @@ -12,8 +12,18 @@ - org.junit.vintage - junit-vintage-engine + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-params + test + + + org.junit.jupiter + junit-jupiter-engine test diff --git a/praxiscore-script/src/main/java/org/praxislive/script/ScriptExecutor.java b/praxiscore-script/src/main/java/org/praxislive/script/ScriptExecutor.java index 4f9cb8b2..f88f0192 100644 --- a/praxiscore-script/src/main/java/org/praxislive/script/ScriptExecutor.java +++ b/praxiscore-script/src/main/java/org/praxislive/script/ScriptExecutor.java @@ -31,7 +31,6 @@ import org.praxislive.core.Lookup; import org.praxislive.core.types.PError; import org.praxislive.script.commands.CoreCommandsInstaller; -import org.praxislive.script.commands.ScriptCmds; import static java.lang.System.Logger.Level; @@ -42,36 +41,27 @@ class ScriptExecutor { private static final System.Logger log = System.getLogger(ScriptExecutor.class.getName()); - private List stack; - private Queue queue; - private Env env; - private Command evaluator; - private Map commandMap; - private Namespace rootNS; + private final List stack; + private final Queue queue; + private final Env env; + private final Map commandMap; + private final Namespace rootNS; - public ScriptExecutor(Env env, boolean inline) { - this.env = env; + ScriptExecutor(Env context, final ComponentAddress ctxt) { + this.env = context; stack = new LinkedList<>(); queue = new LinkedList<>(); - if (inline) { - evaluator = ScriptCmds.INLINE_EVAL; - } else { - evaluator = ScriptCmds.EVAL; - } + commandMap = buildCommandMap(); rootNS = new NS(); - buildCommandMap(); - } - - public ScriptExecutor(Env context, final ComponentAddress ctxt) { - this(context, true); rootNS.addVariable(Env.CONTEXT, new ConstantImpl(ctxt)); } - private void buildCommandMap() { - commandMap = new HashMap<>(); + private Map buildCommandMap() { + Map map = new HashMap<>(); CommandInstaller installer = new CoreCommandsInstaller(); - installer.install(commandMap); - Lookup.SYSTEM.findAll(CommandInstaller.class).forEach(cmds -> cmds.install(commandMap)); + installer.install(map); + Lookup.SYSTEM.findAll(CommandInstaller.class).forEach(cmds -> cmds.install(map)); + return map; } public void queueEvalCall(Call call) { @@ -150,7 +140,11 @@ private void checkAndStartEval() { Call call = queue.peek(); var args = call.args(); try { - stack.add(0, evaluator.createStackFrame(rootNS, args)); + var script = args.get(0).toString(); + var stackFrame = ScriptStackFrame.forScript(rootNS, script) + .inline() + .build(); + stack.add(0, stackFrame); processStack(); break; } catch (Exception ex) { diff --git a/praxiscore-script/src/main/java/org/praxislive/script/ScriptStackFrame.java b/praxiscore-script/src/main/java/org/praxislive/script/ScriptStackFrame.java new file mode 100644 index 00000000..666b477c --- /dev/null +++ b/praxiscore-script/src/main/java/org/praxislive/script/ScriptStackFrame.java @@ -0,0 +1,400 @@ +/* + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER. + * + * Copyright 2024 Neil C Smith. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License version 3 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License + * version 3 for more details. + * + * You should have received a copy of the GNU Lesser General Public License version 3 + * along with this work; if not, see http://www.gnu.org/licenses/ + * + * + * Please visit https://www.praxislive.org if you need additional information or + * have any questions. + */ +package org.praxislive.script; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.praxislive.core.Value; +import org.praxislive.core.Call; +import org.praxislive.core.ControlAddress; +import org.praxislive.core.syntax.InvalidSyntaxException; +import org.praxislive.core.types.PArray; +import org.praxislive.core.types.PError; +import org.praxislive.core.types.PString; +import org.praxislive.script.ast.RootNode; +import org.praxislive.script.ast.ScriptParser; + +import static java.lang.System.Logger.Level; + +/** + * A stackframe implementation that supports parsing and running of Pcl scripts. + */ +public final class ScriptStackFrame implements StackFrame { + + private static final String TRAP = "_TRAP"; + + private static final System.Logger log = System.getLogger(ScriptStackFrame.class.getName()); + + private final Namespace namespace; + private final RootNode rootNode; + private final boolean trapErrors; + private final List scratchList; + + private State state; + private String activeCommand; + private Call pending; + private List result; + private boolean doProcess; + + private ScriptStackFrame(Namespace namespace, + RootNode rootNode, + boolean trapErrors) { + this.namespace = namespace; + this.rootNode = rootNode; + this.state = State.Incomplete; + this.trapErrors = trapErrors; + this.scratchList = new ArrayList<>(); + rootNode.reset(); + if (trapErrors) { + namespace.createVariable(TRAP, PArray.EMPTY); + } + rootNode.init(namespace); + doProcess = true; + } + + @Override + public State getState() { + return state; + } + + @Override + public StackFrame process(Env context) { + if (state != State.Incomplete) { + throw new IllegalStateException(); + } + if (!doProcess) { + return null; + } + while (!rootNode.isDone() && state == State.Incomplete) { + try { + return processNextCommand(context); + } catch (Exception ex) { + postError(List.of(PError.of(ex))); + } + } + if (rootNode.isDone() && state == State.Incomplete) { + + try { + processResultFromNode(); + } catch (Exception ex) { + result = List.of(PError.of(ex)); + state = State.Error; + } + } + return null; + } + + @Override + public void postResponse(Call call) { + if (pending != null && pending.matchID() == call.matchID()) { + pending = null; + if (call.isReply()) { + log.log(Level.TRACE, () -> "EvalStackFrame - Received valid Return call : \n" + call); + postResponse(call.args()); + } else { + log.log(Level.TRACE, () -> "EvalStackFrame - Received valid Error call : \n" + call); + postError(call.args()); + } + doProcess = true; + } else { + log.log(Level.TRACE, () -> "EvalStackFrame - Received invalid call : \n" + call); + } + + } + + @Override + public void postResponse(State state, List args) { + if (this.state != State.Incomplete) { + throw new IllegalStateException(); + } + switch (state) { + case Incomplete -> + throw new IllegalArgumentException(); + case OK -> + postResponse(args); + case Error -> + postError(args); + default -> { + this.state = state; + this.result = List.copyOf(args); + } + } + doProcess = true; + } + + @Override + public List result() { + if (state == State.Incomplete) { + throw new IllegalStateException(); + } + if (result == null) { + return List.of(); + } else { + return result; + } + } + + private void postResponse(List args) { + try { + scratchList.clear(); + scratchList.addAll(args); + rootNode.postResponse(scratchList); + } catch (Exception ex) { + state = State.Error;//@TODO proper error reporting + } + } + + private void postError(List args) { + Variable trap = namespace.getVariable(TRAP); + if (trap != null) { + rootNode.skipCurrentLine(); + PArray existing = PArray.from(trap.getValue()).orElse(PArray.EMPTY); + Value response = args.isEmpty() ? null : args.getFirst(); + trap.setValue(addErrorToTrap(existing, response)); + } else { + result = List.copyOf(args); + state = State.Error; + } + } + + private PArray addErrorToTrap(PArray trap, Value response) { + String msg; + if (response == null) { + msg = activeCommand + " : Error"; + } else { + msg = PError.from(response) + .map(err -> activeCommand + " : " + err.exceptionType().getSimpleName() + + " : " + err.message()) + .orElse(activeCommand + " : Error : " + response); + } + return Stream.concat(trap.stream(), Stream.of(PString.of(msg))) + .collect(PArray.collector()); + } + + private void processResultFromNode() throws Exception { + scratchList.clear(); + Variable trap = namespace.getVariable(TRAP); + if (trapErrors && trap != null && !trap.getValue().isEmpty()) { + String errors = PArray.from(trap.getValue()) + .orElse(PArray.EMPTY) + .asListOf(String.class) + .stream() + .collect(Collectors.joining("\n")); + result = List.of(PString.of(errors)); + state = State.Error; + } else { + rootNode.writeResult(scratchList); + result = List.copyOf(scratchList); + state = State.OK; + } + } + + private StackFrame processNextCommand(Env context) + throws Exception { + + scratchList.clear(); + rootNode.writeNextCommand(scratchList); + if (scratchList.size() < 1) { + throw new Exception(); + } + Value cmdArg = scratchList.get(0); + activeCommand = cmdArg.toString(); + if (cmdArg instanceof ControlAddress) { + routeCall(context, scratchList); + return null; + } + String cmdStr = cmdArg.toString(); + if (cmdStr.isEmpty()) { + throw new IllegalArgumentException("Empty command"); + } + Command cmd = namespace.getCommand(cmdStr); + if (cmd != null) { + scratchList.remove(0); + return cmd.createStackFrame(namespace, List.copyOf(scratchList)); + } + if (cmdStr.charAt(0) == '/' && cmdStr.lastIndexOf('.') > -1) { + routeCall(context, scratchList); + return null; + } + + throw new IllegalArgumentException("Command not found"); + + } + + private void routeCall(Env context, List argList) + throws Exception { + ControlAddress ad = ControlAddress.from(argList.get(0)) + .orElseThrow(Exception::new); + argList.remove(0); + Call call = Call.create(ad, context.getAddress(), context.getTime(), List.copyOf(argList)); + log.log(Level.TRACE, () -> "Sending Call" + call); + pending = call; + context.getPacketRouter().route(call); + } + + /** + * Create a {@link ScriptStackFrame.Builder} for the provided namespace and + * script. By default the script will be evaluated in a dedicated child + * namespace. Neither the builder or the stack frame are reusable. + * + * @param namespace namespace to run script in + * @param script script to parse and run + * @return builder + * @throws InvalidSyntaxException if the script cannot be parsed + */ + public static Builder forScript(Namespace namespace, String script) { + RootNode root = ScriptParser.getInstance().parse(script); + return new Builder(namespace, root); + } + + /** + * A builder for {@link ScriptStackFrame}. + * + * @see #forScript(org.praxislive.script.Namespace, java.lang.String) + */ + public static class Builder { + + private final Namespace namespace; + private final RootNode root; + + private boolean inline; + private List allowedCommands; + private boolean trapErrors; + + private Builder(Namespace namespace, RootNode root) { + this.namespace = namespace; + this.root = root; + } + + /** + * Run the script directly in the provided namespace rather than a + * child. + * + * @return this for chaining + */ + public Builder inline() { + if (trapErrors) { + throw new IllegalStateException("Inline and trap errors cannot be used together"); + } + this.inline = true; + return this; + } + + /** + * Trap errors. Error messages will be aggregated and script execution + * will attempt to continue. If no allowed commands have been specified, + * an empty list of allowed commands will be set. + * + * @return this for chaining + */ + public Builder trapErrors() { + if (inline) { + throw new IllegalStateException("Inline and trap errors cannot be used together"); + } + this.trapErrors = true; + if (this.allowedCommands == null) { + this.allowedCommands = List.of(); + } + return this; + } + + /** + * Specify a list of allowed commands to filter those available from the + * provided namespace. + * + * @param commands list of allowed commands + * @return this for chaining + */ + public Builder allowedCommands(List commands) { + this.allowedCommands = List.copyOf(commands); + return this; + } + + /** + * Build the ScriptStackFrame. + * + * @return script stackframe + */ + public ScriptStackFrame build() { + Namespace ns; + if (inline) { + ns = namespace; + } else { + ns = namespace.createChild(); + } + if (allowedCommands != null) { + ns = new FilteredNamespace(ns, allowedCommands); + } + return new ScriptStackFrame(ns, root, trapErrors); + } + + } + + private static class FilteredNamespace implements Namespace { + + private final Namespace delegate; + private final List allowed; + + private FilteredNamespace(Namespace delegate, List allowed) { + this.delegate = Objects.requireNonNull(delegate); + this.allowed = List.copyOf(allowed); + } + + @Override + public void addCommand(String id, Command cmd) { + if (allowed.contains(id)) { + delegate.addCommand(id, cmd); + } else { + throw new UnsupportedOperationException(); + } + } + + @Override + public void addVariable(String id, Variable var) { + delegate.addVariable(id, var); + } + + @Override + public Namespace createChild() { + return new FilteredNamespace(delegate.createChild(), allowed); + } + + @Override + public Command getCommand(String id) { + if (allowed.contains(id)) { + return delegate.getCommand(id); + } else { + return null; + } + } + + @Override + public Variable getVariable(String id) { + return delegate.getVariable(id); + } + + } + +} diff --git a/praxiscore-script/src/main/java/org/praxislive/script/ast/CompositeNode.java b/praxiscore-script/src/main/java/org/praxislive/script/ast/CompositeNode.java index 50f59598..ec0267ba 100644 --- a/praxiscore-script/src/main/java/org/praxislive/script/ast/CompositeNode.java +++ b/praxiscore-script/src/main/java/org/praxislive/script/ast/CompositeNode.java @@ -1,7 +1,7 @@ /* * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER. * - * Copyright 2018 Neil C Smith. + * Copyright 2024 Neil C Smith. * * This code is free software; you can redistribute it and/or modify it * under the terms of the GNU Lesser General Public License version 3 only, as @@ -19,7 +19,6 @@ * Please visit https://www.praxislive.org if you need additional information or * have any questions. */ - package org.praxislive.script.ast; import java.util.List; @@ -28,19 +27,20 @@ /** * - * + * */ -public abstract class CompositeNode extends Node { - - private Node[] children; +abstract class CompositeNode extends Node { + + private final List children; + private int active; private Namespace namespace; public CompositeNode(List children) { - this.children = children.toArray(new Node[children.size()]); + this.children = List.copyOf(children); } - @Override + @Override public void init(Namespace namespace) { if (namespace == null) { throw new NullPointerException(); @@ -50,9 +50,7 @@ public void init(Namespace namespace) { } this.namespace = namespace; active = 0; - for (Node child : children) { - child.init(namespace); - } + children.forEach(child -> child.init(namespace)); } @Override @@ -63,8 +61,8 @@ public boolean isDone() { if (active < 0) { return isThisDone(); } else { - while (active < children.length) { - if (!children[active].isDone()) { + while (active < children.size()) { + if (!children.get(active).isDone()) { return false; } active++; @@ -77,13 +75,13 @@ public boolean isDone() { protected abstract boolean isThisDone(); @Override - public void writeNextCommand(List args) + public void writeNextCommand(List args) throws Exception { if (namespace == null) { throw new IllegalStateException(); } if (active >= 0) { - children[active].writeNextCommand(args); + children.get(active).writeNextCommand(args); } else { writeThisNextCommand(args); } @@ -92,15 +90,14 @@ public void writeNextCommand(List args) protected abstract void writeThisNextCommand(List args) throws Exception; - @Override - public void postResponse(List args) + public void postResponse(List args) throws Exception { if (namespace == null) { throw new IllegalStateException(); } if (active >= 0) { - children[active].postResponse(args); + children.get(active).postResponse(args); } else { postThisResponse(args); } @@ -116,11 +113,19 @@ public void reset() { } namespace = null; } - - protected Node[] getChildren() { + + protected List getChildren() { return children; } - + protected void skipActive() { + if (active < 0) { + return; + } + active++; + if (active >= children.size()) { + active = -1; + } + } } diff --git a/praxiscore-script/src/main/java/org/praxislive/script/ast/RootNode.java b/praxiscore-script/src/main/java/org/praxislive/script/ast/RootNode.java index afd29eab..794a6f2e 100644 --- a/praxiscore-script/src/main/java/org/praxislive/script/ast/RootNode.java +++ b/praxiscore-script/src/main/java/org/praxislive/script/ast/RootNode.java @@ -19,7 +19,6 @@ * Please visit https://www.praxislive.org if you need additional information or * have any questions. */ - package org.praxislive.script.ast; import java.util.List; @@ -27,7 +26,7 @@ /** * - * + * */ public class RootNode extends CompositeNode { @@ -52,13 +51,17 @@ protected void postThisResponse(List args) { @Override public void writeResult(List args) throws Exception { - Node[] children = getChildren(); - if (children.length > 0) { - children[children.length - 1].writeResult(args); + List children = getChildren(); + if (!children.isEmpty()) { + Node last = children.getLast(); + if (last.isDone()) { + last.writeResult(args); + } } - } - + public void skipCurrentLine() { + skipActive(); + } } diff --git a/praxiscore-script/src/main/java/org/praxislive/script/ast/SubcommandNode.java b/praxiscore-script/src/main/java/org/praxislive/script/ast/SubcommandNode.java index 6fc9660a..0c2829c6 100644 --- a/praxiscore-script/src/main/java/org/praxislive/script/ast/SubcommandNode.java +++ b/praxiscore-script/src/main/java/org/praxislive/script/ast/SubcommandNode.java @@ -26,7 +26,7 @@ /** * - * + * */ public class SubcommandNode extends CompositeNode { @@ -52,7 +52,7 @@ protected void postThisResponse(List args) { @Override public void writeResult(List args) throws Exception { - Node[] children = getChildren(); - children[children.length - 1].writeResult(args); + List children = getChildren(); + children.get(children.size() - 1).writeResult(args); } } diff --git a/praxiscore-script/src/main/java/org/praxislive/script/commands/AtCmds.java b/praxiscore-script/src/main/java/org/praxislive/script/commands/AtCmds.java index f6a9ee83..f1270eeb 100644 --- a/praxiscore-script/src/main/java/org/praxislive/script/commands/AtCmds.java +++ b/praxiscore-script/src/main/java/org/praxislive/script/commands/AtCmds.java @@ -39,6 +39,7 @@ import org.praxislive.script.CommandInstaller; import org.praxislive.script.Env; import org.praxislive.script.Namespace; +import org.praxislive.script.ScriptStackFrame; import org.praxislive.script.StackFrame; /** @@ -171,7 +172,7 @@ public StackFrame process(Env env) { try { Namespace child = namespace.createChild(); child.createConstant(Env.CONTEXT, ctxt); - return ScriptCmds.INLINE_EVAL.createStackFrame(child, List.of(script)); + return ScriptStackFrame.forScript(child, script.toString()).inline().build(); } catch (Exception ex) { state = State.Error; result = List.of(PError.of(ex)); diff --git a/praxiscore-script/src/main/java/org/praxislive/script/commands/EvalStackFrame.java b/praxiscore-script/src/main/java/org/praxislive/script/commands/EvalStackFrame.java deleted file mode 100644 index 9769f552..00000000 --- a/praxiscore-script/src/main/java/org/praxislive/script/commands/EvalStackFrame.java +++ /dev/null @@ -1,202 +0,0 @@ -/* - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER. - * - * Copyright 2024 Neil C Smith. - * - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU Lesser General Public License version 3 only, as - * published by the Free Software Foundation. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License - * version 3 for more details. - * - * You should have received a copy of the GNU Lesser General Public License version 3 - * along with this work; if not, see http://www.gnu.org/licenses/ - * - * - * Please visit https://www.praxislive.org if you need additional information or - * have any questions. - */ -package org.praxislive.script.commands; - -import java.util.LinkedList; -import java.util.List; -import org.praxislive.core.Value; -import org.praxislive.core.Call; -import org.praxislive.core.ControlAddress; -import org.praxislive.core.types.PError; -import org.praxislive.script.Command; -import org.praxislive.script.Env; -import org.praxislive.script.Namespace; -import org.praxislive.script.StackFrame; -import org.praxislive.script.ast.RootNode; - -import static java.lang.System.Logger.Level; - -/** - * - */ -class EvalStackFrame implements StackFrame { - - private static final System.Logger log = System.getLogger(EvalStackFrame.class.getName()); - - private Namespace namespace; - private RootNode rootNode; - private State state; - private Call pending; - private List result; - private List argList; - private boolean doProcess; - - public EvalStackFrame(Namespace namespace, RootNode rootNode) { - this.namespace = namespace; - this.rootNode = rootNode; - this.state = State.Incomplete; - this.argList = new LinkedList<>(); - rootNode.reset(); - rootNode.init(namespace); - doProcess = true; - } - - @Override - public State getState() { - return state; - } - - @Override - public StackFrame process(Env context) { - if (state != State.Incomplete) { - throw new IllegalStateException(); - } - if (!doProcess) { - return null; - } - try { - if (rootNode.isDone()) { - processResultFromNode(); - return null; - } else { - return processNextCommand(context); - } - } catch (Exception ex) { - result = List.of(PError.of(ex)); - state = State.Error; - return null; - } finally { - doProcess = false; - } - - } - - @Override - public void postResponse(Call call) { - if (pending != null && pending.matchID() == call.matchID()) { - pending = null; - if (call.isReply()) { - log.log(Level.TRACE, () -> "EvalStackFrame - Received valid Return call : \n" + call); - postResponse(call.args()); - } else { - log.log(Level.TRACE, () -> "EvalStackFrame - Received valid Error call : \n" + call); - this.state = State.Error; - this.result = call.args(); - } - doProcess = true; - } else { - log.log(Level.TRACE, () -> "EvalStackFrame - Received invalid call : \n" + call); - } - - } - - @Override - public void postResponse(State state, List args) { - if (this.state != State.Incomplete) { - throw new IllegalStateException(); - } - switch (state) { - case Incomplete: - throw new IllegalArgumentException(); - case OK: - postResponse(args); - break; - default: - this.state = state; - this.result = args; - } - doProcess = true; - } - - @Override - public List result() { - if (state == State.Incomplete) { - throw new IllegalStateException(); - } - if (result == null) { - return List.of(); - } else { - return result; - } - } - - private void postResponse(List args) { - try { - argList.clear(); - argList.addAll(args); - rootNode.postResponse(argList); - } catch (Exception ex) { - state = State.Error;//@TODO proper error reporting - } - } - - private void processResultFromNode() throws Exception { - argList.clear(); - rootNode.writeResult(argList); - result = List.copyOf(argList); - state = State.OK; - - } - - private StackFrame processNextCommand(Env context) - throws Exception { - - argList.clear(); - rootNode.writeNextCommand(argList); - if (argList.size() < 1) { - throw new Exception(); - } - Value cmdArg = argList.get(0); - if (cmdArg instanceof ControlAddress) { - routeCall(context, argList); - return null; - } - String cmdStr = cmdArg.toString(); - if (cmdStr.isEmpty()) { - throw new Exception(); - } - Command cmd = namespace.getCommand(cmdStr); - if (cmd != null) { - argList.remove(0); - return cmd.createStackFrame(namespace, List.copyOf(argList)); - } - if (cmdStr.charAt(0) == '/' && cmdStr.lastIndexOf('.') > -1) { - routeCall(context, argList); - return null; - } - - throw new Exception(); - - } - - private void routeCall(Env context, List argList) - throws Exception { - ControlAddress ad = ControlAddress.from(argList.get(0)) - .orElseThrow(Exception::new); - argList.remove(0); - Call call = Call.create(ad, context.getAddress(), context.getTime(), List.copyOf(argList)); - log.log(Level.TRACE, () -> "Sending Call" + call); - pending = call; - context.getPacketRouter().route(call); - } - -} diff --git a/praxiscore-script/src/main/java/org/praxislive/script/commands/ScriptCmds.java b/praxiscore-script/src/main/java/org/praxislive/script/commands/ScriptCmds.java index c5c168cc..9f6cc67c 100644 --- a/praxiscore-script/src/main/java/org/praxislive/script/commands/ScriptCmds.java +++ b/praxiscore-script/src/main/java/org/praxislive/script/commands/ScriptCmds.java @@ -22,17 +22,18 @@ package org.praxislive.script.commands; import java.io.File; +import java.util.ArrayDeque; import java.util.List; import java.util.Map; +import java.util.Queue; import org.praxislive.core.Value; -import org.praxislive.core.syntax.InvalidSyntaxException; +import org.praxislive.core.types.PArray; import org.praxislive.core.types.PResource; import org.praxislive.script.Command; import org.praxislive.script.CommandInstaller; import org.praxislive.script.Namespace; +import org.praxislive.script.ScriptStackFrame; import org.praxislive.script.StackFrame; -import org.praxislive.script.ast.RootNode; -import org.praxislive.script.ast.ScriptParser; /** * @@ -41,7 +42,6 @@ public class ScriptCmds implements CommandInstaller { private final static ScriptCmds instance = new ScriptCmds(); public final static Command EVAL = new Eval(); - public final static Command INLINE_EVAL = new InlineEval(); public final static Command INCLUDE = new Include(); private ScriptCmds() { @@ -62,34 +62,44 @@ private static class Eval implements Command { @Override public StackFrame createStackFrame(Namespace namespace, List args) throws Exception { - if (args.size() != 1) { - throw new Exception(); + if (args.isEmpty()) { + throw new IllegalArgumentException("No script passed to eval"); } - String script = args.get(0).toString(); - try { - RootNode astRoot = ScriptParser.getInstance().parse(script); - return new EvalStackFrame(namespace.createChild(), astRoot); - } catch (InvalidSyntaxException ex) { - throw new Exception(ex); + Queue queue = new ArrayDeque<>(args); + boolean inline = false; + boolean trap = false; + List allowed = null; + String script = null; + while (!queue.isEmpty()) { + String arg = queue.poll().toString(); + if ("--inline".equals(arg)) { + inline = true; + } else if ("--trap-errors".equals(arg)) { + trap = true; + if (allowed == null) { + allowed = List.of(); + } + } else if ("--allowed-commands".equals(arg)) { + allowed = PArray.from(queue.poll()).orElseThrow().asListOf(String.class); + } else { + script = arg; + break; + } } - } - } - - private static class InlineEval implements Command { - - @Override - public StackFrame createStackFrame(Namespace namespace, List args) - throws Exception { - if (args.size() != 1) { - throw new Exception(); + if (!queue.isEmpty()) { + throw new IllegalArgumentException("Additional arguments after script"); } - String script = args.get(0).toString(); - try { - RootNode astRoot = ScriptParser.getInstance().parse(script); - return new EvalStackFrame(namespace, astRoot); - } catch (InvalidSyntaxException ex) { - throw new Exception(ex); + var bld = ScriptStackFrame.forScript(namespace, script); + if (inline) { + bld.inline(); + } + if (trap) { + bld.trapErrors(); + } + if (allowed != null) { + bld.allowedCommands(allowed); } + return bld.build(); } } @@ -105,8 +115,7 @@ public StackFrame createStackFrame(Namespace namespace, List args) throws PResource res = PResource.from(args.get(0)).orElseThrow(); File file = new File(res.value()); String script = Utils.loadStringFromFile(file); - RootNode astRoot = ScriptParser.getInstance().parse(script); - return new EvalStackFrame(namespace.createChild(), astRoot); + return ScriptStackFrame.forScript(namespace, script).build(); } catch (Exception ex) { throw new Exception(ex); } diff --git a/praxiscore-script/src/test/java/org/praxislive/script/DefaultScriptServiceTest.java b/praxiscore-script/src/test/java/org/praxislive/script/DefaultScriptServiceTest.java new file mode 100644 index 00000000..1a5f473d --- /dev/null +++ b/praxiscore-script/src/test/java/org/praxislive/script/DefaultScriptServiceTest.java @@ -0,0 +1,308 @@ +package org.praxislive.script; + +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.praxislive.core.Call; +import org.praxislive.core.Clock; +import org.praxislive.core.ControlAddress; +import org.praxislive.core.Lookup; +import org.praxislive.core.Packet; +import org.praxislive.core.Root; +import org.praxislive.core.RootHub; +import org.praxislive.core.Value; +import org.praxislive.core.types.PArray; +import org.praxislive.core.types.PError; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * + */ +public class DefaultScriptServiceTest { + + private static final boolean VERBOSE = Boolean.getBoolean("praxis.test.verbose"); + private static final int TIMEOUT = Integer.getInteger("praxis.test.timeout", 1000000); + + public DefaultScriptServiceTest() { + } + + @Test + public void testInlineScript() throws Exception { + logTest("testInlineScript"); + String script = """ + set V1 "One" + set V2 "Two" + set V3 $V1 + set RET [array $V1 $V2 $V3] + """; + var root = new DefaultScriptService(); + try (var hub = new RootHubImpl("script", root)) { + hub.start(); + hub.send("/script.eval", "/hub.result", script); + var result = hub.poll(); + logCall("Result received", result); + assertTrue(result.isReply()); + assertEquals(1, result.args().size()); + var expected = Stream.of("One", "Two", "One") + .map(Value::ofObject) + .collect(PArray.collector()); + assertEquals(expected, result.args().get(0)); + } + + } + + @Test + public void testAtScript() throws Exception { + logTest("testAtScript"); + String script = """ + .foo + @ /bar { + set V 42 + @ ./baz test:component { + .value $V + } + } + """; + var root = new DefaultScriptService(); + try (var hub = new RootHubImpl("script", root)) { + hub.start(); + hub.send("/script.eval", "/hub.result", script); + Call call = hub.poll(); + logCall("Call to /hub.foo received", call); + assertTrue(call.isRequest()); + assertEquals("/hub.foo", call.to().toString()); + assertTrue(call.args().isEmpty()); + hub.dispatch(call.reply()); + + call = hub.poll(); + logCall("Call to /bar.add-child received", call); + assertTrue(call.isRequest()); + assertEquals("/bar.add-child", call.to().toString()); + hub.dispatch(call.reply()); + + call = hub.poll(); + logCall("Call to /bar/baz.value received", call); + assertTrue(call.isRequest()); + assertEquals("/bar/baz.value", call.to().toString()); + assertEquals(1, call.args().size()); + assertEquals("42", call.args().get(0).toString()); + hub.dispatch(call.reply()); + + call = hub.poll(); + logCall("Result received", call); + + assertTrue(call.isReply()); + + } + + } + + @Test + public void testEvalInline() throws Exception { + logTest("testEvalInline"); + String script = """ + eval --inline { + set X 42 + /hub.value $X + } + /hub.value $X + eval { + set Y 84 + /hub.value $Y + } + /hub.value $Y + """; + var root = new DefaultScriptService(); + try (var hub = new RootHubImpl("script", root)) { + hub.start(); + hub.send("/script.eval", "/hub.result", script); + + for (String expected : new String[]{"42", "42", "84"}) { + Call call = hub.poll(); + logCall("Value received", call); + assertEquals("/hub.value", call.to().toString()); + assertEquals(expected, call.args().get(0).toString()); + hub.dispatch(call.reply()); + } + + Call call = hub.poll(); + logCall("Expected execution failure", call); + assertTrue(call.isError()); + + } + } + + @Test + public void testEvalTrapErrors() throws Exception { + logTest("testEvalTrapErrors"); + String script = """ + eval --trap-errors { + set X 42 + /hub.value "FOO" + } + """; + var root = new DefaultScriptService(); + try (var hub = new RootHubImpl("script", root)) { + hub.start(); + hub.send("/script.eval", "/hub.result", script); + Call call = hub.poll(); + logCall("Value received", call); + assertEquals("/hub.value", call.to().toString()); + assertEquals("FOO", call.args().get(0).toString()); + hub.dispatch(call.reply()); + call = hub.poll(); + logCall("Error result received", call); + assertTrue(call.isError()); + } + + } + + @Test + public void testEvalTrapErrorsNested() throws Exception { + logTest("testEvalTrapErrorsNested"); + String script = """ + set allowed [array "@"] + eval --trap-errors --allowed-commands $allowed { + @ /bar { + set V 42 + @ ./baz test:component { + .error + .value "FOO" + } + } + set Y [set X] + } + """; + var root = new DefaultScriptService(); + try (var hub = new RootHubImpl("script", root)) { + hub.start(); + hub.send("/script.eval", "/hub.result", script); + Call call = hub.poll(); + logCall("Call to /bar.add-child received", call); + assertTrue(call.isRequest()); + assertEquals("/bar.add-child", call.to().toString()); + hub.dispatch(call.reply()); + + call = hub.poll(); + logCall("Call to /bar/baz.error received", call); + assertTrue(call.isRequest()); + assertEquals("/bar/baz.error", call.to().toString()); + hub.dispatch(call.error(PError.of("BAZ ERROR"))); + + call = hub.poll(); + logCall("Call to /bar/baz.value received", call); + assertTrue(call.isRequest()); + assertEquals("/bar/baz.value", call.to().toString()); + assertEquals(1, call.args().size()); + assertEquals("FOO", call.args().get(0).toString()); + hub.dispatch(call.reply()); + + call = hub.poll(); + logCall("Result received", call); + + assertTrue(call.isError()); + String errors = call.args().get(0).toString(); + if (VERBOSE) { + System.out.println(""); + System.out.println("Completed with errors"); + System.out.println("====================="); + System.out.println(errors); + } + assertTrue(errors.contains("set")); + assertTrue(errors.contains("BAZ ERROR")); + } + + } + + private static void logTest(String testName) { + if (VERBOSE) { + System.out.println(""); + System.out.println(""); + System.out.println(testName); + System.out.println("===================="); + } + } + + private static void logCall(String msg, Call call) { + if (VERBOSE) { + System.out.println(msg); + System.out.println(call); + } + } + + private static class RootHubImpl implements RootHub, AutoCloseable { + + private final String rootID; + private final Root root; + private final BlockingQueue queue; + + private Root.Controller ctrl; + + private RootHubImpl(String rootID, Root root) { + this.rootID = rootID; + this.root = root; + this.queue = new LinkedBlockingQueue<>(); + } + + @Override + public boolean dispatch(Packet packet) { + if (rootID.equals(packet.rootID())) { + return ctrl.submitPacket(packet); + } else { + return queue.offer(packet); + } + } + + @Override + public Clock getClock() { + return System::nanoTime; + } + + @Override + public Lookup getLookup() { + return Lookup.EMPTY; + } + + public void send(String to, String from, Object arg) { + send(to, from, List.of(Value.ofObject(arg))); + } + + public void send(String to, String from, List args) { + dispatch(Call.create(ControlAddress.of(to), + ControlAddress.of(from), + getClock().getTime(), + args + )); + } + + public Call poll() throws InterruptedException, TimeoutException { + Packet p = queue.poll(TIMEOUT, TimeUnit.MILLISECONDS); + if (p instanceof Call c) { + return c; + } + throw new TimeoutException("Call poll timed out"); + } + + public void start() { + ctrl = root.initialize(rootID, this); + ctrl.start(); + } + + @Override + public void close() { + ctrl.shutdown(); + try { + ctrl.awaitTermination(10, TimeUnit.SECONDS); + } catch (Exception ex) { + ex.printStackTrace(); + } + } + + } + +} diff --git a/praxiscore-script/src/test/java/org/praxislive/script/ast/AddressNodeTest.java b/praxiscore-script/src/test/java/org/praxislive/script/ast/AddressNodeTest.java index c96eff8f..2b7f9c4b 100644 --- a/praxiscore-script/src/test/java/org/praxislive/script/ast/AddressNodeTest.java +++ b/praxiscore-script/src/test/java/org/praxislive/script/ast/AddressNodeTest.java @@ -1,95 +1,51 @@ -/* - * To change this template, choose Tools | Templates - * and open the template in the editor. - */ - package org.praxislive.script.ast; -import org.praxislive.script.ast.AddressNode; import java.util.ArrayList; import java.util.List; +import org.junit.jupiter.api.Test; import org.praxislive.core.Value; import org.praxislive.core.ComponentAddress; import org.praxislive.script.Command; import org.praxislive.script.Namespace; import org.praxislive.script.Variable; -import org.junit.After; -import org.junit.AfterClass; -import org.junit.Before; -import org.junit.BeforeClass; -import org.junit.Test; -import static org.junit.Assert.*; - -/** - * - * - */ -public class AddressNodeTest { - public AddressNodeTest() { - } +import static org.junit.jupiter.api.Assertions.*; - @BeforeClass - public static void setUpClass() throws Exception { - } - @AfterClass - public static void tearDownClass() throws Exception { - } - - @Before - public void setUp() { - } - - @After - public void tearDown() { - } - -// /** -// * Test of init method, of class AddressNode. -// */ -// @Test -// public void testInit() { -// System.out.println("init"); -// Namespace namespace = null; -// AddressNode instance = null; -// instance.init(namespace); -// // TODO review the generated test code and remove the default call to fail. -// fail("The test case is a prototype."); -// } -// -// /** -// * Test of reset method, of class AddressNode. -// */ -// @Test -// public void testReset() { -// System.out.println("reset"); -// AddressNode instance = null; -// instance.reset(); -// // TODO review the generated test code and remove the default call to fail. -// fail("The test case is a prototype."); -// } +public class AddressNodeTest { /** * Test of writeResult method, of class AddressNode. */ @Test public void testWriteResult() { - System.out.println("writeResult"); - List args = new ArrayList(); Namespace ns = new NS(); - String[] ads = {"./to/here", "./to/here.control", "./to/here!port", ".control2"}; - for (String ad : ads) { - AddressNode instance = new AddressNode(ad); - instance.init(ns); - instance.writeResult(args); - System.out.println(ad + " : " + args.get(0)); - args.clear(); - } + List scratch = new ArrayList<>(); + List relative = List.of( + "./to/here", + "./to/here.control", + "./to/here!port", + ".control2"); + List absolute = List.of( + "/test/address/to/here", + "/test/address/to/here.control", + "/test/address/to/here!port", + "/test/address.control2"); + List result = relative.stream() + .map(address -> { + var addressNode = new AddressNode(address); + addressNode.init(ns); + scratch.clear(); + addressNode.writeResult(scratch); + return scratch.get(0).toString(); + }).toList(); + assertEquals(absolute, result); + } private class NS implements Namespace, Variable { + @Override public Variable getVariable(String id) { if ("_CTXT".equals(id)) { return this; @@ -97,30 +53,36 @@ public Variable getVariable(String id) { return null; } + @Override public void addVariable(String id, Variable var) { throw new UnsupportedOperationException("Not supported yet."); } + @Override public Namespace createChild() { throw new UnsupportedOperationException("Not supported yet."); } + @Override public void setValue(Value value) { throw new UnsupportedOperationException("Not supported yet."); } + @Override public Value getValue() { return ComponentAddress.of("/test/address"); } + @Override public Command getCommand(String id) { throw new UnsupportedOperationException("Not supported yet."); } + @Override public void addCommand(String id, Command cmd) { throw new UnsupportedOperationException("Not supported yet."); } } -} \ No newline at end of file +}