/* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package net.revelc.code.formatter; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.charset.Charset; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Properties; import org.apache.maven.plugin.AbstractMojo; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; import org.apache.maven.plugin.logging.Log; import org.apache.maven.plugins.annotations.Component; import org.apache.maven.plugins.annotations.LifecyclePhase; import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; import org.codehaus.plexus.resource.ResourceManager; import org.codehaus.plexus.resource.loader.FileResourceLoader; import org.codehaus.plexus.resource.loader.ResourceNotFoundException; import org.codehaus.plexus.util.DirectoryScanner; import org.codehaus.plexus.util.ReaderFactory; import org.codehaus.plexus.util.StringUtils; import org.codehaus.plexus.util.WriterFactory; import org.eclipse.jdt.core.JavaCore; import org.eclipse.jdt.core.formatter.CodeFormatter; import org.eclipse.jface.text.BadLocationException; import org.eclipse.text.edits.MalformedTreeException; import org.xml.sax.SAXException; import com.google.common.base.Strings; import com.google.common.hash.Hashing; import net.revelc.code.formatter.css.CssFormatter; import net.revelc.code.formatter.html.HTMLFormatter; import net.revelc.code.formatter.java.JavaFormatter; import net.revelc.code.formatter.javascript.JavascriptFormatter; import net.revelc.code.formatter.json.JsonFormatter; import net.revelc.code.formatter.model.ConfigReadException; import net.revelc.code.formatter.model.ConfigReader; import net.revelc.code.formatter.xml.XMLFormatter; /** * A Maven plugin mojo to format Java source code using the Eclipse code formatter. * * Mojo parameters allow customizing formatting by specifying the config XML file, line endings, compiler version, and * source code locations. Reformatting source files is avoided using an sha512 hash of the content, comparing to the * original hash to the hash after formatting and a cached hash. * * @author jecki * @author Matt Blanchette * @author marvin.froeder */ @Mojo(name = "format", defaultPhase = LifecyclePhase.PROCESS_SOURCES, requiresProject = true, threadSafe = true) public class FormatterMojo extends AbstractMojo implements ConfigurationSource { private static final String FILE_S = " file(s)"; /** The Constant CACHE_PROPERTIES_FILENAME. */ private static final String CACHE_PROPERTIES_FILENAME = "formatter-maven-cache.properties"; /** The Constant DEFAULT_INCLUDES. */ private static final String[] DEFAULT_INCLUDES = new String[] { "**/*.css", "**/*.json", "**/*.html", "**/*.java", "**/*.js", "**/*.xml" }; /** * ResourceManager for retrieving the configFile resource. */ @Component(role = ResourceManager.class) private ResourceManager resourceManager; /** * Project's source directory as specified in the POM. */ @Parameter(defaultValue = "${project.build.sourceDirectory}", property = "sourceDirectory", required = true) private File sourceDirectory; /** * Project's test source directory as specified in the POM. */ @Parameter(defaultValue = "${project.build.testSourceDirectory}", property = "testSourceDirectory", required = true) private File testSourceDirectory; /** * Project's target directory as specified in the POM. */ @Parameter(defaultValue = "${project.build.directory}", readonly = true, required = true) private File targetDirectory; /** * Project's base directory. */ @Parameter(defaultValue = ".", property = "project.basedir", readonly = true, required = true) private File basedir; @Parameter(defaultValue = "${project.basedir}/.cache") private File cachedir; /** * Location of the Java source files to format. Defaults to source main and test directories if not set. Deprecated * in version 0.3. Reintroduced in 0.4. * * @since 0.4 */ @Parameter private File[] directories; /** * List of fileset patterns for Java source locations to include in formatting. Patterns are relative to the project * source and test source directories. When not specified, the default include is <code>**/*.java</code> * * @since 0.3 */ @Parameter(property = "formatter.includes") private String[] includes; /** * List of fileset patterns for Java source locations to exclude from formatting. Patterns are relative to the * project source and test source directories. When not specified, there is no default exclude. * * @since 0.3 */ @Parameter(property = "formatter.excludes") private String[] excludes; /** * Java compiler source version. */ @Parameter(defaultValue = "1.8", property = "maven.compiler.source", required = true) private String compilerSource; /** * Java compiler compliance version. */ @Parameter(defaultValue = "1.8", property = "maven.compiler.source", required = true) private String compilerCompliance; /** * Java compiler target version. */ @Parameter(defaultValue = "1.8", property = "maven.compiler.target", required = true) private String compilerTargetPlatform; /** * The file encoding used to read and write source files. When not specified and sourceEncoding also not set, * default is platform file encoding. * * @since 0.3 */ @Parameter(property = "project.build.sourceEncoding", required = true) private String encoding; /** * Sets the line-ending of files after formatting. Valid values are: * <ul> * <li><b>"AUTO"</b> - Use line endings of current system</li> * <li><b>"KEEP"</b> - Preserve line endings of files, default to AUTO if ambiguous</li> * <li><b>"LF"</b> - Use Unix and Mac style line endings</li> * <li><b>"CRLF"</b> - Use DOS and Windows style line endings</li> * <li><b>"CR"</b> - Use early Mac style line endings</li> * </ul> * * @since 0.2.0 */ @Parameter(defaultValue = "AUTO", property = "lineending", required = true) private LineEnding lineEnding; /** * File or classpath location of an Eclipse code formatter configuration xml file to use in formatting. */ @Parameter(defaultValue = "formatter-maven-plugin/eclipse/java.xml", property = "configfile", required = true) private String configFile; /** * File or classpath location of an Eclipse code formatter configuration xml file to use in formatting. */ @Parameter(defaultValue = "formatter-maven-plugin/eclipse/javascript.xml", property = "configjsfile", required = true) private String configJsFile; /** * File or classpath location of a properties file to use in html formatting. */ @Parameter(defaultValue = "formatter-maven-plugin/jsoup/html.properties", property = "confightmlfile", required = true) private String configHtmlFile; /** * File or classpath location of a properties file to use in xml formatting. */ @Parameter(defaultValue = "formatter-maven-plugin/eclipse/xml.properties", property = "configxmlfile", required = true) private String configXmlFile; /** * File or classpath location of a properties file to use in json formatting. */ @Parameter(defaultValue = "formatter-maven-plugin/jackson/json.properties", property = "configjsonfile", required = true) private String configJsonFile; /** * File or classpath location of a properties file to use in css formatting. */ @Parameter(defaultValue = "formatter-maven-plugin/ph-css/css.properties", property = "configcssfile", required = true) private String configCssFile; /** * Whether the java formatting is skipped. */ @Parameter(defaultValue = "false", property = "formatter.java.skip") private boolean skipJavaFormatting; /** * Whether the javascript formatting is skipped. */ @Parameter(defaultValue = "false", property = "formatter.js.skip") private boolean skipJsFormatting; /** * Whether the html formatting is skipped. */ @Parameter(defaultValue = "false", property = "formatter.html.skip") private boolean skipHtmlFormatting; /** * Whether the xml formatting is skipped. */ @Parameter(defaultValue = "false", property = "formatter.xml.skip") private boolean skipXmlFormatting; /** * Whether the json formatting is skipped. */ @Parameter(defaultValue = "false", property = "formatter.json.skip") private boolean skipJsonFormatting; /** * Whether the css formatting is skipped. */ @Parameter(defaultValue = "false", property = "formatter.css.skip") private boolean skipCssFormatting; /** * Whether the formatting is skipped. * * @since 0.5 */ @Parameter(defaultValue = "false", alias = "skip", property = "formatter.skip") private boolean skipFormatting; /** * Use eclipse defaults when set to true for java and javascript. */ @Parameter(defaultValue = "false", property = "formatter.useEclipseDefaults") private boolean useEclipseDefaults; private JavaFormatter javaFormatter = new JavaFormatter(); private JavascriptFormatter jsFormatter = new JavascriptFormatter(); private HTMLFormatter htmlFormatter = new HTMLFormatter(); private XMLFormatter xmlFormatter = new XMLFormatter(); private JsonFormatter jsonFormatter = new JsonFormatter(); private CssFormatter cssFormatter = new CssFormatter(); /** * Execute. * * @throws MojoExecutionException * the mojo execution exception * @throws MojoFailureException * the mojo failure exception * * @see org.apache.maven.plugin.AbstractMojo#execute() */ @Override public void execute() throws MojoExecutionException, MojoFailureException { if (this.skipFormatting) { getLog().info("Formatting is skipped"); return; } long startClock = System.currentTimeMillis(); if (StringUtils.isEmpty(this.encoding)) { this.encoding = ReaderFactory.FILE_ENCODING; getLog().warn("File encoding has not been set, using platform encoding (" + this.encoding + ") to format source files, i.e. build is platform dependent!"); } else { if (!Charset.isSupported(this.encoding)) { throw new MojoExecutionException("Encoding '" + this.encoding + "' is not supported"); } getLog().info("Using '" + this.encoding + "' encoding to format source files."); } List<File> files = new ArrayList<>(); if (this.directories != null) { for (File directory : this.directories) { if (directory.exists() && directory.isDirectory()) { files.addAll(addCollectionFiles(directory)); } } } else { // Using defaults of source main and test dirs if (this.sourceDirectory != null && this.sourceDirectory.exists() && this.sourceDirectory.isDirectory()) { files.addAll(addCollectionFiles(this.sourceDirectory)); } if (this.testSourceDirectory != null && this.testSourceDirectory.exists() && this.testSourceDirectory.isDirectory()) { files.addAll(addCollectionFiles(this.testSourceDirectory)); } } int numberOfFiles = files.size(); Log log = getLog(); log.info("Number of files to be formatted: " + numberOfFiles); if (numberOfFiles > 0) { createCodeFormatter(); ResultCollector rc = new ResultCollector(); Properties hashCache = readFileHashCacheFile(); String basedirPath = getBasedirPath(); for (int i = 0, n = files.size(); i < n; i++) { File file = files.get(i); if (file.exists()) { if (file.canWrite()) { formatFile(file, rc, hashCache, basedirPath); } else { rc.readOnlyCount++; } } else { rc.failCount++; } } storeFileHashCache(hashCache); long endClock = System.currentTimeMillis(); log.info("Successfully formatted: " + rc.successCount + FILE_S); log.info("Fail to format: " + rc.failCount + FILE_S); log.info("Skipped: " + rc.skippedCount + FILE_S); log.info("Read only skipped: " + rc.readOnlyCount + FILE_S); log.info("Approximate time taken: " + ((endClock - startClock) / 1000) + "s"); } } /** * Add source files to the files list. * */ List<File> addCollectionFiles(File newBasedir) { final DirectoryScanner ds = new DirectoryScanner(); ds.setBasedir(newBasedir); if (this.includes != null && this.includes.length > 0) { ds.setIncludes(this.includes); } else { ds.setIncludes(DEFAULT_INCLUDES); } ds.setExcludes(this.excludes); ds.addDefaultExcludes(); ds.setCaseSensitive(false); ds.setFollowSymlinks(false); ds.scan(); List<File> foundFiles = new ArrayList<>(); for (String filename : ds.getIncludedFiles()) { foundFiles.add(new File(newBasedir, filename)); } return foundFiles; } /** * Gets the basedir path. * * @return the basedir path */ private String getBasedirPath() { try { return this.basedir.getCanonicalPath(); } catch (IOException e) { getLog().debug("", e); return ""; } } /** * Store file hash cache. * * @param props * the props */ private void storeFileHashCache(Properties props) { File cacheFile = new File(this.cachedir, CACHE_PROPERTIES_FILENAME); try (OutputStream out = new BufferedOutputStream(new FileOutputStream(cacheFile))) { props.store(out, null); } catch (IOException e) { getLog().warn("Cannot store file hash cache properties file", e); } } /** * Read file hash cache file. * * @return the properties */ private Properties readFileHashCacheFile() { Properties props = new Properties(); Log log = getLog(); if (!this.cachedir.exists()) { this.cachedir.mkdirs(); } else if (!this.cachedir.isDirectory()) { log.warn("Something strange here as the '" + this.cachedir.getPath() + "' supposedly cache directory is not a directory."); return props; } File cacheFile = new File(this.cachedir, CACHE_PROPERTIES_FILENAME); if (!cacheFile.exists()) { return props; } try (BufferedInputStream stream = new BufferedInputStream(new FileInputStream(cacheFile))) { props.load(stream); } catch (IOException e) { log.warn("Cannot load file hash cache properties file", e); } return props; } /** * Format file. * * @param file * the file * @param rc * the rc * @param hashCache * the hash cache * @param basedirPath * the basedir path * * @throws MojoFailureException * the mojo failure exception * @throws MojoExecutionException * the mojo execution exception */ private void formatFile(File file, ResultCollector rc, Properties hashCache, String basedirPath) throws MojoFailureException, MojoExecutionException { try { doFormatFile(file, rc, hashCache, basedirPath, false); } catch (IOException | MalformedTreeException | BadLocationException e) { rc.failCount++; getLog().warn(e); } } /** * Format individual file. * * @param file * the file * @param rc * the rc * @param hashCache * the hash cache * @param basedirPath * the basedir path * @param dryRun * the dry run * * @throws IOException * Signals that an I/O exception has occurred. * @throws BadLocationException * the bad location exception * @throws MojoFailureException * the mojo failure exception * @throws MojoExecutionException * the mojo execution exception */ protected void doFormatFile(File file, ResultCollector rc, Properties hashCache, String basedirPath, boolean dryRun) throws IOException, BadLocationException, MojoFailureException, MojoExecutionException { Log log = getLog(); log.debug("Processing file: " + file); String originalCode = readFileAsString(file); String originalHash = sha512hash(originalCode); String canonicalPath = file.getCanonicalPath(); String path = canonicalPath.substring(basedirPath.length()); String cachedHash = hashCache.getProperty(path); if (cachedHash != null && cachedHash.equals(originalHash)) { rc.skippedCount++; log.debug("File is already formatted."); return; } Result result = null; String formattedCode = null; if (file.getName().endsWith(".java") && javaFormatter.isInitialized()) { if (skipJavaFormatting) { getLog().info("Java formatting is skipped"); result = Result.SKIPPED; } else { formattedCode = this.javaFormatter.formatFile(file, originalCode, this.lineEnding); } } else if (file.getName().endsWith(".js") && jsFormatter.isInitialized()) { if (skipJsFormatting) { getLog().info("Javascript formatting is skipped"); result = Result.SKIPPED; } else { formattedCode = this.jsFormatter.formatFile(file, originalCode, this.lineEnding); } } else if (file.getName().endsWith(".html") && htmlFormatter.isInitialized()) { if (skipHtmlFormatting) { getLog().info("Html formatting is skipped"); result = Result.SKIPPED; } else { formattedCode = this.htmlFormatter.formatFile(file, originalCode, this.lineEnding); } } else if (file.getName().endsWith(".xml") && xmlFormatter.isInitialized()) { if (skipXmlFormatting) { getLog().info("Xml formatting is skipped"); result = Result.SKIPPED; } else { formattedCode = this.xmlFormatter.formatFile(file, originalCode, this.lineEnding); } } else if (file.getName().endsWith(".json") && jsonFormatter.isInitialized()) { if (skipJsonFormatting) { getLog().info("json formatting is skipped"); result = Result.SKIPPED; } else { formattedCode = this.jsonFormatter.formatFile(file, originalCode, this.lineEnding); } } else if (file.getName().endsWith(".css") && cssFormatter.isInitialized()) { if (skipCssFormatting) { getLog().info("css formatting is skipped"); result = Result.SKIPPED; } else { formattedCode = this.cssFormatter.formatFile(file, originalCode, this.lineEnding); } } else { log.debug("No formatter found or initialization failed for file " + file.getName()); result = Result.SKIPPED; } // If not skipped, check formatting result if (!Result.SKIPPED.equals(result)) { if (formattedCode == null) { result = Result.FAIL; } else if (originalCode.equals(formattedCode)) { result = Result.SKIPPED; } else { result = Result.SUCCESS; } } // Process the result type if (Result.SKIPPED.equals(result)) { rc.skippedCount++; } else if (Result.SUCCESS.equals(result)) { rc.successCount++; } else if (Result.FAIL.equals(result)) { rc.failCount++; return; } // Write the cache String formattedHash; if (Result.SKIPPED.equals(result)) { formattedHash = originalHash; } else { formattedHash = sha512hash(Strings.nullToEmpty(formattedCode)); } hashCache.setProperty(path, formattedHash); // If we had determined to skip write, do so now after cache was written if (Result.SKIPPED.equals(result)) { log.debug("File is already formatted. Writing to cache only."); return; } // As a safety check, if our hash matches, skip the write of file (should never occur) if (originalHash.equals(formattedHash)) { rc.skippedCount++; log.debug("Equal hash code. Not writing result to file."); return; } // Now write the file if (!dryRun) { writeStringToFile(formattedCode, file); } } /** * sha512hash. * * @param str * the str * * @return the string */ private String sha512hash(String str) { return Hashing.sha512().hashBytes(str.getBytes(getEncoding())).toString(); } /** * Read the given file and return the content as a string. * * @param file * the file * * @return the string * * @throws IOException * Signals that an I/O exception has occurred. */ private String readFileAsString(File file) throws IOException { StringBuilder fileData = new StringBuilder(1000); try (BufferedReader reader = new BufferedReader(ReaderFactory.newReader(file, this.encoding))) { char[] buf = new char[1024]; int numRead = 0; while ((numRead = reader.read(buf)) != -1) { String readData = String.valueOf(buf, 0, numRead); fileData.append(readData); buf = new char[1024]; } } return fileData.toString(); } /** * Write the given string to a file. * * @param str * the str * @param file * the file * * @throws IOException * Signals that an I/O exception has occurred. */ private void writeStringToFile(String str, File file) throws IOException { if (!file.exists() && file.isDirectory()) { return; } try (BufferedWriter bw = new BufferedWriter(WriterFactory.newWriter(file, this.encoding))) { bw.write(str); } } /** * Create a {@link CodeFormatter} instance to be used by this mojo. * * @throws MojoExecutionException * the mojo execution exception */ private void createCodeFormatter() throws MojoExecutionException { // Java Setup Map<String, String> javaFormattingOptions = getFormattingOptions(this.configFile); if (javaFormattingOptions != null) { this.javaFormatter.init(javaFormattingOptions, this); } // Javascript Setup Map<String, String> jsFormattingOptions = getFormattingOptions(this.configJsFile); if (jsFormattingOptions != null) { this.jsFormatter.init(jsFormattingOptions, this); } // Html Setup if (configHtmlFile != null) { this.htmlFormatter.init(getOptionsFromPropertiesFile(this.configHtmlFile), this); } // Xml Setup if (configXmlFile != null) { Map<String, String> xmlFormattingOptions = getOptionsFromPropertiesFile(this.configXmlFile); xmlFormattingOptions.put("lineending", this.lineEnding.getChars()); this.xmlFormatter.init(xmlFormattingOptions, this); } // Json Setup if (configJsonFile != null) { Map<String, String> jsonFormattingOptions = getOptionsFromPropertiesFile(this.configJsonFile); jsonFormattingOptions.put("lineending", this.lineEnding.getChars()); this.jsonFormatter.init(jsonFormattingOptions, this); } // Css Setup if (configCssFile != null) { this.cssFormatter.init(getOptionsFromPropertiesFile(configCssFile), this); } // stop the process if not config files where found if (javaFormattingOptions == null && jsFormattingOptions == null && configHtmlFile == null && configXmlFile == null && configCssFile == null) { throw new MojoExecutionException( "You must provide a Java, Javascript, HTML, XML, JSON, or CSS configuration file."); } } /** * Return the options to be passed when creating {@link CodeFormatter} instance. * * @return the formatting options or null if not config file found * * @throws MojoExecutionException * the mojo execution exception */ private Map<String, String> getFormattingOptions(String newConfigFile) throws MojoExecutionException { if (this.useEclipseDefaults) { getLog().info("Using Ecipse Defaults"); // Use defaults only for formatting Map<String, String> options = new HashMap<>(); options.put(JavaCore.COMPILER_SOURCE, this.compilerSource); options.put(JavaCore.COMPILER_COMPLIANCE, this.compilerCompliance); options.put(JavaCore.COMPILER_CODEGEN_TARGET_PLATFORM, this.compilerTargetPlatform); return options; } return getOptionsFromConfigFile(newConfigFile); } /** * Read config file and return the config as {@link Map}. * * @return the options from config file * * @throws MojoExecutionException * the mojo execution exception */ private Map<String, String> getOptionsFromConfigFile(String newConfigFile) throws MojoExecutionException { this.getLog().debug("Using search path at: " + this.basedir.getAbsolutePath()); this.resourceManager.addSearchPath(FileResourceLoader.ID, this.basedir.getAbsolutePath()); try (InputStream configInput = this.resourceManager.getResourceAsInputStream(newConfigFile)) { return new ConfigReader().read(configInput); } catch (ResourceNotFoundException e) { throw new MojoExecutionException("Cannot find config file [" + newConfigFile + "]"); } catch (IOException e) { throw new MojoExecutionException("Cannot read config file [" + newConfigFile + "]", e); } catch (SAXException e) { throw new MojoExecutionException("Cannot parse config file [" + newConfigFile + "]", e); } catch (ConfigReadException e) { throw new MojoExecutionException(e.getMessage(), e); } } /** * Read properties file and return the properties as {@link Map}. * * @return the options from properties file or null if not properties file found * * @throws MojoExecutionException * the mojo execution exception */ private Map<String, String> getOptionsFromPropertiesFile(String newPropertiesFile) throws MojoExecutionException { this.getLog().debug("Using search path at: " + this.basedir.getAbsolutePath()); this.resourceManager.addSearchPath(FileResourceLoader.ID, this.basedir.getAbsolutePath()); Properties properties = new Properties(); try { properties.load(this.resourceManager.getResourceAsInputStream(newPropertiesFile)); } catch (ResourceNotFoundException e) { getLog().debug("Property file [" + newPropertiesFile + "] cannot be found", e); return new HashMap<>(); } catch (IOException e) { throw new MojoExecutionException("Cannot read config file [" + newPropertiesFile + "]", e); } final Map<String, String> map = new HashMap<>(); for (final String name : properties.stringPropertyNames()) { map.put(name, properties.getProperty(name)); } return map; } class ResultCollector { int successCount; int failCount; int skippedCount; int readOnlyCount; } @Override public String getCompilerSources() { return this.compilerSource; } @Override public String getCompilerCompliance() { return this.compilerCompliance; } @Override public String getCompilerCodegenTargetPlatform() { return this.compilerTargetPlatform; } @Override public File getTargetDirectory() { return this.targetDirectory; } @Override public Charset getEncoding() { return Charset.forName(this.encoding); } }