diff --git a/yaml/shared/src/main/scala/org/virtuslab/yaml/Node.scala b/yaml/shared/src/main/scala/org/virtuslab/yaml/Node.scala index ab0a20eb0..d8c3af374 100644 --- a/yaml/shared/src/main/scala/org/virtuslab/yaml/Node.scala +++ b/yaml/shared/src/main/scala/org/virtuslab/yaml/Node.scala @@ -2,6 +2,7 @@ package org.virtuslab.yaml import org.virtuslab.yaml.Range import org.virtuslab.yaml.Tag +import org.virtuslab.yaml.syntax.NodeSelector import org.virtuslab.yaml.syntax.YamlPrimitive /** @@ -16,6 +17,10 @@ sealed trait Node: ): Either[YamlError, T] = c.construct(this) + def modify(index: Int): NodeVisitor = NodeVisitor(this, List(NodeSelector.IntSelector(index))) + def modify(field: String): NodeVisitor = + NodeVisitor(this, List(NodeSelector.StringSelector(field))) + object Node: final case class ScalarNode private[yaml] (value: String, tag: Tag, pos: Option[Range] = None) extends Node @@ -55,4 +60,11 @@ object Node: new MappingNode(mappings, Tag.map, None) def unapply(node: MappingNode): Option[(Map[Node, Node], Tag)] = Some((node.mappings, node.tag)) end MappingNode + + extension (either: Either[TraverseError, Node]) + def modify(index: Int): Either[TraverseError, NodeVisitor] = either.map(_.modify(index)) + + extension (either: Either[TraverseError, Node]) + def modify(field: String): Either[TraverseError, NodeVisitor] = either.map(_.modify(field)) + end Node diff --git a/yaml/shared/src/main/scala/org/virtuslab/yaml/NodeVisitor.scala b/yaml/shared/src/main/scala/org/virtuslab/yaml/NodeVisitor.scala new file mode 100644 index 000000000..6db70a755 --- /dev/null +++ b/yaml/shared/src/main/scala/org/virtuslab/yaml/NodeVisitor.scala @@ -0,0 +1,103 @@ +package org.virtuslab.yaml + +import org.virtuslab.yaml.Node +import org.virtuslab.yaml.Node.* +import org.virtuslab.yaml.Range +import org.virtuslab.yaml.TraverseError +import org.virtuslab.yaml.YamlError +import org.virtuslab.yaml.syntax.NodeSelector +import org.virtuslab.yaml.syntax.NodeSelector.* + +class NodeVisitor(node: Node, selectors: List[NodeSelector]): + def apply(index: Int): NodeVisitor = + NodeVisitor(node, selectors :+ NodeSelector.IntSelector(index)) + def apply(field: String): NodeVisitor = + NodeVisitor(node, selectors :+ NodeSelector.StringSelector(field)) + + private def updateScalarNode( + value: String, + scalar: ScalarNode + ): Either[TraverseError, ScalarNode] = + selectors match { + case Nil => + Right( + scalar.copy( + value = value + ) + ) + case _ => + Left( + TraverseError( + s"Expected end of scalar path, instead found path ${selectors.map(_.show).mkString(".")}" + ) + ) + } + + private def updateSequenceNode( + value: String, + sequence: SequenceNode + ): Either[TraverseError, SequenceNode] = + selectors match { + case IntSelector(index) :: rest => + val nodes = sequence.nodes + val updateNode = NodeVisitor(nodes(index), rest).setValue(value) + updateNode.map(node => sequence.copy(nodes = nodes.updated(index, node))) + + case StringSelector(field) :: rest => + Left( + TraverseError( + s"Expeceted index of sequence, insted found string path: ${field}" + ) + ) + case _ => Left(TraverseError(s"Expeceted index of sequence, insted found end of path")) + } + + private def updateMappingNode(value: String, mapping: MappingNode) = + selectors match + case StringSelector(field) :: rest => + val mappings = mapping.mappings + val entryToUpdateOpt = mappings.find { + case (ScalarNode(keyName, _), _) => keyName == field + case _ => false + } + + entryToUpdateOpt match + case Some(entryToUpdate) => + val updatedValueE = entryToUpdate match + case (ScalarNode(keyName, _), valueNode) => + val updatedNode = NodeVisitor(valueNode, rest).setValue(value) + updatedNode + case _ => Left(TraverseError(s"Not found $field in mapping")) + + updatedValueE.map { updatedValue => + mapping.copy( + mappings.updated(entryToUpdate._1, updatedValue) + ) + } + case None => Left(TraverseError(s"Not found $field in mapping")) + case IntSelector(index) :: rest => + Left( + TraverseError( + s"Expeceted plain test, insted found index: $index" + ) + ) + case _ => Left(TraverseError(s"Expeceted plain text, insted found end of path")) + + def setValue(value: String): Either[TraverseError, Node] = node match + case scalar: ScalarNode => updateScalarNode(value, scalar) + case sequence: SequenceNode => updateSequenceNode(value, sequence) + case mapping: MappingNode => updateMappingNode(value, mapping) + +object NodeVisitor: + + def apply(node: Node, selectors: List[NodeSelector]): NodeVisitor = + new NodeVisitor(node, selectors) + + extension (either: Either[TraverseError, NodeVisitor]) + def apply(field: String): Either[TraverseError, NodeVisitor] = either.map(_.apply(field)) + + extension (either: Either[TraverseError, NodeVisitor]) + def apply(index: Int): Either[TraverseError, NodeVisitor] = either.map(_.apply(index)) + + extension (either: Either[TraverseError, NodeVisitor]) + def setValue(value: String): Either[TraverseError, Node] = either.flatMap(_.setValue(value)) diff --git a/yaml/shared/src/main/scala/org/virtuslab/yaml/Yaml.scala b/yaml/shared/src/main/scala/org/virtuslab/yaml/Yaml.scala index d5b34614f..994128a66 100644 --- a/yaml/shared/src/main/scala/org/virtuslab/yaml/Yaml.scala +++ b/yaml/shared/src/main/scala/org/virtuslab/yaml/Yaml.scala @@ -35,6 +35,15 @@ extension (str: String) t <- node.as[T] yield t + def asNode: Either[YamlError, Node] = + for + events <- { + val parser = ParserImpl(Scanner(str)) + parser.getEvents() + } + node <- ComposerImpl.fromEvents(events) + yield node + extension [T](t: T) /** * Serialize a [[T]] into a YAML string. diff --git a/yaml/shared/src/main/scala/org/virtuslab/yaml/YamlError.scala b/yaml/shared/src/main/scala/org/virtuslab/yaml/YamlError.scala index c6bb114e4..4bd8391c2 100644 --- a/yaml/shared/src/main/scala/org/virtuslab/yaml/YamlError.scala +++ b/yaml/shared/src/main/scala/org/virtuslab/yaml/YamlError.scala @@ -23,6 +23,8 @@ object ParseError: final case class ComposerError(msg: String) extends YamlError +final case class TraverseError(msg: String) extends YamlError + final case class ConstructError(msg: String) extends YamlError object ConstructError: private def from(errorMsg: String, node: Option[Node], expected: Option[String]): ConstructError = diff --git a/yaml/shared/src/main/scala/org/virtuslab/yaml/syntax/NodeSelector.scala b/yaml/shared/src/main/scala/org/virtuslab/yaml/syntax/NodeSelector.scala new file mode 100644 index 000000000..64e23ed02 --- /dev/null +++ b/yaml/shared/src/main/scala/org/virtuslab/yaml/syntax/NodeSelector.scala @@ -0,0 +1,13 @@ +package org.virtuslab.yaml.syntax + +import org.virtuslab.yaml.Node + +sealed trait NodeSelector: + def show: String + +object NodeSelector: + + case class IntSelector(index: Int) extends NodeSelector: + override def show: String = index.toString + case class StringSelector(field: String) extends NodeSelector: + override def show: String = field diff --git a/yaml/shared/src/test/scala/org/virtuslab/yaml/TestOps.scala b/yaml/shared/src/test/scala/org/virtuslab/yaml/TestOps.scala new file mode 100644 index 000000000..432138501 --- /dev/null +++ b/yaml/shared/src/test/scala/org/virtuslab/yaml/TestOps.scala @@ -0,0 +1,11 @@ +package org.virtuslab.yaml + +object TestOps { + + extension [E <: YamlError, T](either: Either[E, T]) + def orThrow: T = + either match + case Left(e) => throw new RuntimeException(e.msg) + case Right(t) => t + +} diff --git a/yaml/shared/src/test/scala/org/virtuslab/yaml/travers/NodeVisitorSuite.scala b/yaml/shared/src/test/scala/org/virtuslab/yaml/travers/NodeVisitorSuite.scala new file mode 100644 index 000000000..571d8649d --- /dev/null +++ b/yaml/shared/src/test/scala/org/virtuslab/yaml/travers/NodeVisitorSuite.scala @@ -0,0 +1,53 @@ +package org.virtuslab.yaml.travers + +import org.virtuslab.yaml.* +import org.virtuslab.yaml.TestOps.* +import org.virtuslab.yaml.Node +import org.virtuslab.yaml.NodeVisitor._ + +class NodeVisitorSuite extends munit.FunSuite { + + test("should update ports for web") { + + val yaml = + s"""version: "3.9" + |services: + | web: + | build: . + | volumes: + | - .:/code + | - logvolume01:/var/log + | ports: + | - "5000:5000" + | redis: + | image: "redis:alpine" + |""".stripMargin + + val node: Node = yaml.asNode.orThrow + val modifiedNode: Node = + node + .modify("services")("web")("ports")(0) + .setValue("6000:6000") + .modify("services")("redis")("image") + .setValue("openjdk:11") + .orThrow + + val modifiedYaml = modifiedNode.asYaml + + val exptectedYaml = + s"""version: 3.9 + |services: + | web: + | build: . + | volumes: + | - .:/code + | - logvolume01:/var/log + | ports: + | - 6000:6000 + | redis: + | image: openjdk:11 + |""".stripMargin + + assertEquals(modifiedYaml, exptectedYaml) + } +}