Add xml tree renderer

This commit is contained in:
Clément Fournier
2020-01-12 21:32:35 +01:00
parent f7bd2d54b3
commit dacab0b2ae
4 changed files with 585 additions and 1 deletions

View File

@ -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;
}

View File

@ -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}
*
* <p>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("<?xml version=").append(attrDelim).append("1.0").append(attrDelim)
.append(" encoding=").append(attrDelim).append("UTF-8").append(attrDelim)
.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<String, String> 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("<", "&lt;")
.replaceAll("&", "&amp;");
}
private static String escapeXmlAttribute(String xml, boolean isSingleQuoted) {
return isSingleQuoted ? escapeXmlText(xml).replaceAll("'", "&apos;")
: escapeXmlText(xml).replaceAll("\"", "&quot;");
}
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<String, String> getXmlAttributes(Node node) {
Map<String, String> attrs = new TreeMap<>();
Iterator<Attribute> 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;
}
}
}

View File

@ -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<String, String> 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<Attribute> getXPathAttributesIterator() {
List<Attribute> 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;

View File

@ -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("<?xml version='1.0' encoding='UTF-8' ?>\n"
+ "<dummyNode foo='bar' ohio='4'>\n"
+ " <dummyNode o='ha' />\n"
+ " <dummyNode />\n"
+ "</dummyNode>\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("<?xml version='1.0' encoding='UTF-8' ?>\r\n"
+ "<dummyNode foo='bar' ohio='4'>\r\n"
+ " <dummyNode o='ha' />\r\n"
+ " <dummyNode />\r\n"
+ "</dummyNode>\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("<?xml version='1.0' encoding='UTF-8' ?>"
+ "<dummyNode foo='bar' ohio='4'>"
+ "<dummyNode o='ha' />"
+ "<dummyNode />"
+ "</dummyNode>", 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("<?xml version='1.0' encoding='UTF-8' ?>\n"
+ "<dummyNode>\n"
+ " <dummyNode />\n"
+ " <dummyNode />\n"
+ "</dummyNode>\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("<?xml version='1.0' encoding='UTF-8' ?>\n"
+ "<dummyNode ohio='4'>\n"
+ " <dummyNode />\n"
+ " <dummyNode />\n"
+ "</dummyNode>\n", out.toString());
}
@Test
public void testInvalidAttributeName() throws IOException {
DummyNode dummy = dummyTree1();
dummy.setXPathAttribute("&notAName", "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("<?xml version='1.0' encoding='UTF-8' ?>\n"
+ "<dummyNode eh=' &apos;a &amp;> b\" ' foo='bar' ohio='4'>\n"
+ " <dummyNode o='ha' />\n"
+ " <dummyNode />\n"
+ "</dummyNode>\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("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>\n"
+ "<dummyNode eh=\" 'a &amp;> b&quot; \" foo=\"bar\" ohio=\"4\">\n"
+ " <dummyNode o=\"ha\" />\n"
+ " <dummyNode />\n"
+ "</dummyNode>\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("<dummyNode foo='bar' ohio='4'>\n"
+ " <dummyNode o='ha' />\n"
+ " <dummyNode />\n"
+ "</dummyNode>\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("<?xml version='1.0' encoding='UTF-8' ?>" + System.lineSeparator()
+ "<dummyNode foo='bar' ohio='4'>" + System.lineSeparator()
+ " <dummyNode o='ha' />" + System.lineSeparator()
+ " <dummyNode />" + System.lineSeparator()
+ "</dummyNode>" + 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;
}
}