diff --git a/pmd-core/src/main/java/net/sourceforge/pmd/cpd/XMLRenderer.java b/pmd-core/src/main/java/net/sourceforge/pmd/cpd/XMLRenderer.java index 817e56e044..8aa20956d7 100644 --- a/pmd-core/src/main/java/net/sourceforge/pmd/cpd/XMLRenderer.java +++ b/pmd-core/src/main/java/net/sourceforge/pmd/cpd/XMLRenderer.java @@ -6,7 +6,10 @@ package net.sourceforge.pmd.cpd; import java.io.IOException; import java.io.Writer; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; import java.util.Map; +import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; @@ -20,6 +23,7 @@ import javax.xml.transform.stream.StreamResult; import org.w3c.dom.Document; import org.w3c.dom.Element; +import net.sourceforge.pmd.PMDVersion; import net.sourceforge.pmd.lang.document.Chars; import net.sourceforge.pmd.lang.document.FileId; import net.sourceforge.pmd.lang.document.FileLocation; @@ -32,6 +36,8 @@ import net.sourceforge.pmd.util.StringUtil; * */ public final class XMLRenderer implements CPDReportRenderer { + private static final String NAMESPACE_URI = "https://pmd-code.org/ns/cpd-report/1.0.0"; + private static final String NAMESPACE_LOCATION = "https://pmd-code.org/ns/cpd-report_1_0_0.xsd"; private String encoding; @@ -83,7 +89,7 @@ public final class XMLRenderer implements CPDReportRenderer { transformer.setOutputProperty(OutputKeys.METHOD, "xml"); transformer.setOutputProperty(OutputKeys.ENCODING, encoding); transformer.setOutputProperty(OutputKeys.INDENT, "yes"); - transformer.setOutputProperty(OutputKeys.CDATA_SECTION_ELEMENTS, "codefragment"); + transformer.setOutputProperty(OutputKeys.CDATA_SECTION_ELEMENTS, "{" + NAMESPACE_URI + "}codefragment"); transformer.transform(new DOMSource(doc), new StreamResult(writer)); } catch (TransformerException e) { throw new IllegalStateException(e); @@ -94,14 +100,18 @@ public final class XMLRenderer implements CPDReportRenderer { @Override public void render(final CPDReport report, final Writer writer) throws IOException { final Document doc = createDocument(); - final Element root = doc.createElement("pmd-cpd"); + final Element root = doc.createElementNS(NAMESPACE_URI, "pmd-cpd"); + root.setAttributeNS(XMLConstants.W3C_XML_SCHEMA_INSTANCE_NS_URI, "xsi:schemaLocation", NAMESPACE_URI + " " + NAMESPACE_LOCATION); + + root.setAttributeNS(NAMESPACE_URI, "version", PMDVersion.VERSION); + root.setAttributeNS(NAMESPACE_URI, "timestamp", OffsetDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); final Map numberOfTokensPerFile = report.getNumberOfTokensPerFile(); doc.appendChild(root); for (final Map.Entry pair : numberOfTokensPerFile.entrySet()) { - final Element fileElement = doc.createElement("file"); - fileElement.setAttribute("path", report.getDisplayName(pair.getKey())); - fileElement.setAttribute("totalNumberOfTokens", String.valueOf(pair.getValue())); + final Element fileElement = doc.createElementNS(NAMESPACE_URI, "file"); + fileElement.setAttributeNS(NAMESPACE_URI, "path", report.getDisplayName(pair.getKey())); + fileElement.setAttributeNS(NAMESPACE_URI, "totalNumberOfTokens", String.valueOf(pair.getValue())); root.appendChild(fileElement); } @@ -113,9 +123,9 @@ public final class XMLRenderer implements CPDReportRenderer { } for (Report.ProcessingError error : report.getProcessingErrors()) { - Element errorElt = doc.createElement("error"); - errorElt.setAttribute("filename", report.getDisplayName(error.getFileId())); - errorElt.setAttribute("msg", error.getMsg()); + Element errorElt = doc.createElementNS(NAMESPACE_URI, "error"); + errorElt.setAttributeNS(NAMESPACE_URI, "filename", report.getDisplayName(error.getFileId())); + errorElt.setAttributeNS(NAMESPACE_URI, "msg", error.getMsg()); errorElt.setTextContent(error.getDetail()); root.appendChild(errorElt); } @@ -126,17 +136,17 @@ public final class XMLRenderer implements CPDReportRenderer { private void addFilesToDuplicationElement(Document doc, Element duplication, Match match, CPDReport report) { for (Mark mark : match) { - final Element file = doc.createElement("file"); + final Element file = doc.createElementNS(NAMESPACE_URI, "file"); FileLocation loc = mark.getLocation(); - file.setAttribute("line", String.valueOf(loc.getStartLine())); + file.setAttributeNS(NAMESPACE_URI, "line", String.valueOf(loc.getStartLine())); // only remove invalid characters, escaping is done by the DOM impl. String filenameXml10 = StringUtil.removedInvalidXml10Characters(report.getDisplayName(loc.getFileId())); - file.setAttribute("path", filenameXml10); - file.setAttribute("endline", String.valueOf(loc.getEndLine())); - file.setAttribute("column", String.valueOf(loc.getStartColumn())); - file.setAttribute("endcolumn", String.valueOf(loc.getEndColumn())); - file.setAttribute("begintoken", String.valueOf(mark.getBeginTokenIndex())); - file.setAttribute("endtoken", String.valueOf(mark.getEndTokenIndex())); + file.setAttributeNS(NAMESPACE_URI, "path", filenameXml10); + file.setAttributeNS(NAMESPACE_URI, "endline", String.valueOf(loc.getEndLine())); + file.setAttributeNS(NAMESPACE_URI, "column", String.valueOf(loc.getStartColumn())); + file.setAttributeNS(NAMESPACE_URI, "endcolumn", String.valueOf(loc.getEndColumn())); + file.setAttributeNS(NAMESPACE_URI, "begintoken", String.valueOf(mark.getBeginTokenIndex())); + file.setAttributeNS(NAMESPACE_URI, "endtoken", String.valueOf(mark.getEndTokenIndex())); duplication.appendChild(file); } } @@ -146,7 +156,7 @@ public final class XMLRenderer implements CPDReportRenderer { if (codeSnippet != null) { // the code snippet has normalized line endings String platformSpecific = codeSnippet.toString().replace("\n", System.lineSeparator()); - Element codefragment = doc.createElement("codefragment"); + Element codefragment = doc.createElementNS(NAMESPACE_URI, "codefragment"); // only remove invalid characters, escaping is not necessary in CDATA. // if the string contains the end marker of a CDATA section, then the DOM impl will // create two cdata sections automatically. @@ -156,9 +166,9 @@ public final class XMLRenderer implements CPDReportRenderer { } private Element createDuplicationElement(Document doc, Match match) { - Element duplication = doc.createElement("duplication"); - duplication.setAttribute("lines", String.valueOf(match.getLineCount())); - duplication.setAttribute("tokens", String.valueOf(match.getTokenCount())); + Element duplication = doc.createElementNS(NAMESPACE_URI, "duplication"); + duplication.setAttributeNS(NAMESPACE_URI, "lines", String.valueOf(match.getLineCount())); + duplication.setAttributeNS(NAMESPACE_URI, "tokens", String.valueOf(match.getTokenCount())); return duplication; } } diff --git a/pmd-core/src/main/resources/cpd-report_1_0_0.xsd b/pmd-core/src/main/resources/cpd-report_1_0_0.xsd new file mode 100644 index 0000000000..e5d0016b24 --- /dev/null +++ b/pmd-core/src/main/resources/cpd-report_1_0_0.xsd @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pmd-core/src/test/java/net/sourceforge/pmd/cpd/XMLRendererTest.java b/pmd-core/src/test/java/net/sourceforge/pmd/cpd/XMLRendererTest.java index afec863903..fee4de49c6 100644 --- a/pmd-core/src/test/java/net/sourceforge/pmd/cpd/XMLRendererTest.java +++ b/pmd-core/src/test/java/net/sourceforge/pmd/cpd/XMLRendererTest.java @@ -11,17 +11,27 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.StringReader; import java.io.StringWriter; import java.util.Collections; +import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; +import javax.xml.transform.stream.StreamSource; +import javax.xml.validation.Schema; +import javax.xml.validation.SchemaFactory; import org.junit.jupiter.api.Test; import org.w3c.dom.Document; import org.w3c.dom.NamedNodeMap; import org.w3c.dom.Node; import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; import org.xml.sax.SAXException; +import org.xml.sax.SAXParseException; +import org.xml.sax.helpers.DefaultHandler; import net.sourceforge.pmd.cpd.CpdTestUtils.CpdReportBuilder; import net.sourceforge.pmd.lang.ast.LexException; @@ -44,6 +54,17 @@ class XMLRendererTest { StringWriter sw = new StringWriter(); renderer.render(CpdTestUtils.makeReport(Collections.emptyList()), sw); String report = sw.toString(); + assertReportIsValidSchema(report); + + assertEquals("\n" + + "\n", + report.replaceAll(" {4}timestamp=\".+?\"", " timestamp=\"XXX\"") + .replaceAll(" {4}version=\".+?\"", " version=\"XXX\""), + "namespace is missing"); Document doc = DocumentBuilderFactory.newInstance().newDocumentBuilder() .parse(new ByteArrayInputStream(report.getBytes(ENCODING))); @@ -66,6 +87,7 @@ class XMLRendererTest { StringWriter sw = new StringWriter(); renderer.render(builder.build(), sw); String report = sw.toString(); + assertReportIsValidSchema(report); Document doc = DocumentBuilderFactory.newInstance().newDocumentBuilder() .parse(new ByteArrayInputStream(report.getBytes(ENCODING))); @@ -115,6 +137,7 @@ class XMLRendererTest { StringWriter sw = new StringWriter(); renderer.render(builder.build(), sw); String report = sw.toString(); + assertReportIsValidSchema(report); Document doc = DocumentBuilderFactory.newInstance().newDocumentBuilder() .parse(new ByteArrayInputStream(report.getBytes(ENCODING))); @@ -135,6 +158,7 @@ class XMLRendererTest { StringWriter sw = new StringWriter(); renderer.render(builder.build(), sw); String report = sw.toString(); + assertReportIsValidSchema(report); Document doc = DocumentBuilderFactory.newInstance().newDocumentBuilder() .parse(new ByteArrayInputStream(report.getBytes(ENCODING))); @@ -166,7 +190,7 @@ class XMLRendererTest { } @Test - void testRendererEncodedPath() throws IOException { + void testRendererEncodedPath() throws Exception { CPDReportRenderer renderer = new XMLRenderer(); CpdReportBuilder builder = new CpdReportBuilder(); final String escapeChar = "&"; @@ -177,6 +201,7 @@ class XMLRendererTest { StringWriter sw = new StringWriter(); renderer.render(builder.build(), sw); String report = sw.toString(); + assertReportIsValidSchema(report); assertThat(report, containsString(escapeChar)); } @@ -196,6 +221,7 @@ class XMLRendererTest { final StringWriter writer = new StringWriter(); renderer.render(report, writer); final String xmlOutput = writer.toString(); + assertReportIsValidSchema(xmlOutput); final Document doc = DocumentBuilderFactory.newInstance().newDocumentBuilder() .parse(new ByteArrayInputStream(xmlOutput.getBytes(ENCODING))); final NodeList files = doc.getElementsByTagName("file"); @@ -221,6 +247,7 @@ class XMLRendererTest { final StringWriter writer = new StringWriter(); renderer.render(report, writer); final String xmlOutput = writer.toString(); + assertReportIsValidSchema(xmlOutput); final Document doc = DocumentBuilderFactory.newInstance().newDocumentBuilder() .parse(new ByteArrayInputStream(xmlOutput.getBytes(ENCODING))); final NodeList files = doc.getElementsByTagName("file"); @@ -236,7 +263,7 @@ class XMLRendererTest { } @Test - void testRendererXMLEscaping() throws IOException { + void testRendererXMLEscaping() throws Exception { String codefragment = "code fragment" + FORM_FEED + "\nline2\nline3\nno & escaping necessary in CDATA\nx=\"]]>\";"; CPDReportRenderer renderer = new XMLRenderer(); @@ -253,6 +280,7 @@ class XMLRendererTest { StringWriter sw = new StringWriter(); renderer.render(builder.build(), sw); String report = sw.toString(); + assertReportIsValidSchema(report); assertThat(report, not(containsString(FORM_FEED))); assertThat(report, not(containsString(FORM_FEED_ENTITY))); assertThat(report, containsString("no & escaping necessary in CDATA")); @@ -261,7 +289,7 @@ class XMLRendererTest { } @Test - void reportContainsProcessingError() throws IOException, ParserConfigurationException, SAXException { + void reportContainsProcessingError() throws Exception { FileId fileId = FileId.fromPathLikeString("file1.txt"); Report.ProcessingError processingError = new Report.ProcessingError( new LexException(2, 1, fileId, "test exception", new RuntimeException("cause exception")), @@ -270,7 +298,7 @@ class XMLRendererTest { StringWriter sw = new StringWriter(); renderer.render(CpdTestUtils.makeReport(Collections.emptyList(), Collections.emptyMap(), Collections.singletonList(processingError)), sw); String report = sw.toString(); - System.out.println("report = " + report); + assertReportIsValidSchema(report); Document doc = DocumentBuilderFactory.newInstance().newDocumentBuilder() .parse(new ByteArrayInputStream(report.getBytes(ENCODING))); @@ -286,4 +314,27 @@ class XMLRendererTest { String textContent = error.getTextContent(); assertEquals(processingError.getDetail(), textContent); } + + private static void assertReportIsValidSchema(String report) throws SAXException, ParserConfigurationException, IOException { + SchemaFactory schemaFactory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI); + Schema schema = schemaFactory.newSchema(new StreamSource(XMLRenderer.class.getResourceAsStream("/cpd-report_1_0_0.xsd"))); + + SAXParserFactory saxParserFactory = SAXParserFactory.newInstance(); + saxParserFactory.setNamespaceAware(true); + saxParserFactory.setValidating(false); + saxParserFactory.setSchema(schema); + + SAXParser saxParser = saxParserFactory.newSAXParser(); + saxParser.parse(new InputSource(new StringReader(report)), new DefaultHandler() { + @Override + public void error(SAXParseException e) throws SAXException { + throw e; + } + + @Override + public void warning(SAXParseException e) throws SAXException { + throw e; + } + }); + } }