package de.is24.deadcode4j.analyzer; import com.google.common.base.Optional; import com.google.common.collect.Iterables; import de.is24.deadcode4j.AnalysisContext; import org.xml.sax.Attributes; import org.xml.sax.helpers.DefaultHandler; import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; import java.util.*; import static com.google.common.base.Optional.fromNullable; import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.collect.Iterables.getLast; import static com.google.common.collect.Maps.newHashMapWithExpectedSize; import static de.is24.deadcode4j.Utils.checkNotNull; import static de.is24.deadcode4j.Utils.isNotBlank; import static java.util.Collections.emptyMap; /** * Serves as base class with which to analyze XML files by defining which element nodes' text or attributes * contain a fqcn. It allows restricting matches to elements having a specific attribute value and certain parent * elements. * * @since 2.1.0 */ public abstract class ExtendedXmlAnalyzer extends XmlAnalyzer { @Nonnull protected final String dependerId; @Nullable private final String rootElement; @Nonnull private final Collection<XPath> pathsToMatch = new ArrayList<XPath>(); /** * Creates a new <code>ExtendedXmlAnalyzer</code>. * Be sure to call {@link #anyElementNamed(String)} and so forth in the subclasses' constructor. * * @param dependerId a description of the <i>depending entity</i> with which to * call {@link de.is24.deadcode4j.AnalysisContext#addDependencies(String, Iterable)} * @param endOfFileName the file suffix used to determine if a file should be analyzed; this can be a mere file * extension like <tt>.xml</tt> or a partial path like <tt>WEB-INF/web.xml</tt> * @param rootElement the expected XML root element or <code>null</code> if such an element does not exist; * i.e. there are multiple valid root elements * @since 2.1.0 */ protected ExtendedXmlAnalyzer(@Nonnull String dependerId, @Nonnull String endOfFileName, @Nullable String rootElement) { super(endOfFileName); this.dependerId = checkNotNull(dependerId); this.rootElement = rootElement; } /** * Creates a new <code>ExtendedXmlAnalyzer</code> that is not restricted to a specific XML root element. * * @see #ExtendedXmlAnalyzer(String, String, String) * @since 2.1.0 */ protected ExtendedXmlAnalyzer(@Nonnull String dependerId, @Nonnull String endOfFileName) { this(dependerId, endOfFileName, null); } @Override public String toString() { StringBuilder buffy = new StringBuilder(1024).append(super.toString()).append("; registered XPaths are:"); for (XPath xPath : pathsToMatch) { buffy.append('\n'); if (rootElement != null) { buffy.append('/').append(rootElement); } buffy.append(xPath); } return buffy.toString(); } /** * Sets up a path to an element to match. * Be sure to call {@link Path#registerTextAsClass()} or {@link Path#registerAttributeAsClass(String)} eventually. * * @param name the name of the XML element to match * @since 2.1.0 */ @Nonnull public final Path anyElementNamed(@Nonnull String name) { return new Path(new Element(name)); } /** * Sets up a path to match any element. * Be sure to call {@link Path#registerTextAsClass()} or {@link Path#registerAttributeAsClass(String)} eventually. * * @since 2.1.0 */ public Path anyElement() { return new Path(new Element()); } @Nonnull @Override protected final DefaultHandler createHandlerFor(@Nonnull AnalysisContext analysisContext) { return new XmlHandler(analysisContext); } /** * Represents an element node found in the XML to analyze. * * @since 2.1.0 */ @Immutable protected static class XmlElement { @Nonnull public final String name; @Nonnull private final Map<String, String> attributes; public XmlElement(@Nonnull String name, @Nonnull Attributes attributes) { this.name = name; Map<String, String> attributeMap = newHashMapWithExpectedSize(attributes.getLength()); for (int i = attributes.getLength(); i-- > 0; ) { attributeMap.put(attributes.getLocalName(i), attributes.getValue(i)); } this.attributes = Collections.unmodifiableMap(attributeMap); } @Override public String toString() { StringBuilder result = new StringBuilder(name); for (Map.Entry<String, String> attribute : attributes.entrySet()) { result.append(attribute.getKey()).append("='").append(attribute.getValue()).append("',"); } if (result.length() > name.length()) { result.insert(name.length(), "["); result.replace(result.length() - 1, result.length(), "]"); } return result.toString(); } @Nonnull public Optional<String> getAttribute(@Nonnull String name) { return fromNullable(attributes.get(name)); } } /** * A <code>DependeeExtractor</code> is used to extract the dependee to report * if a (sub-)tree of <code>XmlElement</code>s is found that matches against an XPath-like expression. * * @since 2.1.0 */ protected interface DependeeExtractor { /** * Called to extract the dependee to report. * * @param xmlElements the XML tree that matched for a given XPath-like expression * @param containedText the text of the last XML element */ @Nonnull Optional<String> extractDependee(@Nonnull Iterable<XmlElement> xmlElements, @Nonnull Optional<String> containedText); /** * Subclasses are requested to override this, as the outcome will be appended to {@link XPath}'s string representation. */ @Override String toString(); } /** * Represents an XPath equivalent to match against an {@link XmlElement} tree. * This class handles the matching, extracting a class to report is delegated to the provided {@link DependeeExtractor}. * * @since 2.1.0 */ protected static class XPath { @Nonnull private final Path path; @Nonnull private final DependeeExtractor dependeeExtractor; /** * Creates a new <code>XPath</code> expression for the specified path. * * @since 2.1.0 */ protected XPath(@Nonnull Path path, @Nonnull DependeeExtractor dependeeExtractor) { this.path = path; this.dependeeExtractor = dependeeExtractor; } @Override public String toString() { return path.toString() + dependeeExtractor.toString(); } @Nonnull Optional<String> matchAndExtract(@Nonnull Deque<XmlElement> xmlElements, @Nonnull Optional<String> containedText) { for (int i = xmlElements.size(); i-- > 0; ) { Iterable<XmlElement> partialPath = Iterables.skip(xmlElements, i); if (path.matches(partialPath)) { return dependeeExtractor.extractDependee(partialPath, containedText); } } return Optional.absent(); } } /** * Represents an XML element that is to be matched. * * @since 2.1.0 */ @Immutable private static class Element { @Nonnull private final Optional<String> name; @Nonnull private final Map<String, String> attributeRestrictions; Element() { this.name = Optional.absent(); attributeRestrictions = emptyMap(); } Element(@Nonnull String name) { checkArgument(isNotBlank(name), "The Element's [name] must be set!"); this.name = Optional.of(name); attributeRestrictions = emptyMap(); } private Element(@Nonnull Element original, @Nonnull String attribute, @Nonnull String value) { name = original.name; attributeRestrictions = new HashMap<String, String>(original.attributeRestrictions); attributeRestrictions.put(attribute, value); } @Override public String toString() { StringBuilder buffy = new StringBuilder(name.or("*")); if (!attributeRestrictions.isEmpty()) { buffy.append('['); for (Map.Entry<String, String> entry : attributeRestrictions.entrySet()) { buffy.append('@').append(entry.getKey()).append("='").append(entry.getValue()).append("' and "); } buffy.setLength(buffy.length() - 5); buffy.append(']'); } return buffy.toString(); } @Nonnull Element restrictAttribute(@Nonnull String attribute, @Nonnull String value) { return new Element(this, attribute, value); } boolean matches(@Nonnull XmlElement xmlElement) { if (name.isPresent() && !name.get().equals(xmlElement.name)) { return false; } return matchesAttributes(xmlElement); } private boolean matchesAttributes(@Nonnull XmlElement xmlElement) { for (Map.Entry<String, String> attributeRestriction : attributeRestrictions.entrySet()) { Optional<String> value = xmlElement.getAttribute(attributeRestriction.getKey()); if (!(value.isPresent() && attributeRestriction.getValue().equals(value.get()))) { return false; } } return true; } } /** * @since 2.1.0 */ private class XmlHandler extends DefaultHandler { @Nonnull private final AnalysisContext analysisContext; @Nonnull private final Deque<XmlElement> xmlElements = new ArrayDeque<XmlElement>(); @Nonnull private final Deque<StringBuilder> textBuffers = new ArrayDeque<StringBuilder>(); private boolean firstElement = true; public XmlHandler(@Nonnull AnalysisContext analysisContext) { this.analysisContext = analysisContext; } @Override public void startElement(String ignoredUri, String localName, String ignoredQName, Attributes attributes) throws StopParsing { if (firstElement) { if (rootElement != null && !rootElement.equals(localName)) { throw new StopParsing(); } firstElement = false; } xmlElements.addLast(new XmlElement(localName, attributes)); textBuffers.addLast(new StringBuilder(128)); } @Override public void characters(char[] ch, int start, int length) { this.textBuffers.getLast().append(new String(ch, start, length).trim()); } @Override public void endElement(String uri, String localName, String qName) { StringBuilder buffer = textBuffers.removeLast(); Optional<String> text = fromNullable(buffer.length() > 0 ? buffer.toString() : null); for (XPath xPath : pathsToMatch) { Optional<String> dependee = xPath.matchAndExtract(xmlElements, text); if (dependee.isPresent()) { analysisContext.addDependencies(dependerId, dependee.get().trim()); } } xmlElements.removeLast(); } } /** * Represents a path of XML elements to match. * * @since 2.1.0 */ @Immutable protected class Path { @Nonnull private final List<Element> pathElements; Path(@Nonnull Element firstElement) { pathElements = Collections.singletonList(firstElement); } private Path(@Nonnull Path original, @Nonnull String elementName) { pathElements = new ArrayList<Element>(original.pathElements); pathElements.add(new Element(elementName)); } private Path(@Nonnull Path original, @Nonnull Element lastElementReplacement) { pathElements = new ArrayList<Element>(original.pathElements); pathElements.set(pathElements.size() - 1, lastElementReplacement); } @Override public String toString() { StringBuilder buffy = new StringBuilder("//"); for (Element pathElement : pathElements) { buffy.append(pathElement).append('/'); } buffy.setLength(buffy.length() - 1); return buffy.toString(); } /** * Extends the path to match with a subsequent XML element. * * @param name the name of the XML element to match * @since 2.1.0 */ @Nonnull public Path anyElementNamed(@Nonnull String name) { return new Path(this, name); } /** * Restricts the last XML element to only be matched if it has an attribute with the specified value. * * @since 2.1.0 */ @Nonnull public Path withAttributeValue(@Nonnull String attributeName, @Nonnull String requiredValue) { return new Path(this, getLast(pathElements).restrictAttribute(attributeName, requiredValue)); } /** * Registers the specified <code>DependeeExtractor</code> to be called if this XML path is found. * * @see #registerTextAsClass() * @see #registerAttributeAsClass(String) * @since 2.1.0 */ public void registerDependeeExtractor(DependeeExtractor dependeeExtractor) { pathsToMatch.add(new XPath(this, dependeeExtractor)); } /** * Registers the last XML element's text to be treated as a fully qualified class name. * * @since 2.1.0 */ public void registerTextAsClass() { registerDependeeExtractor(new DependeeExtractor() { @Override public String toString() { return "[text()]"; } @Nonnull @Override public Optional<String> extractDependee(@Nonnull Iterable<XmlElement> xmlElements, @Nonnull Optional<String> containedText) { return containedText; } }); } /** * Registers an attribute of the last XML element to be treated as a fully qualified class name. * * @param attributeName the name of the attribute to register * @since 2.1.0 */ public void registerAttributeAsClass(final String attributeName) { registerDependeeExtractor(new DependeeExtractor() { @Override public String toString() { return "/@" + attributeName; } @Nonnull @Override public Optional<String> extractDependee(@Nonnull Iterable<XmlElement> xmlElements, @Nonnull Optional<String> containedText) { return getLast(xmlElements).getAttribute(attributeName); } }); } boolean matches(@Nonnull Iterable<XmlElement> xmlElements) { if (pathElements.size() != Iterables.size(xmlElements)) { return false; } Iterator<XmlElement> xmlElementIterator = xmlElements.iterator(); for (Element pathElement : pathElements) { if (!pathElement.matches(xmlElementIterator.next())) { return false; } } return true; } } }