From af54cf3f0231ec2528405364ed6d2464aeee2e35 Mon Sep 17 00:00:00 2001 From: Chris Jenkinson Date: Fri, 25 Sep 2026 09:41:45 +0000 Subject: [PATCH 1/2] Throw on duplicate child node names and replace traversed children in place --- spec/Node/SimpleNodeSpec.php | 33 +++++++++++++++++++++++- spec/NodeTraverser/NodeTraverserSpec.php | 6 +++-- src/Node/AbstractNode.php | 15 ++++++++++- src/Node/DuplicateNodeException.php | 15 +++++++++++ src/Node/NodeInterface.php | 2 ++ src/Node/NodeNotFoundException.php | 15 +++++++++++ src/NodeTraverser/NodeTraverser.php | 7 +++-- 7 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 src/Node/DuplicateNodeException.php create mode 100644 src/Node/NodeNotFoundException.php diff --git a/spec/Node/SimpleNodeSpec.php b/spec/Node/SimpleNodeSpec.php index 0b5357b..638ef80 100644 --- a/spec/Node/SimpleNodeSpec.php +++ b/spec/Node/SimpleNodeSpec.php @@ -4,7 +4,9 @@ namespace spec\chrisjenkinson\StructuredDocumentParser\Node; +use chrisjenkinson\StructuredDocumentParser\Node\DuplicateNodeException; use chrisjenkinson\StructuredDocumentParser\Node\NodeInterface; +use chrisjenkinson\StructuredDocumentParser\Node\NodeNotFoundException; use PhpSpec\ObjectBehavior; use RuntimeException; @@ -40,7 +42,36 @@ public function it_has_nodes(NodeInterface $node): void public function it_throws_exception_if_no_such_child_exists(): void { - $this->shouldThrow(RuntimeException::class)->duringGetNode('nonexistent'); + $this->shouldThrow(NodeNotFoundException::class)->duringGetNode('nonexistent'); + } + + public function it_throws_when_adding_a_node_whose_name_is_already_taken(NodeInterface $node, NodeInterface $other): void + { + $node->getName()->willReturn('ChildNode'); + $other->getName()->willReturn('ChildNode'); + + $this->addNode($node); + + $this->shouldThrow(DuplicateNodeException::class)->duringAddNode($other); + $this->getNode('ChildNode')->shouldReturn($node); + } + + public function it_replaces_a_node_with_the_same_name(NodeInterface $node, NodeInterface $replacement): void + { + $node->getName()->willReturn('ChildNode'); + $replacement->getName()->willReturn('ChildNode'); + + $this->addNode($node); + $this->replaceNode($replacement); + + $this->getNodes()->shouldReturn(['ChildNode' => $replacement]); + } + + public function it_throws_when_replacing_a_node_that_does_not_exist(NodeInterface $node): void + { + $node->getName()->willReturn('ChildNode'); + + $this->shouldThrow(NodeNotFoundException::class)->duringReplaceNode($node); } public function it_exports_a_tree_as_a_string(NodeInterface $node): void diff --git a/spec/NodeTraverser/NodeTraverserSpec.php b/spec/NodeTraverser/NodeTraverserSpec.php index 6033219..59daa6d 100644 --- a/spec/NodeTraverser/NodeTraverserSpec.php +++ b/spec/NodeTraverser/NodeTraverserSpec.php @@ -28,15 +28,17 @@ public function it_can_traverse_a_node(NodeInterface $node, NodeVisitorInterface $nodeVisitor->afterTraverse($node)->shouldHaveBeenCalled(); } - public function it_can_add_a_child(NodeInterface $node, NodeInterface $child, NodeVisitorInterface $nodeVisitor, NodeVisitorInterface $nodeVisitor2): void + public function it_puts_a_traversed_child_back_in_place(NodeInterface $node, NodeInterface $child, NodeVisitorInterface $nodeVisitor, NodeVisitorInterface $nodeVisitor2): void { $node->getNodes()->willReturn([$child]); $node->getAttributes()->willReturn([]); $child->getNodes()->willReturn([]); $child->getAttributes()->willReturn([]); + $child->getName()->willReturn('ChildNode'); - $node->addNode($child)->shouldBeCalled(); + $node->replaceNode($child)->shouldBeCalled(); + $node->addNode($child)->shouldNotBeCalled(); $this->addVisitor($nodeVisitor); $this->addVisitor($nodeVisitor2); diff --git a/src/Node/AbstractNode.php b/src/Node/AbstractNode.php index 8558774..bf62233 100644 --- a/src/Node/AbstractNode.php +++ b/src/Node/AbstractNode.php @@ -49,7 +49,7 @@ public function getNode(string $key): NodeInterface return $this->nodes[$key]; } - throw new RuntimeException(sprintf('No such node "%s"', $key)); + throw new NodeNotFoundException($key); } /** @@ -62,6 +62,19 @@ public function getNodes(): array public function addNode(NodeInterface $node): void { + if ($this->hasNode($node->getName())) { + throw new DuplicateNodeException($node->getName()); + } + + $this->nodes[$node->getName()] = $node; + } + + public function replaceNode(NodeInterface $node): void + { + if (!$this->hasNode($node->getName())) { + throw new NodeNotFoundException($node->getName()); + } + $this->nodes[$node->getName()] = $node; } diff --git a/src/Node/DuplicateNodeException.php b/src/Node/DuplicateNodeException.php new file mode 100644 index 0000000..e783bb7 --- /dev/null +++ b/src/Node/DuplicateNodeException.php @@ -0,0 +1,15 @@ +removeNode($child); + if ($newChild->getName() === $child->getName()) { + $node->replaceNode($newChild); + + return; } + $node->removeNode($child); $node->addNode($newChild); }, $children); } From 15c6dcbf0422b8c44325011ce0efcb8048a3c3a0 Mon Sep 17 00:00:00 2001 From: Chris Jenkinson Date: Fri, 25 Sep 2026 09:43:57 +0000 Subject: [PATCH 2/2] Export the whole node tree in __toString and substitute invalid UTF-8 --- spec/Node/SimpleNodeSpec.php | 48 ++++++++++++++++++++++++++++++++---- src/Node/AbstractNode.php | 10 +++++++- src/Node/NodeInterface.php | 4 ++- 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/spec/Node/SimpleNodeSpec.php b/spec/Node/SimpleNodeSpec.php index 638ef80..28dcd67 100644 --- a/spec/Node/SimpleNodeSpec.php +++ b/spec/Node/SimpleNodeSpec.php @@ -7,6 +7,7 @@ use chrisjenkinson\StructuredDocumentParser\Node\DuplicateNodeException; use chrisjenkinson\StructuredDocumentParser\Node\NodeInterface; use chrisjenkinson\StructuredDocumentParser\Node\NodeNotFoundException; +use chrisjenkinson\StructuredDocumentParser\Node\SimpleNode; use PhpSpec\ObjectBehavior; use RuntimeException; @@ -74,21 +75,58 @@ public function it_throws_when_replacing_a_node_that_does_not_exist(NodeInterfac $this->shouldThrow(NodeNotFoundException::class)->duringReplaceNode($node); } - public function it_exports_a_tree_as_a_string(NodeInterface $node): void + public function it_exports_a_tree_as_a_string(): void { - $node->getName()->willReturn('ChildNode'); - $node->__toString()->willReturn(''); + $child = new SimpleNode(); + $grandchild = new SimpleNode(); + $listItem = new SimpleNode(); + + $grandchild->setAttribute('depth', 2); + $listItem->setAttribute('item', 'first'); + $child->addNode($grandchild); + $child->setAttribute('items', [$listItem]); $this->setAttribute('something', 'result'); - $this->addNode($node); + $this->addNode($child); $this->__toString()->shouldReturn('{ "attributes": { "something": "result" }, "nodes": { - "ChildNode": {} + "SimpleNode": { + "attributes": { + "items": [ + { + "attributes": { + "item": "first" + }, + "nodes": [] + } + ] + }, + "nodes": { + "SimpleNode": { + "attributes": { + "depth": 2 + }, + "nodes": [] + } + } + } } +}'); + } + + public function it_replaces_invalid_utf8_when_exporting_as_a_string(): void + { + $this->setAttribute('bad', "a\xffb"); + + $this->__toString()->shouldReturn('{ + "attributes": { + "bad": "a\ufffdb" + }, + "nodes": [] }'); } } diff --git a/src/Node/AbstractNode.php b/src/Node/AbstractNode.php index bf62233..e3f70f6 100644 --- a/src/Node/AbstractNode.php +++ b/src/Node/AbstractNode.php @@ -21,7 +21,15 @@ abstract class AbstractNode implements NodeInterface public function __toString(): string { - return json_encode(['attributes' => $this->attributes, 'nodes' => $this->nodes], JSON_PRETTY_PRINT); + return json_encode($this, JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR); + } + + /** + * @return array{attributes: array, nodes: NodeInterface[]} + */ + public function jsonSerialize(): array + { + return ['attributes' => $this->attributes, 'nodes' => $this->nodes]; } /** diff --git a/src/Node/NodeInterface.php b/src/Node/NodeInterface.php index 6a94172..4898950 100644 --- a/src/Node/NodeInterface.php +++ b/src/Node/NodeInterface.php @@ -4,7 +4,9 @@ namespace chrisjenkinson\StructuredDocumentParser\Node; -interface NodeInterface +use JsonSerializable; + +interface NodeInterface extends JsonSerializable { public function __toString(): string;