diff --git a/pmd-core/src/main/java/net/sourceforge/pmd/util/treeexport/TreeRenderer.java b/pmd-core/src/main/java/net/sourceforge/pmd/util/treeexport/TreeRenderer.java
new file mode 100644
index 0000000000..4a0a358c4d
--- /dev/null
+++ b/pmd-core/src/main/java/net/sourceforge/pmd/util/treeexport/TreeRenderer.java
@@ -0,0 +1,34 @@
+/*
+ * BSD-style license; for more info see http://pmd.sourceforge.net/license.html
+ */
+
+package net.sourceforge.pmd.util.treeexport;
+
+import java.io.IOException;
+
+import net.sourceforge.pmd.annotation.Experimental;
+import net.sourceforge.pmd.lang.ast.Node;
+
+/**
+ * An object that can export a tree to an external text format.
+ *
+ * @see XmlTreeRenderer
+ */
+@Experimental
+public interface TreeRenderer {
+
+
+ /**
+ * Appends the subtree rooted at the given node on the provided
+ * output writer. The implementation is free to filter out some
+ * nodes from the subtree.
+ *
+ * @param node Node to render
+ * @param out Object onto which the output is appended
+ *
+ * @throws IOException If an IO error occurs while appending to the output
+ */
+ void renderSubtree(Node node, Appendable out) throws IOException;
+
+
+}
diff --git a/pmd-core/src/main/java/net/sourceforge/pmd/util/treeexport/XmlTreeRenderer.java b/pmd-core/src/main/java/net/sourceforge/pmd/util/treeexport/XmlTreeRenderer.java
new file mode 100644
index 0000000000..a2744a54eb
--- /dev/null
+++ b/pmd-core/src/main/java/net/sourceforge/pmd/util/treeexport/XmlTreeRenderer.java
@@ -0,0 +1,248 @@
+/*
+ * BSD-style license; for more info see http://pmd.sourceforge.net/license.html
+ */
+
+package net.sourceforge.pmd.util.treeexport;
+
+import java.io.IOException;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Objects;
+import java.util.TreeMap;
+import java.util.regex.Pattern;
+
+import org.apache.commons.lang3.StringUtils;
+
+import net.sourceforge.pmd.annotation.Experimental;
+import net.sourceforge.pmd.lang.ast.Node;
+import net.sourceforge.pmd.lang.ast.xpath.Attribute;
+
+/**
+ * Renders a tree to XML. The resulting document is as close as possible
+ * to the representation PMD uses to run XPath queries on nodes. This
+ * allows the same XPath queries to match, in theory (it would depend
+ * on the XPath engine used I believe).
+ */
+@Experimental
+public final class XmlTreeRenderer implements TreeRenderer {
+
+ // See https://www.w3.org/TR/2008/REC-xml-20081126/#NT-Name
+ private static final String XML_START_CHAR = "[:A-Z_a-z\\xC0-\\xD6\\xD8-\\xF6\\xF8-\\x{2FF}\\x{370}-\\x{37D}\\x{37F}-\\x{1FFF}\\x{200C}-\\x{200D}\\x{2070}-\\x{218F}\\x{2C00}-\\x{2FEF}\\x{3001}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFFD}\\x{10000}-\\x{EFFFF}]";
+ private static final String XML_CHAR = "[" + XML_START_CHAR + ".\\-0-9\\xB7\\x{0300}-\\x{036F}\\x{203F}-\\x{2040}]";
+ private static final Pattern XML_NAME = Pattern.compile(XML_START_CHAR + XML_CHAR + "*");
+
+
+ private final XmlRenderingConfig strategy;
+ private final char attrDelim;
+
+ /*
+ TODO it's unclear to me how the strong typing of XPath 2.0 would
+ impact XPath queries run on the output XML. PMD maps attributes
+ to typed values, the XML only has untyped strings.
+
+ OTOH users should expect differences, and it's even documented
+ on this class.
+
+ */
+
+
+ /**
+ * Creates a new XML renderer.
+ *
+ * @param strategy Strategy to parameterize the output of this instance
+ */
+ public XmlTreeRenderer(XmlRenderingConfig strategy) {
+ this.strategy = strategy;
+ this.attrDelim = strategy.singleQuoteAttributes ? '\'' : '"';
+ }
+
+ /**
+ * Creates a new XML renderer with a default configuration.
+ */
+ public XmlTreeRenderer() {
+ this(new XmlRenderingConfig());
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ *
Each node of the AST has a corresponding XML element, whose
+ * name and attributes are the one the node presents in XPath queries.
+ *
+ * @param node {@inheritDoc}
+ * @param out {@inheritDoc}
+ *
+ * @throws IllegalArgumentException If some node has attributes or
+ * a name that is not a valid XML name
+ */
+ @Override
+ public void renderSubtree(Node node, Appendable out) throws IOException {
+ if (strategy.renderProlog) {
+ renderProlog(out);
+ }
+ renderSubtree(0, node, out);
+ out.append(strategy.lineSeparator);
+ }
+
+ private void renderProlog(Appendable out) throws IOException {
+ out.append("").append(strategy.lineSeparator);
+ }
+
+ private void renderSubtree(int depth, Node node, Appendable out) throws IOException {
+
+ String eltName = node.getXPathNodeName();
+
+ checkValidName(eltName);
+
+
+ indent(depth, out).append('<').append(eltName);
+
+ Map attributes = strategy.getXmlAttributes(node);
+
+ for (String attrName : attributes.keySet()) {
+ appendAttribute(out, attrName, attributes.get(attrName));
+ }
+
+ if (node.jjtGetNumChildren() == 0) {
+ out.append(" />");
+ return;
+ }
+
+
+ out.append(">");
+
+ for (int i = 0; i < node.jjtGetNumChildren(); i++) {
+ out.append(strategy.lineSeparator);
+ renderSubtree(depth + 1, node.jjtGetChild(i), out);
+ }
+
+ out.append(strategy.lineSeparator);
+
+ indent(depth, out).append("").append(eltName).append('>');
+ }
+
+ private void appendAttribute(Appendable out, String name, String value) throws IOException {
+ checkValidName(name);
+
+ out.append(' ')
+ .append(name)
+ .append('=')
+ .append(attrDelim)
+ .append(escapeXmlAttribute(value, strategy.singleQuoteAttributes))
+ .append(attrDelim);
+ }
+
+ private void checkValidName(String name) {
+ if (!isValidXmlName(name) || isReservedXmlName(name)) {
+ throw new IllegalArgumentException(name + " is not a valid XML name");
+ }
+ }
+
+ private Appendable indent(int depth, Appendable out) throws IOException {
+ while (depth-- > 0) {
+ out.append(strategy.indentString);
+ }
+ return out;
+ }
+
+ private static String escapeXmlText(String xml) {
+ return xml.replaceAll("<", "<")
+ .replaceAll("&", "&");
+
+ }
+
+ private static String escapeXmlAttribute(String xml, boolean isSingleQuoted) {
+
+ return isSingleQuoted ? escapeXmlText(xml).replaceAll("'", "'")
+ : escapeXmlText(xml).replaceAll("\"", """);
+ }
+
+ private static boolean isValidXmlName(String xml) {
+ return XML_NAME.matcher(xml).matches();
+ }
+
+ private static boolean isReservedXmlName(String xml) {
+ return StringUtils.startsWithIgnoreCase(xml, "xml");
+ }
+
+ /**
+ * A strategy to parameterize an {@link XmlTreeRenderer}.
+ */
+ public static class XmlRenderingConfig {
+
+ private String indentString = " ";
+ private String lineSeparator = System.lineSeparator();
+ private boolean singleQuoteAttributes = true;
+ private boolean renderProlog = true;
+
+ private Map getXmlAttributes(Node node) {
+ Map attrs = new TreeMap<>();
+ Iterator iter = node.getXPathAttributesIterator();
+ while (iter.hasNext()) {
+ Attribute next = iter.next();
+ if (takeAttribute(node, next)) {
+ attrs.put(next.getName(), next.getStringValue());
+ }
+ }
+ return attrs;
+ }
+
+ /**
+ * Returns true if the attribute should be included in the element
+ * corresponding to the given node. Subclasses can override this
+ * method to filter out some attributes.
+ *
+ * @param node Node owning the attribute
+ * @param attribute Attribute to test
+ */
+ protected boolean takeAttribute(Node node, Attribute attribute) {
+ return true;
+ }
+
+ /**
+ * Sets the string that should be used to separate lines. The
+ * default is the platform-specific line separator.
+ *
+ * @throws NullPointerException If the argument is null
+ */
+ public XmlRenderingConfig lineSeparator(String lineSeparator) {
+ this.lineSeparator = Objects.requireNonNull(lineSeparator);
+ return this;
+ }
+
+ /**
+ * Sets the delimiters use for attribute values. The default is
+ * to use single quotes.
+ *
+ * @param useSingleQuote True for single quotes, false for double quotes
+ */
+ public XmlRenderingConfig singleQuoteAttributes(boolean useSingleQuote) {
+ this.singleQuoteAttributes = useSingleQuote;
+ return this;
+ }
+
+ /**
+ * Sets whether to render an XML prolog or not. The default is
+ * true.
+ */
+ public XmlRenderingConfig renderProlog(boolean renderProlog) {
+ this.renderProlog = renderProlog;
+ return this;
+ }
+
+ /**
+ * Sets the string that should be used to indent children elements.
+ * The default is four spaces.
+ *
+ * @throws NullPointerException If the argument is null
+ */
+ public XmlRenderingConfig indentWith(String indentString) {
+ this.indentString = Objects.requireNonNull(indentString);
+ return this;
+ }
+
+ }
+
+}
diff --git a/pmd-core/src/test/java/net/sourceforge/pmd/lang/ast/DummyNode.java b/pmd-core/src/test/java/net/sourceforge/pmd/lang/ast/DummyNode.java
index e6fa7597ea..893965442d 100644
--- a/pmd-core/src/test/java/net/sourceforge/pmd/lang/ast/DummyNode.java
+++ b/pmd-core/src/test/java/net/sourceforge/pmd/lang/ast/DummyNode.java
@@ -4,14 +4,28 @@
package net.sourceforge.pmd.lang.ast;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+import net.sourceforge.pmd.lang.ast.xpath.Attribute;
+
public class DummyNode extends AbstractNode {
private final boolean findBoundary;
private final String xpathName;
+ private final Map attributes = new HashMap<>();
+
public DummyNode(int id) {
this(id, false);
}
+ public DummyNode() {
+ this(0);
+ }
+
public DummyNode(int id, boolean findBoundary) {
this(id, findBoundary, "dummyNode");
}
@@ -22,6 +36,21 @@ public class DummyNode extends AbstractNode {
this.xpathName = xpathName;
}
+ public void setXPathAttribute(String name, String value) {
+ attributes.put(name, value);
+ }
+
+ @Override
+ public Iterator getXPathAttributesIterator() {
+
+ List attrs = new ArrayList<>();
+ for (String name : attributes.keySet()) {
+ attrs.add(new Attribute(this, name, attributes.get(name)));
+ }
+
+ return attrs.iterator();
+ }
+
@Override
public String toString() {
return xpathName;
@@ -31,7 +60,7 @@ public class DummyNode extends AbstractNode {
public String getXPathNodeName() {
return xpathName;
}
-
+
@Override
public boolean isFindBoundary() {
return findBoundary;
diff --git a/pmd-core/src/test/java/net/sourceforge/pmd/util/treeexport/XmlTreeRendererTest.java b/pmd-core/src/test/java/net/sourceforge/pmd/util/treeexport/XmlTreeRendererTest.java
new file mode 100644
index 0000000000..4362eed195
--- /dev/null
+++ b/pmd-core/src/test/java/net/sourceforge/pmd/util/treeexport/XmlTreeRendererTest.java
@@ -0,0 +1,273 @@
+/*
+ * BSD-style license; for more info see http://pmd.sourceforge.net/license.html
+ */
+
+package net.sourceforge.pmd.util.treeexport;
+
+import java.io.IOException;
+
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+import net.sourceforge.pmd.lang.ast.DummyNode;
+import net.sourceforge.pmd.lang.ast.Node;
+import net.sourceforge.pmd.lang.ast.xpath.Attribute;
+import net.sourceforge.pmd.util.treeexport.XmlTreeRenderer.XmlRenderingConfig;
+
+/**
+ */
+public class XmlTreeRendererTest {
+
+ @Rule
+ public ExpectedException expect = ExpectedException.none();
+
+ @Test
+ public void testRenderWithAttributes() throws IOException {
+
+ DummyNode dummy = dummyTree1();
+
+ XmlRenderingConfig strat = new XmlRenderingConfig();
+ strat.lineSeparator("\n");
+ XmlTreeRenderer renderer = new XmlTreeRenderer(strat);
+
+ StringBuilder out = new StringBuilder();
+
+
+ renderer.renderSubtree(dummy, out);
+
+ Assert.assertEquals("\n"
+ + "\n"
+ + " \n"
+ + " \n"
+ + "\n", out.toString());
+
+ }
+
+ @Test
+ public void testRenderWithCustomLineSep() throws IOException {
+
+ DummyNode dummy = dummyTree1();
+
+ XmlRenderingConfig strat = new XmlRenderingConfig();
+ strat.lineSeparator("\r\n");
+ XmlTreeRenderer renderer = new XmlTreeRenderer(strat);
+
+ StringBuilder out = new StringBuilder();
+
+
+ renderer.renderSubtree(dummy, out);
+
+ Assert.assertEquals("\r\n"
+ + "\r\n"
+ + " \r\n"
+ + " \r\n"
+ + "\r\n", out.toString());
+
+ }
+
+ @Test
+ public void testRenderWithCustomIndent() throws IOException {
+
+ DummyNode dummy = dummyTree1();
+
+ XmlRenderingConfig strat = new XmlRenderingConfig().lineSeparator("").indentWith("");
+
+ XmlTreeRenderer renderer = new XmlTreeRenderer(strat);
+
+ StringBuilder out = new StringBuilder();
+
+
+ renderer.renderSubtree(dummy, out);
+
+ Assert.assertEquals(""
+ + ""
+ + ""
+ + ""
+ + "", out.toString());
+
+ }
+
+ @Test
+ public void testRenderWithNoAttributes() throws IOException {
+
+ DummyNode dummy = dummyTree1();
+
+ XmlRenderingConfig strat = new XmlRenderingConfig() {
+ @Override
+ public boolean takeAttribute(Node node, Attribute attribute) {
+ return false;
+ }
+ };
+
+ XmlTreeRenderer renderer = new XmlTreeRenderer(strat);
+
+ StringBuilder out = new StringBuilder();
+
+
+ renderer.renderSubtree(dummy, out);
+
+ Assert.assertEquals("\n"
+ + "\n"
+ + " \n"
+ + " \n"
+ + "\n", out.toString());
+
+ }
+
+ @Test
+ public void testRenderFilterAttributes() throws IOException {
+
+ DummyNode dummy = dummyTree1();
+
+ XmlRenderingConfig strategy = new XmlRenderingConfig() {
+ @Override
+ public boolean takeAttribute(Node node, Attribute attribute) {
+ return attribute.getName().equals("ohio");
+ }
+ };
+
+ XmlTreeRenderer renderer = new XmlTreeRenderer(strategy);
+
+ StringBuilder out = new StringBuilder();
+
+ renderer.renderSubtree(dummy, out);
+
+ Assert.assertEquals("\n"
+ + "\n"
+ + " \n"
+ + " \n"
+ + "\n", out.toString());
+
+ }
+
+ @Test
+ public void testInvalidAttributeName() throws IOException {
+
+ DummyNode dummy = dummyTree1();
+
+ dummy.setXPathAttribute("¬AName", "foo");
+
+ XmlRenderingConfig config = new XmlRenderingConfig();
+ config.lineSeparator("\n");
+
+ XmlTreeRenderer renderer = new XmlTreeRenderer(config);
+
+ StringBuilder out = new StringBuilder();
+
+ expect.expect(IllegalArgumentException.class);
+
+ renderer.renderSubtree(dummy, out);
+
+ }
+
+
+ @Test
+ public void testEscapeAttributes() throws IOException {
+
+ DummyNode dummy = dummyTree1();
+
+ dummy.setXPathAttribute("eh", " 'a &> b\" ");
+
+ XmlRenderingConfig strat = new XmlRenderingConfig().lineSeparator("\n");
+ XmlTreeRenderer renderer = new XmlTreeRenderer(strat);
+
+ StringBuilder out = new StringBuilder();
+
+
+ renderer.renderSubtree(dummy, out);
+
+ Assert.assertEquals("\n"
+ + "\n"
+ + " \n"
+ + " \n"
+ + "\n", out.toString());
+
+ }
+
+ @Test
+ public void testEscapeDoubleAttributes() throws IOException {
+
+ DummyNode dummy = dummyTree1();
+
+ dummy.setXPathAttribute("eh", " 'a &> b\" ");
+
+ XmlRenderingConfig strat = new XmlRenderingConfig().lineSeparator("\n").singleQuoteAttributes(false);
+ XmlTreeRenderer renderer = new XmlTreeRenderer(strat);
+
+ StringBuilder out = new StringBuilder();
+
+
+ renderer.renderSubtree(dummy, out);
+
+ Assert.assertEquals("\n"
+ + " b" \" foo=\"bar\" ohio=\"4\">\n"
+ + " \n"
+ + " \n"
+ + "\n", out.toString());
+
+ }
+
+ @Test
+ public void testNoProlog() throws IOException {
+
+ DummyNode dummy = dummyTree1();
+
+
+ XmlRenderingConfig strat = new XmlRenderingConfig().lineSeparator("\n").renderProlog(false);
+ XmlTreeRenderer renderer = new XmlTreeRenderer(strat);
+
+ StringBuilder out = new StringBuilder();
+
+
+ renderer.renderSubtree(dummy, out);
+
+ Assert.assertEquals("\n"
+ + " \n"
+ + " \n"
+ + "\n", out.toString());
+
+ }
+
+
+ @Test
+ public void testDefaultLineSep() throws IOException {
+
+ DummyNode dummy = dummyTree1();
+
+ XmlTreeRenderer renderer = new XmlTreeRenderer();
+
+ StringBuilder out = new StringBuilder();
+
+
+ renderer.renderSubtree(dummy, out);
+
+ Assert.assertEquals("" + System.lineSeparator()
+ + "" + System.lineSeparator()
+ + " " + System.lineSeparator()
+ + " " + System.lineSeparator()
+ + "" + System.lineSeparator(), out.toString());
+
+ }
+
+
+ public DummyNode dummyTree1() {
+ DummyNode dummy = new DummyNode();
+
+ dummy.setXPathAttribute("foo", "bar");
+ dummy.setXPathAttribute("ohio", "4");
+
+ DummyNode dummy1 = new DummyNode();
+
+ dummy1.setXPathAttribute("o", "ha");
+
+ DummyNode dummy2 = new DummyNode();
+
+ dummy.jjtAddChild(dummy1, 0);
+ dummy.jjtAddChild(dummy2, 1);
+ return dummy;
+ }
+
+
+}