/******************************************************************************** * CruiseControl, a Continuous Integration Toolkit * Copyright (c) 2001-2003, 2006, ThoughtWorks, Inc. * 200 E. Randolph, 25th Floor * Chicago, IL 60601 USA * All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions * are met: * * + Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * * + Redistributions in binary form must reproduce the above * copyright notice, this list of conditions and the following * disclaimer in the documentation and/or other materials provided * with the distribution. * * + Neither the name of ThoughtWorks, Inc., CruiseControl, nor the * names of its contributors may be used to endorse or promote * products derived from this software without specific prior * written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ********************************************************************************/ package net.sourceforge.cruisecontrol.gendoc; import java.io.IOException; import java.io.InputStream; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import net.sourceforge.cruisecontrol.CruiseControlException; import net.sourceforge.cruisecontrol.PluginRegistry; import net.sourceforge.cruisecontrol.gendoc.annotations.Cardinality; import net.sourceforge.cruisecontrol.gendoc.annotations.Default; import net.sourceforge.cruisecontrol.gendoc.annotations.Description; import net.sourceforge.cruisecontrol.gendoc.annotations.DescriptionFile; import net.sourceforge.cruisecontrol.gendoc.annotations.Examples; import net.sourceforge.cruisecontrol.gendoc.annotations.ExamplesFile; import net.sourceforge.cruisecontrol.gendoc.annotations.ManualChildName; import net.sourceforge.cruisecontrol.gendoc.annotations.Optional; import net.sourceforge.cruisecontrol.gendoc.annotations.Required; import net.sourceforge.cruisecontrol.gendoc.annotations.SkipDoc; import net.sourceforge.cruisecontrol.gendoc.annotations.Title; import net.sourceforge.cruisecontrol.util.IO; /** * Utility class for parsing PluginInfo objects reflexively. * @author Seth Pollen (pollens@msoe.edu) */ public class PluginInfoParser { /** Mapping of Java types to allowed XML attribute types. */ private static final Map<Class< ? >, AttributeType> ATTRIBUTE_TYPES = new HashMap<Class< ? >, AttributeType>(); static { ATTRIBUTE_TYPES.put(boolean.class, AttributeType.BOOLEAN); ATTRIBUTE_TYPES.put(Boolean.class, AttributeType.BOOLEAN); ATTRIBUTE_TYPES.put(int.class, AttributeType.NUMBER); ATTRIBUTE_TYPES.put(Integer.class, AttributeType.NUMBER); ATTRIBUTE_TYPES.put(short.class, AttributeType.NUMBER); ATTRIBUTE_TYPES.put(Short.class, AttributeType.NUMBER); ATTRIBUTE_TYPES.put(byte.class, AttributeType.NUMBER); ATTRIBUTE_TYPES.put(Byte.class, AttributeType.NUMBER); ATTRIBUTE_TYPES.put(long.class, AttributeType.NUMBER); ATTRIBUTE_TYPES.put(Long.class, AttributeType.NUMBER); ATTRIBUTE_TYPES.put(double.class, AttributeType.NUMBER); ATTRIBUTE_TYPES.put(Double.class, AttributeType.NUMBER); ATTRIBUTE_TYPES.put(float.class, AttributeType.NUMBER); ATTRIBUTE_TYPES.put(Float.class, AttributeType.NUMBER); ATTRIBUTE_TYPES.put(String.class, AttributeType.STRING); } /** PluginRegistry used to look up mappings of XML node names to class names. */ private final PluginRegistry registry; /** * Map containing all the inheritance relationships of all the classes from the PluginRegistry. * The mapping is from a superclass to a Set of all its subclasses contained in the * PluginRegistry. */ private final Map<Class< ? >, Set<Class< ? >>> subclassesFromRegistry = new HashMap<Class< ? >, Set<Class< ? >>>(); /** Cache of already-parsed plugins. */ private final Map<PluginKey, PluginInfo> parsedPluginCache = new HashMap<PluginKey, PluginInfo>(); /** Cache to contain the root plugin, once it has been parsed. */ private final PluginInfo rootPlugin; /** List of general errors occurring during parsing. Use a Set to prevent duplicate messages. */ private final Set<String> parsingErrors = new LinkedHashSet<String>(); /** Validator for XML descriptions and notes. */ private final XMLValidator validator = new XMLValidator(); /** * Creates a new PluginInfoParser. * @param registry PluginRegistry to use for this parser. * @param rootPluginName the name of the root plugin. See CruiseControlControllerJMXAdaptor.ROOT_PLUGIN. */ public PluginInfoParser(final PluginRegistry registry, final String rootPluginName) { this.registry = registry; buildSubclassesFromRegistryMap(); // Fetch the root plugin class. final Class< ? > rootClass; try { rootClass = registry.getPluginClass(rootPluginName); } catch (CruiseControlException e) { // This should never happen; we should always be able to load the root node. throw new RuntimeException(e); } rootPlugin = parsePlugin(rootClass, null); rootPlugin.finishConstruction(); } /** * Gets the PluginInfo representing the root node. * @return The plugin tree, fully parsed. */ public PluginInfo getRootPlugin() { return rootPlugin; } /** * Gets all the plugins parsed by this parser, in alphabetical order. * @return The list of all the plugins that have been parsed. */ public List<PluginInfo> getAllPlugins() { List<PluginInfo> list = new ArrayList<PluginInfo>(parsedPluginCache.values()); Collections.sort(list); // Sort the plugins by name. return list; } /** * Gets a list of parsing errors that occurred. Errors returned by this method will not * be associated with a specific PluginInfo. Errors that are associated with a specific * PluginInfo instance can be accessed using that instance's getParsingErrors method. * @return List of error messages. */ public List<String> getParsingErrors() { return new ArrayList<String>(parsingErrors); } /** * Initializes the subclassesFromRegistry Map based on the contents of the registry. */ private void buildSubclassesFromRegistryMap() { for (String pluginClassName : registry) { // Load the class, using its name. try { final Class< ? > subclass = Class.forName(pluginClassName); // Iterate through all available superclasses and add mappings for each one. Class< ? > superclass = subclass; do { // Add a mapping for the direct superclass. addSubclassMapping(subclass, superclass); // Add mappings for all interfaces declared by the superclass. for (Class< ? > intrface : superclass.getInterfaces()) { addSubclassMapping(subclass, intrface); } // Move to the next superclass up in the hierarchy. superclass = superclass.getSuperclass(); } while (superclass != null); // Stop at the top of the inheritance hierarchy. } catch (ClassNotFoundException e) { // Don't try to parse this class, since it won't load. Instead, record an error. parsingErrors.add("Failed to load class from PluginRegistry: " + pluginClassName); } } } /** * Adds a mapping to the subclassesFromRegistry Map that indicates a single inheritance * relationship. * @param subclass The subclass to associate with the superclass. * @param superclass The superclass to whose list of subclasses the subclass will be added. */ private void addSubclassMapping(Class< ? > subclass, Class< ? > superclass) { // Check if a Set has already been started for this superclass. Set<Class< ? >> subclasses = subclassesFromRegistry.get(superclass); if (subclasses == null) { // Create the list. subclasses = new HashSet<Class< ? >>(); subclassesFromRegistry.put(superclass, subclasses); } // Add the given subclass to the list of subclasses inheriting from the // given superclass. subclasses.add(subclass); } /** * Parses a plugin from a Class. * @param pluginClass The Class representing the plugin to be parsed. * @param pluginName The name to use for the plugin. If this is null, the default name will be inferred * from the plugin registry or the class name. * @return A PluginInfo object representing the class (and including representations of all its methods). */ private PluginInfo parsePlugin(Class< ? > pluginClass, String pluginName) { // Check if we need to infer the name or if it was manually specified. if (pluginName == null) { // Infer the name. pluginName = computePluginName(pluginClass); } // Construct a key for querying the cache. PluginKey key = new PluginKey(pluginClass, pluginName); // Check the cache to see if we already parsed this class. PluginInfo pluginInfo = parsedPluginCache.get(key); if (pluginInfo == null) { // We haven't done this class already, so we have to do it now. Associate this parser with // the plugin. pluginInfo = new PluginInfo(pluginClass.getName()); // Put the plugin info object into the cache now (before parsing it), in case it makes // a reference to itself in one of its children. parsedPluginCache.put(key, pluginInfo); // Populate the plugin info fields. pluginInfo.setName(pluginName); pluginInfo.setTitle(computePluginTitle(pluginClass)); try { pluginInfo.setDescription(computePluginDescription(pluginClass)); } catch (PluginInfoParsingException e) { // Log the error. Leave description as null. pluginInfo.addParsingError("Error - " + e.getMessage()); } try { pluginInfo.setExamples(computePluginExamples(pluginClass)); } catch (PluginInfoParsingException e) { // Log the error. Leave description as null. pluginInfo.addParsingError("Error - " + e.getMessage()); } // Parse attributes and children. parsePluginAttributes(pluginInfo, pluginClass); parsePluginChildren(pluginInfo, pluginClass); } return pluginInfo; } private String computePluginName(Class< ? > pluginClass) { // First, check if this plugin is in the registry. String name = registry.getPluginName(pluginClass); if (name == null) { // We didn't find it in the registry, so just use the class name. name = pluginClass.getSimpleName().toLowerCase(Locale.US); } return name; } private String computePluginExamples(Class< ? > pluginClass) throws PluginInfoParsingException { Examples examples = pluginClass.getAnnotation(Examples.class); ExamplesFile examplesFile = pluginClass.getAnnotation(ExamplesFile.class); if (examples != null && examplesFile != null) { throw new PluginInfoParsingException( "Cannot have @Examples and @ExamplesFile on the same element."); } String text; if (examples == null) { if (examplesFile == null) { text = null; } else { // Use the ExamplesFile annotation to load text from a file. String path = examplesFile.value(); if (path.length() == 0) { // Use the default path. path = computeDefaultExamplesFilePath(pluginClass); } // Load the examples text file using the class loader for the // plugin class. InputStream stream = pluginClass.getResourceAsStream(path); try { text = IO.readText(stream); } catch (IOException e) { throw new PluginInfoParsingException( "Could not read from examples file: " + path); } } } else { // Load examples text directly from the annotation. text = examples.value(); } if (text != null) { validator.validateWellFormed(text, "examples"); } return text; } private String computePluginDescription(Class< ? > pluginClass) throws PluginInfoParsingException { String text = computeDescription(pluginClass); if (text != null) { // Make sure the description is properly surrounded with <p></p> tags. if (!text.startsWith("<p>") && !text.endsWith("</p>")) { text = "<p>" + text + "</p>"; } } return text; } /** * Computes the description text from the annotations on a class or method. * @param classOrMethod A Class or Method object to compute the description from. * @return The description text, without any modification. * @throws PluginInfoParsingException if error occurs during parsing. */ private String computeDescription(Object classOrMethod) throws PluginInfoParsingException { boolean isClass = classOrMethod instanceof Class; Class< ? > clazz; Method method; Description description; DescriptionFile descriptionFile; if (isClass) { method = null; clazz = (Class< ? >) classOrMethod; description = clazz.getAnnotation(Description.class); descriptionFile = clazz.getAnnotation(DescriptionFile.class); } else { method = (Method) classOrMethod; clazz = method.getDeclaringClass(); // Keep a reference to the declaring class, so we can get its loader. description = method.getAnnotation(Description.class); descriptionFile = method.getAnnotation(DescriptionFile.class); } if (description != null && descriptionFile != null) { throw new PluginInfoParsingException( "Cannot have @Description and @DescriptionFile on the same element."); } String text; if (description == null) { if (descriptionFile == null) { text = null; } else { // Use the DescriptionFile annotation to load text from a file. String path = descriptionFile.value(); if (path.length() == 0) { // Use the default path. path = isClass ? computeDefaultDescriptionFilePath(clazz) : computeDefaultDescriptionFilePath(method); } // Load the description text file using the class loader for the // plugin class. InputStream stream = clazz.getResourceAsStream(path); try { text = IO.readText(stream); } catch (IOException e) { throw new PluginInfoParsingException( "Could not read from description file: " + path); } } } else { // Load description text directly from the annotation. text = description.value(); } if (text != null) { validator.validateWellFormed(text, "description"); } return text; } private String computeDefaultDescriptionFilePath(Class< ? > clazz) { return clazz.getSimpleName() + ".html"; } private String computeDefaultDescriptionFilePath(Method method) { return method.getDeclaringClass().getSimpleName() + "." + method.getName() + ".html"; } private String computeDefaultExamplesFilePath(Class< ? > clazz) { return clazz.getSimpleName() + ".examples.html"; } private String computePluginTitle(Class< ? > pluginClass) { Title annotation = pluginClass.getAnnotation(Title.class); if (annotation == null) { return null; } else { return annotation.value(); } } /** * Parses the attributes of a plugin into a PluginInfo object. * @param pluginInfo PluginInfo where attribute metadata will be placed. * @param pluginClass The plugin class to be parsed. */ private void parsePluginAttributes(PluginInfo pluginInfo, Class< ? > pluginClass) { // Search for methods with the signature setXXX (and which are not annotated to be skipped). for (Method attributeMethod : pluginClass.getMethods()) { String methName = attributeMethod.getName(); if (methName.startsWith("set") && (null == attributeMethod.getAnnotation(SkipDoc.class))) { // This method represents an attribute. AttributeInfo attributeInfo = new AttributeInfo(); try { // Populate the AttributeInfo properties. String attributeName = methName.substring(3).toLowerCase(Locale.US); // Remove the "set" // Check for duplicate attribute names. if (null != pluginInfo.getAttributeByName(attributeName)) { throw new PluginInfoParsingException( "Two different setter methods found defining attribute '" + attributeName + "'"); } attributeInfo.setName(attributeName); attributeInfo.setDescription(computeMemberDescription(attributeMethod)); attributeInfo.setTitle(computeMemberTitle(attributeMethod)); attributeInfo.setType(computeAttributeType(attributeMethod)); attributeInfo.setDefaultValue(computeAttributeDefaultValue(attributeMethod)); parseAttributeCardinality(attributeInfo, attributeMethod); // Store the AttributeInfo. pluginInfo.addAttribute(attributeInfo); } catch (PluginInfoParsingException e) { // Log the error. pluginInfo.addParsingError( "Error while parsing attribute '" + attributeMethod + "' - " + e.getMessage()); } } } // Finally, sort the attributes. pluginInfo.sortAttributes(); } private String computeMemberDescription(Method method) throws PluginInfoParsingException { // Don't wrap member declarations with <p> tags, since most members won't have // multi-paragraph descriptions. return computeDescription(method); } private String computeMemberTitle(Method method) { Title annotation = method.getAnnotation(Title.class); if (annotation == null) { return null; } else { return annotation.value(); } } private AttributeType computeAttributeType(Method attributeMethod) throws PluginInfoParsingException { Class< ? >[] paramTypes = attributeMethod.getParameterTypes(); if (paramTypes.length != 1) { throw new PluginInfoParsingException( "Attribute setter method of plugin class must have exactly one parameter"); } Class< ? > javaType = paramTypes[0]; AttributeType attributeType = ATTRIBUTE_TYPES.get(javaType); if (attributeType == null) { throw new PluginInfoParsingException( "Attribute setter method of plugin class must accept a boolean, String, or numeric type"); } else { return attributeType; } } private String computeAttributeDefaultValue(Method attributeMethod) { Default annotation = attributeMethod.getAnnotation(Default.class); if (annotation == null) { return null; } else { return annotation.value(); } } private void parseAttributeCardinality(AttributeInfo memberInfo, Method memberMethod) throws PluginInfoParsingException { Optional optional = memberMethod.getAnnotation(Optional.class); Required required = memberMethod.getAnnotation(Required.class); if ((optional != null) && (required != null)) { throw new PluginInfoParsingException( "Cannot have @Required and @Optional annotations on the same attribute method"); } // Multiplicity of attributes is not permitted. Thus, the maximum cardinality is always 1. memberInfo.setMaxCardinality(1); // Now determine the minimum cardinality and cardinality note. String note = null; Cardinality cardinality = memberMethod.getAnnotation(Cardinality.class); if (cardinality == null) { if (required != null) { // Make it required (1..1) memberInfo.setMinCardinality(1); note = required.value(); } else { if (optional != null) { note = optional.value(); } // Default the cardinality to optional, whether or not an @Optional annotation // is present. memberInfo.setMinCardinality(0); } } else { // There is a Cardinality annotation. Error check for the other annotations. if (optional != null) { throw new PluginInfoParsingException( "Cannot have @Cardinality and @Optional annotations on the same attribute method"); } if (required != null) { throw new PluginInfoParsingException( "Cannot have @Cardinality and @Required annotations on the same attribute method"); } final int min = cardinality.min(); final int max = cardinality.max(); if (max != 1) { throw new PluginInfoParsingException( "Maximum cardinality must be 1 for attributes."); } if ((min != 0) && (min != 1)) { throw new PluginInfoParsingException( "Minimum cardinality must be 0 or 1 for attributes."); } memberInfo.setMinCardinality(min); note = cardinality.note(); } if (note != null) { validator.validateWellFormed(note, "cardinality"); } memberInfo.setCardinalityNote(note); } /** * Parses the children of a plugin into a PluginInfo object. * @param pluginInfo PluginInfo where child metadata will be placed. * @param pluginClass The plugin class to be parsed. */ private void parsePluginChildren(PluginInfo pluginInfo, Class< ? > pluginClass) { // Search for methods with the signature addXXX or createXXX (and which are not annotated to be skipped). for (Method childMethod : pluginClass.getMethods()) { String methName = childMethod.getName(); if ( (methName.startsWith("add") || methName.startsWith("create")) && (null == childMethod.getAnnotation(SkipDoc.class)) ) { try { // This method represents a child. ChildInfo childInfo = new ChildInfo(); // Populate the ChildInfo properties. childInfo.setDescription(computeMemberDescription(childMethod)); childInfo.setTitle(computeMemberTitle(childMethod)); parseChildCardinality(childInfo, childMethod); parseChildAllowedTypes(childInfo, childMethod); // Store the ChildInfo. pluginInfo.addChild(childInfo); } catch (PluginInfoParsingException e) { // Log the error. pluginInfo.addParsingError( "Error while parsing child method '" + childMethod + "' - " + e.getMessage()); } } } } private void parseChildCardinality(ChildInfo memberInfo, Method memberMethod) throws PluginInfoParsingException { // Make sure there is no Optional or Required tag; these are not allowed for children. if (null != memberMethod.getAnnotation(Optional.class)) { throw new PluginInfoParsingException( "@Optional annotation not allowed on child method; use @Cardinality instead"); } if (null != memberMethod.getAnnotation(Required.class)) { throw new PluginInfoParsingException( "@Required annotation not allowed on child method; use @Cardinality instead"); } Cardinality cardinality = memberMethod.getAnnotation(Cardinality.class); if (cardinality == null) { // For children, the default cardinality is 0..* memberInfo.setMinCardinality(0); memberInfo.setMaxCardinality(-1); } else { final int min = cardinality.min(); final int max = cardinality.max(); if (min < 0) { throw new PluginInfoParsingException( "Minimum cardinality cannot be negative."); } if ((max != -1) && (max < min)) { throw new PluginInfoParsingException( "Maximum cardinality may not be less than minimum cardinality."); } memberInfo.setMinCardinality(cardinality.min()); memberInfo.setMaxCardinality(cardinality.max()); String note = cardinality.note(); if (note != null) { validator.validateWellFormed(note, "cardinality"); } memberInfo.setCardinalityNote(note); } } /** * Parses the allowed node types for a child and adds them to a ChildInfo object. * @param childInfo ChildInfo being constructed. This will receive the parsed allowed type * information. * @param childMethod Java createXXX or addXXX Method that is generating this child. * @throws PluginInfoParsingException If the plugin class to be parsed is malformed and cannot be parsed. */ private void parseChildAllowedTypes(ChildInfo childInfo, Method childMethod) throws PluginInfoParsingException { boolean isAddMethod = childMethod.getName().startsWith("add"); Class< ? > childType = computeChildType(childMethod, isAddMethod); if (isAddMethod) { // Check the plugin registry for classes that inherit from the declared child type. For each // possible child implementation class, parse a PluginInfo and add it to the ChildInfo's list. Set<Class< ? >> subclasses = subclassesFromRegistry.get(childType); if (subclasses != null) { for (Class< ? > subclass : subclasses) { // Pass null for the name, since manual renaming of addXXX children is not allowed. PluginInfo childPluginInfo = parsePlugin(subclass, null); childInfo.addAllowedNode(childPluginInfo); } } } else { // This is a createXXX method. // For createXXX methods, there can only be one child class; it is the concrete return // type of the method. createXXX methods also allow manual renaming of children. Check // for the manual renaming annotation to see if a manual name should be supplied. String manualName = null; ManualChildName annotation = childMethod.getAnnotation(ManualChildName.class); if (annotation != null) { // Do not force the manual name to lower case. manualName = annotation.value(); } // Parse the plugin, using the manual name if it was supplied. PluginInfo childPluginInfo = parsePlugin(childType, manualName); childInfo.addAllowedNode(childPluginInfo); } // Make sure the child can accept at least one type of node. if (childInfo.getAllowedNodes().isEmpty()) { throw new PluginInfoParsingException( "Child method does not accept any valid node classes."); } // Sort the plugins in the child's list of allowed nodes. childInfo.sortAllowedNodes(); } private Class< ? > computeChildType(Method childMethod, boolean isAddMethod) throws PluginInfoParsingException { if (isAddMethod) { // It's an addXXX method, so use the first argument type as the child class. Class< ? >[] paramTypes = childMethod.getParameterTypes(); if (paramTypes.length != 1) { throw new PluginInfoParsingException( "Child add method of plugin class must have exactly one parameter"); } return paramTypes[0]; } else { // Check the number of parameters. This will prevent us from unknowingly parsing a createXXX method // that we really should skip. if (childMethod.getParameterTypes().length != 0) { throw new PluginInfoParsingException( "Child create method of plugin class may not take parameters"); } // It's a createXXX method, so use the return type as the child class. Class< ? > returnType = childMethod.getReturnType(); if (returnType.equals(void.class)) { throw new PluginInfoParsingException( "Child create method of plugin class must have a non-void return type"); } return returnType; } } /** * Class to represent the unique key to identify a plugin. This is a tuple of the plugin * class and the XML name for the plugin. This tuple is required because there may be * multiple plugins with different XML names but the same underlying Java class. * @author pollens@msoe.edu */ private static class PluginKey { /** The class that generated the plugin. */ private final Class< ? > clazz; /** The name used to identify the plugin in an XML config file. */ private final String name; public PluginKey(Class< ? > clazz, String name) { this.clazz = clazz; this.name = name; } @Override public boolean equals(Object o) { if (o instanceof PluginKey) { PluginKey other = (PluginKey) o; return this.clazz.equals(other.clazz) && this.name.equals(other.name); } else { return false; } } @Override public int hashCode() { return clazz.hashCode() + name.hashCode(); } @Override public String toString() { return "(" + clazz + ", \"" + name + "\")"; } } }