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..2c7e1b4d
--- /dev/null
+++ b/praxiscore-project/pom.xml
@@ -0,0 +1,61 @@
+
+
+ 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
+ 5.10.2
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-params
+ 5.10.2
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ 5.10.2
+ test
+
+
+
+
+
+ maven-compiler-plugin
+
+
+ -Xlint:all
+
+
+
+
+
+
+
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..98772af1
--- /dev/null
+++ b/praxiscore-project/src/main/java/org/praxislive/project/GraphBuilder.java
@@ -0,0 +1,235 @@
+/*
+ * 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;
+
+/**
+ *
+ */
+public final class GraphBuilder {
+
+ private GraphBuilder() {
+ }
+
+ public static Component component(ComponentType type) {
+ return new Component(type);
+ }
+
+ public static Component component(GraphElement.Component component) {
+ return new Component(component);
+ }
+
+ static Root root(String id, ComponentType type) {
+ return new Root(id, type);
+ }
+
+ static Root root(GraphElement.Root root) {
+ return new Root(root);
+ }
+
+ static Root syntheticRoot() {
+ return new Root("", GraphElement.Root.SYNTHETIC);
+ }
+
+ @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());
+ }
+
+ 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;
+ }
+
+ public B child(String id, ComponentType type, Consumer constructor) {
+ Component childBuilder = new Component(type);
+ constructor.accept(childBuilder);
+ return child(id, childBuilder.build());
+ }
+
+ public B comment(String text) {
+ comments.add(new GraphElement.Comment(text));
+ return (B) this;
+ }
+
+ public B comment(GraphElement.Comment comment) {
+ comments.add(Objects.requireNonNull(comment));
+ return (B) this;
+ }
+
+ public B connection(String sourceComponent, String sourcePort,
+ String targetComponent, String targetPort) {
+ connections.add(GraphElement.connection(sourceComponent, sourcePort,
+ targetComponent, targetPort));
+ return (B) this;
+ }
+
+ public B connection(GraphElement.Connection connection) {
+ connections.add(Objects.requireNonNull(connection));
+ return (B) this;
+ }
+
+ public B property(String id, Value value) {
+ return property(id, new GraphElement.Property(value));
+ }
+
+ 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;
+ }
+
+ public B clearChildren() {
+ children.clear();
+ return (B) this;
+ }
+
+ public B clearComments() {
+ comments.clear();
+ return (B) this;
+ }
+
+ public B clearConnections() {
+ connections.clear();
+ return (B) this;
+ }
+
+ public B clearProperties() {
+ properties.clear();
+ return (B) 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;
+ }
+
+ public B transformComments(
+ Function, List> transform) {
+ var transformed = transform.apply(comments.stream());
+ clearComments();
+ transformed.forEach(c -> comment(c));
+ return (B) this;
+ }
+
+ public B transformConnections(
+ Function, List> transform) {
+ var transformed = transform.apply(connections.stream());
+ clearConnections();
+ transformed.forEach(c -> connection(c));
+ return (B) 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;
+ }
+
+ }
+
+ public static final class Component extends Base {
+
+ private Component(ComponentType type) {
+ super(type);
+ }
+
+ private Component(GraphElement.Component component) {
+ super(component);
+ }
+
+ public GraphElement.Component build() {
+ return new GraphElement.Component(type, comments, properties, children, connections);
+ }
+
+ }
+
+ 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());
+ }
+
+ public GraphElement.Root build() {
+ return new 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..4847bb1f
--- /dev/null
+++ b/praxiscore-project/src/main/java/org/praxislive/project/GraphElement.java
@@ -0,0 +1,353 @@
+/*
+ * 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.List;
+import java.util.Map;
+import java.util.Objects;
+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;
+
+/**
+ *
+ */
+public sealed abstract class GraphElement {
+
+ private GraphElement() {
+ }
+
+ public static Connection connection(String sourceComponent, String sourcePort,
+ String targetComponent, String targetPort) {
+ return new GraphElement.Connection(sourceComponent, sourcePort, targetComponent, targetPort);
+ }
+
+ public static Comment comment(String text) {
+ return new Comment(text);
+ }
+
+ public static Property property(Value value) {
+ return new Property(value);
+ }
+
+ 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;
+
+ 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);
+ }
+
+ public ComponentType type() {
+ return type;
+ }
+
+ public List comments() {
+ return comments;
+ }
+
+ public OrderedMap properties() {
+ return properties;
+ }
+
+ public OrderedMap children() {
+ return children;
+ }
+
+ public OrderedSet 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 + "}";
+ }
+
+ }
+
+ public static final class Root extends Component {
+
+ static final ComponentType SYNTHETIC = ComponentType.of("root:synthetic");
+
+ private final String id;
+ private final List commands;
+
+ 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);
+ }
+
+ public String id() {
+ return id;
+ }
+
+ public List commands() {
+ return commands;
+ }
+
+ 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 List tokens;
+
+ Command(List tokens) {
+ this.tokens = List.copyOf(tokens);
+ }
+
+ public List tokens() {
+ return tokens;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(tokens);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ return obj == this
+ || obj instanceof Command c
+ && Objects.equals(this.tokens, c.tokens);
+ }
+
+ @Override
+ public String toString() {
+ return "Command{" + "tokens=" + tokens + "}";
+ }
+
+ }
+
+ public static final class Comment extends GraphElement {
+
+ private final String text;
+
+ 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;
+
+ 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;
+
+ 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..cb5c5861
--- /dev/null
+++ b/praxiscore-project/src/main/java/org/praxislive/project/GraphModel.java
@@ -0,0 +1,170 @@
+/*
+ * 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.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.types.PArray;
+import org.praxislive.core.types.PMap;
+
+/**
+ *
+ */
+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;
+ }
+
+ public GraphElement.Root root() {
+ return root;
+ }
+
+ public Optional context() {
+ return Optional.ofNullable(context);
+ }
+
+ public static GraphModel parse(String graph) throws ParseException {
+ GraphElement.Root root = GraphParser.parse(graph);
+ return new GraphModel(root, null);
+ }
+
+ public static GraphModel parse(URI context, String graph) throws ParseException {
+ GraphElement.Root root = GraphParser.parse(context, graph);
+ return new GraphModel(root, context);
+ }
+
+ public static GraphModel parseSubgraph(String graph) throws ParseException {
+ GraphElement.Root root = GraphParser.parseSubgraph(graph);
+ return new GraphModel(root, null);
+ }
+
+ public static GraphModel parseSubgraph(URI context, String graph) throws ParseException {
+ GraphElement.Root root = GraphParser.parseSubgraph(context, graph);
+ return new GraphModel(root, context);
+ }
+
+ 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);
+ }
+
+ 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);
+ }
+
+ public static GraphModel fromSerializedSubgraph(PMap data) {
+ return fromSerializedSubgraph(data, null);
+ }
+
+ public static GraphModel fromSerializedSubgraph(PMap data, Predicate filter) {
+ GraphBuilder.Root rootBuilder = GraphBuilder.syntheticRoot();
+ buildSerializedComponent(rootBuilder, data, false, filter);
+ return new GraphModel(rootBuilder.build(), null);
+ }
+
+ 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..8e834d61
--- /dev/null
+++ b/praxiscore-project/src/main/java/org/praxislive/project/GraphParser.java
@@ -0,0 +1,286 @@
+/*
+ * 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 ATTRIBUTE_PREFIX = "%";
+ 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 {
+ Iterator tokens = new Tokenizer(script).iterator();
+
+ // ignore comments and white space at beginning of file
+ Token t = nextNonCommentOrWhiteSpace(tokens);
+ if (t != null && t.getType() == PLAIN && AT.equals(t.getText())) {
+ GraphElement.Root root = parseRoot(tokens);
+ // ignore comments and white space at end of file
+ t = nextNonCommentOrWhiteSpace(tokens);
+ if (t != null) {
+ throw new IllegalArgumentException("Unexpected commands found after root.");
+ }
+ return root;
+ } else {
+ throw new IllegalArgumentException("No root found in script.");
+ }
+
+ } 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 Token nextNonCommentOrWhiteSpace(Iterator tokens) {
+ while (tokens.hasNext()) {
+ Token t = tokens.next();
+ if (t.getType() == COMMENT
+ || t.getType() == EOL) {
+ continue;
+ }
+ return t;
+ }
+ return null;
+ }
+
+ 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 GraphElement.Root parseRoot(Iterator tokens) {
+
+ String id;
+ ComponentType type;
+
+ Token t;
+ if (tokens.hasNext() && (t = tokens.next()).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.");
+ }
+ if (tokens.hasNext() && (t = tokens.next()).getType() == PLAIN) {
+ type = ComponentType.of(t.getText());
+ } else {
+ throw new IllegalArgumentException("No root type found.");
+ }
+
+ GraphBuilder.Root root = GraphBuilder.root(id, type);
+
+ if (tokens.hasNext()) {
+ t = tokens.next();
+ if (t.getType() == BRACED) {
+ parseComponentBody(root, t.getText());
+ return root.build();
+ } else if (t.getType() == EOL) {
+ return root.build();
+ }
+ }
+ throw new IllegalArgumentException("Root body format error " + tokens);
+ }
+
+ 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;
+ }
+
+ Iterator tokens = new Tokenizer(body).iterator();
+ while (tokens.hasNext()) {
+ Token t = tokens.next();
+ String txt = t.getText();
+ switch (t.getType()) {
+ case COMMENT ->
+ component.comment(txt);
+ case PLAIN -> {
+ if (txt.startsWith(PROPERTY_PREFIX) && txt.length() > 1) {
+ parseProperty(component, txt.substring(1), tokensToEOL(tokens));
+ } else if (AT.equals(txt)) {
+ parseComponent(component, tokensToEOL(tokens));
+ } else if (CONNECT.equals(txt)) {
+ parseConnection(component, tokensToEOL(tokens));
+ } else {
+ throw new IllegalArgumentException("Unexpected PLAIN token : " + txt);
+ }
+ }
+ case EOL -> {
+ // no op
+ }
+ default ->
+ throw new IllegalArgumentException(
+ "Unexpected token of type : " + t.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..bbf1cbce
--- /dev/null
+++ b/praxiscore-project/src/main/java/org/praxislive/project/GraphWriter.java
@@ -0,0 +1,29 @@
+/*
+ * 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;
+
+/**
+ *
+ */
+class GraphWriter {
+
+}
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..9ab8965d
--- /dev/null
+++ b/praxiscore-project/src/main/java/org/praxislive/project/ParseException.java
@@ -0,0 +1,69 @@
+/*
+ * 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;
+
+/**
+ *
+ */
+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..b0072e1a
--- /dev/null
+++ b/praxiscore-project/src/main/java/org/praxislive/project/SyntaxUtils.java
@@ -0,0 +1,437 @@
+/*
+ * 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;
+
+/**
+ *
+ */
+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() {
+ }
+
+ public static String escape(String input) {
+ String res = doPlain(input);
+ if (res == null) {
+ res = doQuoted(input);
+ }
+ return res;
+ }
+
+ public static String escapeQuoted(String input) {
+ return doQuoted(input);
+ }
+
+ 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;
+ }
+
+ public static Value valueFromToken(Token token) {
+ return valueFromTokenImpl(null, token);
+ }
+
+ public static Value valueFromToken(URI context, Token token) {
+ return valueFromTokenImpl(Objects.requireNonNull(context), token);
+ }
+
+ public static String valueToToken(Value value) {
+ StringBuilder sb = new StringBuilder();
+ try {
+ writeValue(value, sb);
+ } catch (IOException ex) {
+ throw new IllegalStateException(ex);
+ }
+ return sb.toString();
+ }
+
+ 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();
+ }
+
+ public static void writeValue(Value value, Appendable out) throws IOException {
+ writeValueImpl(null,
+ Objects.requireNonNull(value),
+ Objects.requireNonNull(out));
+ }
+
+ 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.getType());
+ 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..251e4687
--- /dev/null
+++ b/praxiscore-project/src/test/java/org/praxislive/project/GraphModelTest.java
@@ -0,0 +1,285 @@
+/*
+ * 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.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.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") || true;
+
+ 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 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"), model.root().children().keys());
+ 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 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"), model.root().children().keys());
+ assertEquals("core:container", model.root().children().get("foo").type().toString());
+ assertEquals(List.of("child1"), model.root().children().get("foo").children().keys());
+ 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"), model.root().children().keys());
+ 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());
+ }
+
+ 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"), model.root().children().keys());
+ 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)));
+
+ }
+
+}