package org.netbeans.gradle.project.coverage; import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; import javax.swing.text.Document; import org.jtrim.utils.ExceptionHelper; import org.netbeans.api.annotations.common.CheckForNull; import org.netbeans.api.java.classpath.ClassPath; import org.netbeans.api.project.Project; import org.netbeans.gradle.model.java.JacocoModel; import org.netbeans.gradle.project.java.JavaExtension; import org.netbeans.gradle.project.java.model.NbCodeCoverage; import org.netbeans.gradle.project.java.query.GradleClassPathProvider; import org.netbeans.gradle.project.java.tasks.GradleJavaBuiltInCommands; import org.netbeans.modules.gsf.codecoverage.api.CoverageManager; import org.netbeans.modules.gsf.codecoverage.api.CoverageProvider; import org.netbeans.modules.gsf.codecoverage.api.CoverageType; import org.netbeans.modules.gsf.codecoverage.api.FileCoverageDetails; import org.netbeans.modules.gsf.codecoverage.api.FileCoverageSummary; import org.openide.filesystems.FileChangeAdapter; import org.openide.filesystems.FileChangeListener; import org.openide.filesystems.FileEvent; import org.openide.filesystems.FileObject; import org.openide.filesystems.FileUtil; import org.openide.util.Pair; import org.openide.xml.XMLUtil; import org.w3c.dom.Element; import org.w3c.dom.NodeList; import org.xml.sax.EntityResolver; import org.xml.sax.InputSource; import org.xml.sax.SAXException; /** * Implementation of CoverageProvider for Gradle project infrastructure. * Actual implementation suppors jacoco (cobertura TBD) * @author sven */ public class GradleCoverageProvider implements CoverageProvider { private static final Logger LOG = Logger.getLogger(GradleCoverageProvider.class.getName()); private final JavaExtension javaExt; private final Project p; private Map<String, GradleSummary> summaryCache; private FileChangeListener listener; public GradleCoverageProvider(JavaExtension javaExt) { ExceptionHelper.checkNotNullArgument(javaExt, "javaExt"); this.javaExt = javaExt; this.p = javaExt.getProject(); this.summaryCache = null; this.listener = null; } @Override public boolean supportsHitCounts() { return true; } @Override public boolean supportsAggregation() { return false; } private NbCodeCoverage getModel() { return javaExt.getCurrentModel().getMainModule().getCodeCoverage(); } private boolean hasPlugin() { return getModel().hasCodeCoverage(); } @Override public boolean isEnabled() { return hasPlugin(); } @Override public boolean isAggregating() { throw new UnsupportedOperationException("Not supported yet."); //To change body of generated methods, choose Tools | Templates. } @Override public void setAggregating(boolean bln) { throw new UnsupportedOperationException("Not supported yet."); //To change body of generated methods, choose Tools | Templates. } @Override public Set<String> getMimeTypes() { return Collections.singleton("text/x-java"); } @Override public void setEnabled(boolean bln) { //TODO: add jacoco to build.gradle } private @CheckForNull File report() { JacocoModel jacocoModel = getModel().tryGetJacocoModel(); if (jacocoModel == null) { return null; } File result = jacocoModel.getReport().getXml(); return FileUtil.normalizeFile(result); } public @Override synchronized void clear() { File r = report(); if (r != null && r.isFile() && r.delete()) { summaryCache = null; CoverageManager.INSTANCE.resultsUpdated(p, GradleCoverageProvider.this); } } @Override public FileCoverageDetails getDetails(FileObject fo, Document doc) { String path = srcPath().getResourceName(fo); if (path == null) { return null; } GradleDetails det = null; synchronized (this) { GradleSummary summ = summaryCache != null ? summaryCache.get(path) : null; if (summ != null) { det = summ.getDetails(); //we have to set the linecount here, as the entire line span is not apparent from the parsed xml, giving strange results then. det.lineCount = doc.getDefaultRootElement().getElementCount(); } } return det; } private @CheckForNull Pair<File, org.w3c.dom.Document> parse() { File r = report(); if (r == null) { LOG.fine("undefined report location"); return null; } CoverageManager.INSTANCE.setEnabled(p, true); // XXX otherwise it defaults to disabled?? not clear where to call this if (listener == null) { listener = new FileChangeAdapter() { public @Override void fileChanged(FileEvent fe) { fire(); } public @Override void fileDataCreated(FileEvent fe) { fire(); } public @Override void fileDeleted(FileEvent fe) { fire(); } private void fire() { synchronized (GradleCoverageProvider.this) { summaryCache = null; } CoverageManager.INSTANCE.resultsUpdated(p, GradleCoverageProvider.this); } }; FileUtil.addFileChangeListener(listener, r); } if (!r.isFile()) { LOG.log(Level.FINE, "missing {0}", r); return null; } if (r.length() == 0) { // When not previously existent, seems to get created first and written later; file event picks it up when empty. LOG.log(Level.FINE, "empty {0}", r); return null; } try { org.w3c.dom.Document report = XMLUtil.parse(new InputSource(r.toURI().toString()), true, false, XMLUtil.defaultErrorHandler(), new EntityResolver() { public @Override InputSource resolveEntity(String publicId, String systemId) throws SAXException, IOException { if (systemId.equals("http://cobertura.sourceforge.net/xml/coverage-04.dtd")) { return new InputSource(GradleCoverageProvider.class.getResourceAsStream("coverage-04.dtd")); // NOI18N } else if (publicId.equals("-//JACOCO//DTD Report 1.0//EN")) { return new InputSource(GradleCoverageProvider.class.getResourceAsStream("jacoco-1.0.dtd")); } else { return null; } } }); LOG.log(Level.FINE, "parsed {0}", r); return Pair.of(r, report); } catch (IOException | SAXException ex) { LOG.log(Level.INFO, "Could not parse " + r, ex); return null; } } private ClassPath srcPath() { GradleClassPathProvider gcp = p.getLookup().lookup(GradleClassPathProvider.class); assert gcp != null; ClassPath cp = gcp.getClassPaths(ClassPath.SOURCE); assert cp != null; return cp; } @Override public List<FileCoverageSummary> getResults() { Pair<File, org.w3c.dom.Document> r = parse(); if (r == null) { return null; } ClassPath src = srcPath(); List<FileCoverageSummary> summs = new ArrayList<>(); Map<String, GradleSummary> summaries = new HashMap<>(); boolean jacoco = hasPlugin(); NodeList nl = r.second().getElementsByTagName(jacoco ? "sourcefile" : "class"); // NOI18N for (int i = 0; i < nl.getLength(); i++) { Element clazz = (Element)nl.item(i); String filename; List<Element> lines; String name; if (jacoco) { filename = ((Element)clazz.getParentNode()).getAttribute("name") + '/' + clazz.getAttribute("name"); lines = new ArrayList<>(); for (Element line: XMLUtil.findSubElements(clazz)) { if (line.getTagName().equals("line")) { lines.add(line); } } name = filename.replaceFirst("[.]java$", "").replace('/', '.'); } else { filename = clazz.getAttribute("filename"); Element linesE = XMLUtil.findElement(clazz, "lines", null); // NOI18N lines = linesE != null ? XMLUtil.findSubElements(linesE) : Collections.<Element>emptyList(); // XXX nicer to collect together nested classes in same compilation unit name = clazz.getAttribute("name").replace('$', '.'); } FileObject java = src.findResource(filename); // NOI18N if (java == null) { continue; } final GradleSummary summar = summaryOf(java, name, lines, jacoco, r.first().lastModified()); summaries.put(filename, summar); summs.add(summar); } synchronized (this) { summaryCache = summaries; } return summs; } private GradleSummary summaryOf(FileObject java, String name, List<Element> lines, boolean jacoco, long lastUpdated) { // Not really the total number of lines in the file at all, but close enough - the ones Cobertura recorded. int lineCount = 0; int executedLineCount = 0; Map<Integer, Integer> detLines = new HashMap<>(); for (Element line : lines) { lineCount++; String attr = line.getAttribute(jacoco ? "ci" : "hits"); String num = line.getAttribute(jacoco ? "nr" : "number"); detLines.put(Integer.valueOf(num) - 1,Integer.valueOf(attr)); if (!attr.equals("0")) { executedLineCount++; } } GradleDetails det = new GradleDetails(java, lastUpdated, lineCount, detLines); GradleSummary s = new GradleSummary(java, name, det, executedLineCount); return s; } @Override public String getTestAllAction() { return GradleJavaBuiltInCommands.TEST_WITH_COVERAGE; } private static class GradleSummary extends FileCoverageSummary { private final GradleDetails details; public GradleSummary(FileObject file, String displayName, GradleDetails details, int executedLineCount) { super(file, displayName, details.getLineCount(), executedLineCount, 0, 0); this.details = details; details.setSummary(this); } GradleDetails getDetails() { return details; } } private static class GradleDetails implements FileCoverageDetails { private final FileObject fileObject; private final long lastUpdated; private FileCoverageSummary summary; private final Map<Integer, Integer> lineHitCounts; int lineCount; public GradleDetails(FileObject fileObject, long lastUpdated, int lineCount, Map<Integer, Integer> lineHitCounts) { this.fileObject = fileObject; this.lastUpdated = lastUpdated; this.lineHitCounts = lineHitCounts; this.lineCount = lineCount; } @Override public FileObject getFile() { return fileObject; } @Override public int getLineCount() { return lineCount; } @Override public boolean hasHitCounts() { return true; } @Override public long lastUpdated() { return lastUpdated; } @Override public FileCoverageSummary getSummary() { return summary; } public void setSummary(FileCoverageSummary summary) { this.summary = summary; } @Override public CoverageType getType(int lineNo) { Integer count = lineHitCounts.get(lineNo); return count == null ? CoverageType.INFERRED : count == 0 ? CoverageType.NOT_COVERED : CoverageType.COVERED; } @Override public int getHitCount(int lineNo) { Integer ret = lineHitCounts.get(lineNo); if (ret == null) { return 0; } return ret; } } }