diff --git a/pom.xml b/pom.xml index 79763f1c..527286fb 100644 --- a/pom.xml +++ b/pom.xml @@ -29,6 +29,7 @@ praxiscore-hub praxiscore-script praxiscore-launcher + praxiscore-project praxiscore-data praxiscore-video praxiscore-video-code diff --git a/praxiscore-project/pom.xml b/praxiscore-project/pom.xml new file mode 100644 index 00000000..67afa828 --- /dev/null +++ b/praxiscore-project/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + + org.praxislive + praxiscore + 6.0.0-SNAPSHOT + + praxiscore-project + jar + PraxisCORE Project + + + + ${project.groupId} + praxiscore-api + ${project.version} + + + ${project.groupId} + praxiscore-base + ${project.version} + + + ${project.groupId} + praxiscore-script + ${project.version} + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-params + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + + + + maven-compiler-plugin + + + + + + + + + + diff --git a/praxiscore-project/src/main/java/module-info.java b/praxiscore-project/src/main/java/module-info.java new file mode 100644 index 00000000..fe732679 --- /dev/null +++ b/praxiscore-project/src/main/java/module-info.java @@ -0,0 +1,11 @@ + +module org.praxislive.project { + + requires transitive org.praxislive.core; + + requires org.praxislive.base; + requires org.praxislive.script; + + exports org.praxislive.project; + +} diff --git a/praxiscore-project/src/main/java/org/praxislive/project/GraphBuilder.java b/praxiscore-project/src/main/java/org/praxislive/project/GraphBuilder.java new file mode 100644 index 00000000..bb557d52 --- /dev/null +++ b/praxiscore-project/src/main/java/org/praxislive/project/GraphBuilder.java @@ -0,0 +1,467 @@ +/* + * 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.project; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.SequencedMap; +import java.util.SequencedSet; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Stream; +import org.praxislive.core.ComponentAddress; +import org.praxislive.core.ComponentType; +import org.praxislive.core.ControlAddress; +import org.praxislive.core.Value; + +/** + * Builders for graph component elements. + */ +public final class GraphBuilder { + + private GraphBuilder() { + } + + /** + * Create a component element builder for the provided component type. + * + * @param type component type + * @return component builder + */ + public static Component component(ComponentType type) { + return new Component(type); + } + + /** + * Create a component element builder initialized with the type and + * sub-elements of the provided component element. + * + * @param component base component + * @return builder + */ + public static Component component(GraphElement.Component component) { + return new Component(component); + } + + /** + * Create a root element builder for the provided ID and type. + * + * @param id root ID + * @param type root type + * @return builder + */ + public static Root root(String id, ComponentType type) { + return new Root(id, type); + } + + /** + * Create a root element builder initialized with the ID, type and + * sub-elements of the provided root element. If the provided root is + * synthetic then the builder will be too. + * + * @param root base root + * @return builder + */ + public static Root root(GraphElement.Root root) { + return new Root(root); + } + + /** + * Create a root element builder for a synthetic root. + * + * @return builder + */ + public static Root syntheticRoot() { + return new Root("", GraphElement.Root.SYNTHETIC); + } + + /** + * Abstract base class of component and root element builders. + * + * @param builder type + */ + @SuppressWarnings("unchecked") + public static sealed abstract class Base> { + + final ComponentType type; + final List comments; + final SequencedMap properties; + final SequencedMap children; + final SequencedSet connections; + + private Base(ComponentType type) { + this.type = Objects.requireNonNull(type); + comments = new ArrayList<>(); + properties = new LinkedHashMap<>(); + children = new LinkedHashMap<>(); + connections = new LinkedHashSet<>(); + } + + private Base(GraphElement.Component component) { + this(component.type()); + comments.addAll(component.comments()); + properties.putAll(component.properties()); + children.putAll(component.children()); + connections.addAll(component.connections()); + } + + /** + * Add a child component element. + * + * @param id child ID + * @param child child element + * @return this + */ + public B child(String id, GraphElement.Component child) { + if (!ComponentAddress.isValidID(id)) { + throw new IllegalArgumentException(id + " is not a valid child ID"); + } + children.put(id, Objects.requireNonNull(child)); + return (B) this; + } + + /** + * Add a child of the given type, configured by the passed in builder + * consumer. + * + * @param id child ID + * @param type child component type + * @param constructor builder consumer to configure the component + * @return this + */ + public B child(String id, ComponentType type, Consumer constructor) { + Component childBuilder = new Component(type); + constructor.accept(childBuilder); + return child(id, childBuilder.build()); + } + + /** + * Add a comment element. + * + * @param text comment text + * @return this + */ + public B comment(String text) { + return comment(GraphElement.comment(text)); + } + + /** + * Add a comment element. + * + * @param comment comment element + * @return this + */ + public B comment(GraphElement.Comment comment) { + comments.add(Objects.requireNonNull(comment)); + return (B) this; + } + + /** + * Add a connection element. + * + * @param sourceComponent source component ID + * @param sourcePort source port ID + * @param targetComponent target component ID + * @param targetPort target port ID + * @return this + */ + public B connection(String sourceComponent, String sourcePort, + String targetComponent, String targetPort) { + return connection(GraphElement.connection(sourceComponent, sourcePort, + targetComponent, targetPort)); + } + + /** + * Add a connection element. + * + * @param connection connection element + * @return this + */ + public B connection(GraphElement.Connection connection) { + connections.add(Objects.requireNonNull(connection)); + return (B) this; + } + + /** + * Add a property element. + * + * @param id property ID + * @param value property value + * @return this + */ + public B property(String id, Value value) { + return property(id, GraphElement.property(value)); + } + + /** + * Add a property element. + * + * @param id property ID + * @param property property element + * @return this + */ + public B property(String id, GraphElement.Property property) { + if (!ControlAddress.isValidID(id)) { + throw new IllegalArgumentException(id + " is not a valid property ID"); + } + properties.put(id, Objects.requireNonNull(property)); + return (B) this; + } + + /** + * Clear the existing children. + * + * @return this + */ + public B clearChildren() { + children.clear(); + return (B) this; + } + + /** + * Clear the existing comments. + * + * @return this + */ + public B clearComments() { + comments.clear(); + return (B) this; + } + + /** + * Clear the existing connections. + * + * @return this + */ + public B clearConnections() { + connections.clear(); + return (B) this; + } + + /** + * Clear the existing properties. + * + * @return this + */ + public B clearProperties() { + properties.clear(); + return (B) this; + } + + /** + * Transform the existing children. The transform function is called + * with a stream of the existing child map entries, and should return a + * list of desired child elements. The returned list will be used to + * replace the existing children. The map entries in the stream are + * immutable, but the component elements may be reused. + * + * @param transform children transform function + * @return this + */ + public B transformChildren( + Function>, List>> transform) { + var transformed = transform.apply(children.entrySet().stream()); + clearChildren(); + transformed.forEach(c -> child(c.getKey(), c.getValue())); + return (B) this; + } + + /** + * Transform the existing comments. The transform function is called + * with a stream of the existing comment elements, and should return a + * list of desired comment elements. The returned list will be used to + * replace the existing comments. + * + * @param transform comment transform function + * @return this + */ + public B transformComments( + Function, List> transform) { + var transformed = transform.apply(comments.stream()); + clearComments(); + transformed.forEach(c -> comment(c)); + return (B) this; + } + + /** + * Transform the existing connections. The transform function is called + * with a stream of the existing connection elements, and should return + * a list of desired connection elements. The returned list will be used + * to replace the existing connections. + * + * @param transform connection transform function + * @return this + */ + public B transformConnections( + Function, List> transform) { + var transformed = transform.apply(connections.stream()); + clearConnections(); + transformed.forEach(c -> connection(c)); + return (B) this; + } + + /** + * Transform the existing properties. The transform function is called + * with a stream of the existing property map entries, and should return + * a list of desired property elements. The returned list will be used + * to replace the existing properties. The map entries in the stream are + * immutable, but the property elements may be reused. + * + * @param transform property transform function + * @return this + */ + public B transformProperties( + Function>, List>> transform) { + var transformed = transform.apply(properties.entrySet().stream()); + clearProperties(); + transformed.forEach(p -> property(p.getKey(), p.getValue())); + return (B) this; + } + + } + + /** + * Component element builder. + */ + public static final class Component extends Base { + + private Component(ComponentType type) { + super(type); + } + + private Component(GraphElement.Component component) { + super(component); + } + + /** + * Build a component element from this builder. + * + * @return created component element + */ + public GraphElement.Component build() { + return GraphElement.component(type, comments, properties, children, connections); + } + + } + + /** + * Root element builder. + */ + public static final class Root extends Base { + + private final String id; + private final List commands; + + private Root(String id, ComponentType type) { + super(type); + this.id = id; + this.commands = new ArrayList<>(); + } + + private Root(GraphElement.Root root) { + super(root); + this.id = root.id(); + this.commands = new ArrayList<>(root.commands()); + } + + /** + * Add a command element. + * + * @param command command element + * @return this + */ + public Root command(GraphElement.Command command) { + commands.add(Objects.requireNonNull(command)); + return this; + } + + /** + * Add a command element. + * + * @param command command line + * @return this + */ + public Root command(String command) { + return command(GraphElement.command(command)); + } + + /** + * Clear the existing commands. + * + * @return this + */ + public Root clearCommands() { + commands.clear(); + return this; + } + + @Override + public Root property(String id, GraphElement.Property property) { + if (isSynthetic()) { + throw new IllegalStateException("Synthetic roots cannot have properties."); + } + return super.property(id, property); + } + + /** + * Transform the existing commands. The transform function is called + * with a stream of the existing command elements, and should return a + * list of desired command elements. The returned list will be used to + * replace the existing commands. + * + * @param transform command transform function + * @return this + */ + public Root transformCommands( + Function, List> transform) { + var transformed = transform.apply(commands.stream()); + clearCommands(); + transformed.forEach(c -> command(c)); + return this; + } + + /** + * Query whether the root is synthetic. + * + * @return true if synthetic + */ + public boolean isSynthetic() { + return id.isEmpty(); + } + + /** + * Build a root element from this builder. + * + * @return created root element + */ + public GraphElement.Root build() { + return GraphElement.root(id, type, comments, commands, + properties, children, connections); + } + + } + +} diff --git a/praxiscore-project/src/main/java/org/praxislive/project/GraphElement.java b/praxiscore-project/src/main/java/org/praxislive/project/GraphElement.java new file mode 100644 index 00000000..76026e86 --- /dev/null +++ b/praxiscore-project/src/main/java/org/praxislive/project/GraphElement.java @@ -0,0 +1,489 @@ +/* + * 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.project; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.SequencedMap; +import java.util.SequencedSet; +import java.util.Set; +import org.praxislive.core.ComponentAddress; +import org.praxislive.core.ComponentType; +import org.praxislive.core.OrderedMap; +import org.praxislive.core.OrderedSet; +import org.praxislive.core.PortAddress; +import org.praxislive.core.Value; +import org.praxislive.core.syntax.Token; +import org.praxislive.core.syntax.Tokenizer; + +/** + * Elements of a graph tree. + */ +public sealed abstract class GraphElement { + + private GraphElement() { + } + + /** + * Create a Command element from the given script line. The command must be + * a single line of script with a plain first token. + * + * @param command script line + * @return command element + * @throws IllegalArgumentException if the command fails to parse according + * to the rules + */ + public static Command command(String command) { + Iterator itr = new Tokenizer(command).iterator(); + List tokens = new ArrayList<>(); + while (itr.hasNext()) { + Token token = itr.next(); + if (tokens.isEmpty()) { + if (token.getType() != Token.Type.PLAIN) { + throw new IllegalArgumentException("First token of a command must be plain"); + } + } + if (token.getType() == Token.Type.COMMENT) { + throw new IllegalArgumentException("Invalid command - contains a comment"); + } else if (token.getType() == Token.Type.EOL) { + break; + } + tokens.add(token); + } + if (itr.hasNext()) { + throw new IllegalArgumentException("Invalid command - tokens found after EOL"); + } + return new Command(command, tokens); + } + + /** + * Create a Comment element from the given text. + * + * @param text comment text + * @return comment element + */ + public static Comment comment(String text) { + return new Comment(text); + } + + /** + * Create a Connection element between the given source component ID and + * port ID, and target component ID and port ID. + * + * @param sourceComponent source component ID + * @param sourcePort source port ID + * @param targetComponent target component ID + * @param targetPort target port ID + * @return connection element + */ + public static Connection connection(String sourceComponent, String sourcePort, + String targetComponent, String targetPort) { + return new GraphElement.Connection(sourceComponent, sourcePort, targetComponent, targetPort); + } + + /** + * Create a property element of the given value. + * + * @param value property value + * @return property element + */ + public static Property property(Value value) { + return new Property(value); + } + + static Component component(ComponentType type, + List comments, + Map properties, + Map children, + Set connections) { + return new Component(type, comments, properties, children, connections); + } + + static Root root(String id, ComponentType type, + List comments, + List commands, + Map properties, + Map children, + Set connections) { + return new Root(id, type, comments, commands, properties, children, connections); + } + + /** + * A component element. A component has a type, and may have associated + * comments, properties, children and/or connections. + *

+ * To create a Component element, see {@link GraphBuilder}. + */ + public static sealed class Component extends GraphElement { + + private final ComponentType type; + private final List comments; + private final OrderedMap properties; + private final OrderedMap children; + private final OrderedSet connections; + + private Component(ComponentType type, + List comments, + Map properties, + Map children, + Set connections) { + this.type = Objects.requireNonNull(type); + this.comments = List.copyOf(comments); + this.properties = OrderedMap.copyOf(properties); + this.children = OrderedMap.copyOf(children); + this.connections = OrderedSet.copyOf(connections); + } + + /** + * Component type. + * + * @return type + */ + public ComponentType type() { + return type; + } + + /** + * Immutable list of comment elements. + * + * @return comments + */ + public List comments() { + return comments; + } + + /** + * Immutable ordered map of property elements by ID. + * + * @return properties + */ + public SequencedMap properties() { + return properties; + } + + /** + * Immutable ordered map of child component elements by ID. + * + * @return children + */ + public SequencedMap children() { + return children; + } + + /** + * Immutable ordered set of connection elements. + * + * @return connections + */ + public SequencedSet connections() { + return connections; + } + + @Override + public int hashCode() { + return Objects.hash(type, comments, properties, children, connections); + } + + @Override + public boolean equals(Object obj) { + return obj == this + || obj instanceof Component c + && c.getClass() == Component.class + && Objects.equals(this.type, c.type) + && Objects.equals(this.comments, c.comments) + && Objects.equals(this.properties, c.properties) + && Objects.equals(this.children, c.children) + && Objects.equals(this.connections, c.connections); + } + + @Override + public String toString() { + return "Component{" + "type=" + type + ",\ncomments=" + comments + + ",\nproperties=" + properties + ",\nchildren=" + children + + ",\nconnections=" + connections + "}"; + } + + } + + /** + * A root component element. A normal root component has a type and ID, and + * may have associated comments, commands, properties, children and/or + * connections. A synthetic root is used for subgraphs, and has an empty ID + * and no properties. + *

+ * To create a Root element, see {@link GraphBuilder}. + */ + public static final class Root extends Component { + + static final ComponentType SYNTHETIC = ComponentType.of("root:synthetic"); + + private final String id; + private final List commands; + + private Root(String id, ComponentType type, + List comments, + List commands, + Map properties, + Map children, + Set connections) { + super(type, comments, properties, children, connections); + if (id.isEmpty()) { + if (!SYNTHETIC.equals(type)) { + throw new IllegalArgumentException("Invalid type for synthetic root"); + } + this.id = ""; + } else { + if (!ComponentAddress.isValidID(id)) { + throw new IllegalArgumentException("Invalid root ID"); + } + this.id = id; + } + this.commands = List.copyOf(commands); + } + + /** + * Root ID. + * + * @return ID + */ + public String id() { + return id; + } + + /** + * Immutable list of commands. + * + * @return commands + */ + public List commands() { + return commands; + } + + /** + * Query whether the root element is synthetic. + * + * @return true if synthetic + */ + public boolean isSynthetic() { + return id.isEmpty(); + } + + @Override + public int hashCode() { + return Objects.hash(id, commands, type(), comments(), properties(), children(), connections()); + } + + @Override + public boolean equals(Object obj) { + return obj == this + || obj instanceof Root r + && r.getClass() == Root.class + && Objects.equals(id, r.id) + && Objects.equals(commands, r.commands) + && Objects.equals(this.type(), r.type()) + && Objects.equals(this.comments(), r.comments()) + && Objects.equals(this.properties(), r.properties()) + && Objects.equals(this.children(), r.children()) + && Objects.equals(this.connections(), r.connections()); + } + + @Override + public String toString() { + return "Root{" + "id=" + id + ",\ncommands=" + commands + + "type=" + type() + ",\ncomments=" + comments() + + ",\nproperties=" + properties() + ",\nchildren=" + children() + + ",\nconnections=" + connections() + "}"; + } + + } + + public static final class Command extends GraphElement { + + private final String command; + private final List tokens; + + Command(String command, List tokens) { + this.command = Objects.requireNonNull(command); + this.tokens = List.copyOf(tokens); + } + + public String command() { + return command; + } + + public List tokens() { + return tokens; + } + + @Override + public int hashCode() { + return Objects.hashCode(command); + } + + @Override + public boolean equals(Object obj) { + return obj == this + || obj instanceof Command c + && Objects.equals(this.command, c.command); + } + + @Override + public String toString() { + return "Command{" + "command=" + command + "}"; + } + + } + + public static final class Comment extends GraphElement { + + private final String text; + + private Comment(String text) { + this.text = Objects.requireNonNull(text); + } + + public String text() { + return text; + } + + @Override + public int hashCode() { + return Objects.hashCode(text); + } + + @Override + public boolean equals(Object obj) { + return obj == this + || obj instanceof Comment c + && Objects.equals(this.text, c.text); + } + + @Override + public String toString() { + return "Comment{" + "text=" + text + "}"; + } + + } + + public static final class Property extends GraphElement { + + private final Value value; + + private Property(Value value) { + this.value = Objects.requireNonNull(value); + } + + public Value value() { + return value; + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } + + @Override + public boolean equals(Object obj) { + return obj == this + || obj instanceof Property p + && Objects.equals(this.value, p.value); + } + + @Override + public String toString() { + return "Property{" + "value=" + value + "}"; + } + + } + + public static final class Connection extends GraphElement { + + private final String sourceComponent; + private final String sourcePort; + private final String targetComponent; + private final String targetPort; + + private Connection(String sourceComponent, String sourcePort, String targetComponent, String targetPort) { + if (!ComponentAddress.isValidID(Objects.requireNonNull(sourceComponent))) { + throw new IllegalArgumentException(sourceComponent + " is not a valid component ID"); + } + if (!ComponentAddress.isValidID(Objects.requireNonNull(targetComponent))) { + throw new IllegalArgumentException(targetComponent + " is not a valid component ID"); + } + if (!PortAddress.isValidID(Objects.requireNonNull(sourcePort))) { + throw new IllegalArgumentException(sourcePort + " is not a valid port ID"); + } + if (!PortAddress.isValidID(Objects.requireNonNull(targetPort))) { + throw new IllegalArgumentException(targetPort + " is not a valid port ID"); + } + + this.sourceComponent = sourceComponent; + this.sourcePort = sourcePort; + this.targetComponent = targetComponent; + this.targetPort = targetPort; + } + + public String sourceComponent() { + return sourceComponent; + } + + public String sourcePort() { + return sourcePort; + } + + public String targetComponent() { + return targetComponent; + } + + public String targetPort() { + return targetPort; + } + + @Override + public int hashCode() { + return Objects.hash(sourceComponent, sourcePort, targetComponent, targetPort); + } + + @Override + public boolean equals(Object obj) { + return obj == this + || (obj instanceof Connection c + && Objects.equals(this.sourceComponent, c.sourceComponent) + && Objects.equals(this.sourcePort, c.sourcePort) + && Objects.equals(this.targetComponent, c.targetComponent) + && Objects.equals(this.targetPort, c.targetPort)); + } + + @Override + public String toString() { + return "Connection{" + "sourceComponent=" + sourceComponent + + ", sourcePort=" + sourcePort + + ", targetComponent=" + targetComponent + + ", targetPort=" + targetPort + "}"; + } + + } + +} diff --git a/praxiscore-project/src/main/java/org/praxislive/project/GraphModel.java b/praxiscore-project/src/main/java/org/praxislive/project/GraphModel.java new file mode 100644 index 00000000..492eed4e --- /dev/null +++ b/praxiscore-project/src/main/java/org/praxislive/project/GraphModel.java @@ -0,0 +1,333 @@ +/* + * 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.project; + +import java.io.IOException; +import java.net.URI; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Predicate; +import org.praxislive.core.ComponentInfo; +import org.praxislive.core.ComponentType; +import org.praxislive.core.ControlAddress; +import org.praxislive.core.ControlInfo; +import org.praxislive.core.Value; +import org.praxislive.core.protocols.SerializableProtocol; +import org.praxislive.core.types.PArray; +import org.praxislive.core.types.PMap; + +/** + * Model for graph and subgraph scripts, encompassing the element tree and + * related information. + *

+ * A graph model can be parsed from a script (eg. contents of .pxr / .pxg file), + * or created from the serialization data returned from + * {@link SerializableProtocol}. A graph model can also be written back out to a + * script for execution or saving to a file. + *

+ * A graph model, and the underlying tree, are immutable. Transformative methods + * return a new model instance. + */ +public final class GraphModel { + + private final GraphElement.Root root; + private final URI context; + + private GraphModel(GraphElement.Root root, URI context) { + this.root = root; + this.context = context; + } + + /** + * Access the root of the element tree. For a subgraph, the root will be + * synthetic. + * + * @return root element + */ + public GraphElement.Root root() { + return root; + } + + /** + * Access the optional context (eg. working dir) for resolving relative file + * values. + * + * @return optional context + */ + public Optional context() { + return Optional.ofNullable(context); + } + + /** + * Create a new graph model with a different context. The context is used to + * relativize resources when writing. Use {@code null} to create a model + * without context. + * + * @param context new resource context + * @return new graph model + */ + public GraphModel withContext(URI context) { + return new GraphModel(root, context); + } + + /** + * Write the model as a script to the given target. + * + * @param target write destination + * @throws IOException + */ + public void write(Appendable target) throws IOException { + GraphWriter.write(this, target); + } + + /** + * Write the graph model to a String. This is shorthand for passing in a + * {@link StringBuilder} to {@link #write(java.lang.Appendable)}. + *

+ * The output of this method is suitable for parsing back into a model, as + * distinct from the output of {@link #toString()}. + * + * @return model as script + */ + public String writeToString() { + StringBuilder sb = new StringBuilder(); + try { + write(sb); + } catch (IOException ex) { + throw new IllegalStateException(ex); + } + return sb.toString(); + } + + /** + * Create a graph model from the serialization data of a single component. + * The data should be in the format specified by + * {@link SerializableProtocol}. The graph model will consist of a synthetic + * root with a single child component element with the given ID and data. + * + * @param componentID id of the component + * @param data serialization data + * @return created graph model + */ + public static GraphModel fromSerializedComponent(String componentID, PMap data) { + GraphElement.Root root = GraphBuilder.syntheticRoot() + .child(componentID, typeFromData(data), + cmp -> buildSerializedComponent(cmp, data, true, null)) + .build(); + return new GraphModel(root, null); + } + + /** + * Create a graph model from the serialization data of a complete root. The + * data should be in the format specified by {@link SerializableProtocol}. + * + * @param rootID if of the root + * @param data serialization data + * @return created graph model + */ + public static GraphModel fromSerializedRoot(String rootID, PMap data) { + GraphBuilder.Root rootBuilder = GraphBuilder.root(rootID, typeFromData(data)); + buildSerializedComponent(rootBuilder, data, true, null); + return new GraphModel(rootBuilder.build(), null); + } + + /** + * Create a graph model from the serialization data of a container. The data + * should be in the format specified by {@link SerializableProtocol}. The + * graph model will consist of a synthetic root with all the children and + * connections of the container. Properties of the container itself will be + * ignored. + * + * @param data container serialization data + * @return created graph model + */ + public static GraphModel fromSerializedSubgraph(PMap data) { + return fromSerializedSubgraph(data, null); + } + + /** + * Create a graph model from the serialization data of a container. The data + * should be in the format specified by {@link SerializableProtocol}. The + * graph model will consist of a synthetic root with all the children of the + * container that pass the given child ID filter. Connections will be + * filtered to those between included components. Properties of the + * container itself will be ignored. + * + * @param data container serialization data + * @param filter child ID filter + * @return created graph model + */ + public static GraphModel fromSerializedSubgraph(PMap data, Predicate filter) { + GraphBuilder.Root rootBuilder = GraphBuilder.syntheticRoot(); + buildSerializedComponent(rootBuilder, data, false, filter); + return new GraphModel(rootBuilder.build(), null); + } + + /** + * Create a graph model of the provided root element. + * + * @param root root element + * @return created graph model + */ + public static GraphModel of(GraphElement.Root root) { + return new GraphModel(Objects.requireNonNull(root), null); + } + + /** + * Create a graph model of the provided root element and context. + * + * @param root root element + * @param context resource context + * @return created graph model + */ + public static GraphModel of(GraphElement.Root root, URI context) { + return new GraphModel(Objects.requireNonNull(root), + Objects.requireNonNull(context)); + } + + /** + * Parse the given graph script into a graph model. The script must be a + * valid full root graph. + * + * @param graph graph script + * @return created graph model + * @throws ParseException if the graph is invalid + */ + public static GraphModel parse(String graph) throws ParseException { + GraphElement.Root root = GraphParser.parse(graph); + return new GraphModel(root, null); + } + + /** + * Parse the given graph script into a graph model. Relative resources will + * be resolved against the provided context. The script must be a full root + * graph. + * + * @param context resource context + * @param graph graph script + * @return created graph model + * @throws ParseException if the graph is invalid + */ + public static GraphModel parse(URI context, String graph) throws ParseException { + GraphElement.Root root = GraphParser.parse(context, graph); + return new GraphModel(root, context); + } + + /** + * Parse the given subgraph script into a graph model. The script must be a + * valid subgraph script. + * + * @param graph subgraph script + * @return created graph model + * @throws ParseException if the subgraph is invalid + */ + public static GraphModel parseSubgraph(String graph) throws ParseException { + GraphElement.Root root = GraphParser.parseSubgraph(graph); + return new GraphModel(root, null); + } + + /** + * Parse the given subgraph script into a graph model. Relative resources + * will be resolved against the provided context. The script must be a valid + * subgraph script. + * + * @param context resource context + * @param graph subgraph script + * @return created graph model + * @throws ParseException if the subgraph is invalid + */ + public static GraphModel parseSubgraph(URI context, String graph) throws ParseException { + GraphElement.Root root = GraphParser.parseSubgraph(context, graph); + return new GraphModel(root, context); + } + + private static void buildSerializedComponent(GraphBuilder.Base component, + PMap data, + boolean includeProperties, + Predicate filter) { + ComponentInfo info = Optional.ofNullable(data.get("%info")) + .flatMap(ComponentInfo::from) + .orElse(null); + data.asMap().forEach((key, value) -> { + if (key.startsWith("@")) { + String id = key.substring(1); + if (filter == null || filter.test(id)) { + PMap childData = PMap.from(value).orElseThrow(IllegalArgumentException::new); + component.child(id, typeFromData(childData), + cmp -> buildSerializedComponent(cmp, childData, true, null)); + } + } else if (key.startsWith("%")) { + if ("%connections".equals(key)) { + buildConnectionsList(value, filter).forEach(c -> component.connection(c)); + } + } else if (includeProperties && ControlAddress.isValidID(key)) { + component.property(key, coerceTypeFromInfo(key, info, value)); + } + }); + + } + + private static ComponentType typeFromData(PMap data) { + return Optional.ofNullable(data.get("%type")) + .flatMap(ComponentType::from) + .orElseThrow(() -> new IllegalArgumentException("No type in data map")); + } + + private static Value coerceTypeFromInfo(String id, ComponentInfo info, Value value) { + Value.Type type = Optional.ofNullable(info.controlInfo(id)) + .map(ControlInfo::inputs) + .filter(ins -> !ins.isEmpty()) + .map(ins -> ins.get(0)) + .flatMap(in -> Value.Type.fromName(in.argumentType())) + .orElse(null); + if (type != null) { + Value coerced = type.converter().apply(value).orElse(null); + if (coerced != null) { + return coerced; + } + } + return value; + } + + private static List buildConnectionsList(Value connections, + Predicate filter) { + return PArray.from(connections).orElseThrow(() -> new IllegalArgumentException("Connections is not an array : " + connections)) + .stream() + .map(connection -> { + PArray arr = PArray.from(connection) + .filter(a -> a.size() == 4) + .orElseThrow(() -> new IllegalArgumentException("Not a valid connection : " + connection)); + String sourceComponent = arr.get(0).toString(); + String sourcePort = arr.get(1).toString(); + String targetComponent = arr.get(2).toString(); + String targetPort = arr.get(3).toString(); + return GraphElement.connection( + sourceComponent, sourcePort, targetComponent, targetPort); + }) + .filter(c -> filter == null ? true + : filter.test(c.sourceComponent()) && filter.test(c.targetComponent())) + .toList(); + + } + +} diff --git a/praxiscore-project/src/main/java/org/praxislive/project/GraphParser.java b/praxiscore-project/src/main/java/org/praxislive/project/GraphParser.java new file mode 100644 index 00000000..ff6f7eb5 --- /dev/null +++ b/praxiscore-project/src/main/java/org/praxislive/project/GraphParser.java @@ -0,0 +1,299 @@ +/* + * 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.project; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import org.praxislive.core.ComponentAddress; +import org.praxislive.core.ComponentType; +import org.praxislive.core.PortAddress; +import org.praxislive.core.Value; +import org.praxislive.core.syntax.Token; +import org.praxislive.core.syntax.Tokenizer; + +import static org.praxislive.core.syntax.Token.Type.BRACED; +import static org.praxislive.core.syntax.Token.Type.COMMENT; +import static org.praxislive.core.syntax.Token.Type.EOL; +import static org.praxislive.core.syntax.Token.Type.PLAIN; + +/** + * + */ +class GraphParser { + + private final static String AT = "@"; + private final static String CONNECT = "~"; + private final static String PROPERTY_PREFIX = "."; + private final static String RELATIVE_ADDRESS_PREFIX = "./"; + + private final String script; + private final boolean subgraph; + private final URI context; + + private GraphParser(String script, boolean subgraph, URI context) { + this.script = script; + this.subgraph = subgraph; + this.context = context; + } + + private GraphElement.Root doParse() throws ParseException { + if (subgraph) { + return parseSubGraph(); + } else { + return parseFullGraph(); + } + } + + private GraphElement.Root parseFullGraph() throws ParseException { + try { + GraphBuilder.Root root = null; + Iterator tokens = new Tokenizer(script).iterator(); + List commands = new ArrayList<>(); + while (tokens.hasNext()) { + Token token = tokens.next(); + Token.Type type = token.getType(); + if (type == COMMENT || type == EOL) { + continue; + } + if (type == PLAIN) { + if (AT.equals(token.getText())) { + root = parseRoot(tokensToEOL(tokens)); + break; + } else { + List toEOL = tokensToEOL(tokens); + commands.add(GraphElement.command(script.substring(token.getStartIndex(), + toEOL.isEmpty() ? token.getEndIndex() : toEOL.getLast().getEndIndex()))); + } + } + } + if (root == null) { + throw new ParseException("No root element found"); + } + while (tokens.hasNext()) { + Token.Type type = tokens.next().getType(); + if (type != COMMENT && type != EOL) { + throw new ParseException("Unexpected content found after root element"); + } + } + commands.forEach(root::command); + return root.build(); + + } catch (ParseException pex) { + throw pex; + } catch (Exception ex) { + throw new ParseException(ex); + } + } + + private GraphElement.Root parseSubGraph() throws ParseException { + + try { + GraphBuilder.Root root = GraphBuilder.syntheticRoot(); + parseComponentBody(root, script); + return root.build(); + } catch (Exception ex) { + throw new ParseException(ex); + } + } + + private static List tokensToEOL(Iterator tokens) { + List tks = new ArrayList<>(); + while (tokens.hasNext()) { + Token t = tokens.next(); + if (t.getType() == EOL) { + break; + } + tks.add(t); + } + return tks; + } + + private GraphBuilder.Root parseRoot(List tokens) { + if (tokens.size() < 2 || tokens.size() > 3) { + throw new IllegalArgumentException("Unexpected number of tokens in parseComponent"); + } + String id; + ComponentType type; + Token t = tokens.get(0); + if (t.getType() == PLAIN) { + ComponentAddress address = ComponentAddress.of(t.getText()); + if (address.depth() == 1) { + id = address.componentID(); + } else { + throw new IllegalArgumentException("Invalid root address " + address); + } + } else { + throw new IllegalArgumentException("No root address found."); + } + t = tokens.get(1); + if (t.getType() == PLAIN) { + type = ComponentType.of(t.getText()); + } else { + throw new IllegalArgumentException("No root type found."); + } + + GraphBuilder.Root root = GraphBuilder.root(id, type); + + if (tokens.size() == 3) { + t = tokens.get(2); + if (t.getType() != BRACED) { + throw new IllegalArgumentException("Invalid token at end of component line : " + tokens); + } + parseComponentBody(root, t.getText()); + } + return root; + } + + private void parseComponent(GraphBuilder.Base parent, List tokens) { + if (tokens.size() < 2 || tokens.size() > 3) { + throw new IllegalArgumentException("Unexpected number of tokens in parseComponent"); + } + // next token should be relative component address + String id = null; + ComponentType type = null; + Token t = tokens.get(0); + if (t.getType() == PLAIN && t.getText().startsWith(RELATIVE_ADDRESS_PREFIX)) { + id = t.getText().substring(RELATIVE_ADDRESS_PREFIX.length()); + } + t = tokens.get(1); + if (t.getType() == PLAIN) { + type = ComponentType.of(t.getText()); + } + if (id == null || type == null) { + throw new IllegalArgumentException("Invalid component creation line : " + tokens); + } + GraphBuilder.Component child = GraphBuilder.component(type); + if (tokens.size() == 3) { + t = tokens.get(2); + if (t.getType() != BRACED) { + throw new IllegalArgumentException("Invalid token at end of component line : " + tokens); + } + parseComponentBody(child, t.getText()); + } + parent.child(id, child.build()); + } + + private void parseComponentBody(GraphBuilder.Base component, String body) { + if (body == null || body.trim().isEmpty()) { + return; + } + + boolean allowCommands = component instanceof GraphBuilder.Root r && r.isSynthetic(); + Iterator tokens = new Tokenizer(body).iterator(); + while (tokens.hasNext()) { + Token token = tokens.next(); + String txt = token.getText(); + switch (token.getType()) { + case COMMENT -> + component.comment(txt); + case PLAIN -> { + if (txt.startsWith(PROPERTY_PREFIX) && txt.length() > 1) { + parseProperty(component, txt.substring(1), tokensToEOL(tokens)); + allowCommands = false; + } else if (AT.equals(txt)) { + parseComponent(component, tokensToEOL(tokens)); + allowCommands = false; + } else if (CONNECT.equals(txt)) { + parseConnection(component, tokensToEOL(tokens)); + allowCommands = false; + } else if (allowCommands && component instanceof GraphBuilder.Root root) { + List toEOL = tokensToEOL(tokens); + root.command(GraphElement.command(body.substring(token.getStartIndex(), + toEOL.isEmpty() ? token.getEndIndex() : toEOL.getLast().getEndIndex()))); + } else { + throw new IllegalArgumentException("Unexpected PLAIN token : " + txt); + } + } + case EOL -> { + // no op + } + default -> + throw new IllegalArgumentException( + "Unexpected token of type : " + token.getType() + " , body : " + txt); + + } + } + + } + + private void parseProperty(GraphBuilder.Base component, String property, List tokens) { + if (tokens.size() != 1) { + throw new IllegalArgumentException("Empty tokens passed to parseProperty ." + property); + } + Value value; + if (context != null) { + value = SyntaxUtils.valueFromToken(context, tokens.get(0)); + } else { + value = SyntaxUtils.valueFromToken(tokens.get(0)); + } + component.property(property, value); + } + + private void parseConnection(GraphBuilder.Base parent, List tokens) { + if (tokens.size() != 2) { + throw new IllegalArgumentException("Unexpected number of tokens in parseConnection"); + } + Token source = tokens.get(0); + Token target = tokens.get(1); + String sourceComponent = null; + String sourcePort = null; + String targetComponent = null; + String targetPort = null; + try { + if (source.getType() == PLAIN && source.getText().startsWith(RELATIVE_ADDRESS_PREFIX)) { + PortAddress address = PortAddress.of(source.getText().substring(1)); + sourceComponent = address.component().componentID(); + sourcePort = address.portID(); + } + if (target.getType() == PLAIN && target.getText().startsWith(RELATIVE_ADDRESS_PREFIX)) { + PortAddress address = PortAddress.of(target.getText().substring(1)); + targetComponent = address.component().componentID(); + targetPort = address.portID(); + } + parent.connection(sourceComponent, sourcePort, targetComponent, targetPort); + } catch (Exception ex) { + throw new IllegalArgumentException("Invalid connection : " + tokens, ex); + } + } + + static GraphElement.Root parse(String script) throws ParseException { + return new GraphParser(Objects.requireNonNull(script), false, null).doParse(); + } + + static GraphElement.Root parseSubgraph(String script) throws ParseException { + return new GraphParser(Objects.requireNonNull(script), true, null).doParse(); + } + + static GraphElement.Root parse(URI context, String script) throws ParseException { + return new GraphParser(Objects.requireNonNull(script), false, + Objects.requireNonNull(context)).doParse(); + } + + static GraphElement.Root parseSubgraph(URI context, String script) throws ParseException { + return new GraphParser(Objects.requireNonNull(script), true, + Objects.requireNonNull(context)).doParse(); + } + +} diff --git a/praxiscore-project/src/main/java/org/praxislive/project/GraphWriter.java b/praxiscore-project/src/main/java/org/praxislive/project/GraphWriter.java new file mode 100644 index 00000000..b5d5fcee --- /dev/null +++ b/praxiscore-project/src/main/java/org/praxislive/project/GraphWriter.java @@ -0,0 +1,122 @@ +/* + * 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.project; + +import java.io.IOException; +import java.net.URI; +import org.praxislive.core.Value; + +/** + * + */ +class GraphWriter { + + private static final String INDENT = " "; + private static final String AT = "@"; + private static final String CONNECT = "~"; + + private final GraphModel model; + private final URI context; + + private GraphWriter(GraphModel model) { + this.model = model; + this.context = model.context().orElse(null); + } + + private void doWrite(Appendable target) throws IOException { + var root = model.root(); + if (root.isSynthetic()) { + // sub graph + writeChildren(target, root, 0); + writeConnections(target, root, 0); + } else { + // full graph + writeComponent(target, root.id(), root, 0); + } + } + + private void writeComponent(Appendable sb, + String id, + GraphElement.Component cmp, + int level) throws IOException { + writeIndent(sb, level); + sb.append(AT).append(' '); + if (cmp instanceof GraphElement.Root) { + sb.append('/').append(id); + } else { + sb.append("./").append(id); + } + sb.append(' ').append(cmp.type().toString()).append(" {\n"); + writeProperties(sb, cmp, level + 1); + writeChildren(sb, cmp, level + 1); + writeConnections(sb, cmp, level + 1); + writeIndent(sb, level); + sb.append("}\n"); + } + + private void writeProperties(Appendable sb, GraphElement.Component cmp, int level) + throws IOException { + for (var entry : cmp.properties().entrySet()) { + String id = entry.getKey(); + Value value = entry.getValue().value(); + writeIndent(sb, level); + sb.append('.').append(id).append(' '); + if (context != null) { + SyntaxUtils.writeValue(context, value, sb); + } else { + SyntaxUtils.writeValue(value, sb); + } + sb.append('\n'); + } + } + + private void writeChildren(Appendable sb, GraphElement.Component cmp, int level) + throws IOException { + for (var entry : cmp.children().entrySet()) { + String id = entry.getKey(); + GraphElement.Component child = entry.getValue(); + writeComponent(sb, id, child, level); + } + } + + private void writeConnections(Appendable sb, GraphElement.Component cmp, int level) + throws IOException { + for (GraphElement.Connection c : cmp.connections()) { + writeIndent(sb, level); + sb.append(CONNECT).append(' '); + sb.append("./").append(c.sourceComponent()).append('!').append(c.sourcePort()).append(' '); + sb.append("./").append(c.targetComponent()).append('!').append(c.targetPort()).append('\n'); + } + } + + private void writeIndent(Appendable sb, int level) throws IOException { + for (int i = 0; i < level; i++) { + sb.append(INDENT); + } + } + + static void write(GraphModel model, Appendable target) throws IOException { + GraphWriter writer = new GraphWriter(model); + writer.doWrite(target); + } + +} diff --git a/praxiscore-project/src/main/java/org/praxislive/project/ParseException.java b/praxiscore-project/src/main/java/org/praxislive/project/ParseException.java new file mode 100644 index 00000000..ec30367e --- /dev/null +++ b/praxiscore-project/src/main/java/org/praxislive/project/ParseException.java @@ -0,0 +1,70 @@ +/* + * 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.project; + +/** + * Exception thrown when a script cannot be parsed into a graph or project + * model. + */ +public class ParseException extends Exception { + + private static final long serialVersionUID = 1L; + + /** + * Creates a new instance of ParseException without detail + * message. + */ + public ParseException() { + } + + /** + * Constructs an instance of ParseException with the specified + * detail message. + * + * @param message the detail message + */ + public ParseException(String message) { + super(message); + } + + /** + * Constructs an instance of ParseException with the specified + * detail message and cause. + * + * @param message the detail message + * @param cause cause + */ + public ParseException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructs an instance of ParseException with the specified + * cause. + * + * @param cause cause + */ + public ParseException(Throwable cause) { + super(cause); + } + +} diff --git a/praxiscore-project/src/main/java/org/praxislive/project/SyntaxUtils.java b/praxiscore-project/src/main/java/org/praxislive/project/SyntaxUtils.java new file mode 100644 index 00000000..af4c4ddf --- /dev/null +++ b/praxiscore-project/src/main/java/org/praxislive/project/SyntaxUtils.java @@ -0,0 +1,519 @@ +/* + * 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.project; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import org.praxislive.core.ComponentAddress; +import org.praxislive.core.ControlAddress; +import org.praxislive.core.PortAddress; +import org.praxislive.core.Value; +import org.praxislive.core.ValueFormatException; +import org.praxislive.core.syntax.Token; +import org.praxislive.core.syntax.Tokenizer; +import org.praxislive.core.types.PArray; +import org.praxislive.core.types.PBoolean; +import org.praxislive.core.types.PMap; +import org.praxislive.core.types.PNumber; +import org.praxislive.core.types.PResource; +import org.praxislive.core.types.PString; + +import static org.praxislive.core.syntax.Token.Type.COMMENT; +import static org.praxislive.core.syntax.Token.Type.EOL; +import static org.praxislive.core.syntax.Token.Type.PLAIN; +import static org.praxislive.core.syntax.Token.Type.SUBCOMMAND; + +/** + * Various utility functions for parsing and writing values. + */ +public class SyntaxUtils { + + private static final String DIGITS = "0123456789"; + private static final String REQUIRE_QUOTING = "{}[];'\"\\"; + private static final String REQUIRE_QUOTING_START = ".#$" + REQUIRE_QUOTING; + private static final int MAX_LENGTH_PLAIN = 128; + + private SyntaxUtils() { + } + + /** + * Escape the provided input for writing to a script, such that it can be + * parsed as a single value. The value might be wrapped in quotation marks + * if necessary. + * + * @param input text input + * @return escaped text + */ + public static String escape(String input) { + String res = doPlain(input); + if (res == null) { + res = doQuoted(input); + } + return res; + } + + /** + * Escape the provided input for writing to a script, such that it can be + * parsed as a single value. The value will be wrapped in quotation marks. + * + * @param input text input + * @return escaped text + */ + public static String escapeQuoted(String input) { + return doQuoted(input); + } + + /** + * Validate whether the provided input can be safely written as-is between + * braces without needing to be quote escaped. The check iterates through + * the input checking that any braces in the input text are correctly + * matched. + * + * @param input text input + * @return true if safe to write input in braces without escaping + */ + public static boolean isSafeBraced(String input) { + int len = input.length(); + if (len == 0) { + return true; + } + int level = 0; + int idx = 0; + for (; idx < len && level > -1; idx++) { + char ch = input.charAt(idx); + switch (ch) { + case '}' -> { + if (idx > 0 && input.charAt(idx - 1) == '\\') { + // escaped + } else { + level--; + } + } + case '{' -> { + if (idx > 0 && input.charAt(idx - 1) == '\\') { + // escaped + } else { + level++; + } + } + } + } + return idx == len && level == 0; + } + + /** + * Extract a value from the provided token. Will attempt to parse plain + * tokens to the correct value type. Array and map subcommands will be + * parsed to the correct value types, including when nested. + * + * @param token script token + * @return value + */ + public static Value valueFromToken(Token token) { + return valueFromTokenImpl(null, token); + } + + /** + * Extract a value from the provided token. Will attempt to parse plain + * tokens to the correct value type. Array and map subcommands will be + * parsed to the correct value types, including when nested. File + * subcommands will be resolved based on the provided context. + * + * @param context resource context + * @param token script token + * @return value + */ + public static Value valueFromToken(URI context, Token token) { + return valueFromTokenImpl(Objects.requireNonNull(context), token); + } + + /** + * Return the provided value as suitable token text to be included in a + * script. Equivalent to passing a StringBuilder to + * {@link #writeValue(org.praxislive.core.Value, java.lang.Appendable)} and + * returning the result. + * + * @param value value to write + * @return value as script + */ + public static String valueToToken(Value value) { + StringBuilder sb = new StringBuilder(); + try { + writeValue(value, sb); + } catch (IOException ex) { + throw new IllegalStateException(ex); + } + return sb.toString(); + } + + /** + * Return the provided value as suitable token text to be included in a + * script. Equivalent to passing a StringBuilder to + * {@link #writeValue(java.net.URI, org.praxislive.core.Value, java.lang.Appendable)} + * and returning the result. Resources will be relativized, if possible, to + * the provided context. + * + * @param context resource context + * @param value value to write + * @return value as script + */ + public static String valueToToken(URI context, Value value) { + StringBuilder sb = new StringBuilder(); + try { + writeValue(context, value, sb); + } catch (IOException ex) { + throw new IllegalStateException(ex); + } + return sb.toString(); + } + + /** + * Write the provided value as suitable token text to the provided output. + * Values will be quoted as necessary, and map and array values will be + * converted to the equivalent subcommands. + * + * @param value value to write + * @param out target to write the value to + * @throws java.io.IOException if write fails + */ + public static void writeValue(Value value, Appendable out) throws IOException { + writeValueImpl(null, + Objects.requireNonNull(value), + Objects.requireNonNull(out)); + } + + /** + * Write the provided value as suitable token text to the provided output. + * Values will be quoted as necessary, and map and array values will be + * converted to the equivalent subcommands. Resources will be relativized, + * if possible, to the provided context using the file subcommand. + * + * @param context resource context + * @param value value to write + * @param out target to write the value to + * @throws java.io.IOException if write fails + */ + public static void writeValue(URI context, Value value, Appendable out) throws IOException { + writeValueImpl(Objects.requireNonNull(context), + Objects.requireNonNull(value), + Objects.requireNonNull(out)); + } + + private static String doPlain(String input) { + int len = input.length(); + if (len == 0 || len > MAX_LENGTH_PLAIN) { + return null; + } + char c = input.charAt(0); + if (Character.isWhitespace(c) || REQUIRE_QUOTING_START.indexOf(c) > -1) { + return null; + } + for (int i = 1; i < len; i++) { + c = input.charAt(i); + if (Character.isWhitespace(c) || REQUIRE_QUOTING.indexOf(c) > -1) { + return null; + } + } + return input; + } + + private static String doQuoted(String input) { + int len = input.length(); + if (len == 0) { + return "\"\""; + } + StringBuilder sb = new StringBuilder(len * 2); + sb.append("\""); + for (int i = 0; i < len; i++) { + char c = input.charAt(i); + switch (c) { + case '{', '}', '[', ']' -> + sb.append('\\').append(c); + case '\"' -> + sb.append("\\\""); + case '\\' -> + sb.append("\\\\"); + default -> + sb.append(c); + } + } + sb.append("\""); + return sb.toString(); + } + + private static Value valueFromTokenImpl(URI context, Token token) { + Objects.requireNonNull(token); + return switch (token.getType()) { + case PLAIN -> + valueFromPlainToken(token.getText()); + case QUOTED, BRACED -> + PString.of(token.getText()); + case SUBCOMMAND -> + valueFromSubcommand(context, token.getText()); + default -> + throw new IllegalArgumentException("Invalid token type : " + token); + }; + } + + private static Value valueFromPlainToken(String text) { + if (!isSafePlainToken(text)) { + throw new IllegalArgumentException("Unsupported plain token"); + } + if ("true".equals(text)) { + return PBoolean.TRUE; + } + if ("false".equals(text)) { + return PBoolean.FALSE; + } + int length = text.length(); + if (length > 0) { + char c = text.charAt(0); + if (DIGITS.indexOf(c) > -1 + || (c == '-' && length > 1 && DIGITS.indexOf(text.charAt(1)) > -1)) { + return numberOrString(text); + } + if (c == '/' && length > 1) { + return addressOrString(text); + } + } + return PString.of(text); + } + + private static Value numberOrString(String text) { + try { + return PNumber.parse(text); + } catch (Exception ex) { + return PString.of(text); + } + } + + private static Value addressOrString(String text) { + try { + if (text.lastIndexOf('.') > -1) { + return ControlAddress.parse(text); + } else if (text.lastIndexOf('!') > -1) { + return PortAddress.parse(text); + } else { + return ComponentAddress.parse(text); + } + } catch (Exception ex) { + return PString.of(text); + } + } + + private static Value valueFromSubcommand(URI context, String command) { + List tokens = subcommandTokens(command); + Token token = tokens.get(0); + if (tokens.get(0).getType() != PLAIN) { + throw new IllegalArgumentException("First token is not a plain command : " + command); + } + return switch (token.getText()) { + case "array" -> + arrayFromCommand(context, tokens); + case "map" -> + mapFromCommand(context, tokens); + case "file" -> + fileFromCommand(context, tokens); + default -> + throw new IllegalArgumentException("Unsupported subcommand : " + token.getText()); + }; + } + + private static List subcommandTokens(String command) { + Iterator tokens = new Tokenizer(command).iterator(); + List result = new ArrayList<>(); + while (tokens.hasNext()) { + Token t = tokens.next(); + if (t.getType() == COMMENT) { + continue; + } + if (t.getType() == EOL) { + break; + } + result.add(t); + } + if (tokens.hasNext()) { + throw new IllegalArgumentException("More than one command found in subcommand token : " + command); + } + if (result.isEmpty()) { + throw new IllegalArgumentException("Empty subcommand"); + } + return result; + } + + private static PArray arrayFromCommand(URI context, List tokens) { + if (tokens.size() == 1) { + return PArray.EMPTY; + } + List values = tokens.stream().skip(1) + .map(t -> valueFromTokenImpl(context, t)) + .toList(); + return PArray.of(values); + } + + private static PMap mapFromCommand(URI context, List tokens) { + if (tokens.size() == 1) { + return PMap.EMPTY; + } + int size = tokens.size(); + if (size % 2 != 1) { + // first value is `map` + throw new IllegalArgumentException("Map requires an even number of arguments"); + } + PMap.Builder builder = PMap.builder(); + for (int i = 1; i < size; i += 2) { + Token keyToken = tokens.get(i); + if (keyToken.getType() == SUBCOMMAND || keyToken.getType() == EOL + || keyToken.getType() == COMMENT) { + throw new IllegalArgumentException("Invalid key token type : " + keyToken); + } + String key = keyToken.getText(); + if (keyToken.getType() == PLAIN && !isSafePlainToken(key)) { + throw new IllegalArgumentException("Invalid plain key : " + key); + } + builder.put(key, valueFromTokenImpl(context, tokens.get(i + 1))); + } + return builder.build(); + } + + private static PResource fileFromCommand(URI context, List tokens) { + if (context == null) { + throw new IllegalArgumentException("Relative files cannot be parsed without working directory"); + } + if (tokens.size() != 2) { + throw new IllegalArgumentException("Invalid number of arguments for file subcommand"); + } + Token pathToken = tokens.get(1); + if (pathToken.getType() == SUBCOMMAND || pathToken.getType() == EOL + || pathToken.getType() == COMMENT) { + throw new IllegalArgumentException("Invalid path token type : " + pathToken); + } + String path = pathToken.getText(); + if (pathToken.getType() == PLAIN && !isSafePlainToken(path)) { + throw new IllegalArgumentException("Invalid plain path : " + path); + } + try { + if (path.contains(":")) { + try { + URI uri = new URI(path); + if (uri.isAbsolute()) { + return PResource.of(uri); + } + } catch (URISyntaxException ex) { + // fall through? + } + } + URI uri = context.resolve(new URI(null, null, path, null)); + if ("file".equals(uri.getScheme())) { + uri = new File(uri).toURI(); + } + return PResource.of(uri); + } catch (URISyntaxException ex) { + throw new IllegalArgumentException(ex); + } + } + + private static boolean isSafePlainToken(String text) { + return !(text.startsWith("$") || text.startsWith(".")); + } + + private static void writeValueImpl(URI context, Value value, Appendable out) throws IOException { + switch (value) { + case PMap map -> + writePMap(context, map, out); + case PArray array -> + writePArray(context, array, out); + case PResource resource -> + writePResource(context, resource, out); + case PString string -> + writePString(context, string, out); + default -> + out.append(escape(value.toString())); + } + } + + private static void writePMap(URI context, PMap map, Appendable out) throws IOException { + if (map.isEmpty()) { + out.append("[map]"); + return; + } + out.append("[map"); + for (var entry : map.asMap().entrySet()) { + out.append(" ") + .append(escape(entry.getKey())) + .append(" "); + writeValueImpl(context, entry.getValue(), out); + } + out.append("]"); + } + + private static void writePArray(URI context, PArray array, Appendable out) throws IOException { + if (array.isEmpty()) { + out.append("[array]"); + return; + } + out.append("[array"); + for (Value value : array) { + out.append(" "); + writeValueImpl(context, value, out); + } + out.append("]"); + } + + private static void writePResource(URI context, PResource resource, Appendable out) throws IOException { + if (context != null) { + URI res = context.relativize(resource.value()); + if (!res.isAbsolute()) { + out.append("[file ") + .append(escapeQuoted(res.getPath())) + .append("]"); + return; + } + } + out.append(escape(resource.toString())); + } + + private static void writePString(URI context, PString string, Appendable out) throws IOException { + String text = string.toString(); + int lines = (int) text.lines().limit(3).count(); + if (lines > 2 && isSafeBraced(text)) { + out.append("{").append(text).append("}"); + return; + } else if (lines == 1 && text.contains(":/")) { + try { + PResource res = PResource.parse(text); + writePResource(context, res, out); + return; + } catch (ValueFormatException ex) { + // fall through + } + } + out.append(escape(text)); + } + +} diff --git a/praxiscore-project/src/test/java/org/praxislive/project/GraphModelTest.java b/praxiscore-project/src/test/java/org/praxislive/project/GraphModelTest.java new file mode 100644 index 00000000..5d810f32 --- /dev/null +++ b/praxiscore-project/src/test/java/org/praxislive/project/GraphModelTest.java @@ -0,0 +1,418 @@ +/* + * 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.project; + +import java.net.URI; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; +import org.praxislive.core.ComponentType; +import org.praxislive.core.Connection; +import org.praxislive.core.Info; +import org.praxislive.core.protocols.ComponentProtocol; +import org.praxislive.core.protocols.ContainerProtocol; +import org.praxislive.core.syntax.Token; +import org.praxislive.core.types.PArray; +import org.praxislive.core.types.PBoolean; +import org.praxislive.core.types.PMap; +import org.praxislive.core.types.PNumber; +import org.praxislive.core.types.PResource; +import org.praxislive.core.types.PString; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * + */ +public class GraphModelTest { + + private static final boolean VERBOSE = Boolean.getBoolean("praxis.test.verbose"); + + private static final String GRAPH_SCRIPT = textGraph(); + private static final String SUBGRAPH_SCRIPT = textSubgraph(); + private static final PMap GRAPH_SERIALIZED = serializedGraph(); + private static final URI PARENT_CONTEXT = URI.create("file:/parent/"); + + public GraphModelTest() { + } + + @BeforeEach + public void beforeEach(TestInfo info) { + if (VERBOSE) { + System.out.println("START TEST : " + info.getDisplayName()); + } + } + + @AfterEach + public void afterEach(TestInfo info) { + if (VERBOSE) { + System.out.println("END TEST : " + info.getDisplayName()); + System.out.println("====================================="); + } + } + + @Test + public void testParseGraph() throws ParseException { + GraphModel model = GraphModel.parse(PARENT_CONTEXT, GRAPH_SCRIPT); + if (VERBOSE) { + System.out.println("Constructed model"); + System.out.println(model.root()); + } + verifyFullGraphModel(model); + assertEquals(1, model.root().comments().size()); + assertEquals("%graph.x 42", model.root().comments().get(0).text()); + assertEquals(PARENT_CONTEXT, model.context().orElseThrow()); + } + + @Test + public void testParseGraphWithCommands() throws ParseException { + String script = """ + libraries { + pkg:maven/org.praxislive/praxiscore-api + } + # comment + """ + GRAPH_SCRIPT; + GraphModel model = GraphModel.parse(PARENT_CONTEXT, script); + if (VERBOSE) { + System.out.println("Constructed model"); + System.out.println(model.root()); + } + verifyFullGraphModel(model); + assertEquals(1, model.root().comments().size()); + assertEquals("%graph.x 42", model.root().comments().get(0).text()); + assertEquals(PARENT_CONTEXT, model.context().orElseThrow()); + assertEquals(1, model.root().commands().size()); + List command = model.root().commands().get(0).tokens(); + assertEquals(2, command.size()); + assertEquals(Token.Type.PLAIN, command.get(0).getType()); + assertEquals("libraries", command.get(0).getText()); + assertEquals(Token.Type.BRACED, command.get(1).getType()); + assertEquals("pkg:maven/org.praxislive/praxiscore-api", + command.get(1).getText().strip()); + } + + @Test + public void testParseSubGraph() throws ParseException { + GraphModel model = GraphModel.parseSubgraph(PARENT_CONTEXT, SUBGRAPH_SCRIPT); + if (VERBOSE) { + System.out.println("Constructed subgraph model"); + System.out.println(model.root()); + } + assertTrue(model.root().isSynthetic()); + assertEquals("", model.root().id()); + assertEquals(List.of("child1", "container"), List.copyOf(model.root().children().keySet())); + assertEquals(PResource.of(PARENT_CONTEXT.resolve("text")), + model.root().children() + .get("container").children() + .get("child1").properties().get("p1").value() + ); + assertEquals(List.of( + "child1", "out", "container", "in", + "container", "ready", "child1", "trigger"), + model.root().connections().stream() + .flatMap(c -> Stream.of(c.sourceComponent(), c.sourcePort(), + c.targetComponent(), c.targetPort()) + ).toList()); + assertEquals(PARENT_CONTEXT, model.context().orElseThrow()); + } + + @Test + public void testParseSubGraphWithCommands() throws ParseException { + String script = """ + libraries { + pkg:maven/org.praxislive/praxiscore-api + } + shared-code { + SHARED.Test { + // code + } + } + """ + SUBGRAPH_SCRIPT; + GraphModel model = GraphModel.parseSubgraph(PARENT_CONTEXT, script); + if (VERBOSE) { + System.out.println("Constructed subgraph model"); + System.out.println(model.root()); + } + assertTrue(model.root().isSynthetic()); + assertEquals("", model.root().id()); + assertEquals(List.of("child1", "container"), List.copyOf(model.root().children().keySet())); + assertEquals(PResource.of(PARENT_CONTEXT.resolve("text")), + model.root().children() + .get("container").children() + .get("child1").properties().get("p1").value() + ); + assertEquals(List.of( + "child1", "out", "container", "in", + "container", "ready", "child1", "trigger"), + model.root().connections().stream() + .flatMap(c -> Stream.of(c.sourceComponent(), c.sourcePort(), + c.targetComponent(), c.targetPort()) + ).toList()); + assertEquals(PARENT_CONTEXT, model.context().orElseThrow()); + assertEquals(2, model.root().commands().size()); + List command = model.root().commands().get(0).tokens(); + assertEquals(2, command.size()); + assertEquals(Token.Type.PLAIN, command.get(0).getType()); + assertEquals("libraries", command.get(0).getText()); + assertEquals(Token.Type.BRACED, command.get(1).getType()); + assertEquals("pkg:maven/org.praxislive/praxiscore-api", + command.get(1).getText().strip()); + command = model.root().commands().get(1).tokens(); + assertEquals(2, command.size()); + assertEquals(Token.Type.PLAIN, command.get(0).getType()); + assertEquals("shared-code", command.get(0).getText()); + assertEquals(Token.Type.BRACED, command.get(1).getType()); + } + + @Test + public void testInvalidGraphs() { + String script1 = """ + allowed-command + @ /root test:root { + disallowed-command + .property 2 + } + """; + assertThrows(ParseException.class, () -> { + GraphModel.parse(script1); + }); + String script2 = """ + allowed-command + @ /root test:root { + .property 2 + } + disallowed-command + """; + assertThrows(ParseException.class, () -> { + GraphModel.parse(script2); + }); + } + + @Test + public void testFromSerializedRoot() { + GraphModel model = GraphModel.fromSerializedRoot("root", GRAPH_SERIALIZED); + if (VERBOSE) { + System.out.println("Constructed model"); + System.out.println(model.root()); + } + verifyFullGraphModel(model); + } + + @Test + public void testFromSerializedComponent() { + GraphModel model = GraphModel.fromSerializedComponent("foo", + PMap.from(GRAPH_SERIALIZED.get("@container")).orElseThrow()); + if (VERBOSE) { + System.out.println("Constructed model"); + System.out.println(model.root()); + } + assertTrue(model.root().isSynthetic()); + assertEquals(List.of("foo"), List.copyOf(model.root().children().keySet())); + assertEquals("core:container", model.root().children().get("foo").type().toString()); + assertEquals(List.of("child1"), List.copyOf(model.root().children().get("foo").children().keySet())); + assertEquals(PResource.of(PARENT_CONTEXT.resolve("text")), + model.root().children() + .get("foo").children() + .get("child1").properties().get("p1").value() + ); + } + + @Test + public void testFromSerializedSubgraph() { + GraphModel model = GraphModel.fromSerializedSubgraph(GRAPH_SERIALIZED, + id -> List.of("child1", "container").contains(id)); + if (VERBOSE) { + System.out.println("Constructed subgraph model"); + System.out.println(model.root()); + } + assertTrue(model.root().isSynthetic()); + assertEquals("", model.root().id()); + assertNull(model.root().properties().get("p1")); + assertNull(model.root().properties().get("p2")); + assertEquals(List.of("child1", "container"), List.copyOf(model.root().children().keySet())); + assertEquals(PResource.of(PARENT_CONTEXT.resolve("text")), + model.root().children() + .get("container").children() + .get("child1").properties().get("p1").value() + ); + assertEquals(List.of( + "child1", "out", "container", "in", + "container", "ready", "child1", "trigger"), + model.root().connections().stream() + .flatMap(c -> Stream.of(c.sourceComponent(), c.sourcePort(), + c.targetComponent(), c.targetPort()) + ).toList()); + } + + @Test + public void testWriteGraph() throws ParseException { + GraphModel model = GraphModel.fromSerializedRoot("root", GRAPH_SERIALIZED) + .withContext(PARENT_CONTEXT); + + String script = model.writeToString(); + if (VERBOSE) { + System.out.println("Written graph"); + System.out.println(script); + } + String expected = GRAPH_SCRIPT.lines() + .filter(l -> !l.contains("#")) + .collect(Collectors.joining("\n", "", "\n")); + assertEquals(expected, script); + + GraphModel roundTrip = GraphModel.parse(PARENT_CONTEXT, script); + assertEquals(model.root(), roundTrip.root()); + } + + @Test + public void testWriteSubgraph() throws ParseException { + GraphModel model = GraphModel.parseSubgraph(PARENT_CONTEXT, SUBGRAPH_SCRIPT); + String script = model.writeToString(); + if (VERBOSE) { + System.out.println("Written subgraph"); + System.out.println(script); + } + assertEquals(SUBGRAPH_SCRIPT, script); + GraphModel roundTrip = GraphModel.parseSubgraph(PARENT_CONTEXT, script); + assertEquals(model.root(), roundTrip.root()); + } + + private void verifyFullGraphModel(GraphModel model) { + assertEquals("root", model.root().id()); + assertFalse(model.root().isSynthetic()); + assertEquals("root:custom", model.root().type().toString()); + assertInstanceOf(PNumber.class, model.root().properties().get("p1").value()); + assertInstanceOf(PBoolean.class, model.root().properties().get("p2").value()); + assertEquals(List.of("child1", "child2", "container"), List.copyOf(model.root().children().keySet())); + assertEquals("core:subchild", + model.root().children() + .get("container").children() + .get("child1").type().toString() + ); + assertInstanceOf(PMap.class, model.root().children() + .get("child2").properties().get("p2").value()); + assertEquals(PResource.of(PARENT_CONTEXT.resolve("text")), + model.root().children() + .get("container").children() + .get("child1").properties().get("p1").value() + ); + assertEquals(List.of( + "child1", "out", "child2", "in", + "child1", "out", "container", "in", + "container", "ready", "child1", "trigger"), + model.root().connections().stream() + .flatMap(c -> Stream.of(c.sourceComponent(), c.sourcePort(), + c.targetComponent(), c.targetPort()) + ).toList()); + } + + private static String textGraph() { + return """ + @ /root root:custom { + # %graph.x 42 + .p1 1 + .p2 true + @ ./child1 core:type1 { + .p1 value + } + @ ./child2 core:type2 { + .p1 42 + .p2 [map key1 [array 1 2]] + } + @ ./container core:container { + @ ./child1 core:subchild { + .p1 [file "text"] + } + } + ~ ./child1!out ./child2!in + ~ ./child1!out ./container!in + ~ ./container!ready ./child1!trigger + } + """; + } + + private static String textSubgraph() { + return """ + @ ./child1 core:type1 { + .p1 value + } + @ ./container core:container { + @ ./child1 core:subchild { + .p1 [file "text"] + } + } + ~ ./child1!out ./container!in + ~ ./container!ready ./child1!trigger + """; + } + + private static PMap serializedGraph() { + var builder = PMap.builder(); + builder.put("%type", ComponentType.of("root:custom")); + builder.put("%info", Info.component(cmp + -> cmp.protocol(ComponentProtocol.class) + .protocol(ContainerProtocol.class) + .control("p1", c -> c.property().input(PNumber.class)) + .control("p2", c -> c.property().input(PBoolean.class)) + )); + builder.put("%custom", PString.of("FOO")); + builder.put("p1", "1"); + builder.put("p2", "true"); + builder.put("@child1", PMap.builder() + .put("%type", ComponentType.of("core:type1")) + .put("%info", ComponentProtocol.API_INFO) + .put("p1", "value") + .build() + ); + builder.put("@child2", PMap.builder() + .put("%type", ComponentType.of("core:type2")) + .put("%info", ComponentProtocol.API_INFO) + .put("p1", 42) + .put("p2", PMap.of("key1", PArray.of(PNumber.of(1), PNumber.of(2)))) + .build() + ); + builder.put("@container", PMap.builder() + .put("%type", ComponentType.of("core:container")) + .put("%info", Info.component() + .protocol(ComponentProtocol.class) + .protocol(ContainerProtocol.class) + .build()) + .put("@child1", PMap.builder() + .put("%type", ComponentType.of("core:subchild")) + .put("%info", ComponentProtocol.API_INFO) + .put("p1", PResource.of(URI.create("file:/parent/text"))) + .build() + ) + .build() + ); + builder.put("%connections", PArray.of( + new Connection("child1", "out", "child2", "in").dataArray(), + new Connection("child1", "out", "container", "in").dataArray(), + new Connection("container", "ready", "child1", "trigger").dataArray() + )); + return builder.build(); + } + +} diff --git a/praxiscore-project/src/test/java/org/praxislive/project/SyntaxUtilsTest.java b/praxiscore-project/src/test/java/org/praxislive/project/SyntaxUtilsTest.java new file mode 100644 index 00000000..2caad2e0 --- /dev/null +++ b/praxiscore-project/src/test/java/org/praxislive/project/SyntaxUtilsTest.java @@ -0,0 +1,180 @@ +/* + * 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.project; + +import java.net.URI; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; +import org.praxislive.core.ComponentAddress; +import org.praxislive.core.ControlAddress; +import org.praxislive.core.PortAddress; +import org.praxislive.core.Value; +import org.praxislive.core.syntax.Token; +import org.praxislive.core.syntax.Tokenizer; +import org.praxislive.core.types.PArray; +import org.praxislive.core.types.PBoolean; +import org.praxislive.core.types.PMap; +import org.praxislive.core.types.PNumber; +import org.praxislive.core.types.PResource; +import org.praxislive.core.types.PString; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * + */ +public class SyntaxUtilsTest { + + private static final boolean VERBOSE = Boolean.getBoolean("praxis.test.verbose"); + + public SyntaxUtilsTest() { + } + + @BeforeEach + public void beforeEach(TestInfo info) { + if (VERBOSE) { + System.out.println("START TEST : " + info.getDisplayName()); + } + } + + @AfterEach + public void afterEach(TestInfo info) { + if (VERBOSE) { + System.out.println("END TEST : " + info.getDisplayName()); + System.out.println("====================================="); + } + } + + @Test + public void testEscape() { + assertEquals("\"\"", SyntaxUtils.escape("")); + assertEquals("1234", SyntaxUtils.escape("1234")); + assertEquals("\"te\\\"st\"", SyntaxUtils.escape("te\"st")); + assertEquals("te#st", SyntaxUtils.escape("te#st")); + assertEquals("\"#test\"", SyntaxUtils.escape("#test")); + assertEquals("\".test\"", SyntaxUtils.escape(".test")); + assertEquals("\"1234\"", SyntaxUtils.escapeQuoted("1234")); + } + + @Test + public void testIsSafeBraced() { + assertTrue(SyntaxUtils.isSafeBraced("")); + assertTrue(SyntaxUtils.isSafeBraced("{\n {public void foo() {}}\n}")); + assertTrue(SyntaxUtils.isSafeBraced("{\n \\{uneven}")); + assertFalse(SyntaxUtils.isSafeBraced("{")); + assertFalse(SyntaxUtils.isSafeBraced("this is } not OK")); + } + + @Test + public void testValueFromToken() { + String tokenText = "[array [map key true] 1 -2.34 /component /component.control /component!port \"42\" ]"; + List tokens = Tokenizer.parse(tokenText); + if (VERBOSE) { + System.out.println(tokens); + } + Value value = SyntaxUtils.valueFromToken(tokens.get(0)); + assertInstanceOf(PArray.class, value); + PArray array = (PArray) value; + assertEquals(7, array.size()); + assertInstanceOf(PMap.class, array.get(0)); + PMap map = (PMap) array.get(0); + assertEquals(1, map.size()); + assertInstanceOf(PBoolean.class, map.get("key")); + assertTrue(map.getBoolean("key", false)); + assertInstanceOf(PNumber.class, array.get(1)); + assertInstanceOf(PNumber.class, array.get(2)); + assertInstanceOf(ComponentAddress.class, array.get(3)); + assertInstanceOf(ControlAddress.class, array.get(4)); + assertInstanceOf(PortAddress.class, array.get(5)); + assertInstanceOf(PString.class, array.get(6)); + + tokenText = "[array 1\nmap key value]"; + tokens = Tokenizer.parse(tokenText); + Token multi = tokens.get(0); + assertThrows(IllegalArgumentException.class, () -> SyntaxUtils.valueFromToken(multi)); + + tokenText = "[file \"test.txt\"]"; + tokens = Tokenizer.parse(tokenText); + Token file = tokens.get(0); + assertThrows(IllegalArgumentException.class, () -> SyntaxUtils.valueFromToken(file)); + } + + @Test + public void testValueFromTokenWithContext() { + String tokenText = "[file \"test file.txt\"]"; + List tokens = Tokenizer.parse(tokenText); + Token file = tokens.get(0); + URI parent = URI.create("file:/parent/"); + URI expected = URI.create("file:/parent/test%20file.txt"); + Value value = SyntaxUtils.valueFromToken(parent, file); + assertInstanceOf(PResource.class, value); + assertEquals(expected, ((PResource) value).value()); + } + + @Test + public void testValueToToken() { + assertEquals("true", SyntaxUtils.valueToToken(PBoolean.TRUE)); + assertEquals("42", SyntaxUtils.valueToToken(PNumber.of(42))); + assertEquals("\"Hello World\"", SyntaxUtils.valueToToken(PString.of("Hello World"))); + PMap map = PMap.of("key", PArray.of(PNumber.of(1), PNumber.of(2)), + "another key", PResource.of(URI.create("file:/parent/test%20file.txt"))); + assertEquals("[map key [array 1 2] \"another key\" file:/parent/test%20file.txt]", + SyntaxUtils.valueToToken(map)); + String method = """ + @T(1) public void foo() { + if ("".equals(value[0])) { + out.send(); + } + } + """; + assertEquals("{" + method + "}", SyntaxUtils.valueToToken(PString.of(method))); + String notBraceable = """ + @T(1) public void foo() { + if ("".equals(value[0])) { + out.send(); + }"""; + String expected = """ + "@T(1) public void foo() \\{ + if (\\"\\".equals(value\\[0\\])) \\{ + out.send(); + \\}\""""; + assertEquals(expected, SyntaxUtils.valueToToken(PString.of(notBraceable))); + } + + @Test + public void testValueToTokenWithContext() { + URI parent = URI.create("file:/parent/"); + URI file1 = URI.create("file:/parent/test%20file.txt"); + URI file2 = URI.create("file:/parent2/test%20file.txt"); + PArray array = PArray.of(PResource.of(file1), PResource.of(file2)); + assertEquals("[array [file \"test file.txt\"] file:/parent2/test%20file.txt]", + SyntaxUtils.valueToToken(parent, array)); + assertEquals("[file \"test file.txt\"]", SyntaxUtils.valueToToken(parent, PString.of(file1))); + + } + +}