/*
* The MIT License
*
* Copyright 2013 Tim Boudreau.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package com.mastfrog.maven.merge.configuration;
import com.google.common.base.Objects;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.PrintStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;
import java.util.regex.Pattern;
import java.util.zip.ZipException;
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.Parameter;
import org.apache.maven.plugins.annotations.ResolutionScope;
import org.apache.maven.project.DefaultDependencyResolutionRequest;
import org.apache.maven.project.DependencyResolutionException;
import org.apache.maven.project.DependencyResolutionResult;
import org.apache.maven.project.MavenProject;
import org.apache.maven.project.ProjectDependenciesResolver;
import org.eclipse.aether.RepositorySystemSession;
import org.eclipse.aether.graph.Dependency;
/**
* A Maven plugin which, on good days, merges together all properties files on
* the classpath whicha live in target/classes/META-INF/settings into a single
* file in the classes output dir.
* <p/>
* Use this when you want to merge multiple JARs using the default namespace for
* settings into one big JAR without files randomly clobbering each other.
*
* @author Tim Boudreau
*/
@org.apache.maven.plugins.annotations.Mojo(defaultPhase = LifecyclePhase.COMPILE,
requiresDependencyResolution = ResolutionScope.COMPILE,
name = "merge-configuration", threadSafe = false)
public class MergeConfigurationMojo extends AbstractMojo {
// FOR ANYONE UNDER THE ILLUSION THAT WHAT WE DO IS COMPUTER SCIENCE:
//
// The following two unused fields are magical. Remove them and you get
// a plugin which contains no mojo.
@Parameter(defaultValue = "${localRepository}")
private org.apache.maven.artifact.repository.ArtifactRepository localRepository;
@Parameter(defaultValue = "${project.remoteArtifactRepositories}", readonly = true)
private java.util.List remoteRepositories;
@Parameter(defaultValue = "${repositorySystemSession}")
private RepositorySystemSession repoSession;
@Parameter(property = "mainClass", defaultValue = "none")
private String mainClass;
@Parameter(property = "jarName", defaultValue = "none")
private String jarName;
@Parameter(property = "exclude", defaultValue = "")
private String exclude = "";
private static final Pattern PAT = Pattern.compile("META-INF\\/settings\\/[^\\/]*\\.properties");
private static final Pattern SERVICES = Pattern.compile("META-INF\\/services\\/\\S[^\\/]*\\.*");
private static final Pattern SIG1 = Pattern.compile("META-INF\\/[^\\/]*\\.SF");
private static final Pattern SIG2 = Pattern.compile("META-INF\\/[^\\/]*\\.DSA");
private static final Pattern SIG3 = Pattern.compile("META-INF\\/[^\\/]*\\.RSA");
@Component
private ProjectDependenciesResolver resolver;
@Component
MavenProject project;
private List<String> readLines(InputStream in) throws IOException {
List<String> result = new LinkedList<>();
InputStreamReader isr = new InputStreamReader(in);
BufferedReader br = new BufferedReader(isr);
String line;
while ((line = br.readLine()) != null) {
if (!line.trim().isEmpty()) {
result.add(line);
}
}
return result;
}
private static int copy(final InputStream in, final OutputStream out)
throws IOException {
final byte[] buffer = new byte[4096];
int bytesCopied = 0;
for (;;) {
int byteCount = in.read(buffer, 0, buffer.length);
if (byteCount <= 0) {
break;
} else {
out.write(buffer, 0, byteCount);
bytesCopied += byteCount;
}
}
return bytesCopied;
}
private String strip(String name) {
int ix = name.lastIndexOf(".");
if (ix >= 0 && ix != name.length() - 1) {
name = name.substring(ix + 1);
}
return name;
}
@Override
public void execute() throws MojoExecutionException, MojoFailureException {
// XXX a LOT of duplicate code here
Log log = super.getLog();
log.info("Merging properties files");
if (repoSession == null) {
throw new MojoFailureException("RepositorySystemSession is null");
}
List<File> jars = new ArrayList<>();
List<String> exclude = new LinkedList<>();
for (String ex : this.exclude.split(",")) {
ex = ex.trim();
ex = ex.replace('.', '/');
exclude.add(ex);
}
try {
DependencyResolutionResult result
= resolver.resolve(new DefaultDependencyResolutionRequest(project, repoSession));
log.info("FOUND " + result.getDependencies().size() + " dependencies");
for (Dependency d : result.getDependencies()) {
switch (d.getScope()) {
case "test":
case "provided":
break;
default:
File f = d.getArtifact().getFile();
if (f.getName().endsWith(".jar") && f.isFile() && f.canRead()) {
jars.add(f);
}
}
}
} catch (DependencyResolutionException ex) {
throw new MojoExecutionException("Collecting dependencies failed", ex);
}
Map<String, Properties> m = new LinkedHashMap<>();
Map<String, Set<String>> linesForName = new LinkedHashMap<>();
Map<String, Integer> fileCountForName = new HashMap<>();
boolean buildMergedJar = mainClass != null && !"none".equals(mainClass);
JarOutputStream jarOut = null;
Set<String> seen = new HashSet<>();
try {
if (buildMergedJar) {
try {
File outDir = new File(project.getBuild().getOutputDirectory()).getParentFile();
File jar = new File(outDir, project.getBuild().getFinalName() + ".jar");
if (!jar.exists()) {
throw new MojoExecutionException("Could not find jar " + jar);
}
try (JarFile jf = new JarFile(jar)) {
Manifest manifest = new Manifest(jf.getManifest());
if (mainClass != null) {
manifest.getMainAttributes().putValue("Main-Class", mainClass);
}
String jn = jarName == null || "none".equals(jarName) ? strip(mainClass) : jarName;
File outJar = new File(outDir, jn + ".jar");
log.info("Will build merged JAR " + outJar);
if (outJar.equals(jar)) {
throw new MojoExecutionException("Merged jar and output jar are the same file: " + outJar);
}
if (!outJar.exists()) {
outJar.createNewFile();
}
jarOut = new JarOutputStream(new BufferedOutputStream(new FileOutputStream(outJar)), manifest);
jarOut.setLevel(9);
jarOut.setComment("Merged jar created by " + getClass().getName());
Enumeration<JarEntry> en = jf.entries();
while (en.hasMoreElements()) {
JarEntry e = en.nextElement();
String name = e.getName();
for (String s : exclude) {
if (name.startsWith(s)) {
continue;
}
}
// if (!seen.contains(name)) {
switch (name) {
case "META-INF/MANIFEST.MF":
case "META-INF/":
break;
case "META-INF/LICENSE":
case "META-INF/LICENSE.txt":
case "META-INF/http/pages.list":
case "META-INF/http/modules.list":
case "META-INF/http/numble.list":
case "META-INF/settings/namespaces.list":
Set<String> s = linesForName.get(name);
if (s == null) {
s = new LinkedHashSet<>();
linesForName.put(name, s);
}
Integer ct = fileCountForName.get(name);
if (ct == null) {
ct = 1;
}
fileCountForName.put(name, ct);
try (InputStream in = jf.getInputStream(e)) {
s.addAll(readLines(in));
}
break;
default:
if (name.startsWith("META-INF/services/") && !name.endsWith("/")) {
Set<String> s2 = linesForName.get(name);
if (s2 == null) {
s2 = new HashSet<>();
linesForName.put(name, s2);
}
Integer ct2 = fileCountForName.get(name);
if (ct2 == null) {
ct2 = 1;
}
fileCountForName.put(name, ct2);
try (InputStream in = jf.getInputStream(e)) {
s2.addAll(readLines(in));
}
seen.add(name);
} else if (PAT.matcher(name).matches()) {
log.info("Include " + name);
Properties p = new Properties();
try (InputStream in = jf.getInputStream(e)) {
p.load(in);
}
Properties all = m.get(name);
if (all == null) {
all = p;
m.put(name, p);
} else {
for (String key : p.stringPropertyNames()) {
if (all.containsKey(key)) {
Object old = all.get(key);
Object nue = p.get(key);
if (!Objects.equal(old, nue)) {
log.warn(key + '=' + nue + " in " + jar + '!' + name + " overrides " + key + '=' + old);
}
}
}
all.putAll(p);
}
} else if (!seen.contains(name) && !SIG1.matcher(name).find() && !SIG2.matcher(name).find() && !SIG3.matcher(name).find()) {
log.info("Bundle " + name);
JarEntry je = new JarEntry(name);
je.setTime(e.getTime());
try {
jarOut.putNextEntry(je);
} catch (ZipException ex) {
throw new MojoExecutionException("Exception putting zip entry " + name, ex);
}
try (InputStream in = jf.getInputStream(e)) {
copy(in, jarOut);
}
jarOut.closeEntry();
seen.add(name);
} else {
System.err.println("Skip " + name);
}
}
// }
seen.add(e.getName());
}
}
} catch (IOException ex) {
throw new MojoExecutionException("Failed to create merged jar", ex);
}
}
for (File f : jars) {
log.info("Merge JAR " + f);
try (JarFile jar = new JarFile(f)) {
Enumeration<JarEntry> en = jar.entries();
while (en.hasMoreElements()) {
JarEntry entry = en.nextElement();
String name = entry.getName();
for (String s : exclude) {
if (name.startsWith(s)) {
continue;
}
}
if (PAT.matcher(name).matches()) {
log.info("Include " + name + " in " + f);
Properties p = new Properties();
try (InputStream in = jar.getInputStream(entry)) {
p.load(in);
}
Properties all = m.get(name);
if (all == null) {
all = p;
m.put(name, p);
} else {
for (String key : p.stringPropertyNames()) {
if (all.containsKey(key)) {
Object old = all.get(key);
Object nue = p.get(key);
if (!Objects.equal(old, nue)) {
log.warn(key + '=' + nue + " in " + f + '!' + name + " overrides " + key + '=' + old);
}
}
}
all.putAll(p);
}
} else if (SERVICES.matcher(name).matches() || "META-INF/settings/namespaces.list".equals(name) || "META-INF/http/pages.list".equals(name) || "META-INF/http/modules.list".equals(name)|| "META-INF/http/numble.list".equals(name)) {
log.info("Include " + name + " in " + f);
try (InputStream in = jar.getInputStream(entry)) {
List<String> lines = readLines(in);
Set<String> all = linesForName.get(name);
if (all == null) {
all = new LinkedHashSet<>();
linesForName.put(name, all);
}
all.addAll(lines);
}
Integer ct = fileCountForName.get(name);
if (ct == null) {
ct = 1;
} else {
ct++;
}
fileCountForName.put(name, ct);
} else if (jarOut != null) {
// if (!seen.contains(name)) {
switch (name) {
case "META-INF/MANIFEST.MF":
case "META-INF/":
break;
case "META-INF/LICENSE":
case "META-INF/LICENSE.txt":
case "META-INF/settings/namespaces.list":
case "META-INF/http/pages.list":
case "META-INF/http/numble.list":
case "META-INF/http/modules.list":
Set<String> s = linesForName.get(name);
if (s == null) {
s = new LinkedHashSet<>();
linesForName.put(name, s);
}
Integer ct = fileCountForName.get(name);
if (ct == null) {
ct = 1;
}
fileCountForName.put(name, ct);
try (InputStream in = jar.getInputStream(entry)) {
s.addAll(readLines(in));
}
break;
default:
if (!seen.contains(name)) {
if (!SIG1.matcher(name).find() && !SIG2.matcher(name).find() && !SIG3.matcher(name).find()) {
JarEntry je = new JarEntry(name);
je.setTime(entry.getTime());
try {
jarOut.putNextEntry(je);
} catch (ZipException ex) {
throw new MojoExecutionException("Exception putting zip entry " + name, ex);
}
try (InputStream in = jar.getInputStream(entry)) {
copy(in, jarOut);
}
jarOut.closeEntry();
}
} else {
if (!name.endsWith("/") && !name.startsWith("META-INF")) {
log.warn("Saw more than one " + name + ". One will clobber the other.");
}
}
}
// } else {
// if (!name.endsWith("/") && !name.startsWith("META-INF")) {
// log.warn("Saw more than one " + name + ". One will clobber the other.");
// }
// }
seen.add(name);
}
}
} catch (IOException ex) {
throw new MojoExecutionException("Error opening " + f, ex);
}
}
if (!m.isEmpty()) {
log.warn("Writing merged files: " + m.keySet());
} else {
return;
}
String outDir = project.getBuild().getOutputDirectory();
File dir = new File(outDir);
// Don't bother rewriting META-INF/services files of which there is
// only one
// for (Map.Entry<String, Integer> e : fileCountForName.entrySet()) {
// if (e.getValue() == 1) {
// linesForName.remove(e.getKey());
// }
// }
for (Map.Entry<String, Set<String>> e : linesForName.entrySet()) {
File outFile = new File(dir, e.getKey());
log.info("Merge configurating rewriting " + outFile);
Set<String> lines = e.getValue();
if (!outFile.exists()) {
try {
Path path = outFile.toPath();
if (!Files.exists(path.getParent())) {
Files.createDirectories(path.getParent());
}
path = Files.createFile(path);
outFile = path.toFile();
} catch (IOException ex) {
throw new MojoFailureException("Could not create " + outFile, ex);
}
}
if (!outFile.isDirectory()) {
try (FileOutputStream out = new FileOutputStream(outFile)) {
try (PrintStream ps = new PrintStream(out)) {
for (String line : lines) {
ps.println(line);
}
}
} catch (IOException ex) {
throw new MojoFailureException("Exception writing " + outFile, ex);
}
}
if (jarOut != null) {
int count = fileCountForName.get(e.getKey());
if (count > 1) {
log.warn("Concatenating " + count + " copies of " + e.getKey());
}
JarEntry je = new JarEntry(e.getKey());
try {
jarOut.putNextEntry(je);
PrintStream ps = new PrintStream(jarOut);
for (String line : lines) {
ps.println(line);
}
jarOut.closeEntry();
} catch (IOException ex) {
throw new MojoFailureException("Exception writing " + outFile, ex);
}
}
}
for (Map.Entry<String, Properties> e : m.entrySet()) {
File outFile = new File(dir, e.getKey());
Properties local = new Properties();
if (outFile.exists()) {
try {
try (InputStream in = new FileInputStream(outFile)) {
local.load(in);
}
} catch (IOException ioe) {
throw new MojoExecutionException("Could not read " + outFile, ioe);
}
} else {
try {
Path path = outFile.toPath();
if (!Files.exists(path.getParent())) {
Files.createDirectories(path.getParent());
}
path = Files.createFile(path);
outFile = path.toFile();
} catch (IOException ex) {
throw new MojoFailureException("Could not create " + outFile, ex);
}
}
Properties merged = e.getValue();
for (String key : local.stringPropertyNames()) {
if (merged.containsKey(key) && !Objects.equal(local.get(key), merged.get(key))) {
log.warn("Overriding key=" + merged.get(key) + " with locally defined key=" + local.get(key));
}
}
merged.putAll(local);
try {
log.info("Saving merged properties to " + outFile);
try (FileOutputStream out = new FileOutputStream(outFile)) {
merged.store(out, getClass().getName());
}
} catch (IOException ex) {
throw new MojoExecutionException("Failed to write " + outFile, ex);
}
if (jarOut != null) {
JarEntry props = new JarEntry(e.getKey());
try {
jarOut.putNextEntry(props);
merged.store(jarOut, getClass().getName() + " merged " + e.getKey());
jarOut.closeEntry();
} catch (IOException ex) {
throw new MojoExecutionException("Failed to write jar entry " + e.getKey(), ex);
}
}
File copyTo = new File(dir.getParentFile(), "settings");
if (!copyTo.exists()) {
copyTo.mkdirs();
}
File toFile = new File(copyTo, outFile.getName());
try {
Files.copy(outFile.toPath(), toFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
} catch (IOException ex) {
throw new MojoExecutionException("Failed to copy " + outFile + " to " + toFile, ex);
}
}
} finally {
if (jarOut != null) {
try {
jarOut.close();
} catch (IOException ex) {
throw new MojoExecutionException("Failed to close Jar", ex);
}
}
}
}
}