ClassLoaders.java

/*
 * Janino - An embedded Java[TM] compiler
 *
 * Copyright (c) 2019 Arno Unkrig. All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
 * following conditions are met:
 *
 *    1. Redistributions of source code must retain the above copyright notice, this list of conditions and the
 *       following disclaimer.
 *    2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the
 *       following disclaimer in the documentation and/or other materials provided with the distribution.
 *    3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote
 *       products derived from this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
 * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
 * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

package org.codehaus.commons.compiler.lang;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.JarURLConnection;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;

import org.codehaus.commons.compiler.java8.java.util.function.Consumer;
import org.codehaus.commons.compiler.java9.java.lang.module.ModuleFinder;
import org.codehaus.commons.compiler.java9.java.lang.module.ModuleReference;
import org.codehaus.commons.compiler.util.resource.LocatableResource;
import org.codehaus.commons.compiler.util.resource.Resource;
import org.codehaus.commons.compiler.util.resource.ResourceFinder;
import org.codehaus.commons.nullanalysis.NotNullByDefault;
import org.codehaus.commons.nullanalysis.Nullable;

/**
 * Utility methods around the {@link java.lang.ClassLoader}.
 */
public final
class ClassLoaders {

    /**
     * The {@link ClassLoader} that loads the classes on the currently executing JVM's "class path", i.e. the JARs in
     * the JRE's "lib" and "lib/ext" directories, and the JARs and class directories specified through the class path.
     */
    public static final ClassLoader CLASSPATH_CLASS_LOADER = ClassLoader.getSystemClassLoader();

    /**
     * The {@link ClassLoader} that loads the classes on the currently executing JVM's "boot class path", i.e. the JARs
     * in the JRE's "lib" and "lib/ext" directories, but not the JARs and class directories specified through the
     * {@code --classpath} command line option.
     */
    public static final ClassLoader BOOTCLASSPATH_CLASS_LOADER = ClassLoader.getSystemClassLoader().getParent();

    private ClassLoaders() {}

    /**
     * Creates and returns a {@link ClassLoader} that implements {@link ClassLoader#getResourceAsStream(String)} via a
     * {@link ResourceFinder}.
     * <p>
     *   {@link ClassLoader#getResource(String)} returns a non-{@code null} value iff then resoure finder finds a
     *   {@link LocatableResource}.
     * </p>
     * <p>
     *   Notice that {@link ClassLoader#getResources(String)} is <em>not</em> overridden.
     * </p>
     */
    public static ClassLoader
    getsResourceAsStream(final ResourceFinder finder, @Nullable ClassLoader parent) {

        return new ClassLoader(parent) {

            @Override @NotNullByDefault(false) public URL
            getResource(String resourceName) {

                {
                    URL result = super.getResource(resourceName);
                    if (result != null) return result;
                }

                Resource r = finder.findResource(resourceName);
                if (r == null) return null;

                if (r instanceof LocatableResource) {
                    try {
                        return ((LocatableResource) r).getLocation();
                    } catch (IOException ioe) {
                        return null;
                    }
                }

                return null;
            }

            @Override @NotNullByDefault(false) public InputStream
            getResourceAsStream(String resourceName) {

                {
                    InputStream result = super.getResourceAsStream(resourceName);
                    if (result != null) return result;
                }

                try {
                    return finder.findResourceAsStream(resourceName);
                } catch (IOException ioe) {
                    return null;
                }
            }
        };
    }

    /**
     * Returns a name-to-URL mapping of all resources "under" a given directory name.
     * <p>
     *   Iff the <var>name</var> does not end with a slash, then calling this method is equivalent with calling
     *   {@link ClassLoader#getResource(String)}.
     * </p>
     * <p>
     *   Otherwise, if the <var>name</var> <em>does</em> end with a slash, then this method returns a name-to-URL
     *   mapping of all content resources who's names <em>begin</em> with the given <var>name</var>.
     *   Iff <var>recurse</var> is {@code false}, then only <em>immediate</em> subresources are included.
     *   Iff <var>includeDirectories</var> is {@code true}, then also directory resources are included in the result
     *   set; their names all ending with a slash.
     * </p>
     * <p>
     *   If multiple resources have the <var>name</var>, then the resources are retrieved from the <var>first</var>
     *   occurrence.
     * </p>
     *
     * @param classLoader The class loader to use; {@code null} means use the system class loader
     * @param name        No leading slash
     * @return            Keys ending with a slash map to "directory resources", the other keys map to "content
     *                    resources"
     */
    public static Map<String, URL>
    getSubresources(
        @Nullable ClassLoader classLoader,
        final String          name,
        boolean               includeDirectories,
        final boolean         recurse
    ) throws IOException {

        if (classLoader == null) classLoader = ClassLoader.getSystemClassLoader();
        assert classLoader != null;

        HashMap<String, URL> result = new HashMap<>();

        // See if there is a "directory resource" with the given name. This will work for
        //  * file-based classpath entries (e.g. "file:./target/classes/"),
        //  * jar-based classpath entries (e.g. "jar:./path/to/my.jar"), iff the .jar file has directory entries
        for (URL r : Collections.list(classLoader.getResources(name))) {
            result.putAll(ClassLoaders.getSubresourcesOf(r, name, includeDirectories, recurse));
        }

        // Optimization: Iff resources were found on the CLASSPATH, then don't check the BOOTCLASSPATH.
        if (!result.isEmpty()) return result;

        // The .jar files on the BOOTCLASSPATH lack directory entries.
        result.putAll(ClassLoaders.getBootclasspathSubresourcesOf(name, includeDirectories, recurse));

        return result;
    }

    /**
     * Finds subresources on the JVM's <em>bootstrap</em> classpath. This is kind of tricky because the .jar files on
     * the BOOTCLASSPATH don't contain "directory entries".
     */
    private static Map<? extends String, ? extends URL>
    getBootclasspathSubresourcesOf(final String name, boolean includeDirectories, final boolean recurse)
    throws IOException {
        return ClassLoaders.BOOTCLASSPATH_SUBRESOURCES_OF.get(name, includeDirectories, recurse);
    }

    /**
     * @see SubresourceGetter#get(String, boolean, boolean)
     */
    interface SubresourceGetter {

        /**
         * @return All resources that exist "under" a given resource name
         */
        Map<? extends String, ? extends URL>
        get(String name, boolean includeDirectories, boolean recurse) throws IOException;
    }

    private static final SubresourceGetter BOOTCLASSPATH_SUBRESOURCES_OF;
    static {

        // To get to the BOOCLASSPATH resources, we have to get a well-known resource. After that, we can scan the
        // BOOTCLASSPATH.
        URL r = ClassLoader.getSystemClassLoader().getResource("java/lang/Object.class");
        assert r != null;

        String protocol = r.getProtocol();
        if ("jar".equalsIgnoreCase(protocol)) {

            // Pre-Java-9 bootstrap classpath.
            final URL     jarFileURL;
            final JarFile jarFile;
            try {
                JarURLConnection juc = (JarURLConnection) r.openConnection();
                juc.setUseCaches(false);
                jarFileURL = juc.getJarFileURL();
                jarFile    = juc.getJarFile();
            } catch (IOException ioe) {
                throw new AssertionError(ioe);
            }

            BOOTCLASSPATH_SUBRESOURCES_OF = new SubresourceGetter() {

                @Override public Map<? extends String, ? extends URL>
                get(String name, boolean includeDirectories, boolean recurse) {
                    return ClassLoaders.getSubresources(jarFileURL, jarFile, name, includeDirectories, recurse);
                }
            };
        } else
        if ("jrt".equalsIgnoreCase(protocol)) {

            // Java-9+ bootstrap classpath.
            final Set<ModuleReference> mrs = ModuleFinder.ofSystem().findAll();

            BOOTCLASSPATH_SUBRESOURCES_OF = new SubresourceGetter() {

                @Override public Map<? extends String, ? extends URL>
                get(final String name, boolean includeDirectories, final boolean recurse) throws IOException {

                    final Map<String, URL> result = new HashMap<>();

                    for (final ModuleReference mr : mrs) {
                        final URI moduleContentLocation = (URI) mr.location().get();
                        mr.open().list().forEach(new Consumer<Object>() {

                            @Override public void
                            accept(Object resourceNameObject) {
                                String resourceName = (String) resourceNameObject;
                                try {
                                    this.accept2(resourceName);
                                } catch (MalformedURLException mue) {
                                    throw new AssertionError(mue);
                                }
                            }

                            public void
                            accept2(String resourceName) throws MalformedURLException {

                                // This ".class" (?) file exists in every module, and we don't want to list it.
                                if ("module-info.class".equals(resourceName)) return;
                                // Also this one:
                                if ("_imported.marker".equals(resourceName)) return;

                                if (
                                    resourceName.startsWith(name)
                                    && (recurse || resourceName.lastIndexOf('/') == name.length() - 1)
                                ) {
                                    final URL classFileUrl = new URL(moduleContentLocation + "/" + resourceName);

                                    URL prev = (URL) result.put(resourceName, classFileUrl);
                                    assert prev == null : (
                                        "prev="
                                        + prev
                                        + ", resourceName="
                                        + resourceName
                                        + ", classFileUrl="
                                        + classFileUrl
                                    );
                                }
                            }
                        });
                    }

                    return result;
                }
            };
        } else
        {
            throw new AssertionError(
                "\"java/lang/Object.class\" is not in a \"jar:\" location nor in a \"jrt:\" location"
            );
        }
    }

    /**
     * Returns a name-to-URL mapping of all resources "under" a given root resource.
     * <p>
     *   If the <var>root</var> designates a "content resource" (as opposed to a "directory resource"), then the
     *   method returns {@code Collections.singletonMap(name, rootName)}.
     * </p>
     * <p>
     *   Otherwise, if the <var>root</var> designates a "directory resource", then this method returns a name-to-URL
     *   mapping of all content resources that are located "under" the root resource.
     *   Iff <var>recurse</var> is {@code false}, then only <em>immediate</em> subresources are included.
     *   Iff <var>includeDirectories</var> is {@code true}, then directory resources are also included in the result
     *   set; their names all ending with a slash.
     * </p>
     *
     * @return Keys ending with a slash map to "directory resources", the other keys map to "content resources"
     */
    public static Map<String, URL>
    getSubresourcesOf(URL root, String rootName, boolean includeDirectories, boolean recurse) throws IOException {

        String protocol = root.getProtocol();
        if ("jar".equalsIgnoreCase(protocol)) {

            JarURLConnection juc = (JarURLConnection) root.openConnection();
            juc.setUseCaches(false);

            if (!juc.getJarEntry().isDirectory()) return Collections.singletonMap(rootName, root);

            URL     jarFileUrl = juc.getJarFileURL();
            JarFile jarFile    = juc.getJarFile();

            Map<String, URL> result = ClassLoaders.getSubresources(
                jarFileUrl,
                jarFile,
                rootName,
                includeDirectories,
                recurse
            );

            if (includeDirectories) result.put(rootName, root);

            return result;
        }

        if ("file".equalsIgnoreCase(protocol)) {
            return ClassLoaders.getFileResources(root, rootName, includeDirectories, recurse);
        }

        return Collections.singletonMap(rootName, root);
    }

    private static Map<String, URL>
    getSubresources(URL jarFileUrl, JarFile jarFile, String namePrefix, boolean includeDirectories, boolean recurse) {

        Map<String, URL> result = new HashMap<>();

        for (Enumeration<JarEntry> en = jarFile.entries(); en.hasMoreElements();) {
            JarEntry je = (JarEntry) en.nextElement();

            if (
                (!je.isDirectory() || includeDirectories)
                && je.getName().startsWith(namePrefix)
                && (recurse || je.getName().indexOf('/', namePrefix.length()) == -1)
            ) {

                URL url;
                try {
                    url = new URL("jar", null, jarFileUrl.toString() + "!/" + je.getName());
                } catch (MalformedURLException mue) {
                    throw new AssertionError(mue);
                }

                result.put(je.getName(), url);
            }
        }
        return result;
    }

    private static Map<String, URL>
    getFileResources(URL fileUrl, String namePrefix, boolean includeDirectories, boolean recurse) {

        File file = new File(fileUrl.getFile());

        if (file.isFile()) return Collections.singletonMap(namePrefix, fileUrl);

        if (file.isDirectory()) {
            if (!namePrefix.isEmpty() && !namePrefix.endsWith("/")) namePrefix += '/';

            Map<String, URL> result = new HashMap<>();

            if (includeDirectories) result.put(namePrefix, fileUrl);

            for (File member : file.listFiles()) {
                String memberName = namePrefix + member.getName();
                URL    memberUrl  = ClassLoaders.fileUrl(member);

                if (recurse) {
                    result.putAll(ClassLoaders.getFileResources(memberUrl, memberName, includeDirectories, recurse));
                } else {
                    if (member.isFile()) result.put(memberName, memberUrl);
                }
            }

            return result;
        }

        return Collections.emptyMap();
    }

    private static URL
    fileUrl(File file) {
        try {
            return file.toURI().toURL();
        } catch (MalformedURLException mue) {
            throw new AssertionError(mue);
        }
    }
}