package restx.classloader;
import com.google.common.base.*;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.eventbus.EventBus;
import com.google.common.eventbus.Subscribe;
import com.google.common.hash.Hashing;
import org.joda.time.DateTime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import restx.common.MoreFiles;
import restx.common.watch.FileWatchEvent;
import restx.common.watch.WatcherSettings;
import javax.tools.*;
import java.io.*;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.FileSystem;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.collect.Iterables.isEmpty;
import static com.google.common.collect.Iterables.transform;
import static java.util.Arrays.asList;
import static java.util.Collections.singleton;
import static java.util.Collections.unmodifiableCollection;
/**
* A compilation manager is responsible for compiling a set of source roots into a
* destination directory.
*
* It is able to scan for changes and compile only modified files, and also watch for changes
* to automatically compile on changes.
*
* It also trigger events whenever a compilation ends.
*/
public class CompilationManager {
private static final Runnable NO_OP = new Runnable() {
@Override
public void run() {
}
};
public static final Predicate<Path> DEFAULT_CLASSPATH_RESOURCE_FILTER = new Predicate<Path>() {
@Override
public boolean apply(java.nio.file.Path path) {
return
// Intellij IDEA temporary files
!path.toString().endsWith("___jb_old___")
&& !path.toString().endsWith("___jb_bak___")
// svn
&& path.toAbsolutePath().toString().replace('\\', '/').indexOf("/.svn/") == -1;
}
};
public static final CompilationSettings DEFAULT_SETTINGS = new CompilationSettings() {
@Override
public int autoCompileCoalescePeriod() {
return 50;
}
@Override
public Predicate<Path> classpathResourceFilter() {
return DEFAULT_CLASSPATH_RESOURCE_FILTER;
}
};
private static final Logger logger = LoggerFactory.getLogger(CompilationManager.class);
private final EventBus eventBus;
private final Predicate<Path> classpathResourceFilter;
private final JavaCompiler javaCompiler;
private final Iterable<Path> sourceRoots;
private final Path destination;
private final CompilationSettings settings;
private final StandardJavaFileManager fileManager;
// compile executor is a single thread, we can't perform compilations concurrently
// all compilation will be done by the compile executor thread
private final ScheduledExecutorService compileExecutor = Executors.newSingleThreadScheduledExecutor();
private final ConcurrentLinkedDeque<Path> compileQueue = new ConcurrentLinkedDeque<>();
private final Map<Path, SourceHash> hashes = new HashMap<>();
private ExecutorService watcherExecutor;
private volatile boolean compiling;
// these parameters should be overridable, at least with system properties
private final long compilationTimeout = 60; // in seconds
private final int autoCompileQuietPeriod = 50; // ms
private final boolean useLastModifiedTocheckChanges = true;
private Collection<Diagnostic<?>> lastDiagnostics = new CopyOnWriteArrayList<>();
public CompilationManager(EventBus eventBus, Iterable<Path> sourceRoots, Path destination) {
this(eventBus, sourceRoots, destination, DEFAULT_SETTINGS);
}
public CompilationManager(EventBus eventBus, Iterable<Path> sourceRoots, Path destination, CompilationSettings settings) {
this.eventBus = checkNotNull(eventBus);
this.sourceRoots = checkNotNull(sourceRoots);
this.destination = checkNotNull(destination);
this.classpathResourceFilter = checkNotNull(settings.classpathResourceFilter());
this.settings = settings;
javaCompiler = ToolProvider.getSystemJavaCompiler();
fileManager = javaCompiler.getStandardFileManager(new DiagnosticCollector<JavaFileObject>(), Locale.ENGLISH, Charsets.UTF_8);
try {
if (!destination.toFile().exists()) {
destination.toFile().mkdirs();
}
fileManager.setLocation(StandardLocation.SOURCE_PATH, transform(sourceRoots, MoreFiles.pathToFile));
fileManager.setLocation(StandardLocation.CLASS_OUTPUT, singleton(destination.toFile()));
} catch (IOException e) {
throw new RuntimeException(e);
}
loadHashes();
eventBus.register(new Object() {
@Subscribe
public void onWatchEvent(FileWatchEvent event) {
WatchEvent.Kind<?> kind = event.getKind();
Path source = event.getDir().resolve(event.getPath());
if (!source.toFile().isFile()) {
return;
}
if (isSource(source)) {
if (kind == StandardWatchEventKinds.ENTRY_MODIFY
|| kind == StandardWatchEventKinds.ENTRY_CREATE) {
if (!queueCompile(source)) {
rebuild();
}
} else if (kind == StandardWatchEventKinds.ENTRY_DELETE) {
rebuild();
} else {
rebuild();
}
} else {
Optional<SourcePath> sourcePath = SourcePath.resolve(CompilationManager.this.sourceRoots, source);
if (sourcePath.isPresent()) {
logger.info("classpath resource updated: {}", sourcePath.get().getPath());
copyResource(sourcePath.get());
}
}
}
});
}
public EventBus getEventBus() {
return eventBus;
}
public Path getDestination() {
return destination;
}
public Iterable<Path> getSourceRoots() {
return sourceRoots;
}
private void copyResource(final SourcePath resourcePath) {
compileExecutor.submit(new Runnable() {
@Override
public void run() {
doCopyResource(resourcePath);
}
});
}
// IMPORTANT: this should be called in compile executor thread only
private void doCopyResource(SourcePath resourcePath) {
File source = resourcePath.toAbsolutePath().toFile();
if (source.isFile() && classpathResourceFilter.apply(source.toPath())) {
try {
File to = destination.resolve(resourcePath.getPath()).toFile();
to.getParentFile().mkdirs();
boolean existed = to.exists();
if (!existed || to.lastModified() < source.lastModified()) {
com.google.common.io.Files.copy(source, to);
ClasspathResourceEvent.Kind kind = existed ?
ClasspathResourceEvent.Kind.UPDATED : ClasspathResourceEvent.Kind.CREATED;
eventBus.post(new ClasspathResourceEvent(kind, resourcePath.getPath().toString()));
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
private boolean queueCompile(final Path source) {
boolean b = compileQueue.offerLast(source);
if (!b) {
return false;
}
compileExecutor.schedule(new Runnable() {
@Override
public void run() {
// nothing added since submission, quiet period is over
if (compileQueue.getLast() == source) {
Collection<Path> sources = new HashSet<>();
while (!compileQueue.isEmpty()) {
sources.add(compileQueue.removeFirst());
}
compile(sources);
}
}
}, autoCompileQuietPeriod, TimeUnit.MILLISECONDS);
return true;
}
/**
* Returns the path of the .class file containing bytecode for the given class (by name).
*
* @param className the class for which the class file should be returned
* @return the Path of the class file, absent if it doesn't exists.
*/
public Optional<Path> getClassFile(String className) {
Path classFilePath = destination.resolve(className.replace('.', '/') + ".class");
if (classFilePath.toFile().exists()) {
return Optional.of(classFilePath);
} else {
return Optional.absent();
}
}
public void startAutoCompile() {
synchronized (this) {
if (watcherExecutor == null) {
watcherExecutor = Executors.newCachedThreadPool();
Collection<Path> watched = new ArrayList<>();
for (Path sourceRoot : sourceRoots) {
if (sourceRoot.toFile().exists()) {
watched.add(sourceRoot);
MoreFiles.watch(sourceRoot, eventBus, watcherExecutor, new WatcherSettings() {
@Override
public int coalescePeriod() {
return settings.autoCompileCoalescePeriod();
}
@Override
public boolean recurse() {
return true;
}
});
} else {
logger.info("source root {} does not exist - IGNORED", sourceRoot);
}
}
logger.info("watching for changes in {}; current location is {}",
watched, new File(".").getAbsoluteFile());
}
}
}
public void stopAutoCompile() {
synchronized (this) {
if (watcherExecutor != null) {
watcherExecutor.shutdownNow();
watcherExecutor = null;
}
}
}
public void awaitAutoCompile() {
try {
if (compileQueue.isEmpty()) {
// nothing in compile queue, we wait for current compilation if any by submitting a noop task
// and waiting for it
compileExecutor.submit(NO_OP).get(compilationTimeout, TimeUnit.SECONDS);
} else {
// we are in quiet period, let's submit a task after the quiet period and for it
// if more file changes occur during that period we may miss them, but the purpose of this method
// is to wait for autoCompile triggered *before* the call.
compileExecutor.schedule(
NO_OP, autoCompileQuietPeriod + 10, TimeUnit.MILLISECONDS)
.get(compilationTimeout, TimeUnit.SECONDS);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
} catch (ExecutionException e) {
throw new RuntimeException(e);
} catch (TimeoutException e) {
throw new RuntimeException(e);
}
}
/**
* Performs an incremental compilation.
*/
public void incrementalCompile() {
try {
Exception e = compileExecutor.submit(new Callable<Exception>() {
@Override
public Exception call() throws Exception {
try {
final Collection<Path> sources = new ArrayList<>();
for (final Path sourceRoot : sourceRoots) {
if (sourceRoot.toFile().exists()) {
Files.walkFileTree(sourceRoot, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
if (isSource(file)) {
if (hasSourceChanged(sourceRoot, sourceRoot.relativize(file))) {
sources.add(file);
}
} else if (file.toFile().isFile()) {
doCopyResource(SourcePath.valueOf(sourceRoot, sourceRoot.relativize(file)));
}
return FileVisitResult.CONTINUE;
}
});
}
}
compile(sources);
return null;
} catch (Exception e) {
return e;
}
}
}).get(compilationTimeout, TimeUnit.SECONDS);
if (e != null) {
throw new RuntimeException(e);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
} catch (ExecutionException e) {
throw new RuntimeException(e);
} catch (TimeoutException e) {
throw new RuntimeException(e);
}
}
private boolean isSource(Path file) {
return file.toString().endsWith(".java");
}
/**
* Clean destination and do a full build.
*/
public void rebuild() {
try {
Exception e = compileExecutor.submit(new Callable<Exception>() {
@Override
public Exception call() throws Exception {
try {
compileQueue.clear();
MoreFiles.delete(destination);
destination.toFile().mkdirs();
final Collection<Path> sources = new ArrayList<>();
for (final Path sourceRoot : sourceRoots) {
if (sourceRoot.toFile().exists()) {
Files.walkFileTree(sourceRoot, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
throws IOException {
if (isSource(file)) {
sources.add(file);
} else if (file.toFile().isFile()) {
doCopyResource(SourcePath.valueOf(sourceRoot, sourceRoot.relativize(file)));
}
return FileVisitResult.CONTINUE;
}
});
}
}
compile(sources);
return null;
} catch (Exception e) {
return e;
}
}
}).get(compilationTimeout, TimeUnit.SECONDS);
if (e != null) {
throw new RuntimeException(e);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
} catch (ExecutionException e) {
throw new RuntimeException(e);
} catch (TimeoutException e) {
throw new RuntimeException(e);
}
}
public void compileSources(final Path... sources) {
try {
Exception e = compileExecutor.submit(new Callable<Exception>() {
@Override
public Exception call() throws Exception {
compile(asList(sources));
return null;
}
}).get(compilationTimeout, TimeUnit.SECONDS);
if (e != null) {
throw new RuntimeException(e);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
} catch (ExecutionException e) {
throw new RuntimeException(e);
} catch (TimeoutException e) {
throw new RuntimeException(e);
}
}
public Collection<Diagnostic<?>> getLastDiagnostics() {
return unmodifiableCollection(lastDiagnostics);
}
private final static AtomicLong CLASSLOADER_COUNT = new AtomicLong();
public HotReloadingClassLoader newHotReloadingClassLoader(String rootPackage, ImmutableSet<Class> coldClasses) {
try {
CLASSLOADER_COUNT.incrementAndGet();
final String name = "HotCompile[" + CLASSLOADER_COUNT + "]";
final Path destinationDir = getDestination();
return new HotReloadingClassLoader(
new URLClassLoader(
new URL[]{destinationDir.toUri().toURL()},
Thread.currentThread().getContextClassLoader()),
rootPackage, coldClasses
) {
protected InputStream getInputStream(String path) {
try {
return Files.newInputStream(destinationDir.resolve(path));
} catch (IOException e) {
return null;
}
}
@Override
public String toString() {
return name;
}
};
} catch (MalformedURLException e) {
throw new RuntimeException(e);
}
}
private void compile(Collection<Path> sources) {
// MUST BE CALLED in compileExecutor only
Stopwatch stopwatch = Stopwatch.createStarted();
compiling = true;
try {
lastDiagnostics.clear();
DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<>();
Iterable<? extends JavaFileObject> javaFileObjects =
fileManager.getJavaFileObjectsFromFiles(transform(sources, MoreFiles.pathToFile));
if (isEmpty(javaFileObjects)) {
logger.debug("compilation finished: up to date");
return;
}
JavaCompiler.CompilationTask compilationTask = javaCompiler.getTask(
null, fileManager, diagnostics, asList("-g"), null, javaFileObjects);
boolean valid = compilationTask.call();
if (valid) {
for (Path source : sources) {
Optional<SourcePath> sourcePath = SourcePath.resolve(sourceRoots, source);
if (sourcePath.isPresent()) {
SourceHash sourceHash = newSourceHashFor(sourcePath.get());
hashes.put(source.toAbsolutePath(), sourceHash);
}
}
saveHashes();
logger.info("compilation finished: {} sources compiled in {}", sources.size(), stopwatch.stop());
eventBus.post(new CompilationFinishedEvent(this, DateTime.now(), ImmutableList.copyOf(sources)));
for (Diagnostic<?> d : diagnostics.getDiagnostics()) {
logger.debug("{}", d);
}
} else {
StringBuilder sb = new StringBuilder();
for (Diagnostic<?> d : diagnostics.getDiagnostics()) {
sb.append(d).append("\n");
}
lastDiagnostics.addAll(diagnostics.getDiagnostics());
throw new RuntimeException("Compilation failed:\n" + sb);
}
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
compiling = false;
}
}
private void saveHashes() {
File hashesFile = hashesFile();
hashesFile.getParentFile().mkdirs();
try (Writer w = com.google.common.io.Files.newWriter(hashesFile, Charsets.UTF_8)) {
for (SourceHash sourceHash : hashes.values()) {
w.write(sourceHash.serializeAsString());
w.write("\n");
}
} catch (FileNotFoundException e) {
throw new RuntimeException(e);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private void loadHashes() {
File hashesFile = hashesFile();
if (hashesFile.exists()) {
try (BufferedReader r = com.google.common.io.Files.newReader(hashesFile, Charsets.UTF_8)) {
String line;
while ((line = r.readLine()) != null) {
SourceHash sourceHash = parse(line);
hashes.put(sourceHash.getSourcePath().toAbsolutePath(), sourceHash);
}
} catch (FileNotFoundException e) {
throw new RuntimeException(e);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
private File hashesFile() {
return destination.resolve("META-INF/.hashes").toFile();
}
/**
* @return true if this compilation manager is currently performing a compilation task.
*/
public boolean isCompiling() {
return compiling;
}
private boolean hasSourceChanged(Path dir, Path source) {
try {
SourceHash sourceHash = hashes.get(dir.resolve(source).toAbsolutePath());
if (sourceHash != null) {
return sourceHash.hasChanged() != sourceHash;
} else {
return true;
}
} catch (IOException e) {
return true;
}
}
private static class SourcePath {
public static Optional<SourcePath> resolve(Iterable<Path> sourceRoots, Path source) {
Path dir = null;
for (Path sourceRoot : sourceRoots) {
if ((source.isAbsolute() && source.startsWith(sourceRoot.toAbsolutePath()))
|| (!source.isAbsolute() && source.startsWith(sourceRoot))) {
dir = sourceRoot;
break;
}
}
if (dir == null) {
logger.warn("can't find sourceRoot for {}", source);
return Optional.absent();
} else {
return Optional.of(new SourcePath(dir, source.isAbsolute() ?
dir.toAbsolutePath().relativize(source) :
dir.relativize(source)));
}
}
public static SourcePath valueOf(Path sourceRoot, Path path) {
return new SourcePath(sourceRoot, path);
}
private final Path sourceDir;
private final Path path;
private SourcePath(Path sourceDir, Path path) {
this.sourceDir = sourceDir;
this.path = path;
}
public Path getSourceDir() {
return sourceDir;
}
public Path getPath() {
return path;
}
public Path toAbsolutePath() {
return sourceDir.resolve(path).toAbsolutePath();
}
@Override
public String toString() {
return "SourcePath{" +
"sourceDir=" + sourceDir +
", path=" + path +
'}';
}
}
private class SourceHash {
private final SourcePath sourcePath;
private final String hash;
private final long lastModified;
private SourceHash(SourcePath sourcePath, String hash, long lastModified) {
this.sourcePath = sourcePath;
this.hash = hash;
this.lastModified = lastModified;
}
@Override
public String toString() {
return "SourceHash{" +
"sourcePath=" + sourcePath +
", hash='" + hash + '\'' +
", lastModified=" + lastModified +
'}';
}
public SourcePath getSourcePath() {
return sourcePath;
}
public String getHash() {
return hash;
}
public long getLastModified() {
return lastModified;
}
public SourceHash hasChanged() throws IOException {
File sourceFile = sourcePath.toAbsolutePath().toFile();
if (useLastModifiedTocheckChanges) {
if (lastModified < sourceFile.lastModified()) {
return new SourceHash(sourcePath,
computeHash(), sourceFile.lastModified());
}
} else {
String currentHash = computeHash();
if (!currentHash.equals(hash)) {
return new SourceHash(sourcePath,
currentHash, sourceFile.lastModified());
}
}
return this;
}
private String computeHash() throws IOException {
return hash(sourcePath.toAbsolutePath().toFile());
}
public String serializeAsString() throws IOException {
return Joiner.on("**").join(sourcePath.getSourceDir(), sourcePath.getPath(), hash, lastModified);
}
}
private SourceHash newSourceHashFor(SourcePath sourcePath) throws IOException {
File sourceFile = sourcePath.toAbsolutePath().toFile();
return new SourceHash(sourcePath, hash(sourceFile), sourceFile.lastModified());
}
private String hash(File file) throws IOException {
return com.google.common.io.Files.hash(file, Hashing.md5()).toString();
}
private SourceHash parse(String str) {
Iterator<String> parts = Splitter.on("**").split(str).iterator();
FileSystem fileSystem = FileSystems.getDefault();
return new SourceHash(
SourcePath.valueOf(fileSystem.getPath(parts.next()),
fileSystem.getPath(parts.next())),
parts.next(),
Long.parseLong(parts.next())
);
}
}