diff --git a/src/main/java/org/scijava/plugins/scripting/java/JavaEngine.java b/src/main/java/org/scijava/plugins/scripting/java/JavaEngine.java index ed66b5c..1686d50 100644 --- a/src/main/java/org/scijava/plugins/scripting/java/JavaEngine.java +++ b/src/main/java/org/scijava/plugins/scripting/java/JavaEngine.java @@ -43,10 +43,14 @@ import java.io.Reader; import java.io.StringReader; import java.io.Writer; +import java.net.MalformedURLException; import java.net.URL; import java.net.URLClassLoader; import java.util.ArrayList; import java.util.List; +import java.util.jar.JarFile; +import java.util.jar.Manifest; +import java.util.jar.Attributes.Name; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -92,7 +96,11 @@ public class JavaEngine extends AbstractScriptEngine { private final static String DEFAULT_GROUP_ID = "net.imagej"; private final static String DEFAULT_VERSION = "1.0.0-SNAPSHOT"; + /** + * The key to specify how to indent the XML written out by Xalan. + */ private final static String XALAN_INDENT_AMOUNT = "{http://xml.apache.org/xslt}indent-amount"; + { engineScopeBindings = new JavaEngineBindings(); } @@ -106,11 +114,31 @@ public class JavaEngine extends AbstractScriptEngine { @Parameter private JavaService javaService; + /** + * Compiles and runs the specified {@code .java} class. + *

+ * The currently active {@link JavaService} is responsible for running the + * class. + *

+ * + * @param script the source code for a Java class + * @return null + */ @Override public Object eval(String script) throws ScriptException { return eval(new StringReader(script)); } + /** + * Compiles and runs the specified {@code .java} class. + *

+ * The currently active {@link JavaService} is responsible for running the + * class. + *

+ * + * @param reader the reader producing the source code for a Java class + * @return null + */ @Override public Object eval(Reader reader) throws ScriptException { final String path = (String)get(FILENAME); @@ -140,7 +168,7 @@ public Object eval(Reader reader) throws ScriptException { urls[i] = new URL("file:" + paths[i] + (paths[i].endsWith(".jar") ? "" : "/")); URLClassLoader classLoader = new URLClassLoader(urls, - getClass().getClassLoader()); + Thread.currentThread().getContextClassLoader()); // needed for sezpoz Thread.currentThread().setContextClassLoader(classLoader); @@ -165,6 +193,12 @@ public Object eval(Reader reader) throws ScriptException { return null; } + /** + * Compiles the specified {@code .java} file. + * + * @param file the source code + * @param errorWriter where to write the errors + */ public void compile(final File file, final Writer errorWriter) { try { final Builder builder = new Builder(file, null, errorWriter); @@ -203,6 +237,17 @@ public void makeJar(final File file, final boolean includeSources, final File ou } } + /** + * Reports an exception. + *

+ * If a writer for errors is specified (e.g. when being called from the script + * editor), we should just print the error and return. Otherwise, we'll throw + * the exception back at the caller. + *

+ * + * @param t the exception + * @param errorWriter the error writer, or null + */ private void printOrThrow(Throwable t, Writer errorWriter) { RuntimeException e = t instanceof RuntimeException ? (RuntimeException) t : new RuntimeException(t); @@ -214,6 +259,11 @@ private void printOrThrow(Throwable t, Writer errorWriter) { err.flush(); } + /** + * A wrapper around a (possibly only temporary) project. + * + * @author Johannes Schindelin + */ private class Builder { private final PrintStream err; private final File temporaryDirectory; @@ -276,6 +326,9 @@ public void println(final String line) throws IOException { } } + /** + * Cleans up the project, if it was only temporary. + */ private void cleanup() { if (err != null) err.close(); @@ -288,6 +341,24 @@ private void cleanup() { } } + /** + * Returns a Maven POM associated with a {@code .java} file. + *

+ * If the file is not part of a valid Maven project, one will be generated. + *

+ * + * @param env the {@link BuildEnvironment} + * @param file the {@code .java} file + * @param mainClass the name of the class to execute + * @return the Maven POM + * @throws IOException + * @throws ParserConfigurationException + * @throws SAXException + * @throws ScriptException + * @throws TransformerConfigurationException + * @throws TransformerException + * @throws TransformerFactoryConfigurationError + */ private MavenProject getMavenProject(final BuildEnvironment env, final File file, final String mainClass) throws IOException, ParserConfigurationException, SAXException, ScriptException, @@ -306,6 +377,13 @@ private MavenProject getMavenProject(final BuildEnvironment env, return writeTemporaryProject(env, new FileReader(file)); } + /** + * Determines the class name of a Java class given its source code. + * + * @param file the source code + * @return the class name including the package + * @throws IOException + */ private static String getFullClassName(final File file) throws IOException { String name = file.getName(); if (!name.endsWith(".java")) { @@ -345,6 +423,19 @@ private static String getFullClassName(final File file) throws IOException { return packageName + name; // the 'package' statement must be the first in the file } + /** + * Makes a temporary Maven project for a virtual {@code .java} file. + * + * @param env the {@link BuildEnvironment} to store the generated Maven POM + * @param reader the virtual {@code .java} file + * @return the generated Maven POM + * @throws IOException + * @throws ParserConfigurationException + * @throws SAXException + * @throws TransformerConfigurationException + * @throws TransformerException + * @throws TransformerFactoryConfigurationError + */ private static MavenProject writeTemporaryProject(final BuildEnvironment env, final Reader reader) throws IOException, ParserConfigurationException, SAXException, TransformerConfigurationException, TransformerException, @@ -378,6 +469,18 @@ private static MavenProject writeTemporaryProject(final BuildEnvironment env, return fakePOM(env, directory, artifactId, mainClass, true); } + /** + * Fakes a sensible, valid {@code artifactId}. + *

+ * Given a name for a project or {@code .java} file, this function generated a + * proper {@code artifactId} for use in faked Maven POMs. + *

+ * + * @param env the associated {@link BuildEnvironment} (to avoid duplicate + * {@code artifactId}s) + * @param name the project name + * @return the generated {@code artifactId} + */ private static String fakeArtifactId(final BuildEnvironment env, final String name) { int dot = name.indexOf('.'); final String prefix = dot < 0 ? name : dot == 0 ? "dependency" : name.substring(0, dot); @@ -392,6 +495,29 @@ private static String fakeArtifactId(final BuildEnvironment env, final String na } } + /** + * Fakes a single Maven POM for a given dependency. + *

+ * When discovering possible dependencies on the class path, we do not + * necessarily deal with proper Maven-generated artifacts. To be able to use + * them for single {@code .java} "scripts", we simply fake Maven POMs for + * those files. + *

+ * + * @param env the {@link BuildEnvironment} to house the faked POM + * @param directory the directory associated with the Maven project + * @param artifactId the {@code artifactId} of the dependency + * @param mainClass the main class, if any + * @param writePOM whether to write the Maven POM as {@code pom.xml} into the + * specified directory + * @return the faked POM + * @throws IOException + * @throws ParserConfigurationException + * @throws SAXException + * @throws TransformerConfigurationException + * @throws TransformerException + * @throws TransformerFactoryConfigurationError + */ private static MavenProject fakePOM(final BuildEnvironment env, final File directory, final String artifactId, final String mainClass, boolean writePOM) throws IOException, ParserConfigurationException, SAXException, @@ -449,6 +575,15 @@ private static MavenProject fakePOM(final BuildEnvironment env, return env.parse(new ByteArrayInputStream(out.toByteArray()), directory, null, null); } + /** + * Writes out the specified XML element. + * + * @param document the XML document + * @param parent the parent node + * @param tag the tag to append + * @param content the content of the tag to append + * @return the appended node + */ private static Element append(final Document document, final Element parent, final String tag, final String content) { Element child = document.createElement(tag); if (content != null) child.appendChild(document.createCDATASection(content)); @@ -456,17 +591,34 @@ private static Element append(final Document document, final Element parent, fin return child; } + /** + * Discovers all current class path elements and offers them as faked Maven + * POMs. + *

+ * When constructing an in-memory Maven POM for a single {@code .java} file, + * we need to make sure that all class path elements are available to the + * compiler. Since we use MiniMaven to compile everything (in order to be + * consistent, and also to be able to generate Maven projects conveniently, to + * turn hacky projects into proper ones), we need to put all of that into the + * Maven context, i.e. fake Maven POMs for all the dependencies. + *

+ * + * @param env the {@link BuildEnvironment} in which the faked POMs are stored + * @return the list of dependencies, as {@link Coordinate}s + */ private static List getAllDependencies(final BuildEnvironment env) { final List result = new ArrayList(); - for (ClassLoader loader = env.getClass().getClassLoader(); loader != null; loader = loader.getParent()) { + for (ClassLoader loader = Thread.currentThread().getContextClassLoader(); + loader != null; loader = loader.getParent()) { if (loader instanceof URLClassLoader) { for (final URL url : ((URLClassLoader)loader).getURLs()) { if (url.getProtocol().equals("file")) { final File file = new File(url.getPath()); - final String artifactId = fakeArtifactId(env, file.getName()); - Coordinate dependency = new Coordinate(DEFAULT_GROUP_ID, artifactId, "1.0.0"); - env.fakePOM(file, dependency); - result.add(dependency); + if (url.toString().matches(".*/target/surefire/surefirebooter[0-9]*\\.jar")) { + getSurefireBooterURLs(file, url, env, result); + continue; + } + result.add(fakeDependency(env, file)); } } } @@ -474,4 +626,73 @@ private static List getAllDependencies(final BuildEnvironment env) { return result; } + /** + * Fakes a Maven POM in memory for a specified dependency. + *

+ * When compiling bare {@code .java} files, we need to fake a full-blown Maven + * project, including full-blown Maven dependencies for all of the files + * present on the current class path. + *

+ * + * @param env the {@link BuildEnvironment} for storing the faked Maven POM + * @param file the dependency + * @return the {@link Coordinate} specifying the dependency + */ + private static Coordinate fakeDependency(final BuildEnvironment env, final File file) { + final String artifactId = fakeArtifactId(env, file.getName()); + Coordinate dependency = new Coordinate(DEFAULT_GROUP_ID, artifactId, "1.0.0"); + env.fakePOM(file, dependency); + return dependency; + } + + /** + * Figures out the class path given a {@code .jar} file generated by the + * {@code maven-surefire-plugin}. + *

+ * A little-known feature of JAR files is that their manifest can specify + * additional class path elements in a {@code Class-Path} entry. The + * {@code maven-surefire-plugin} makes extensive use of that: the URLs of the + * of the active {@link URLClassLoader} will consist of only a single + * {@code .jar} file that is empty except for a manifest whose sole purpose is + * to specify the dependencies. + *

+ *

+ * This method can be used to discover those additional class path elements. + *

+ * + * @param file the {@code .jar} file generated by the + * {@code maven-surefire-plugin} + * @param baseURL the {@link URL} of the {@code .jar} file, needed for class + * path elements specified as relative paths + * @param env the {@link BuildEnvironment}, to store the Maven POMs faked for + * the class path elements + * @param result the list of dependencies to which the discovered dependencies + * are added + */ + private static void getSurefireBooterURLs(final File file, final URL baseURL, + final BuildEnvironment env, final List result) + { + try { + final JarFile jar = new JarFile(file); + Manifest manifest = jar.getManifest(); + if (manifest != null) { + final String classPath = + manifest.getMainAttributes().getValue(Name.CLASS_PATH); + if (classPath != null) { + for (final String element : classPath.split(" +")) + try { + final File dependency = new File(new URL(baseURL, element).getPath()); + result.add(fakeDependency(env, dependency)); + } + catch (MalformedURLException e) { + e.printStackTrace(); + } + } + } + } + catch (final IOException e) { + e.printStackTrace(); + } + } + } diff --git a/src/test/java/org/scijava/plugins/scripting/java/JavaEngineTest.java b/src/test/java/org/scijava/plugins/scripting/java/JavaEngineTest.java index 08cadb1..ad8f8c9 100644 --- a/src/test/java/org/scijava/plugins/scripting/java/JavaEngineTest.java +++ b/src/test/java/org/scijava/plugins/scripting/java/JavaEngineTest.java @@ -48,11 +48,9 @@ import org.junit.Test; import org.scijava.Context; import org.scijava.object.ObjectService; -import org.scijava.plugins.scripting.java.JavaScriptLanguage; import org.scijava.script.ScriptLanguage; import org.scijava.script.ScriptService; import org.scijava.test.TestUtils; -import org.scijava.util.FileUtils; /** * Tests the Java 'scripting' backend. @@ -139,8 +137,10 @@ public void minimalProjectFromSource() throws Exception { public void testEvalReader() throws Exception { final String source = "" + // "package pinky.brain;\n" + // + "import org.scijava.util.AppUtils;\n" + // "public class TakeOverTheWorld {\n" + // "\tpublic static void main(final String[] arguments) {\n" + // + "\t\tSystem.err.println(\"main class: \" + AppUtils.getMainClass());\n" + // "\t\tthrow new RuntimeException(\"Egads!\");\n" + // "\t}\n" + // "}";