diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6fc521197b..3c85044f09 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -56,6 +56,7 @@ clojure = "1.12.2" jaxb-runtime = "2.3.2" kaptcha = "0.0.8" spring-websocket = "6.0.11" +jaxen = "2.0.0" # IOC JCACHE @@ -158,7 +159,7 @@ clojure = { module = "org.clojure:clojure", version.ref = "clojure" } jaxb-runtime = { module = "org.glassfish.jaxb:jaxb-runtime", version.ref = "jaxb-runtime" } kaptcha = { module = "com.github.axet:kaptcha", version.ref = "kaptcha" } spring-websocket = { module = "org.springframework:spring-websocket", version.ref = "spring-websocket" } - +jaxen = { module = "jaxen:jaxen", version.ref = "jaxen" } # IOC JCACHE javax-cache-api = { module = "javax.cache:cache-api", version.ref = "javax-cache" } diff --git a/tapestry-core/build.gradle b/tapestry-core/build.gradle index a5bdb253b3..8b919ee453 100644 --- a/tapestry-core/build.gradle +++ b/tapestry-core/build.gradle @@ -19,6 +19,8 @@ dependencies { implementation libs.commons.codec implementation libs.commons.lang3 + implementation libs.jaxen + provided project(':tapestry-test') provided project(':tapestry-test-constants') diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/dom/BoundXPath.java b/tapestry-core/src/main/java/org/apache/tapestry5/dom/BoundXPath.java new file mode 100644 index 0000000000..de7ed28704 --- /dev/null +++ b/tapestry-core/src/main/java/org/apache/tapestry5/dom/BoundXPath.java @@ -0,0 +1,182 @@ +// Copyright 2026 The Apache Software Foundation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package org.apache.tapestry5.dom; + +import java.util.List; + +import org.apache.tapestry5.dom.xpath.XPath; +import org.apache.tapestry5.dom.xpath.XPathException; + +/** + * An {@link XPath} pre-bound to a context {@link Node}. + *

+ * This allows fluent, node-first queries without repeating the context node: + *

+ * List<Element> items = element.xpath("ul/li").elements();
+ * String text = document.xpath("id('header')").singleNormalizedDescendantText();
+ * 
+ * + * @since 5.10 + * @see Node#xpath(String) + * @see XPath + */ +public class BoundXPath +{ + private final XPath xpath; + + private final Object contextNode; + + BoundXPath(XPath xpath, Object contextNode) + { + assert xpath != null; + assert contextNode != null; + + this.xpath = xpath; + this.contextNode = contextNode; + } + + /** + * Evaluates the XPath and returns all matching DOM nodes. + * + * @return list of matching {@link Node}s; never {@code null} + * @throws XPathException if evaluation fails + * @see XPath#selectNodes(Object) + */ + public List nodes() + { + return xpath.selectNodes(contextNode); + } + + /** + * As {@link #nodes()} but assumes every result is an {@link Element}. + * + * @return list of matched {@link Element}s; never {@code null} + * @throws XPathException if evaluation fails + * @see XPath#selectElements(Object) + */ + public List elements() + { + return xpath.selectElements(contextNode); + } + + /** + * As {@link #nodes()} but assumes every result is an {@link Attribute}. + * + * @return list of matched {@link Attribute}s; never {@code null} + * @throws XPathException if evaluation fails + * @see XPath#selectAttributes(Object) + */ + public List attributes() + { + return xpath.selectAttributes(contextNode); + } + + /** + * Returns the first matching node, or {@code null} if there is no match. + * + * @return the first matched node, or {@code null} + * @throws XPathException if evaluation fails + * @see XPath#selectSingleNode(Object) + */ + public Object singleNode() + { + return xpath.selectSingleNode(contextNode); + } + + /** + * As {@link #singleNode()} but assumes the result is an {@link Element}. + * + * @return the first matched {@link Element}, or {@code null} + * @throws XPathException if evaluation fails + * @see XPath#selectSingleElement(Object) + */ + public Element singleElement() + { + return xpath.selectSingleElement(contextNode); + } + + /** + * Returns the XPath string-value of each matched node. + * + * @return list of string values; never {@code null} + * @throws XPathException if evaluation fails + * @see XPath#stringValuesOf(Object) + */ + public List stringValuesOf() + { + return xpath.stringValuesOf(contextNode); + } + + /** + * Returns {@link Element#getChildMarkup()} for each matched element. + * + * @return list of child-markup strings; never {@code null} + * @throws XPathException if evaluation fails + * @see XPath#selectElementsChildMarkup(Object) + */ + public List elementsChildMarkup() + { + return xpath.selectElementsChildMarkup(contextNode); + } + + /** + * Returns {@link Element#getAttribute(String)} for each matched element. + *

+ * Unlike XPath attribute selection, elements that lack the attribute contribute + * a {@code null} entry so the result list is always the same size as the matched + * element list. + * + * @param attributeName name of the attribute to retrieve; must not be {@code null} or empty + * @return list of attribute values, with {@code null} for missing attributes; never {@code null} + * @throws IllegalArgumentException if {@code attributeName} is {@code null} or empty + * @throws XPathException if evaluation fails + * @see XPath#selectElementsAttribute(Object, String) + */ + public List elementsAttribute(String attributeName) + { + if (attributeName == null || attributeName.isEmpty()) + { + throw new IllegalArgumentException("attributeName must not be null or empty"); + } + + return xpath.selectElementsAttribute(contextNode, attributeName); + } + + /** + * Selects a single node and concatenates all its descendant text, then normalises + * whitespace via {@link XPath#normalizeText(String)}. + * + * @return normalised text string; never {@code null} + * @throws XPathException if evaluation fails + * @see XPath#singleNormalizedDescendantText(Object) + */ + public String singleNormalizedDescendantText() + { + return xpath.singleNormalizedDescendantText(contextNode); + } + + /** + * For each matched node, concatenates all descendant text and normalises + * whitespace via {@link XPath#normalizeText(String)}. + * + * @return list of normalised text strings, one per matched node; never {@code null} + * @throws XPathException if evaluation fails + * @see XPath#normalizedDescendantText(Object) + */ + public List normalizedDescendantText() + { + return xpath.normalizedDescendantText(contextNode); + } +} diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/dom/CData.java b/tapestry-core/src/main/java/org/apache/tapestry5/dom/CData.java index 24ac3cf921..efb9e130ba 100644 --- a/tapestry-core/src/main/java/org/apache/tapestry5/dom/CData.java +++ b/tapestry-core/src/main/java/org/apache/tapestry5/dom/CData.java @@ -1,4 +1,4 @@ -// Copyright 2007, 2008, 2009 The Apache Software Foundation +// Copyright 2007-2009, 2026 The Apache Software Foundation // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -32,6 +32,15 @@ public CData(Element container, String content) this.content = content; } + /** + * Returns the raw character content stored in this node, without any CDATA wrapping or entity encoding. + * @since 5.10 + */ + public String getContent() + { + return content; + } + @Override void toMarkup(Document document, PrintWriter writer, Map namespaceURIToPrefix) { @@ -49,4 +58,15 @@ void toMarkup(Document document, PrintWriter writer, Map namespa writer.print(model.encode(content)); } + + /** + * Returns a deep copy of this CDATA node, detached from any parent. + * + * @since 5.10 + */ + @Override + public CData deepClone() + { + return new CData(null, content); + } } diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Comment.java b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Comment.java index 2e74fe6d77..aaefee6166 100644 --- a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Comment.java +++ b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Comment.java @@ -1,4 +1,4 @@ -// Copyright 2006, 2008, 2009, 2010 The Apache Software Foundation +// Copyright 2006, 2008-2010, 2026 The Apache Software Foundation // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -38,4 +38,15 @@ void toMarkup(Document document, PrintWriter writer, Map namespa writer.print(comment); writer.print("-->"); } + + /** + * Returns a deep copy of this comment node, detached from any parent. + * + * @since 5.10 + */ + @Override + public Comment deepClone() + { + return new Comment(null, comment); + } } diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Document.java b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Document.java index f48ca8fac4..462cd10027 100644 --- a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Document.java +++ b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Document.java @@ -1,4 +1,4 @@ -// Copyright 2006, 2007, 2008, 2009, 2010, 2014 The Apache Software Foundation +// Copyright 2006-2010, 2014, 2026 The Apache Software Foundation // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -248,6 +248,18 @@ void visit(Visitor visitor) rootElement.visit(visitor); } + /** + * Visits all nodes in the document tree, starting from the root element, invoking the + * appropriate {@link NodeVisitor} method for each node type encountered. + * + * @param visitor callback + * @since 5.10 + */ + public void visit(NodeVisitor visitor) + { + rootElement.visit(visitor); + } + private T newChild(T child) { if (preamble == null) @@ -305,6 +317,40 @@ public CData cdata(String content) return newChild(new CData(null, content)); } + /** + * Returns an independent deep copy of this document, including its DTD, preamble nodes, and + * entire element tree. + * + * @return a fully independent deep copy of this document + * @since 5.10 + */ + @Override + public Document deepClone() + { + Document clone = new Document(model, encoding, mimeType); + + // DTD is immutable after construction, so sharing the reference is safe. + clone.dtd = dtd; + + if (preamble != null) + { + clone.preamble = CollectionFactory.newList(); + for (Node n : preamble) + clone.preamble.add(n.deepClone()); + } + + if (rootElement != null) + { + // Use the Document-accepting constructor so the clone's root element + // has its 'document' back-reference set correctly. + Element cloneRoot = new Element(clone, rootElement.getNamespace(), rootElement.getName()); + clone.rootElement = cloneRoot; + rootElement.copyInto(cloneRoot); + } + + return clone; + } + /** * Returns the MIME type of this document. * @return the MIME type. @@ -314,5 +360,4 @@ public String getMimeType() { return mimeType; } - } diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Element.java b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Element.java index 99eb740994..3e0b843cab 100644 --- a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Element.java +++ b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Element.java @@ -33,7 +33,7 @@ public final class Element extends Node private final String name; - private Node firstChild; + Node firstChild; private Node lastChild; @@ -705,6 +705,64 @@ public void pop() remove(); } + /** + * Inserts {@code node} as the first child of this element, shifting any existing children + * forward. If {@code node} is currently attached to another parent, it is detached first. + *

+ * This is the anchor-centric complement to {@link Node#moveToTop(Element)}. + * + * @param node the node to prepend; must not be {@code null} or an ancestor of this element + * @return this element, for method chaining + * @throws IllegalArgumentException if {@code node} is {@code null} or is this element or an ancestor of it + * @since 5.10 + */ + public Element prependChild(Node node) + { + if (node == null) + throw new IllegalArgumentException("node must not be null"); + + Node search = this; + while (search != null) + { + if (search == node) + throw new IllegalArgumentException("Cannot prepend a node to itself or one of its ancestors"); + search = search.container; + } + + node.detach(); + insertChildAt(0, node); + return this; + } + + /** + * Appends {@code node} as the last child of this element. If {@code node} is currently + * attached to another parent, it is detached first. + *

+ * This is the anchor-centric complement to {@link Node#moveToBottom(Element)}. + * + * @param node the node to append; must not be {@code null} or an ancestor of this element + * @return this element, for method chaining + * @throws IllegalArgumentException if {@code node} is {@code null} or is this element or an ancestor of it + * @since 5.10 + */ + public Element appendChild(Node node) + { + if (node == null) + throw new IllegalArgumentException("node must not be null"); + + Node search = this; + while (search != null) + { + if (search == node) + throw new IllegalArgumentException("Cannot append a node to itself or one of its ancestors"); + search = search.container; + } + + node.detach(); + addChild(node); + return this; + } + /** * Removes all children from this element. * @@ -865,6 +923,46 @@ public void visit(Visitor visitor) } } + /** + * Depth-first visitor traversal of this Element and all its descendant nodes, + * including {@link Text}, {@link Comment}, {@link CData}, and {@link Raw} nodes. The traversal + * order is the same as render order (pre-order). + *

+ * All {@link NodeVisitor} methods have default no-op implementations, so implementors only + * need to override the node types they are interested in. + * + * @param visitor callback + * @since 5.10 + */ + public void visit(NodeVisitor visitor) + { + Stack queue = CollectionFactory.newStack(); + + queue.push(this); + + while (!queue.isEmpty()) + { + Node n = queue.pop(); + + if (n instanceof Element) + { + Element e = (Element) n; + visitor.visit(e); + + List children = CollectionFactory.newList(); + for (Node cursor = e.firstChild; cursor != null; cursor = cursor.nextSibling) + children.add(cursor); + Collections.reverse(children); + for (Node child : children) + queue.push(child); + } + else if (n instanceof Text) visitor.visit((Text) n); + else if (n instanceof Comment) visitor.visit((Comment) n); + else if (n instanceof CData) visitor.visit((CData) n); + else if (n instanceof Raw) visitor.visit((Raw) n); + } + } + private void queueChildren(Stack queue) { if (firstChild == null) @@ -945,12 +1043,15 @@ void writeChildMarkup(Document document, PrintWriter writer, Map /** * @return the concatenation of the String representations {@link #toString()} of its children. + * Uses {@link DefaultMarkupModel} as a fallback when the element is not attached to a document. */ public final String getChildMarkup() { PrintOutCollector collector = new PrintOutCollector(); - writeChildMarkup(getDocument(), collector.getPrintWriter(), null); + Document doc = getDocument(); + + writeChildMarkup(doc != null ? doc : new Document(), collector.getPrintWriter(), null); return collector.getPrintOut(); } @@ -976,6 +1077,137 @@ public List getChildren() return result; } + /** + * Returns a deep copy of this element and its entire subtree, detached from any parent or + * document. + *

+ * The clone has no {@link #getContainer() container} and, for elements, no owning + * {@link Document}. It needs to be attached to a tree before calling rendering or + * document-traversal methods. + * + * @return a fully independent deep copy of this element + * @since 5.10 + */ + @Override + public Element deepClone() + { + Element clone = new Element((Element) null, namespace, name); + copyInto(clone); + return clone; + } + + /** + * Detaches this element from its containing element and returns it as an {@link Element}, + * allowing fluent use without a cast. If already detached, this is a no-op. + * + * @return this element, now detached + * @since 5.10 + */ + @Override + public Element detach() + { + return (Element) super.detach(); + } + + /** + * Copies this element's namespace mappings, attributes, and children (recursively deep-cloned) + * into {@code target}. Used internally by {@link #deepClone()} and + * {@link Document#deepClone()}. + */ + void copyInto(Element target) + { + if (namespaceToPrefix != null) + { + target.namespaceToPrefix = CollectionFactory.newMap(); + target.namespaceToPrefix.putAll(namespaceToPrefix); + } + + // Rebuild the attribute linked-list in the same order. + Attribute srcAttr = firstAttribute; + Attribute prevCloneAttr = null; + Attribute firstCloneAttr = null; + + while (srcAttr != null) + { + Attribute cloneAttr = new Attribute(target, + srcAttr.getNamespace(), srcAttr.getName(), srcAttr.getValue(), null); + + if (prevCloneAttr == null) + firstCloneAttr = cloneAttr; + else + prevCloneAttr.nextAttribute = cloneAttr; + + prevCloneAttr = cloneAttr; + srcAttr = srcAttr.nextAttribute; + } + + target.firstAttribute = firstCloneAttr; + + // Recursively clone children. + Node cursor = firstChild; + while (cursor != null) + { + target.addChild(cursor.deepClone()); + cursor = cursor.nextSibling; + } + } + + /** + * Returns the next sibling of this element that is itself an {@link Element}, skipping over + * any intervening text, comment, or other non-element nodes. Returns {@code null} if no such + * sibling exists, or if this element has no container (root element or detached). + * + * @return the next sibling element, or {@code null} + * @since 5.10 + */ + public Element getNextSiblingElement() + { + Node cursor = nextSibling; + + while (cursor != null) + { + if (cursor instanceof Element) + return (Element) cursor; + + cursor = cursor.nextSibling; + } + + return null; + } + + /** + * Returns the previous sibling of this element that is itself an {@link Element}, skipping + * over any intervening text, comment, or other non-element nodes. Returns {@code null} if no + * such sibling exists, or if this element has no container (root element or detached). + *

+ * Because the DOM uses a singly-linked sibling list, this method walks forward from the + * parent's first child; it is O(n) in the number of siblings. + * + * @return the previous sibling element, or {@code null} + * @since 5.10 + */ + public Element getPreviousSiblingElement() + { + if (container == null) + return null; + + Element previousElement = null; + Node cursor = container.firstChild; + + while (cursor != null) + { + if (cursor == this) + return previousElement; + + if (cursor instanceof Element) + previousElement = (Element) cursor; + + cursor = cursor.nextSibling; + } + + throw new IllegalStateException("Element is not a child of its own container."); + } + void remove(Node node) { Node prior = null; diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Node.java b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Node.java index fb5c351a00..19c20a7b5c 100644 --- a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Node.java +++ b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Node.java @@ -15,8 +15,11 @@ package org.apache.tapestry5.dom; import java.io.PrintWriter; +import java.util.Collections; import java.util.Map; +import org.apache.tapestry5.dom.xpath.XPath; +import org.apache.tapestry5.dom.xpath.XPathException; import org.apache.tapestry5.internal.util.PrintOutCollector; /** @@ -51,14 +54,18 @@ public Element getContainer() return container; } + /** + * Returns the owning {@link Document}, or {@code null} if this node is detached (not part of + * any document tree). + */ public Document getDocument() { - return container.getDocument(); + return container != null ? container.getDocument() : null; } - /** * Invokes {@link #toMarkup(PrintWriter)}, collecting output in a string, which is returned. + * When the node is detached from a document, {@link DefaultMarkupModel} is used as a fallback. */ @Override public String toString() @@ -70,20 +77,46 @@ public String toString() return collector.getPrintOut(); } + /** + * Renders this node to a {@code String} using the specified {@link MarkupModel}, without + * requiring the node to be attached to a {@link Document}. + *

+ * This is the preferred rendering method for detached nodes (such as those returned by + * {@link #deepClone()}). For attached nodes, {@link #toString()} uses the document's + * model automatically. + * + * @param model the markup model controlling encoding and tag-style decisions; must not be null + * @return the rendered markup as a string + * @since 5.10 + */ + public String toMarkup(MarkupModel model) + { + assert model != null; + + PrintOutCollector collector = new PrintOutCollector(); + + toMarkup(new Document(model), collector.getPrintWriter(), getNamespaceURIToPrefix()); + + return collector.getPrintOut(); + } /** - * Writes the markup for this node to the writer. + * Writes the markup for this node to the writer, using {@link DefaultMarkupModel} as a + * fallback when the node is not attached to a document. */ public void toMarkup(PrintWriter writer) { - toMarkup(getDocument(), writer, getNamespaceURIToPrefix()); + Document doc = getDocument(); + + toMarkup(doc != null ? doc : new Document(), writer, getNamespaceURIToPrefix()); } protected Map getNamespaceURIToPrefix() { // For non-Elements, the container (which should be an Element) will provide the mapping. + // For detached nodes the container is null; return an empty map (no ancestor namespaces). - return container.getNamespaceURIToPrefix(); + return container != null ? container.getNamespaceURIToPrefix() : Collections.emptyMap(); } /** @@ -160,6 +193,150 @@ public Node moveToBottom(Element element) return this; } + /** + * Inserts {@code node} immediately before {@code this} in the parent's child list, detaching + * it from any prior parent first. + *

+ * This is the anchor-centric complement to {@link #moveBefore(Element)}; unlike + * {@code moveBefore}, the anchor ({@code this}) may be any {@link Node} type, not just an + * {@link Element}. + * + * @param node the node to insert; must not be {@code null} or {@code this} + * @return the inserted {@code node}, now in position + * @throws IllegalArgumentException if {@code node} is {@code null}, is {@code this}, or is an ancestor of {@code this} + * @throws IllegalStateException if {@code this} is detached (has no parent) + * @since 5.10 + */ + public Node insertBefore(Node node) + { + if (node == null) + { + throw new IllegalArgumentException("node must not be null"); + } + if (node == this) + { + throw new IllegalArgumentException("A node cannot be inserted relative to itself"); + } + if (container == null) + { + throw new IllegalStateException("Cannot insert relative to a detached node"); + } + + Node search = container; + while (search != null) + { + if (search == node) + { + throw new IllegalArgumentException("Cannot insert an ancestor node"); + } + + search = search.container; + } + + node.detach(); + container.insertChildBefore(this, node); + + return node; + } + + /** + * Inserts {@code node} immediately after {@code this} in the parent's child list, detaching + * it from any prior parent first. + *

+ * This is the anchor-centric complement to {@link #moveAfter(Element)}; unlike + * {@code moveAfter}, the anchor ({@code this}) may be any {@link Node} type, not just an + * {@link Element}. + * + * @param node the node to insert; must not be {@code null} or {@code this} + * @return the inserted {@code node}, now in position + * @throws IllegalArgumentException if {@code node} is {@code null}, is {@code this}, or is an ancestor of {@code this} + * @throws IllegalStateException if {@code this} is detached (has no parent) + * @since 5.10 + */ + public Node insertAfter(Node node) + { + if (node == null) + { + throw new IllegalArgumentException("node must not be null"); + } + if (node == this) + { + throw new IllegalArgumentException("A node cannot be inserted relative to itself"); + } + if (container == null) + { + throw new IllegalStateException("Cannot insert relative to a detached node"); + } + + Node search = container; + while (search != null) + { + if (search == node) + { + throw new IllegalArgumentException("Cannot insert an ancestor node"); + } + + search = search.container; + } + + node.detach(); + container.insertChildAfter(this, node); + + return node; + } + + /** + * Returns the next sibling node within the containing element, or {@code null} + * if {@code this} is: + *

    + *
  • the last child
  • + *
  • not part of a sibling list (root element, document-preamble node, or + * detached node)
  • + *
+ * + * @return the next sibling node, or {@code null} + * @since 5.10 + */ + public Node getNextSibling() + { + return nextSibling; + } + + /** + * Returns the previous sibling node within the containing element, or {@code null} + * if {@this} is: + *
    + *
  • the first child
  • + *
  • not part of a sibling list (root element, document-preamble node, or detached node). + *
+ * + * @return the previous sibling node, or {@code null} + * @since 5.10 + */ + public Node getPreviousSibling() + { + if (container == null) + { + return null; + } + + Node previous = null; + Node cursor = container.firstChild; + + while (cursor != null) + { + if (cursor == this) + { + return previous; + } + + previous = cursor; + cursor = cursor.nextSibling; + } + + throw new IllegalStateException("Node is not a child of its own container."); + } + private void validateElement(Element element) { assert element != null; @@ -187,6 +364,69 @@ public void remove() container = null; } + /** + * Replaces this node in the DOM with the given {@code replacement} node, detaching this node and + * inserting the replacement in its place. + *

+ * If {@code replacement} is currently attached to another parent, it is detached first. + * + * @param replacement the node to put in place of this node, must not be {@code null} or {@this} + * @return the replacement node, now in position + * @throws IllegalArgumentException if {@code replacement} is {@code null}, is {@this}, or is an ancestor of {@this} + * @throws IllegalStateException if {@this} is detached (has no parent) + * @since 5.10 + */ + public Node replaceWith(Node replacement) + { + if (replacement == null) + { + throw new IllegalArgumentException("replacement must not be null"); + } + if (replacement == this) + { + throw new IllegalArgumentException("A node cannot replace itself"); + } + if (container == null) + { + throw new IllegalStateException("Cannot replace a detached node"); + } + + // Guard against ancestor cycles: replacement must not be an ancestor of this node. + Node search = container; + while (search != null) + { + if (search == replacement) + { + throw new IllegalArgumentException("Cannot replace a node with one of its ancestors"); + } + + search = search.container; + } + + Element parent = container; + replacement.detach(); + parent.insertChildBefore(this, replacement); + remove(); + + return replacement; + } + + /** + * Detaches this node from its containing element and returns it. + *

+ * If the node is already detached (no container), this method is a no-op. + * + * @return this node, now detached + * @since 5.10 + */ + public Node detach() + { + if (container != null) + remove(); + + return this; + } + /** * Wraps a node inside a new element. The new element is created before the node, then the node is moved inside the * new element. @@ -207,4 +447,39 @@ public Element wrap(String elementName, String... namesAndValues) return element; } + + /** + * Returns a deep copy of this node and its entire subtree, detached from any parent or + * document. + *

+ * Detached nodes can be still be rendered via {@link #toString()} (which falls back to + * {@link DefaultMarkupModel}) or with an explicit model via {@link #toMarkup(MarkupModel)}. + * + * @return a fully independent deep copy of this node + * @since 5.10 + */ + public abstract Node deepClone(); + + /** + * Creates a {@link BoundXPath} for the given XPath expression with this node as the context, + * allowing fluent queries without repeating the context node: + *

+     * List<Element> items = element.xpath("ul/li").elements();
+     * 
+ * + * @param expression XPath expression; must not be null or empty + * @return a {@link BoundXPath} bound to this node + * @throws IllegalArgumentException if {@code expression} is null or empty + * @throws XPathException if the expression cannot be parsed + * @since 5.10 + */ + public BoundXPath xpath(String expression) + { + if (expression == null || expression.isEmpty()) + { + throw new IllegalArgumentException("XPath expression must not be null or empty"); + } + + return new BoundXPath(XPath.of(expression), this); + } } diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/dom/NodeVisitor.java b/tapestry-core/src/main/java/org/apache/tapestry5/dom/NodeVisitor.java new file mode 100644 index 0000000000..9f75cec17d --- /dev/null +++ b/tapestry-core/src/main/java/org/apache/tapestry5/dom/NodeVisitor.java @@ -0,0 +1,67 @@ +// Copyright 2026 The Apache Software Foundation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package org.apache.tapestry5.dom; + +/** + * A callback interface used to traverse every kind of {@link Node} in a DOM subtree: + * {@link Element}, {@link Text}, {@link Comment}, {@link CData}, and {@link Raw}. + *

+ * All methods have empty default implementations so implementors only override the node types + * they care about. Traversal is depth-first pre-order (render order), matching the behaviour of + * {@link Element#visit(Visitor)} but including non-element nodes. + *

+ * Use {@link Element#visit(NodeVisitor)} or {@link Document#visit(NodeVisitor)} to start a + * traversal. + * + * @since 5.10 + * @see Visitor + */ +public interface NodeVisitor +{ + /** + * Called for each {@link Element} encountered during traversal. + * + * @param element the element being visited + */ + default void visit(Element element) {} + + /** + * Called for each {@link Text} node encountered during traversal. + * + * @param text the text node being visited + */ + default void visit(Text text) {} + + /** + * Called for each {@link Comment} node encountered during traversal. + * + * @param comment the comment node being visited + */ + default void visit(Comment comment) {} + + /** + * Called for each {@link CData} node encountered during traversal. + * + * @param cdata the CDATA node being visited + */ + default void visit(CData cdata) {} + + /** + * Called for each {@link Raw} node encountered during traversal. + * + * @param raw the raw-markup node being visited + */ + default void visit(Raw raw) {} +} diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Raw.java b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Raw.java index 5aa74fa312..63a764e745 100644 --- a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Raw.java +++ b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Raw.java @@ -1,4 +1,4 @@ -// Copyright 2007, 2008, 2009 The Apache Software Foundation +// Copyright 2007, 2008, 2009, 2026 The Apache Software Foundation // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -39,4 +39,15 @@ void toMarkup(Document document, PrintWriter writer, Map namespa { writer.print(text); } + + /** + * Returns a deep copy of this raw-markup node, detached from any parent. + * + * @since 5.10 + */ + @Override + public Raw deepClone() + { + return new Raw(null, text); + } } diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Text.java b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Text.java index 02a2abf39a..186196db56 100644 --- a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Text.java +++ b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Text.java @@ -1,4 +1,4 @@ -// Copyright 2006, 2008, 2009 The Apache Software Foundation +// Copyright 2006, 2008, 2009, 2026 The Apache Software Foundation // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -22,20 +22,20 @@ */ public final class Text extends Node { - private final StringBuilder buffer; + private final StringBuilder builder; Text(Element container, String text) { super(container); - buffer = new StringBuilder(text.length()); + builder = new StringBuilder(text.length()); write(text); } boolean isEmpty() { - return buffer.length() == 0 || buffer.toString().trim().length() == 0; + return builder.length() == 0 || builder.toString().trim().length() == 0; } /** @@ -43,7 +43,7 @@ boolean isEmpty() */ public void write(String text) { - buffer.append(text); + builder.append(text); } public void writef(String format, Object... args) @@ -54,8 +54,19 @@ public void writef(String format, Object... args) @Override void toMarkup(Document document, PrintWriter writer, Map namespaceURIToPrefix) { - String encoded = document.getMarkupModel().encode(buffer.toString()); + String encoded = document.getMarkupModel().encode(builder.toString()); writer.print(encoded); } + + /** + * Returns a deep copy of this text node, detached from any parent. + * + * @since 5.10 + */ + @Override + public Text deepClone() + { + return new Text(null, builder.toString()); + } } diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Visitor.java b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Visitor.java index 577ed8840a..6bc8a9d72b 100644 --- a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Visitor.java +++ b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Visitor.java @@ -15,16 +15,49 @@ package org.apache.tapestry5.dom; /** - * A callback interface used navigate the {@link org.apache.tapestry5.dom.Element}s of a document. + * A callback interface used to traverse the nodes of a DOM subtree. Traversal is depth-first + * pre-order (render order), visiting {@link Element}, {@link Text}, {@link Comment}, {@link CData}, + * and {@link Raw} nodes. + *

+ * {@link #visit(Element)} is the only required method; the remaining overloads have empty default + * implementations so implementors only override the node types they care about. * * @since 5.1.0.0 */ public interface Visitor { /** - * Called for each Element being visited. + * Called for each {@link Element} encountered during traversal. * - * @param element visited + * @param element the element being visited */ void visit(Element element); + + /** + * Called for each {@link Text} node encountered during traversal. + * + * @param text the text node being visited + */ + default void visit(Text text) {} + + /** + * Called for each {@link Comment} node encountered during traversal. + * + * @param comment the comment node being visited + */ + default void visit(Comment comment) {} + + /** + * Called for each {@link CData} node encountered during traversal. + * + * @param cdata the CDATA node being visited + */ + default void visit(CData cdata) {} + + /** + * Called for each {@link Raw} node encountered during traversal. + * + * @param raw the raw-markup node being visited + */ + default void visit(Raw raw) {} } diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/dom/xpath/DocumentNavigator.java b/tapestry-core/src/main/java/org/apache/tapestry5/dom/xpath/DocumentNavigator.java new file mode 100644 index 0000000000..8441364e9f --- /dev/null +++ b/tapestry-core/src/main/java/org/apache/tapestry5/dom/xpath/DocumentNavigator.java @@ -0,0 +1,430 @@ +// Copyright 2026 The Apache Software Foundation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package org.apache.tapestry5.dom.xpath; + +import java.util.Iterator; + +import org.apache.tapestry5.dom.Attribute; +import org.apache.tapestry5.dom.CData; +import org.apache.tapestry5.dom.Comment; +import org.apache.tapestry5.dom.Document; +import org.apache.tapestry5.dom.Element; +import org.apache.tapestry5.dom.Node; +import org.apache.tapestry5.dom.Text; +import org.jaxen.BaseXPath; +import org.jaxen.DefaultNavigator; +import org.jaxen.JaxenConstants; +import org.jaxen.JaxenException; +import org.jaxen.UnsupportedAxisException; +import org.jaxen.saxpath.SAXPathException; +import org.jaxen.util.SingleObjectIterator; + +/** + * Jaxen {@link org.jaxen.Navigator} implementation that adapts Tapestry's DOM + * nodes. + * + *

Intentionally unsupported: namespace nodes and processing + * instructions, because Tapestry's DOM does not model either.

+ * + * @since 5.10 + * @see XPath + */ +class DocumentNavigator extends DefaultNavigator +{ + public static final DocumentNavigator INSTANCE = new DocumentNavigator(); + + private static final int COMMENT_PREFIX_LENGTH = "".length(); + + /** + * Returns an iterator over the attributes of {@code contextNode}. + *

+ * Only {@link Element} nodes have attributes, all other node types + * yield an empty iterator. + * + * @param contextNode the node whose attribute axis is requested + * @return iterator of {@link Attribute} objects, or an empty iterator + */ + @Override + public Iterator getAttributeAxisIterator(Object contextNode) + { + if (contextNode instanceof Element) + { + return ((Element) contextNode).getAttributes().iterator(); + } + + return JaxenConstants.EMPTY_ITERATOR; + } + + /** + * Returns an iterator over the children of {@code contextNode}. + *

    + *
  • {@link Document}: its single root {@link Element}.
  • + *
  • {@link Element}: all child {@link Node}s in document order.
  • + *
  • Other node types: empty iterator (leaf nodes).
  • + *
+ * + * @param contextNode the node whose child axis is requested + * @return iterator of child nodes, or an empty iterator + */ + @Override + public Iterator getChildAxisIterator(Object contextNode) + { + if (contextNode instanceof Document) + { + return new SingleObjectIterator(((Document) contextNode).getRootElement()); + } + + if (contextNode instanceof Element) + { + return ((Element) contextNode).getChildren().iterator(); + } + + return JaxenConstants.EMPTY_ITERATOR; + } + + /** + * Returns an iterator over the parent of {@code contextNode}. + *
    + *
  • Non-root {@link Element}: the containing {@link Element}.
  • + *
  • Root {@link Element}: the owning {@link Document}.
  • + *
  • {@link Text}, {@link Comment}, {@link CData}, {@link org.apache.tapestry5.dom.Raw}: + * the containing {@link Element}.
  • + *
  • {@link Document} and anything else: delegates to the default + * implementation (returns empty).
  • + *
+ * + * @param contextNode the node whose parent axis is requested + * @return single-element iterator containing the parent node, or an empty iterator + * @throws UnsupportedAxisException if the axis is not supported for the node type + */ + @Override + public Iterator getParentAxisIterator(Object contextNode) throws UnsupportedAxisException + { + if (contextNode instanceof Element) + { + Element e = (Element) contextNode; + Element parent = e.getContainer(); + // Root element's logical parent in XPath is the Document, not null + return new SingleObjectIterator(parent != null ? parent : e.getDocument()); + } + + if (contextNode instanceof Node) + { + // Text, Comment, CData, Raw: parent is always their containing Element + Element container = ((Node) contextNode).getContainer(); + return container != null + ? new SingleObjectIterator(container) + : JaxenConstants.EMPTY_ITERATOR; + } + + return super.getParentAxisIterator(contextNode); + } + + /** + * Returns the {@link Document} that owns {@code contextNode}. + * + * @param contextNode any {@link Node} in the document + * @return the owning {@link Document}; never {@code null} + */ + @Override + public Object getDocumentNode(Object contextNode) + { + return ((Node) contextNode).getDocument(); + } + + /** + * Looks up an element by its {@code id} attribute within the document that + * contains {@code contextNode}. + * + * @param contextNode any {@link Node} used to locate the owning {@link Document} + * @param elementId the value of the {@code id} attribute to search for + * @return the matching {@link Element}, or {@code null} if not found + */ + @Override + public Object getElementById(Object contextNode, String elementId) + { + return ((Node) contextNode).getDocument().getElementById(elementId); + } + + /** + * Namespace nodes are not supported. + * Always returns {@code null}. + * + * @param contextNode ignored + * @return {@code null} + */ + @Override + public String getNamespacePrefix(Object contextNode) + { + return null; + } + + /** + * Namespace nodes are not supported. + * Always returns {@code null}. + * + * @param contextNode ignored + * @return {@code null} + */ + @Override + public String getNamespaceStringValue(Object contextNode) + { + return null; + } + + /** + * Returns the local name of {@code element}. + * + * @param element an {@link Element} node + * @return the element's local name; never {@code null} + */ + @Override + public String getElementName(Object element) + { + return ((Element) element).getName(); + } + + /** + * Namespace URIs are not supported. + * Always returns {@code null}. + * + * @param arg0 ignored + * @return {@code null} + */ + @Override + public String getElementNamespaceUri(Object arg0) + { + return null; + } + + /** + * Qualified names are not supported. + * Always returns {@code null}. + * + * @param contextNode ignored + * @return {@code null} + */ + @Override + public String getElementQName(Object contextNode) + { + return null; + } + + /** + * Returns the XPath string-value of {@code element}, which is the concatenated + * text of the element's rendered child markup. + * + * @param element an {@link Element} node + * @return the element's child markup string, never {@code null} + */ + @Override + public String getElementStringValue(Object element) + { + return ((Element) element).getChildMarkup(); + } + + /** + * Returns the local name of {@code attr}. + * + * @param attr an {@link Attribute} + * @return the attribute's name, never {@code null} + */ + @Override + public String getAttributeName(Object attr) + { + return ((Attribute) attr).getName(); + } + + /** + * Namespace URIs are not supported. + * Always returns {@code null}. + * + * @param contextNode ignored + * @return {@code null} + */ + @Override + public String getAttributeNamespaceUri(Object contextNode) + { + return null; + } + + /** + * Qualified names are not supported. + * Always returns {@code null}. + * + * @param contextNode ignored + * @return {@code null} + */ + @Override + public String getAttributeQName(Object contextNode) + { + return null; + } + + /** + * Returns the value of {@code attr}. + * + * @param attr an {@link Attribute} + * @return the attribute's value; never {@code null} + */ + @Override + public String getAttributeStringValue(Object attr) + { + return ((Attribute) attr).getValue(); + } + + /** + * Returns the text content of {@code comment}. + * The comment string gets stripped of its {@code } suffix. + * + * @param comment a {@link Comment} node + * @return the comment's inner text, including any surrounding whitespace + */ + @Override + public String getCommentStringValue(Object comment) + { + String fullComment = ((Comment) comment).toString(); + return fullComment.substring(COMMENT_PREFIX_LENGTH, fullComment.length() - COMMENT_SUFFIX_LENGTH); + } + + /** + * Returns the string value of a text or CDATA node. + * Both {@link Text} and {@link CData} are treated as XPath text nodes. + * + * @param text a {@link Text} or {@link CData} node + * @return the node's text content, never {@code null} + */ + @Override + public String getTextStringValue(Object text) + { + if (text instanceof CData) + { + return ((CData) text).getContent(); + } + + return ((Text) text).toString(); + } + + /** + * Returns whether the given object is an attribute node. + * + * @param object the object to test + * @return {@code true} if {@code object} is an {@link Attribute} + */ + @Override + public boolean isAttribute(Object object) + { + return object instanceof Attribute; + } + + /** + * Returns whether the given object is a comment node. + * + * @param object the object to test + * @return {@code true} if {@code object} is a {@link Comment} + */ + @Override + public boolean isComment(Object object) + { + return object instanceof Comment; + } + + /** + * Returns whether the given object is a document node. + * + * @param object the object to test + * @return {@code true} if {@code object} is a {@link Document} + */ + @Override + public boolean isDocument(Object object) + { + return object instanceof Document; + } + + /** + * Returns whether the given object is an element node. + * + * @param object the object to test + * @return {@code true} if {@code object} is an {@link Element} + */ + @Override + public boolean isElement(Object object) + { + return object instanceof Element; + } + + /** + * Namespace nodes are not supported. + * Always returns {@code false}. + * + * @param object ignored + * @return {@code false} + */ + @Override + public boolean isNamespace(Object object) + { + return false; + } + + /** + * Processing instructions are not supported. + * Always returns {@code false}. + * + * @param object ignored + * @return {@code false} + */ + @Override + public boolean isProcessingInstruction(Object object) + { + return false; + } + + /** + * Returns {@code true} for {@link Text} and {@link CData} nodes. + * CDATA sections are text nodes in the XPath data model. + * + * @param object the object to test + * @return {@code true} if {@code object} is a {@link Text} or {@link CData} + */ + @Override + public boolean isText(Object object) + { + return object instanceof Text || object instanceof CData; + } + + /** + * Parses {@code xpath} and returns a Jaxen {@link org.jaxen.XPath} instance backed + * by this navigator. Used internally by Jaxen to evaluate sub-expressions such as + * those inside predicates. + * + * @param xpath XPath expression string + * @return compiled Jaxen {@link org.jaxen.XPath} + * @throws SAXPathException if the expression cannot be parsed + */ + @Override + public org.jaxen.XPath parseXPath(String xpath) throws SAXPathException + { + try + { + return new BaseXPath(xpath, INSTANCE); + } + catch (JaxenException e) + { + throw new SAXPathException(e.getMessage()); + } + } +} diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/dom/xpath/XPath.java b/tapestry-core/src/main/java/org/apache/tapestry5/dom/xpath/XPath.java new file mode 100644 index 0000000000..6a68d38368 --- /dev/null +++ b/tapestry-core/src/main/java/org/apache/tapestry5/dom/xpath/XPath.java @@ -0,0 +1,393 @@ +// Copyright 2026 The Apache Software Foundation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package org.apache.tapestry5.dom.xpath; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.tapestry5.dom.Attribute; +import org.apache.tapestry5.dom.BoundXPath; +import org.apache.tapestry5.dom.Document; +import org.apache.tapestry5.dom.Element; +import org.apache.tapestry5.dom.Node; +import org.jaxen.BaseXPath; +import org.jaxen.JaxenException; +import org.jaxen.function.StringFunction; + +/** + * XPath engine to navigate Tapestry's DOM. + *

+ * Use the factory method {@link #of(String)} to create instances, then call one of + * the {@code select*} methods with a {@link Document} or {@link Node} as the context. + *

+ * For a node-first, fluent style without repeating the context, obtain a + * {@link BoundXPath} via {@link Node#xpath(String)} instead. + * + * @since 5.10 + * @see Node#xpath(String) + * @see BoundXPath + */ +public class XPath +{ + private final BaseXPath delegate; + + private static final XPath TEXT_DESCENDANTS = XPath.of("descendant::text()"); + + private XPath(String expression) throws JaxenException + { + this.delegate = new BaseXPath(expression, DocumentNavigator.INSTANCE); + } + + /** + * Creates a new {@code XPath} for the given {@code expression}. + * + * @param expression XPath expression to compile; must not be {@code null} or empty + * @return an {@code XPath} instance + * @throws IllegalArgumentException if {@code expression} is {@code null} or empty + * @throws XPathException if the expression cannot be parsed + */ + public static XPath of(String expression) + { + if (expression == null || expression.isEmpty()) + { + throw new IllegalArgumentException("XPath expression must not be null or empty"); + } + + try + { + return new XPath(expression); + } + catch (JaxenException e) + { + throw new XPathException("Invalid XPath expression: " + expression, e); + } + } + + /** + * Evaluates this instance against {@code node} and returns all matching DOM + * objects as Tapestry {@link Node} instances. + * + * @param node context {@link Document} or {@link Node} to evaluate against + * @return list of matching nodes; never {@code null} + * @throws XPathException if evaluation fails + */ + @SuppressWarnings("unchecked") + public List selectNodes(Object node) + { + try + { + return (List) delegate.selectNodes(node); + } + catch (JaxenException e) + { + throw new XPathException("XPath evaluation failed", e); + } + } + + /** + * Uses {@link #selectNodes(Object)} but assumes every result is an {@link Element}. + * + * @param node context {@link Document} or {@link Node} + * @return list of matched {@link Element}s, never {@code null} + * @throws XPathException if evaluation fails + */ + @SuppressWarnings("unchecked") + public List selectElements(Object node) + { + try + { + return (List) delegate.selectNodes(node); + } + catch (JaxenException e) + { + throw new XPathException("XPath evaluation failed", e); + } + } + + /** + * Uses {@link #selectNodes(Object)} but assumes every result is an {@link Attribute}. + * + * @param node context {@link Document} or {@link Node} + * @return list of matched {@link Attribute}s, never {@code null} + * @throws XPathException if evaluation fails + */ + @SuppressWarnings("unchecked") + public List selectAttributes(Object node) + { + try + { + return (List) delegate.selectNodes(node); + } + catch (JaxenException e) + { + throw new XPathException("XPath evaluation failed", e); + } + } + + /** + * Evaluates this instance and converts each matched node to its XPath string-value. + * + * @param node context {@link Document} or {@link Node} + * @return list of string values; never {@code null} + * @throws XPathException if evaluation fails + * @see XPath Spec: string-value + * @see #normalizedDescendantText(Object) + */ + public List stringValuesOf(Object node) + { + try + { + List resultNodes = delegate.selectNodes(node); + + List resultStrings = new ArrayList(resultNodes.size()); + + for (Object result : resultNodes) + { + resultStrings.add(StringFunction.evaluate(result, DocumentNavigator.INSTANCE)); + } + + return resultStrings; + } + catch (JaxenException e) + { + throw new XPathException("XPath evaluation failed", e); + } + } + + /** + * Returns {@link Element#getChildMarkup()} for each element matched by this instance. + * + * @param node context {@link Document} or {@link Node} + * @return list of child-markup strings; never {@code null} + * @throws XPathException if evaluation fails + */ + public List selectElementsChildMarkup(Object node) + { + List selectedNodes = selectElements(node); + + List childMarkup = new ArrayList(selectedNodes.size()); + + for (Element element : selectedNodes) + { + childMarkup.add(element.getChildMarkup()); + } + + return childMarkup; + } + + /** + * Returns {@link Element#getAttribute(String)} for each element matched by this XPath. + *

+ * Unlike using XPath attribute selection, elements that lack the attribute contribute a + * {@code null} entry, so the result list is always the same size as the matched element list. + * + * @param node context {@link Document} or {@link Node} + * @param attributeName name of the attribute to retrieve from each matched element; must not be {@code null} or empty + * @return list of attribute values, with {@code null} for missing attributes; never {@code null} + * @throws IllegalArgumentException if {@code attributeName} is {@code null} or empty + * @throws XPathException if evaluation fails + */ + public List selectElementsAttribute(Object node, String attributeName) + { + if (attributeName == null || attributeName.isEmpty()) + { + throw new IllegalArgumentException("attributeName must not be null or empty"); + } + + List selectedNodes = selectElements(node); + + List attributeValues = new ArrayList(selectedNodes.size()); + + for (Element element : selectedNodes) + { + attributeValues.add(element.getAttribute(attributeName)); + } + + return attributeValues; + } + + /** + * Uses {@link #selectSingleNode(Object)} but assumes the result is an {@link Element}. + * + * @param node context {@link Document} or {@link Node} + * @return the first matched {@link Element}, or {@code null} if there is no match + * @throws XPathException if evaluation fails + */ + public Element selectSingleElement(Object node) + { + return (Element) selectSingleNode(node); + } + + /** + * Returns the first node matched by this instance, or {@code null} if there is no match. + * + * @param node context {@link Document} or {@link Node} + * @return the first matched node, or {@code null} + * @throws XPathException if evaluation fails + */ + public Object selectSingleNode(Object node) + { + try + { + return delegate.selectSingleNode(node); + } + catch (JaxenException e) + { + throw new XPathException("XPath evaluation failed", e); + } + } + + /** + * Selects a single node and concatenates all its descendant text nodes, then + * normalizes whitespace via {@link #normalizeText(String)}. + *

+ * Useful for getting an approximation of the text a browser would display. + * + * @param node context {@link Document} or {@link Node} + * @return normalised descendant text; never {@code null} + * @throws XPathException if evaluation fails + * @see #normalizeText(String) + */ + public String singleNormalizedDescendantText(Object node) + { + Object foundNode = selectSingleNode(node); + + List textNodeValues = TEXT_DESCENDANTS.stringValuesOf(foundNode); + + return normalizeText(concatenate(textNodeValues)); + } + + /** + * Xoncatenates all descendant text nodes for each node matched by this XPath + * and normalizes whitespace via {@link #normalizeText(String)}. + *

+ * Useful for getting an approximation of the text a browser would display. + * + * @param node context {@link Document} or {@link Node} + * @return list of normalised text strings, one per matched node; never {@code null} + * @throws XPathException if evaluation fails + * @see #normalizeText(String) + */ + public List normalizedDescendantText(Object node) + { + List nodes = selectNodes(node); + + List results = new ArrayList(nodes.size()); + + for (Node foundNode : nodes) + { + List textNodeValues = TEXT_DESCENDANTS.stringValuesOf(foundNode); + results.add(normalizeText(concatenate(textNodeValues))); + } + return results; + } + + /** + * Normalizes text from the DOM to approximate what a browser would display for + * Latin scripts: all whitespace characters (including non-breaking space) are + * replaced with a plain space, consecutive spaces are collapsed to one, and + * leading/trailing spaces are removed. + *

+ * This method is package-private so it can be tested. + * + * @param text raw text to normalise; may be {@code null} or empty + * @return normalised text, or the original value if it was {@code null} or empty + */ + String normalizeText(String text) + { + if (text == null || text.isEmpty()) + { + return text; + } + + int len = text.length(); + int start = 0; + + // Skip leading whitespace without allocating anything + while (start < len && isWhitespace(text.charAt(start))) + { + start++; + } + + if (start == len) + { + return ""; + } + + // Find trailing whitespace to reduce scope + int end = len - 1; + while (end >= start && isWhitespace(text.charAt(end))) + { + end--; + } + + StringBuilder builder = new StringBuilder(end - start + 1); + boolean inWhitespace = false; + + for (int i = start; i <= end; i++) + { + char c = text.charAt(i); + if (isWhitespace(c)) + { + if (!inWhitespace) + { + builder.append(' '); + inWhitespace = true; + } + } else + { + builder.append(c); + inWhitespace = false; + } + } + + return builder.toString(); + } + + // Private helper (JIT compiler will likely inline this) + private static boolean isWhitespace(char c) { + // 0x100002600L is a bitmask where bits 9, 10, 13, and 32 are set to 1 + // Checks for: c == ' ' || c == '\t' || c == '\r' || c == '\n' + return (c <= 32 && (0x100002600L & (1L << c)) != 0) || c == '\u00A0'; + } + + private String concatenate(List strings) + { + if (strings.isEmpty()) + { + return ""; + } + + if (strings.size() == 1) + { + return strings.get(0); + } + + int totalLength = 0; + for (String s : strings) + { + if (s != null) totalLength += s.length(); + } + + StringBuilder builder = new StringBuilder(totalLength); + + for (String string : strings) + { + builder.append(string); + } + + return builder.toString(); + } +} diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/dom/xpath/XPathException.java b/tapestry-core/src/main/java/org/apache/tapestry5/dom/xpath/XPathException.java new file mode 100644 index 0000000000..ae394a345c --- /dev/null +++ b/tapestry-core/src/main/java/org/apache/tapestry5/dom/xpath/XPathException.java @@ -0,0 +1,34 @@ +// Copyright 2026 The Apache Software Foundation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package org.apache.tapestry5.dom.xpath; + +/** + * Unchecked exception thrown when an XPath expression cannot be parsed or evaluated. + *

+ * Wraps Jaxen's checked {@link org.jaxen.JaxenException} so callers do not need to + * handle it explicitly. + * + * @since 5.10 + */ +public class XPathException extends RuntimeException { + + /** + * @param message human-readable description of the failure + * @param cause the underlying {@link org.jaxen.JaxenException} + */ + public XPathException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/tapestry-core/src/test/java/org/apache/tapestry5/dom/BoundXPathTest.java b/tapestry-core/src/test/java/org/apache/tapestry5/dom/BoundXPathTest.java new file mode 100644 index 0000000000..da362f2ccf --- /dev/null +++ b/tapestry-core/src/test/java/org/apache/tapestry5/dom/BoundXPathTest.java @@ -0,0 +1,222 @@ +// Copyright 2026 The Apache Software Foundation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package org.apache.tapestry5.dom; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Arrays; +import java.util.List; + +import org.apache.tapestry5.dom.xpath.XPathException; +import org.apache.tapestry5.test.PageTester; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class BoundXPathTest { + + private static Document page; + + @BeforeAll + static void setup() + { + PageTester pageTester = new PageTester("org.apache.tapestry5.dom", "app"); + page = pageTester.renderPage("XPathTest"); + pageTester.shutdown(); + } + + @Nested + @DisplayName("Element selection") + class ElementSelection + { + @Test + void selectsAllMatchingElements() + { + List children = page.xpath("id('child-elements')/*").elements(); + + assertEquals(2, children.size()); + assertEquals("Div Child", children.get(0).getChildMarkup()); + assertEquals("Span Child", children.get(1).getChildMarkup()); + } + + @Test + void selectsSingleElement() + { + Element div = page.xpath("id('single-div')").singleElement(); + + assertEquals("single-div", div.getAttribute("id")); + } + + @Test + void selectsSingleElementFromElementContext() + { + Element childElements = page.xpath("id('child-elements')").singleElement(); + Element span = childElements.xpath("span").singleElement(); + + assertEquals("Span Child", span.getChildMarkup()); + } + + @Test + void selectsSingleElementReturnsNullWhenNotFound() + { + Element element = page.xpath("id('does-not-exist')").singleElement(); + + assertNull(element); + } + } + + @Nested + @DisplayName("Node selection") + class NodeSelection + { + @Test + void selectsCommentNodes() + { + List comments = page.xpath("id('comment-parent')//comment()").nodes(); + + assertEquals(2, comments.size()); + assertEquals("", comments.get(0).toString()); + assertEquals("", comments.get(1).toString()); + } + + @Test + void selectsSingleNode() + { + Object node = page.xpath("id('single-div')").singleNode(); + + assertEquals("single-div", ((Element) node).getAttribute("id")); + } + + @Test + void selectsSingleNodeReturnsNullWhenNotFound() + { + Object node = page.xpath("id('does-not-exist')").singleNode(); + + assertNull(node); + } + } + + @Nested + @DisplayName("Attribute selection") + class AttributeSelection + { + @Test + void selectsAllAttributesOfElement() + { + // Tapestry outputs attributes in reverse template order (chain-of-attributes implementation), + // so the XPath attribute order here is: title, class, id. + List attrs = page.xpath("id('attr-with-id')/@*").attributes(); + + assertEquals(3, attrs.size()); + assertAttributeEquals("title", "First", attrs.get(0)); + assertAttributeEquals("class", "cls-first", attrs.get(1)); + assertAttributeEquals("id", "attr-with-id", attrs.get(2)); + } + + @Test + void selectsAttributeValuePerElement() + { + // Elements without the requested attribute contribute null, keeping list size == element count. + List ids = page.xpath("id('attr-parent')/*").elementsAttribute("id"); + + assertEquals(Arrays.asList("attr-with-id", null), ids); + } + } + + @Nested + @DisplayName("String and text methods") + class StringAndTextMethods + { + @Test + void returnsStringValuesOfAttributes() + { + List classes = page.xpath("id('attr-parent')//@class").stringValuesOf(); + + assertIterableEquals(Arrays.asList("cls-first", "cls-second"), classes); + } + + @Test + void returnsChildMarkupPerElement() + { + List markup = page.xpath("id('child-elements')/*").elementsChildMarkup(); + + assertIterableEquals(Arrays.asList("Div Child", "Span Child"), markup); + } + + @Test + void returnsSingleNormalizedDescendantText() + { + // The span contains a deliberate double-space that normalization collapses. + String text = page.xpath("id('mixed-text')").singleNormalizedDescendantText(); + + assertEquals("before inner text after", text); + } + + @Test + void returnsNormalizedDescendantTextForMultipleNodes() + { + List text = page.xpath("id('multi-text')/div").normalizedDescendantText(); + + assertIterableEquals(Arrays.asList("left center right", "top middle bottom"), text); + } + } + + @Nested + @DisplayName("Validation and exceptions") + class ValidationAndExceptions + { + @Test + void invalidExpressionThrowsXPathException() + { + assertThrows(XPathException.class, () -> page.xpath("^^^")); + } + + @Test + void nullExpressionThrowsIllegalArgumentException() + { + assertThrows(IllegalArgumentException.class, () -> page.xpath(null)); + } + + @Test + void emptyExpressionThrowsIllegalArgumentException() + { + assertThrows(IllegalArgumentException.class, () -> page.xpath("")); + } + + @Test + void nullAttributeNameThrowsIllegalArgumentException() + { + assertThrows(IllegalArgumentException.class, () -> + page.xpath("id('attr-parent')/*").elementsAttribute(null)); + } + + @Test + void emptyAttributeNameThrowsIllegalArgumentException() + { + assertThrows(IllegalArgumentException.class, () -> + page.xpath("id('attr-parent')/*").elementsAttribute("")); + } + } + + private void assertAttributeEquals(String expectedName, String expectedValue, Attribute attribute) + { + assertEquals(expectedName, attribute.getName()); + assertEquals(expectedValue, attribute.getValue()); + } +} diff --git a/tapestry-core/src/test/java/org/apache/tapestry5/dom/DOMTest.java b/tapestry-core/src/test/java/org/apache/tapestry5/dom/DOMTest.java index 922c5bd7a1..d8d5806eb4 100644 --- a/tapestry-core/src/test/java/org/apache/tapestry5/dom/DOMTest.java +++ b/tapestry-core/src/test/java/org/apache/tapestry5/dom/DOMTest.java @@ -1,4 +1,4 @@ -// Copyright 2006, 2007, 2008, 2009, 2010, 2011, 2012 The Apache Software Foundation +// Copyright 2006-2012, 2026 The Apache Software Foundation // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -14,45 +14,51 @@ package org.apache.tapestry5.dom; +import org.apache.tapestry5.MarkupWriter; +import org.apache.tapestry5.commons.util.CollectionFactory; +import org.apache.tapestry5.internal.services.MarkupWriterImpl; +import org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Set; +import java.util.stream.Collectors; -import org.apache.tapestry5.MarkupWriter; -import org.apache.tapestry5.commons.util.CollectionFactory; -import org.apache.tapestry5.internal.services.MarkupWriterImpl; -import org.apache.tapestry5.internal.test.InternalBaseTestCase; -import org.testng.annotations.Test; +import static org.junit.jupiter.api.Assertions.*; /** * Tests for a number of DOM node classes, including {@link org.apache.tapestry5.dom.Element} and {@link * org.apache.tapestry5.dom.Document}. */ -public class DOMTest extends InternalBaseTestCase +class DOMTest { @Test - public void document_with_empty_root_element() + void document_with_empty_root_element() { Document d = new Document(); d.newRootElement("empty"); - assertEquals(d.toString(), ""); + assertEquals("", d.toString()); } @Test - public void xml_style_empty_element() + void xml_style_empty_element() { Document d = new Document(new XMLMarkupModel()); d.newRootElement("empty"); - assertEquals(d.toString(), "\n"); + assertEquals("\n", d.toString()); } @Test - public void namespaced_elements() throws Exception + void namespaced_elements() throws Exception { Document d = new Document(new XMLMarkupModel()); @@ -65,11 +71,11 @@ public void namespaced_elements() throws Exception nested.elementNS("barneyns", "deepest"); - assertEquals(d.toString(), readFile("namespaced_elements.txt")); + assertEquals(readFile("namespaced_elements.txt"), d.toString()); } @Test - public void quote_using_apostrophes() throws Exception + void quote_using_apostrophes() throws Exception { Document d = new Document(new XMLMarkupModel(true)); @@ -84,11 +90,11 @@ public void quote_using_apostrophes() throws Exception nested.elementNS("barneyns", "deepest"); - assertEquals(d.toString(), readFile("quote_using_apostrophes.txt")); + assertEquals(readFile("quote_using_apostrophes.txt"), d.toString()); } @Test - public void namespace_element_without_a_prefix() throws Exception + void namespace_element_without_a_prefix() throws Exception { Document d = new Document(new XMLMarkupModel()); @@ -104,11 +110,11 @@ public void namespace_element_without_a_prefix() throws Exception barney.attribute("bettyns", "betty", "b"); barney.attribute("wilmans", "wilma", "c"); - assertEquals(d.toString(), readFile("namespace_element_without_a_prefix.txt")); + assertEquals(readFile("namespace_element_without_a_prefix.txt"), d.toString()); } @Test - public void default_namespace() + void default_namespace() { Document d = new Document(new XMLMarkupModel()); @@ -120,14 +126,14 @@ public void default_namespace() root.attribute(namespaceURI, "gnip", "gnop"); - assertEquals(d.toString(), "\n"); + assertEquals("\n", d.toString()); } /** * Also demonstrates that attributes are provided in alphabetical order. */ @Test - public void document_with_root_element_and_attributes() throws Exception + void document_with_root_element_and_attributes() throws Exception { Document d = new Document(); @@ -136,11 +142,11 @@ public void document_with_root_element_and_attributes() throws Exception e.attribute("fred", "flintstone"); e.attribute("barney", "rubble"); - assertEquals(d.toString(), readFile("document_with_root_element_and_attributes.txt")); + assertEquals(readFile("document_with_root_element_and_attributes.txt"), d.toString()); } @Test - public void nested_elements() throws Exception + void nested_elements() throws Exception { Document d = new Document(); @@ -150,29 +156,29 @@ public void nested_elements() throws Exception p.attribute("first-name", "Fred"); p.attribute("last-name", "Flintstone"); - assertSame(p.getContainer(), e); + assertSame(e, p.getContainer()); p = e.element("person"); p.attribute("first-name", "Barney"); p.attribute("last-name", "Rubble"); - assertSame(p.getContainer(), e); + assertSame(e, p.getContainer()); - assertEquals(d.toString(), readFile("nested_elements.txt")); + assertEquals(readFile("nested_elements.txt"), d.toString()); } - @Test(expectedExceptions = AssertionError.class) - public void attribute_names_may_not_be_blank() + @Test + void attribute_names_may_not_be_blank() { Document d = new Document(); Element e = d.newRootElement("fred"); - e.attribute("", "value"); + assertThrows(AssertionError.class, () -> e.attribute("", "value")); } @Test - public void element_name_may_not_be_blank() + void element_name_may_not_be_blank() { Document d = new Document(); @@ -180,7 +186,7 @@ public void element_name_may_not_be_blank() } @Test - public void attribute_value_null_is_no_op() + void attribute_value_null_is_no_op() { Document d = new Document(); @@ -190,19 +196,19 @@ public void attribute_value_null_is_no_op() final String expected = ""; - assertEquals(d.toString(), expected); + assertEquals(expected, d.toString()); e.attribute("foo", null); - assertEquals(d.toString(), expected); + assertEquals(expected, d.toString()); e.attribute("gnip", null); - assertEquals(d.toString(), expected); + assertEquals(expected, d.toString()); } @Test - public void comments() throws Exception + void comments() throws Exception { Document d = new Document(); @@ -212,11 +218,11 @@ public void comments() throws Exception e.comment(" Created by Tapestry 5.0 "); - assertEquals(d.toString(), ""); + assertEquals("", d.toString()); } @Test - public void text() + void text() { Document d = new Document(); @@ -224,11 +230,11 @@ public void text() e.text("Tapestry does DOM."); - assertEquals(d.toString(), "Tapestry does DOM."); + assertEquals("Tapestry does DOM.", d.toString()); } @Test - public void text_with_control_characters() + void text_with_control_characters() { Document d = new Document(); @@ -236,11 +242,11 @@ public void text_with_control_characters() e.text(" & "); - assertEquals(d.toString(), "<this> & <that>"); + assertEquals("<this> & <that>", d.toString()); } @Test - public void specify_attributes_with_new_element() + void specify_attributes_with_new_element() { Document d = new Document(); @@ -248,11 +254,11 @@ public void specify_attributes_with_new_element() e.element("foo", "alpha", "legion"); - assertEquals(d.toString(), ""); + assertEquals("", d.toString()); } @Test - public void writef_with_text() + void writef_with_text() { Document d = new Document(); @@ -262,34 +268,34 @@ public void writef_with_text() t.writef("** %s: %d **", "foo", 5); - assertEquals(d.toString(), "Start: ** foo: 5 **"); + assertEquals("Start: ** foo: 5 **", d.toString()); } @Test - public void get_element_by_id() + void get_element_by_id() { Document d = new Document(); Element e = d.newRootElement("root"); Element e1 = e.element("e1", "id", "x"); Element e2 = e.element("e2", "id", "y"); - assertSame(e1.getElementById("x"), e1); - assertSame(e.getElementById("y"), e2); + assertSame(e1, e1.getElementById("x")); + assertSame(e2, e.getElementById("y")); assertNull(e.getElementById("z")); } @Test - public void get_child_markup() + void get_child_markup() { Document d = new Document(); Element e0 = d.newRootElement("root"); Element e1 = e0.element("e1"); e1.text("123"); - assertEquals(e1.getChildMarkup(), "123"); - assertEquals(e0.getChildMarkup(), "123"); + assertEquals("123", e1.getChildMarkup()); + assertEquals("123", e0.getChildMarkup()); } @Test - public void document_find_no_root_element() + void document_find_no_root_element() { Document d = new Document(); @@ -297,7 +303,7 @@ public void document_find_no_root_element() } @Test - public void document_find_not_a_match() + void document_find_not_a_match() { Document d = new Document(); @@ -308,17 +314,17 @@ public void document_find_not_a_match() } @Test - public void document_find_root_is_match() + void document_find_root_is_match() { Document d = new Document(); Element root = d.newRootElement("fred"); - assertSame(d.find("fred"), root); + assertSame(root, d.find("fred")); } @Test - public void document_find_match() + void document_find_match() { Document d = new Document(); @@ -328,12 +334,12 @@ public void document_find_match() Element barney = root.element("barney"); Element bambam = barney.element("bambam"); - assertSame(d.find("fred/barney/bambam"), bambam); - assertSame(root.find("barney/bambam"), bambam); + assertSame(bambam, d.find("fred/barney/bambam")); + assertSame(bambam, root.find("barney/bambam")); } @Test - public void document_find_no_match() + void document_find_no_match() { Document d = new Document(); @@ -348,7 +354,7 @@ public void document_find_no_match() } @Test - public void insert_element_at() + void insert_element_at() { Document d = new Document(new XMLMarkupModel()); @@ -360,12 +366,12 @@ public void insert_element_at() root.elementAt(1, "one").element("tiny"); root.elementAt(2, "two").element("bubbles"); - assertEquals(d.toString(), - "\n"); + assertEquals( + "\n", d.toString()); } @Test - public void force_attributes_overrides_existing() + void force_attributes_overrides_existing() { Document d = new Document(new XMLMarkupModel()); @@ -373,18 +379,18 @@ public void force_attributes_overrides_existing() root.attributes("hi", "ho", "gnip", "gnop"); - assertEquals(root.toString(), ""); + assertEquals("", root.toString()); root.forceAttributes("hi", "bit", "gnip", null); - assertEquals(root.toString(), ""); + assertEquals("", root.toString()); } /** * TAP5-708 */ @Test - public void namespace_element_force_attributes_overrides_existing() + void namespace_element_force_attributes_overrides_existing() { Document d = new Document(new XMLMarkupModel()); @@ -392,16 +398,16 @@ public void namespace_element_force_attributes_overrides_existing() root.attributes("hi", "ho", "gnip", "gnop"); - assertEquals(root.toString(), ""); + assertEquals("", root.toString()); root.forceAttributes("hi", "bit", "gnip", null); - assertEquals(root.toString(), ""); + assertEquals("", root.toString()); } @Test - public void raw_output() + void raw_output() { Document d = new Document(new XMLMarkupModel()); @@ -416,36 +422,38 @@ public void raw_output() // The '<' and '>' are filtered into entities, but the '&' in   is left alone (left // raw). - assertEquals(root.toString(), "< >"); + assertEquals("< >", root.toString()); } @Test - public void dtd_with_markup() + void dtd_with_markup() { Document d = new Document(new XMLMarkupModel()); Element root = d.newRootElement("prime"); root.element("slag"); d.dtd("prime", "-//TF", "tf"); String expected = "\n"; - assertEquals(d.toString(), expected); + assertEquals(expected, d.toString()); } @Test - public void dtd_with_nullids() + void dtd_with_nullids() { Document d = new Document(new XMLMarkupModel()); d.newRootElement("prime"); + d.dtd("prime", null, null); - assertEquals(d.toString(), "\n"); + assertEquals("\n", d.toString()); + d.dtd("prime", "-//TF", null); - assertEquals(d.toString(), "\n"); + assertEquals("\n", d.toString()); d.dtd("prime", null, "tf"); - assertEquals(d.toString(), "\n"); + assertEquals("\n", d.toString()); } @Test - public void markup_characters_inside_attributes_are_escaped() + void markup_characters_inside_attributes_are_escaped() { Document d = new Document(new XMLMarkupModel()); @@ -454,11 +462,11 @@ public void markup_characters_inside_attributes_are_escaped() root.attribute("alpha-only", "abcdef"); root.attribute("entities", "\"<>&"); - assertEquals(root.toString(), ""); + assertEquals("", root.toString()); } @Test - public void apostrophes_are_escaped() + void apostrophes_are_escaped() { Document d = new Document(new XMLMarkupModel(true)); @@ -466,40 +474,40 @@ public void apostrophes_are_escaped() root.attribute("apostrophie", "some'thing"); - assertEquals(root.toString(), ""); + assertEquals("", root.toString()); } @Test - public void add_class_names() + void add_class_names() { Document d = new Document(new XMLMarkupModel()); Element root = d.newRootElement("div"); - assertSame(root.addClassName("fred"), root); + assertSame(root, root.addClassName("fred")); - assertEquals(root.toString(), "

"); + assertEquals("
", root.toString()); - assertSame(root.addClassName("barney", "wilma"), root); + assertSame(root, root.addClassName("barney", "wilma")); - assertEquals(extract(root, "class"), stringSet("fred", "barney", "wilma")); + assertEquals(stringSet("fred", "barney", "wilma"), extract(root, "class")); } // TAP5-2660 @Test - public void add_class_names_empty_namespace() + void add_class_names_empty_namespace() { Document d = new Document(new XMLMarkupModel()); Element root = d.newRootElement("div"); - assertSame(root.attribute("", "class", "fred"), root); + assertSame(root, root.attribute("", "class", "fred")); - assertEquals(root.toString(), "
"); + assertEquals("
", root.toString()); - assertSame(root.addClassName("barney", "wilma"), root); + assertSame(root, root.addClassName("barney", "wilma")); - assertEquals(extract(root, "class"), stringSet("fred", "barney", "wilma")); + assertEquals(stringSet("fred", "barney", "wilma"), extract(root, "class")); } private Set extract(Element e, String attributeName) @@ -517,7 +525,7 @@ private Set stringSet(String... value) { * TAP5-804 */ @Test - public void namespace_add_class_name() + void namespace_add_class_name() { Document document = new Document(new DefaultMarkupModel()); @@ -525,15 +533,15 @@ public void namespace_add_class_name() element.attribute("class", "a"); - assertEquals(element.toString(), ""); + assertEquals("", element.toString()); element.addClassName("b"); - assertEquals(extract(element, "class"), stringSet("a", "b")); + assertEquals(stringSet("a", "b"), extract(element, "class")); } @Test - public void cdata_in_HTML_document() + void cdata_in_HTML_document() { Document d = new Document(); @@ -541,11 +549,11 @@ public void cdata_in_HTML_document() // The '&' is expanded to an entity: - assertEquals(d.toString(), "This & That"); + assertEquals("This & That", d.toString()); } @Test - public void cdata_in_XML_document() + void cdata_in_XML_document() { Document d = new Document(new XMLMarkupModel()); @@ -553,20 +561,20 @@ public void cdata_in_XML_document() // The '&' is expanded to an entity: - assertEquals(d.toString(), "\n"); + assertEquals("\n", d.toString()); } @Test - public void encoding_specified() + void encoding_specified() { Document d = new Document(new XMLMarkupModel(), "utf-8"); d.newRootElement("root"); - assertEquals(d.toString(), "\n"); + assertEquals("\n", d.toString()); } @Test - public void move_before() + void move_before() { Document d = new Document(); @@ -579,18 +587,18 @@ public void move_before() mobile.text("On the move"); - assertEquals(d.toString(), - "On the move"); + assertEquals( + "On the move", d.toString()); mobile.moveBefore(target); - assertEquals(d.toString(), - "On the move"); + assertEquals( + "On the move", d.toString()); } @Test - public void move_after() + void move_after() { Document d = new Document(); @@ -602,18 +610,18 @@ public void move_after() mobile.text("On the move"); - assertEquals(d.toString(), - "On the move"); + assertEquals( + "On the move", d.toString()); mobile.moveAfter(target); - assertEquals(d.toString(), - "On the move"); + assertEquals( + "On the move", d.toString()); } @Test - public void move_to_top() + void move_to_top() { Document d = new Document(); @@ -625,17 +633,17 @@ public void move_to_top() mobile.text("On the move"); - assertEquals(d.toString(), - "On the move"); + assertEquals( + "On the move", d.toString()); mobile.moveToTop(target); - assertEquals(d.toString(), - "On the move"); + assertEquals( + "On the move", d.toString()); } @Test - public void move_to_bottom() + void move_to_bottom() { Document d = new Document(); @@ -647,17 +655,17 @@ public void move_to_bottom() mobile.text("On the move"); - assertEquals(d.toString(), - "On the move"); + assertEquals( + "On the move", d.toString()); mobile.moveToBottom(target); - assertEquals(d.toString(), - "On the move"); + assertEquals( + "On the move", d.toString()); } @Test - public void remove_children() + void remove_children() { Document d = new Document(); @@ -671,17 +679,17 @@ public void remove_children() mobile.text("On the move"); - assertEquals(d.toString(), - "On the move"); + assertEquals( + "On the move", d.toString()); source.removeChildren(); - assertEquals(d.toString(), - ""); + assertEquals( + "", d.toString()); } @Test - public void pop() + void pop() { Document d = new Document(); @@ -691,17 +699,17 @@ public void pop() source.element("mobile").text("On the move"); source.element("grok"); - assertEquals(d.toString(), - "On the move"); + assertEquals( + "On the move", d.toString()); source.pop(); - assertEquals(d.toString(), - "On the move"); + assertEquals( + "On the move", d.toString()); } @Test - public void move_an_node_into_itself() + void move_an_node_into_itself() { Document d = new Document(); @@ -713,18 +721,12 @@ public void move_an_node_into_itself() mobile.text("On the move"); Element inside = mobile.element("inside"); - try - { - mobile.moveToTop(inside); - unreachable(); - } catch (IllegalArgumentException ex) - { - assertEquals(ex.getMessage(), "Unable to move a node relative to itself."); - } + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> mobile.moveToTop(inside)); + assertEquals("Unable to move a node relative to itself.", ex.getMessage()); } @Test - public void wrap() + void wrap() { Document d = new Document(); @@ -736,20 +738,20 @@ public void wrap() Node text = mobile.text("On the move"); - assertEquals(d.toString(), - "On the move"); + assertEquals( + "On the move", d.toString()); text.wrap("em", "class", "bold"); - assertEquals(d.toString(), - "On the move"); + assertEquals( + "On the move", d.toString()); } /** * TAP5-385 */ @Test - public void empty_html_elements() + void empty_html_elements() { Document d = new Document(); @@ -759,14 +761,14 @@ public void empty_html_elements() root.element("br"); root.element("img"); - assertEquals(d.toString(), "

"); + assertEquals("

", d.toString()); } /** * TAP5-402 */ @Test - public void is_empty() + void is_empty() { Document d = new Document(); @@ -797,7 +799,7 @@ public void is_empty() * TAP5-457 */ @Test - public void defaults_for_xml_defined_namespaces() throws Exception + void defaults_for_xml_defined_namespaces() throws Exception { Document d = new Document(); @@ -809,11 +811,11 @@ public void defaults_for_xml_defined_namespaces() throws Exception // Before TAP5-457, it would be ns0: not xml: - assertEquals(d.toString(), readFile("defaults_for_xml_defined_namespaces.txt")); + assertEquals(readFile("defaults_for_xml_defined_namespaces.txt"), d.toString()); } @Test - public void visit_order() + void visit_order() { Document d = new Document(); @@ -840,15 +842,80 @@ public void visit(Element element) } }); - assertListsEquals(elementNames, "parent", "child1", "child1a", "child1b", "child2", "child2a", "child2b", - "child2c"); + assertIterableEquals(Arrays.asList("parent", "child1", "child1a", "child1b", "child2", "child2a", "child2b", + "child2c"), elementNames); + } + + // --- NodeVisitor --- + + @Test + void nodeVisitor_visits_all_node_types_in_document_order() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + root.text("hello"); + root.comment(" c "); + Element nested = root.element("nested"); + nested.cdata("data"); + nested.raw(""); + + List visits = CollectionFactory.newList(); + + root.visit(new NodeVisitor() + { + @Override public void visit(Element e) { visits.add("Element:" + e.getName()); } + @Override public void visit(Text t) { visits.add("Text"); } + @Override public void visit(Comment c) { visits.add("Comment"); } + @Override public void visit(CData c) { visits.add("CData"); } + @Override public void visit(Raw r) { visits.add("Raw"); } + }); + + assertIterableEquals(Arrays.asList("Element:root", "Text", "Comment", "Element:nested", "CData", "Raw"), visits); + } + + @Test + void nodeVisitor_default_methods_allow_selective_override() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + root.text("first"); + root.element("child").text("second"); + + List textContents = CollectionFactory.newList(); + + // Only override visit(Text) — elements are silently skipped + root.visit(new NodeVisitor() + { + @Override + public void visit(Text t) { textContents.add(t.toString()); } + }); + + assertIterableEquals(Arrays.asList("first", "second"), textContents); + } + + @Test + void nodeVisitor_on_document_delegates_to_root_element() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + root.element("child"); + + List names = CollectionFactory.newList(); + + d.visit(new NodeVisitor() + { + @Override + public void visit(Element e) { names.add(e.getName()); } + }); + + assertIterableEquals(Arrays.asList("root", "child"), names); } /** * TAP5-559 */ @Test - public void later_updates_to_same_attribute_are_ignored() + void later_updates_to_same_attribute_are_ignored() { Document d = new Document(); @@ -861,11 +928,11 @@ public void later_updates_to_same_attribute_are_ignored() root.attribute("baggins", "frodo"); - assertEquals(d.toString(), ""); + assertEquals("", d.toString()); } @Test - public void force_attributes_changes_attribute_value() + void force_attributes_changes_attribute_value() { Document d = new Document(); @@ -879,11 +946,11 @@ public void force_attributes_changes_attribute_value() root.forceAttributes("baggins", "frodo"); - assertEquals(d.toString(), ""); + assertEquals("", d.toString()); } @Test - public void force_attributes_to_null_removes_attribute() + void force_attributes_to_null_removes_attribute() { Document d = new Document(); @@ -895,16 +962,16 @@ public void force_attributes_to_null_removes_attribute() root.forceAttributes("friend", null); - assertEquals(root.toString(), ""); + assertEquals("", root.toString()); root.forceAttributes("baggins", null, "enemy", "gollum"); - assertEquals(root.toString(), ""); + assertEquals("", root.toString()); } @Test - public void get_attributes() + void get_attributes() { Document d = new Document(); @@ -916,19 +983,19 @@ public void get_attributes() Collection attributes = root.getAttributes(); - assertEquals(attributes.size(), 1); + assertEquals(1, attributes.size()); Attribute attribute = attributes.iterator().next(); - assertEquals(attribute.getName(), "fred"); - assertEquals(attribute.getValue(), "flintstone"); + assertEquals("fred", attribute.getName()); + assertEquals("flintstone", attribute.getValue()); } /** * TAP5-636 */ @Test - public void force_null_for_first_attribute_is_noop() + void force_null_for_first_attribute_is_noop() { Document d = new Document(); @@ -936,11 +1003,11 @@ public void force_null_for_first_attribute_is_noop() root.forceAttributes("null", null); - assertEquals(root.toString(), ""); + assertEquals("", root.toString()); } @Test - public void remove_while_rendering() + void remove_while_rendering() { MarkupWriter writer = new MarkupWriterImpl(new XMLMarkupModel()); @@ -965,15 +1032,15 @@ public void remove_while_rendering() writer.end(); - assertEquals(writer.toString(), "\n" + - "
  • 0
  • 1
  • 3
"); + assertEquals("\n" + + "
  • 0
  • 1
  • 3
", writer.toString()); } /** * TAP5-2071 */ @Test - public void html5_void_elements() + void html5_void_elements() { final List voidElements = CollectionFactory.newList("area", "base", "br", "col", "command", "embed", "hr", "img", "input", "keygen", "link", "meta", "param", @@ -991,7 +1058,764 @@ public void html5_void_elements() writer.end(); - assertEquals(writer.toString(), - "

"); + assertEquals( + "

", writer.toString()); + } + + // --- Deep clone --- + + @Test + void element_deep_clone_is_detached() + { + Document d = new Document(); + Element src = d.newRootElement("root").element("section"); + + assertNull(src.deepClone().getContainer()); + } + + @Test + void element_deep_clone_preserves_name() + { + Document d = new Document(); + Element src = d.newRootElement("root").element("section"); + + assertEquals("section", src.deepClone().getName()); + } + + @Test + void element_deep_clone_copies_attributes() + { + Document d = new Document(); + Element src = d.newRootElement("root").element("div", "id", "foo", "class", "bar"); + + Element clone = src.deepClone(); + + assertEquals("foo", clone.getAttribute("id")); + assertEquals("bar", clone.getAttribute("class")); + } + + @Test + void element_deep_clone_copies_children_recursively() + { + Document d = new Document(); + Element src = d.newRootElement("root").element("parent"); + src.element("child-a").element("grandchild"); + src.element("child-b"); + + Element clone = src.deepClone(); + + List children = clone.getChildren(); + assertEquals(2, children.size()); + assertEquals("child-a", ((Element) children.get(0)).getName()); + assertEquals("child-b", ((Element) children.get(1)).getName()); + assertEquals(1, ((Element) children.get(0)).getChildren().size()); // grandchild present + } + + @Test + void detached_node_toString_uses_default_markup_model() + { + Document d = new Document(); + Element src = d.newRootElement("root").element("p", "class", "note"); + src.text("Hello, "); + src.element("strong").text("world"); + src.text("!"); + + // Both src (attached, DefaultMarkupModel) and clone (detached, fallback DefaultMarkupModel) + // must produce identical markup without any extra attachment step. + Element clone = src.deepClone(); + + assertEquals(src.toString(), clone.toString()); + } + + @Test + void detached_node_getChildMarkup_uses_default_markup_model() + { + Document d = new Document(); + Element src = d.newRootElement("root").element("div"); + src.element("span").text("content"); + + Element clone = src.deepClone(); + + assertEquals(src.getChildMarkup(), clone.getChildMarkup()); + } + + @Test + void detached_node_toMarkup_uses_provided_markup_model() + { + Document d = new Document(); + Element src = d.newRootElement("root").element("br"); + + Element clone = src.deepClone(); + + // Html5MarkupModel treats
as a void element (no closing tag or slash). + // DefaultMarkupModel abbreviates it as a self-closing tag. + assertEquals("
", clone.toMarkup(new Html5MarkupModel())); + assertEquals("
", clone.toMarkup(new DefaultMarkupModel())); + } + + @Test + void element_deep_clone_is_independent_of_original() + { + Document d = new Document(); + Element src = d.newRootElement("root").element("div", "id", "original"); + + Element clone = src.deepClone(); + + // Mutate the original — clone must remain unchanged. + src.forceAttributes("id", "changed"); + src.element("extra"); + + assertEquals("original", clone.getAttribute("id")); + assertEquals(0, clone.getChildren().size()); + } + + @Test + void element_deep_clone_changes_do_not_affect_original() + { + Document d = new Document(); + Element src = d.newRootElement("root").element("div", "id", "original"); + + // Mutate the clone — original must remain unchanged. + Element clone = src.deepClone(); + Document wrapper = new Document(); + wrapper.newRootElement("wrapper").addChild(clone); + clone.forceAttributes("id", "modified"); + clone.element("extra"); + + assertEquals("original", src.getAttribute("id")); + assertEquals(0, src.getChildren().size()); + } + + @Test + void document_deep_clone_produces_same_markup() + { + Document d = new Document(); + Element root = d.newRootElement("html"); + Element body = root.element("body"); + body.element("h1", "class", "title").text("Hello"); + body.element("p").text("World"); + + Document clone = d.deepClone(); + + assertEquals(d.toString(), clone.toString()); + } + + @Test + void document_deep_clone_is_independent() + { + Document d = new Document(); + d.newRootElement("root").element("child").text("original"); + + Document clone = d.deepClone(); + + // Empty the clone's tree. + clone.getRootElement().removeChildren(); + + // Original is unaffected. + assertEquals(1, d.getRootElement().getChildren().size()); + assertTrue(clone.getRootElement().getChildren().isEmpty()); + } + + // --- Detach --- + + @Test + void detach_removes_node_from_parent() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element child = root.element("child"); + + child.detach(); + + assertNull(child.getContainer()); + assertTrue(root.getChildren().isEmpty()); + } + + @Test + void detach_returns_the_same_instance() + { + Document d = new Document(); + Element child = d.newRootElement("root").element("child"); + + assertSame(child, child.detach()); + } + + @Test + void detach_is_noop_when_already_detached() + { + Document d = new Document(); + Element child = d.newRootElement("root").element("child"); + child.detach(); + + // Second call must not throw. + assertSame(child, child.detach()); + assertNull(child.getContainer()); + } + + @Test + void detach_returns_element_without_cast() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element child = root.element("child", "id", "c"); + + // detach() on Element has a covariant return type of Element, so Element-specific + // methods (like getAttribute) can be called directly without a cast. + String id = child.detach().getAttribute("id"); + + assertEquals("c", id); + assertTrue(root.getChildren().isEmpty()); + } + + // --- replaceWith --- + + @Test + void replaceWith_swaps_node_in_place() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element a = root.element("a"); + Element b = root.element("b"); + Element c = root.element("c"); + + Element newB = new Element((Element) null, null, "x"); + // Attach newB to a temp parent so we can verify detach + Document tmp = new Document(); + Element tmpRoot = tmp.newRootElement("tmp"); + tmpRoot.addChild(newB); + + Node result = b.replaceWith(newB); + + assertSame(newB, result); + // root now contains a, newB, c in order + List children = root.getChildren(); + assertEquals(3, children.size()); + assertSame(a, children.get(0)); + assertSame(newB, children.get(1)); + assertSame(c, children.get(2)); + // b is now detached + assertNull(b.getContainer()); + // newB was detached from tmpRoot + assertTrue(tmpRoot.getChildren().isEmpty()); + } + + @Test + void replaceWith_detaches_replacement_from_prior_parent() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element target = root.element("target"); + + Document d2 = new Document(); + Element root2 = d2.newRootElement("root2"); + Element mover = root2.element("mover"); + + target.replaceWith(mover); + + // After replaceWith, mover's container is root (not root2) + assertEquals("root", mover.getContainer().getName()); + assertTrue(root2.getChildren().isEmpty()); + } + + @Test + void replaceWith_returns_replacement() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element old = root.element("old"); + Element fresh = root.element("fresh"); + // detach fresh so it's free to use as replacement + fresh.detach(); + + Node returned = old.replaceWith(fresh); + assertSame(fresh, returned); + } + + @Test + void replaceWith_throws_for_null() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element child = root.element("child"); + assertThrows(IllegalArgumentException.class, () -> child.replaceWith(null)); + } + + @Test + void replaceWith_throws_when_replacing_self() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element child = root.element("child"); + assertThrows(IllegalArgumentException.class, () -> child.replaceWith(child)); + } + + @Test + void replaceWith_throws_when_node_is_detached() + { + Element detached = new Element((Element) null, null, "detached"); + Element other = new Element((Element) null, null, "other"); + assertThrows(IllegalStateException.class, () -> detached.replaceWith(other)); + } + + @Test + void replaceWith_throws_when_replacement_is_ancestor() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element parent = root.element("parent"); + Element child = parent.element("child"); + // Replacing child with its ancestor (parent) would create a cycle + assertThrows(IllegalArgumentException.class, () -> child.replaceWith(parent)); + } + + // --- insertBefore / insertAfter --- + + @Test + void insertBefore_places_node_before_anchor() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element a = root.element("a"); + Element b = root.element("b"); + Element c = root.element("c"); + + Element x = new Element((Element) null, null, "x"); + Document tmp = new Document(); + tmp.newRootElement("tmp").appendChild(x); + + b.insertBefore(x); + + List children = root.getChildren(); + assertEquals(4, children.size()); + assertSame(a, children.get(0)); + assertSame(x, children.get(1)); + assertSame(b, children.get(2)); + assertSame(c, children.get(3)); + } + + @Test + void insertBefore_detaches_from_prior_parent() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element anchor = root.element("anchor"); + + Document d2 = new Document(); + Element other = d2.newRootElement("other"); + Element mover = other.element("mover"); + + anchor.insertBefore(mover); + + assertTrue(other.getChildren().isEmpty()); + assertEquals("root", mover.getContainer().getName()); + } + + @Test + void insertBefore_works_with_non_element_anchor() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Text text = root.text("hello"); + Element after = root.element("after"); + + Element inserted = new Element((Element) null, null, "span"); + Node result = text.insertBefore(inserted); + + assertSame(inserted, result); + List children = root.getChildren(); + assertSame(inserted, children.get(0)); + assertSame(text, children.get(1)); + assertSame(after, children.get(2)); + } + + @Test + void insertBefore_returns_inserted_node() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element anchor = root.element("anchor"); + Element fresh = new Element((Element) null, null, "fresh"); + + Node returned = anchor.insertBefore(fresh); + assertSame(fresh, returned); + } + + @Test + void insertBefore_throws_for_null() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element child = root.element("child"); + assertThrows(IllegalArgumentException.class, () -> child.insertBefore(null)); + } + + @Test + void insertBefore_throws_for_self() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element child = root.element("child"); + assertThrows(IllegalArgumentException.class, () -> child.insertBefore(child)); + } + + @Test + void insertBefore_throws_when_anchor_is_detached() + { + Element detached = new Element((Element) null, null, "detached"); + Element other = new Element((Element) null, null, "other"); + assertThrows(IllegalStateException.class, () -> detached.insertBefore(other)); + } + + @Test + void insertBefore_throws_when_node_is_ancestor() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element parent = root.element("parent"); + Element child = parent.element("child"); + assertThrows(IllegalArgumentException.class, () -> child.insertBefore(parent)); + } + + @Test + void insertAfter_places_node_after_anchor() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element a = root.element("a"); + Element b = root.element("b"); + Element c = root.element("c"); + + Element x = new Element((Element) null, null, "x"); + + b.insertAfter(x); + + List children = root.getChildren(); + assertEquals(4, children.size()); + assertSame(a, children.get(0)); + assertSame(b, children.get(1)); + assertSame(x, children.get(2)); + assertSame(c, children.get(3)); + } + + @Test + void insertAfter_detaches_from_prior_parent() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element anchor = root.element("anchor"); + + Document d2 = new Document(); + Element other = d2.newRootElement("other"); + Element mover = other.element("mover"); + + anchor.insertAfter(mover); + + assertTrue(other.getChildren().isEmpty()); + assertEquals("root", mover.getContainer().getName()); + } + + @Test + void insertAfter_works_with_non_element_anchor() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element before = root.element("before"); + Text text = root.text("hello"); + + Element inserted = new Element((Element) null, null, "span"); + Node result = text.insertAfter(inserted); + + assertSame(inserted, result); + List children = root.getChildren(); + assertSame(before, children.get(0)); + assertSame(text, children.get(1)); + assertSame(inserted, children.get(2)); + } + + @Test + void insertAfter_returns_inserted_node() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element anchor = root.element("anchor"); + Element fresh = new Element((Element) null, null, "fresh"); + + Node returned = anchor.insertAfter(fresh); + assertSame(fresh, returned); + } + + @Test + void insertAfter_throws_when_anchor_is_detached() + { + Element detached = new Element((Element) null, null, "detached"); + Element other = new Element((Element) null, null, "other"); + assertThrows(IllegalStateException.class, () -> detached.insertAfter(other)); + } + + @Test + void insertAfter_throws_when_node_is_ancestor() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element parent = root.element("parent"); + Element child = parent.element("child"); + assertThrows(IllegalArgumentException.class, () -> child.insertAfter(parent)); + } + + // --- prependChild / appendChild --- + + @Test + void prependChild_inserts_as_first_child() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element a = root.element("a"); + Element b = root.element("b"); + + Element x = new Element((Element) null, null, "x"); + root.prependChild(x); + + List children = root.getChildren(); + assertSame(x, children.get(0)); + assertSame(a, children.get(1)); + assertSame(b, children.get(2)); + } + + @Test + void prependChild_detaches_from_prior_parent() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + + Document d2 = new Document(); + Element other = d2.newRootElement("other"); + Element mover = other.element("mover"); + + root.prependChild(mover); + + assertTrue(other.getChildren().isEmpty()); + assertSame(root, mover.getContainer()); + } + + @Test + void prependChild_returns_element_for_chaining() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element x = new Element((Element) null, null, "x"); + Element y = new Element((Element) null, null, "y"); + + Element returned = root.prependChild(x); + assertSame(root, returned); + + root.prependChild(y); // y is now first + assertSame(y, root.getChildren().get(0)); + assertSame(x, root.getChildren().get(1)); + } + + @Test + void prependChild_throws_for_null() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + assertThrows(IllegalArgumentException.class, () -> root.prependChild(null)); + } + + @Test + void prependChild_throws_when_node_is_ancestor() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element child = root.element("child"); + assertThrows(IllegalArgumentException.class, () -> child.prependChild(root)); + } + + @Test + void appendChild_appends_as_last_child() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element a = root.element("a"); + Element b = root.element("b"); + + Element x = new Element((Element) null, null, "x"); + root.appendChild(x); + + List children = root.getChildren(); + assertSame(a, children.get(0)); + assertSame(b, children.get(1)); + assertSame(x, children.get(2)); + } + + @Test + void appendChild_detaches_from_prior_parent() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + + Document d2 = new Document(); + Element other = d2.newRootElement("other"); + Element mover = other.element("mover"); + + root.appendChild(mover); + + assertTrue(other.getChildren().isEmpty()); + assertSame(root, mover.getContainer()); + } + + @Test + void appendChild_returns_element_for_chaining() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element x = new Element((Element) null, null, "x"); + + Element returned = root.appendChild(x); + assertSame(root, returned); + } + + @Test + void appendChild_throws_for_null() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + assertThrows(IllegalArgumentException.class, () -> root.appendChild(null)); + } + + @Test + void appendChild_throws_when_node_is_this() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element child = root.element("child"); + assertThrows(IllegalArgumentException.class, () -> child.appendChild(child)); + } + + // --- Sibling navigation --- + + @Test + void next_sibling_returns_following_node() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element first = root.element("first"); + Text text = root.text("between"); + Element second = root.element("second"); + + assertSame(text, first.getNextSibling()); + assertSame(second, text.getNextSibling()); + assertNull(second.getNextSibling()); + } + + @Test + void next_sibling_returns_null_for_root_element() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + + assertNull(root.getNextSibling()); + } + + @Test + void next_sibling_returns_null_for_detached_node() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element child = root.element("child"); + child.remove(); + + assertNull(child.getNextSibling()); + } + + @Test + void previous_sibling_returns_preceding_node() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element first = root.element("first"); + Text text = root.text("between"); + Element second = root.element("second"); + + assertNull(first.getPreviousSibling()); + assertSame(first, text.getPreviousSibling()); + assertSame(text, second.getPreviousSibling()); + } + + @Test + void previous_sibling_returns_null_for_root_element() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + + assertNull(root.getPreviousSibling()); + } + + @Test + void previous_sibling_returns_null_for_detached_node() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element child = root.element("child"); + child.remove(); + + assertNull(child.getPreviousSibling()); + } + + @Test + void next_sibling_element_skips_non_element_nodes() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element first = root.element("first"); + root.text("between"); + root.comment("also between"); + Element second = root.element("second"); + + assertSame(second, first.getNextSiblingElement()); + assertNull(second.getNextSiblingElement()); + } + + @Test + void next_sibling_element_returns_null_for_root_element() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + + assertNull(root.getNextSiblingElement()); + } + + @Test + void previous_sibling_element_skips_non_element_nodes() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + Element first = root.element("first"); + root.text("between"); + root.comment("also between"); + Element second = root.element("second"); + + assertNull(first.getPreviousSiblingElement()); + assertSame(first, second.getPreviousSiblingElement()); + } + + @Test + void previous_sibling_element_returns_null_for_root_element() + { + Document d = new Document(); + Element root = d.newRootElement("root"); + + assertNull(root.getPreviousSiblingElement()); + } + + // --- Helper methods formerly from InternalBaseTestCase --- + + private String readFile(String file) throws IOException + { + InputStream is = getClass().getResourceAsStream(file); + if (is == null) return ""; + try (BufferedReader reader = new BufferedReader(new InputStreamReader(is))) + { + return reader.lines().collect(Collectors.joining("\n")); + } } -} +} \ No newline at end of file diff --git a/tapestry-core/src/test/java/org/apache/tapestry5/dom/pages/XPathTestPage.java b/tapestry-core/src/test/java/org/apache/tapestry5/dom/pages/XPathTestPage.java new file mode 100644 index 0000000000..3f267cbe50 --- /dev/null +++ b/tapestry-core/src/test/java/org/apache/tapestry5/dom/pages/XPathTestPage.java @@ -0,0 +1,5 @@ +package org.apache.tapestry5.dom.pages; + +public class XPathTestPage { + +} diff --git a/tapestry-core/src/test/java/org/apache/tapestry5/dom/xpath/XPathTest.java b/tapestry-core/src/test/java/org/apache/tapestry5/dom/xpath/XPathTest.java new file mode 100644 index 0000000000..962626aa9e --- /dev/null +++ b/tapestry-core/src/test/java/org/apache/tapestry5/dom/xpath/XPathTest.java @@ -0,0 +1,467 @@ +// Copyright 2026 The Apache Software Foundation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package org.apache.tapestry5.dom.xpath; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Arrays; +import java.util.List; + +import org.apache.tapestry5.dom.Attribute; +import org.apache.tapestry5.dom.Document; +import org.apache.tapestry5.dom.Element; +import org.apache.tapestry5.dom.Node; +import org.apache.tapestry5.test.PageTester; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class XPathTest { + + private static Document page; + + @BeforeAll + static void setup() + { + PageTester pageTester = new PageTester("org.apache.tapestry5.dom", "app"); + page = pageTester.renderPage("XPathTest"); + pageTester.shutdown(); + } + + @Nested + @DisplayName("Element selection") + class ElementSelection + { + @Test + void testCanFindElementsById() + { + XPath xpath = XPath.of("id('single-div')"); + + List container = xpath.selectElements(page); + + assertEquals(1, container.size()); + assertEquals("Single Div", container.get(0).getChildMarkup()); + } + + @Test + void testCanFindElements() + { + XPath xpath = XPath.of("id('child-elements')/*"); + + List children = xpath.selectElements(page); + + assertEquals(2, children.size()); + assertEquals("Div Child", children.get(0).getChildMarkup()); + assertEquals("Span Child", children.get(1).getChildMarkup()); + } + + @Test + void testCanFindElementsByName() + { + XPath xpath = XPath.of("id('child-elements')/span"); + + List children = xpath.selectElements(page); + + assertEquals(1, children.size()); + assertEquals("Span Child", children.get(0).getChildMarkup()); + } + + @Test + void testCanFindElementsByContents() + { + XPath xpath = XPath.of("//*[.='Single Div']"); + + List elements = xpath.selectElements(page); + + assertEquals(1, elements.size()); + assertEquals("single-div", elements.get(0).getAttribute("id")); + } + + @Test + void testCanFindChildren() + { + List children = XPath.of("id('tree')/*").selectElements(page); + + assertEquals(2, children.size()); + assertEquals("branch-a", children.get(0).getAttribute("id")); + assertEquals("branch-b", children.get(1).getAttribute("id")); + } + + @Test + void testCanFindDescendents() + { + XPath xpath = XPath.of("id('tree')//*"); + + List descendents = xpath.selectElements(page); + + assertEquals(5, descendents.size()); + assertEquals("branch-a", descendents.get(0).getAttribute("id")); + assertEquals("branch-a-leaf", descendents.get(1).getAttribute("id")); + assertEquals("branch-b", descendents.get(2).getAttribute("id")); + assertEquals("branch-b-leaf-one", descendents.get(3).getAttribute("id")); + assertEquals("branch-b-leaf-two", descendents.get(4).getAttribute("id")); + } + + @Test + void testCanFindParents() + { + XPath xpath = XPath.of("id('branch-a-leaf')/parent::*"); + + List parents = xpath.selectElements(page); + + assertEquals(1, parents.size()); + assertEquals("branch-a", parents.get(0).getAttribute("id")); + } + + @Test + void testSelectSingleElement() + { + XPath xpath = XPath.of("id('child-elements')/span"); + + Element span = xpath.selectSingleElement(page); + + assertEquals("Span Child", span.getChildMarkup()); + } + + @Test + void testSelectSingleElementReturnsNullWhenNotFound() + { + XPath xpath = XPath.of("id('does-not-exist')"); + + Element element = xpath.selectSingleElement(page); + + assertNull(element); + } + } + + + @Nested + @DisplayName("Node selection") + class NodeSelection + { + @Test + void testCanFindDocument() + { + XPath xpath = XPath.of("/"); + + List nodes = xpath.selectNodes(page); + + assertEquals(Arrays.asList(page), nodes); + } + + @Test + void testCanFindByTextNodeValue() + { + XPath xpath = XPath.of("//text()[.='Before Comment']"); + + List nodes = xpath.selectNodes(page); + + assertEquals(1, nodes.size()); + assertEquals("comment-parent", nodes.get(0).getContainer().getAttribute("id")); + } + + @Test + void testCanFindComments() + { + XPath xpath = XPath.of("id('comment-parent')//comment()"); + + List nodes = xpath.selectNodes(page); + + assertEquals(2, nodes.size()); + assertEquals("", nodes.get(0).toString()); + assertEquals("", nodes.get(1).toString()); + } + + @Test + void testCanFindCommentByValue() + { + XPath xpath = XPath.of("id('comment-parent')//comment()[.=' First ']"); + + List nodes = xpath.selectNodes(page); + + assertEquals(1, nodes.size()); + assertEquals("", nodes.get(0).toString()); + } + + @Test + void testCanFindTextNodesFromDocumentContext() + { + Document doc = new Document(); + Element root = doc.newRootElement("root"); + root.text("hello"); + root.comment("a comment"); + + List textFromDoc = XPath.of("//text()").selectNodes(doc); + List commentFromDoc = XPath.of("//comment()").selectNodes(doc); + List textFromElem = XPath.of("//text()").selectNodes(root); + List commentFromElem = XPath.of("//comment()").selectNodes(root); + + assertEquals(1, textFromDoc.size(), "//text() from doc"); + assertEquals(1, commentFromDoc.size(), "//comment() from doc"); + assertEquals(1, textFromElem.size(), "//text() from element"); + assertEquals(1, commentFromElem.size(),"//comment() from element"); + } + } + + @Nested + @DisplayName("Attribute selection") + class AttributeSelection + { + @Test + void testCanFindAttributes() + { + XPath xpath = XPath.of("id('attr-with-id')/@*"); + + List attributes = xpath.selectAttributes(page); + + // Slightly brittle test: XPath will return the attributes in document order. + // Tapestry seems to output attributes in reverse order of the template + // (probably because of its "chain of attributes" implementation). + // This test will break if Tapestry starts outputting attributes in a different order. + assertEquals(3, attributes.size()); + assertAttributeEquals("title", "First", attributes.get(0)); + assertAttributeEquals("class", "cls-first", attributes.get(1)); + assertAttributeEquals("id", "attr-with-id", attributes.get(2)); + } + + @Test + void testCanFindAttributesByName() + { + XPath xpath = XPath.of("id('attr-parent')//@class"); + + List classes = xpath.selectAttributes(page); + + assertEquals(2, classes.size()); + assertAttributeEquals("class", "cls-first", classes.get(0)); + assertAttributeEquals("class", "cls-second", classes.get(1)); + } + + @Test + void testCanFindByAttributeValue() + { + XPath xpath = XPath.of("id('attr-parent')//@*[.='cls-first']"); + + List elements = xpath.selectAttributes(page); + + assertEquals(1, elements.size()); + assertAttributeEquals("class", "cls-first", elements.get(0)); + } + + @Test + void testSelectElementsAttribute() + { + XPath xpath = XPath.of("id('attr-parent')/*"); + + List ids = xpath.selectElementsAttribute(page, "id"); + + assertEquals(Arrays.asList("attr-with-id", null), ids); + } + } + + @Nested + @DisplayName("String/text methods") + class StringTextMethods + { + @Test + void testStringValuesOf() + { + XPath xpath = XPath.of("id('attr-parent')//@class"); + + List classes = xpath.stringValuesOf(page); + + assertIterableEquals(Arrays.asList("cls-first", "cls-second"), classes); + } + + @Test + void testSelectElementsChildMarkup() + { + XPath xpath = XPath.of("id('child-elements')/*"); + + List childMarkup = xpath.selectElementsChildMarkup(page); + + assertIterableEquals(Arrays.asList("Div Child", "Span Child"), childMarkup); + } + + @Test + void testSingleNormalizedDescendantText() + { + XPath xpath = XPath.of("id('mixed-text')"); + + // The span contains a deliberate double-space that normalization collapses. + String text = xpath.singleNormalizedDescendantText(page); + + assertEquals("before inner text after", text); + } + + @Test + void testNormalizedDescendantText() + { + XPath xpath = XPath.of("id('multi-text')/div"); + + List text = xpath.normalizedDescendantText(page); + + assertIterableEquals(Arrays.asList("left center right", "top middle bottom"), text); + } + + @Test + void testNormalizedTextTreatsNbspAsSpace() + { + Element element = page.getElementById("nbsp-text"); + + assertEquals("", XPath.of("/").normalizeText(element.getChildMarkup())); + } + + @Test + void testNormalizedText() + { + XPath xpath = XPath.of("/"); + + assertEquals("", xpath.normalizeText("")); + assertEquals("", xpath.normalizeText(" ")); + assertEquals("a", xpath.normalizeText("a")); + assertEquals("a", xpath.normalizeText(" a ")); + assertEquals("a b", xpath.normalizeText("a b")); + } + } + + @Nested + @DisplayName("Parent axis for non-Element nodes") + class ParentAxisNonElementNodes + { + @Test + void testParentOfTextNode() + { + XPath xpath = XPath.of("//text()[.='Before Comment']/parent::*"); + + List parents = xpath.selectElements(page); + + assertEquals(1, parents.size()); + assertEquals("comment-parent", parents.get(0).getAttribute("id")); + } + + @Test + void testParentOfCommentNode() { + // Both comments share the same parent div. + // XPath node-sets deduplicate, so one result. + XPath xpath = XPath.of("id('comment-parent')//comment()/parent::*"); + + List parents = xpath.selectElements(page); + + assertEquals(1, parents.size()); + assertEquals("comment-parent", parents.get(0).getAttribute("id")); + } + + @Test + void testAncestorFromTextNode() { + XPath xpath = XPath.of("//text()[.='Before Comment']/ancestor::body"); + + List ancestors = xpath.selectElements(page); + + assertEquals(1, ancestors.size()); + assertEquals("body", ancestors.get(0).getName()); + } + + @Test + void testParentOfRootElementIsDocument() { + // The parent axis of the root element must return the Document. + XPath xpath = XPath.of("/html/parent::node()"); + + List parents = xpath.selectNodes(page); + + assertEquals(1, parents.size()); + assertEquals(page, parents.get(0)); + } + } + + @Nested + @DisplayName("CData as text node") + class CDataTextNode + { + @Test + void testCDataIsVisibleAsTextNode() + { + Document doc = new Document(); + Element root = doc.newRootElement("root"); + root.cdata("hello cdata"); + root.text(" plain"); + + XPath xpath = XPath.of("//text()"); + + List textNodes = xpath.selectNodes(doc); + + assertEquals(2, textNodes.size()); + } + + @Test + void testCDataTextValueIsMatchable() + { + Document doc = new Document(); + Element root = doc.newRootElement("root"); + root.cdata("cdata content"); + + XPath xpath = XPath.of("//text()[.='cdata content']"); + + List matched = xpath.selectNodes(doc); + + assertEquals(1, matched.size()); + } + } + + @Nested + @DisplayName("Validation/Exceptions") + class ValidationExceptions + { + @Test + void testXPathOfNullThrowsIllegalArgument() + { + assertThrows(IllegalArgumentException.class, () -> { + XPath.of(null); + }); + } + + @Test + void testXPathOfEmptyThrowsIllegalArgument() + { + assertThrows(IllegalArgumentException.class, () -> { + XPath.of(""); + }); + } + + @Test + void testSelectElementsAttributeNullNameThrowsIllegalArgument() + { + assertThrows(IllegalArgumentException.class, () -> { + XPath.of("id('attr-parent')/*").selectElementsAttribute(page, null); + }); + } + + @Test + void testSelectElementsAttributeEmptyNameThrowsIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> { + XPath.of("id('attr-parent')/*").selectElementsAttribute(page, ""); + }); + } + } + + private void assertAttributeEquals(String expectedName, String expectedValue, Attribute attribute) + { + assertEquals(expectedName, attribute.getName()); + assertEquals(expectedValue, attribute.getValue()); + } +} diff --git a/tapestry-core/src/test/resources/org/apache/tapestry5/dom/pages/XPathTestPage.tml b/tapestry-core/src/test/resources/org/apache/tapestry5/dom/pages/XPathTestPage.tml new file mode 100644 index 0000000000..201817a813 --- /dev/null +++ b/tapestry-core/src/test/resources/org/apache/tapestry5/dom/pages/XPathTestPage.tml @@ -0,0 +1,47 @@ + + + + +
Single Div
+ + +
+
With ID
+
No ID
+
+ + +
+
Div Child
+ Span Child +
+ + +
Before CommentAfter Comment
+ + +
+
+
+
+
+
+
+
+
+ + +
+ before inner text after +
+ + +
+
left center right
+
top middle bottom
+
+ + +
 
+ +