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("'); + } + + 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; + } + + +}