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)));
+
+ }
+
+}