GenerateMojo.java
package de.r3s6.maven.constcreator;
/*
* Copyright 2021 Ralf Schandl
*
* 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.
*/
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import javax.lang.model.SourceVersion;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugin.MojoFailureException;
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.apache.maven.plugins.annotations.ResolutionScope;
import org.apache.maven.project.MavenProject;
import org.codehaus.plexus.util.Scanner;
import org.sonatype.plexus.build.incremental.BuildContext;
import freemarker.core.ParseException;
import freemarker.template.TemplateException;
import freemarker.template.TemplateNotFoundException;
/**
* Goal to create Java constant classes from properties files.
*
* @author Ralf Schandl
*/
@Mojo(name = "generate", defaultPhase = LifecyclePhase.GENERATE_SOURCES, requiresDependencyResolution = ResolutionScope.COMPILE)
public class GenerateMojo extends AbstractMojo {
private static final String KEYS_TEMPLATE_ID = "keys";
private static final String VALUES_TEMPLATE_ID = "values";
private static final String DEFAULT_TEMPLATE_ID = KEYS_TEMPLATE_ID;
private static final String KEY_TEMPLATE_FMT = "plugin-default-templates/%s-template.ftl";
/**
* Match locale marker of resource bundle properties files.
* <ul>
* <li>_de
* <li>_de_DE
* <li>_de_DE_Windows
* <li>_de_Latn_DE
* <li>_de_Latn_DE_Windows
* </ul>
*/
private static final Pattern RESOURCE_BUNDLE_LOCALE_PATTERN = Pattern
.compile("_[a-z]{2}((_[A-z]{4})?_[A-Z]{2}(_\\w+)?)?$");
@Parameter(defaultValue = "${project}", readonly = true, required = true)
private MavenProject project;
@Component
private BuildContext buildContext;
/**
* Whether to skip the plugin execution.
*/
@Parameter(property = "properties-constants.skip")
private boolean skip;
/**
* Directory to search for properties files.
*/
@Parameter(defaultValue = "src/main/resources")
private File resourceDir;
/**
* File patterns of files to include.
*/
@Parameter(defaultValue = "*.properties")
private String[] includes;
/**
* File patterns of files to exclude.
*/
@Parameter
private String[] excludes;
/**
* Output directory for generated java files.
*/
@Parameter(defaultValue = "${project.build.directory}/generated-sources/prop-constants")
private File outputDir;
/**
* Specifies the character encoding of the generated Java files.
*/
@Parameter(defaultValue = "${project.build.sourceEncoding}")
private String sourceEncoding;
/**
* Specifies the Java package of the generated Java classes.
*/
@Parameter(required = true)
private String basePackage;
/**
* Whether all constants file end up in base package independent of the
* directory structure.
* <p>
* By default the constants Java classes of properties files located in
* sub-directories of {@code resourceDir} will be put in a matching sub-packages
* of {@code basePackage}.
* <p>
* If {@code flattenPackage} is true, all classes will end up in
* {@code basePackage}. Note that this might lead to name collisions.
*/
@Parameter(defaultValue = "false")
private boolean flattenPackage;
/**
* Suffix to append to generated class names.
* <p>
* Must be a valid java name by itself,
* {@code SourceVersion.isIdentifier(classNameSuffix))} must return true.
*/
@Parameter(defaultValue = "")
private String classNameSuffix = "";
/**
* Template id or file name.
* <p>
* The plugin provides the templates <code>keys</code> and <code>values</code>.
* <p>
* A file name can be given for a custom Freemarker template. File name lookup
* is:
* <ol>
* <li>classpath</li>
* <li>relative to project basedir</li>
* </ol>
*/
@Parameter(defaultValue = DEFAULT_TEMPLATE_ID)
private String template = DEFAULT_TEMPLATE_ID;
/**
* Additional options for the selected template.
* <p>
* The template <code>keys</code> supports the following options:
* <dl>
* <dt>genPropertiesFilenameConstant</dt>
* <dd>generate the constant <code>PROPERTIES_FILE_NAME</code></dd>
* <dt>propertiesFilenameConstant</dt>
* <dd>changes the name of the properties file name constant</dd>
* <dt>genBundleNameConstant</dt>
* <dd>generate the constant <code>BUNDLE_NAME</code></dd>
* <dt>bundleNameConstant</dt>
* <dd>changes the name of the bundle name constant</dd>
* </dl>
*/
@Parameter
private Map<String, String> templateOptions = new HashMap<>();
private TemplateHandler tmplHandler;
private List<String> errorMessages = new ArrayList<>();
@Override
public void execute() throws MojoExecutionException, MojoFailureException {
checkConfig();
if (!skip) {
checkResourceDir();
tmplHandler = new TemplateHandler(project.getBasedir(), sourceEncoding);
cleanupDeletes();
final Collection<GeneratorRequest> genRequests = scanProperties();
if (genRequests.isEmpty()) {
getLog().info("No properties files found - no Java classes to generate");
} else {
getLog().info("Generating " + genRequests.size() + " Java constant class"
+ (genRequests.size() == 1 ? "" : "es"));
}
for (final GeneratorRequest genReq : genRequests) {
getLog().debug("Generating constants for " + genReq.getPropertiesFileName() + ": "
+ genReq.getFullClassName());
createConstants(genReq);
}
} else {
getLog().info("Skipped - skip == true");
}
if (outputDir.isDirectory()) {
project.addCompileSourceRoot(outputDir.getPath());
}
if (!errorMessages.isEmpty()) {
throw new MojoExecutionException(errorMessages.stream().collect(Collectors.joining("\n")));
}
}
private void checkConfig() throws MojoExecutionException {
if (!SourceVersion.isName(basePackage)) {
throw new MojoExecutionException("Configured basePackage \"" + basePackage + "\" is invalid.");
}
if (classNameSuffix.length() != 0 && !SourceVersion.isIdentifier(classNameSuffix)) {
throw new MojoExecutionException("Configured classNameSuffix \"" + classNameSuffix + "\" is invalid.");
}
}
private void checkResourceDir() throws MojoExecutionException {
if (!resourceDir.exists()) {
throw new MojoExecutionException("Configured resourceDir \"" + resourceDir + "\" does not exist.");
}
if (!resourceDir.isDirectory()) {
throw new MojoExecutionException("Configured resourceDir \"" + resourceDir + "\" is not a directory.");
}
}
/**
* Scan for properties files.
*
* @return Collection of GeneratorRequests
*/
private Collection<GeneratorRequest> scanProperties() {
final Scanner scanner = buildContext.newScanner(resourceDir);
scanner.setIncludes(includes);
scanner.setExcludes(excludes);
scanner.scan();
final Map<String, GeneratorRequest> genRequests = new LinkedHashMap<>();
for (final String propFile : scanner.getIncludedFiles()) {
final GeneratorRequest gr = buildGeneratorRequest(propFile);
if (!genRequests.containsKey(gr.getFullClassName())) {
genRequests.put(gr.getFullClassName(), gr);
} else {
addError(gr.getPropertiesFile(), 0, 0, "Would create same constant class " + gr.getFullClassName()
+ " as " + genRequests.get(gr.getFullClassName()).getPropertiesFileName());
}
}
return genRequests.values();
}
private void cleanupDeletes() {
/*
* The delete scanner will only find something when run within Eclipse.
*/
final Scanner scanner = buildContext.newDeleteScanner(resourceDir);
scanner.setIncludes(includes);
scanner.setExcludes(excludes);
scanner.scan();
for (final String propFile : scanner.getIncludedFiles()) {
final GeneratorRequest gr = buildGeneratorRequest(propFile);
if (gr.getJavaFile().exists()) {
try {
Files.delete(gr.getJavaFile().toPath());
buildContext.refresh(gr.getJavaFile());
} catch (NoSuchFileException e) {
// IGNORED: file already gone -- fine
} catch (IOException e) {
addError(gr.getJavaFile(), 0, 0, "Could not delete: " + gr.getJavaFile() + " (" + e + ")", e);
}
}
}
}
private void createConstants(final GeneratorRequest genReq) throws MojoExecutionException {
getLog().debug("Creating " + genReq.getFullClassName() + " from " + genReq.getPropertiesFileName());
final File propFile = genReq.getPropertiesFile();
buildContext.removeMessages(propFile);
final Properties props = new OrderedProperties();
try (InputStream is = new FileInputStream(propFile)) {
if (genReq.isXmlProperties()) {
props.loadFromXML(is);
} else {
props.load(is);
}
} catch (final IOException e) {
addError(propFile, 0, 0, "Error loading properties file: " + e.getMessage(), e);
return;
}
final File javaFile = genReq.getJavaFile();
javaFile.getParentFile().mkdirs();
try {
createStringConstants(genReq, props);
} catch (TemplateNotFoundException e) {
throw new MojoExecutionException("Code template not found: " + e.getTemplateName(), e);
} catch (final ParseException e) {
throw new MojoExecutionException("Error parsing template:" + e.getMessage(), e);
} catch (TemplateException e) {
final String tmplName = e.getEnvironment().getMainTemplate().getName();
throw new MojoExecutionException("Error in template processing: " + tmplName + " (" + e.getMessage() + ")",
e);
} catch (final IOException e) {
addError(genReq.getPropertiesFile(), 0, 0,
"Error generating Java file " + genReq.getJavaFileName() + " (" + e.toString() + ")", e);
} catch (final InvalidPropertyKeyException e) {
addError(propFile, 0, 0, "Properties file " + propFile + " contains invalid key: " + e.getMessage(), e);
}
}
/**
* Creates the java constants file.
*
* @param genRequest {@link GeneratorRequest}
* @param props loaded properties
* @throws ParseException invalid template, thrown by Freemarker
* @throws TemplateException runtime problem in template processing,
* thrown by Freemarker
* @throws TemplateNotFoundException thrown by Freemarker
* @throws IOException template loading or writing the Java file
* failed
* @throws InvalidPropertyKeyException the properties contain a key that can't
* be translated to a Java variable/constant
* name
*/
private void createStringConstants(final GeneratorRequest genRequest, final Properties props)
throws IOException, TemplateException {
final Map<String, Object> model = buildModel(genRequest, props);
final String templateFile;
switch (this.template) {
case KEYS_TEMPLATE_ID:
case VALUES_TEMPLATE_ID:
templateFile = String.format(KEY_TEMPLATE_FMT, this.template);
break;
default:
templateFile = this.template;
break;
}
try (PrintWriter pw = new PrintWriter(
new OutputStreamWriter(buildContext.newFileOutputStream(genRequest.getJavaFile()), sourceEncoding))) {
tmplHandler.process(templateFile, model, pw);
}
}
private Map<String, Object> buildModel(final GeneratorRequest genReq, final Properties props) {
final List<PropEntry> entryList = props.keySet().stream()
.map(k -> new PropEntry((String) k, props.getProperty((String) k))).collect(Collectors.toList());
final Map<String, Object> model = new HashMap<>();
model.put("packageName", genReq.getPackageName());
model.put("simpleClassName", genReq.getSimpleClassName());
model.put("fullClassName", genReq.getFullClassName());
model.put("propertiesFileName", genReq.getPropertiesFileName());
model.put("javaFileName", genReq.getJavaFileName());
model.put("bundleName", genReq.getBundleName());
model.put("isXmlProperties", genReq.isXmlProperties());
model.put("entries", entryList);
model.put("properties", props);
model.put("options", templateOptions);
return model;
}
private void addError(final File file, final int line, final int column, final String message) {
addError(file, line, column, message, null);
}
private void addError(final File file, final int line, final int column, final String message,
final Throwable thr) {
buildContext.addMessage(file, line, column, message, BuildContext.SEVERITY_ERROR, thr);
errorMessages.add(String.format("%s[%d:%d] %s", file.getPath(), line, column, message));
}
// package visibility for testing
GeneratorRequest buildGeneratorRequest(final String propertyFileName) {
final GeneratorRequest.Builder builder = new GeneratorRequest.Builder();
final String portableName = propertyFileName.replace('\\', '/');
builder.propertiesFileName(portableName);
builder.propertiesFile(new File(resourceDir, portableName));
// Is it XML?
final int lastDot = portableName.lastIndexOf('.');
if (lastDot >= 0) {
final String extension = portableName.substring(lastDot + 1);
builder.xmlProperties(extension.toLowerCase(Locale.US).contains("xml"));
}
final Path path = Paths.get(portableName);
final String basename = path.getFileName().toString();
final int cnt = path.getNameCount();
final List<String> nameParts = new ArrayList<>();
if (!flattenPackage) {
for (int x = 0; x < cnt - 1; x++) {
final String name = path.getName(x).toString();
if (SourceVersion.isName(name)) {
nameParts.add(name);
} else {
nameParts.add(createValidJavaName(name));
}
}
}
nameParts.add(baseNametoClassname(basename) + classNameSuffix);
// build java fully qualified class name
final String className = basePackage + "." + String.join(".", nameParts);
final String javaFilename = className.replace('.', '/') + ".java";
builder.className(className);
builder.javaFileName(javaFilename);
builder.javaFile(new File(outputDir, javaFilename));
nameParts.clear();
for (int x = 0; x < cnt - 1; x++) {
nameParts.add(path.getName(x).toString());
}
nameParts.add(baseNametoBundleName(basename));
final String bundleName = String.join(".", nameParts);
builder.bundleName(bundleName);
if (getLog().isDebugEnabled() && !SourceVersion.isName(bundleName)) {
getLog().debug("Bundle name is not a valid class name (might work anyways): " + bundleName);
}
return builder.build();
}
private String baseNametoClassname(final String basename) {
return NameHandler.createTypeName(baseNametoBundleName(basename));
}
private String baseNametoBundleName(final String basename) {
// remove extension
final int idx = basename.lastIndexOf('.');
final String name;
if (idx > 0) {
name = basename.substring(0, idx);
} else {
name = basename;
}
return removeResourceBundleLocale(name);
}
// package visibility for testing
String removeResourceBundleLocale(final String name) {
return RESOURCE_BUNDLE_LOCALE_PATTERN.matcher(name).replaceAll("");
}
private String createValidJavaName(final String name) {
final StringBuilder sb = new StringBuilder();
final char[] chrs = name.toCharArray();
if (Character.isJavaIdentifierStart(chrs[0])) {
sb.append(chrs[0]);
} else if (Character.isJavaIdentifierPart(chrs[0])) {
sb.append('_');
sb.append(chrs[0]);
} else {
sb.append('_');
}
for (int x = 1; x < chrs.length; x++) {
final char c = chrs[x];
if (Character.isJavaIdentifierPart(c)) {
sb.append(c);
}
}
return sb.toString();
}
}