AbstractClassBasedListenerObjectCreator.java

/*
 * Copyright (C) 2012-2024 RRiBbit.org
 *
 * 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
 *
 * https://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.
 */
package org.rribbit.creation;

import org.rribbit.Listener;
import org.rribbit.ListenerObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.File;
import java.io.IOException;
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Enumeration;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;

/**
 * This {@link ListenerObjectCreator} creates {@link ListenerObject}s from classes. Users can pass in {@link Class}es or packagenames and this class will scan the {@link Class}es
 * and create {@link ListenerObject}s for the public methods that are annotated with {@link Listener}. Note that public methods inherited from superclasses and superinterfaces will also be
 * scanned. This means that users must take care not to scan a method twice, once as a method of a class and once as a method of a superclass, by passing a class/interface and its
 * superclass/superinterface separately to this {@link ListenerObjectCreator}.
 * <p />
 * Please note that in Java, method annotations are NOT inherited. This means that, if you override/implement a method in a subclass or subinterface, and the overriding/implementing method
 * does not have the annotation, then that method will not inherit it. If a class or interface just inherits a method, without overriding it, then the annotation WILL exist.
 * <p />
 * This class does NOT set the execution target for the created {@link ListenerObject}s. That is the responsibility of the implementations of this abstract class.
 *
 * @author G.J. Schouten
 *
 */
public abstract class AbstractClassBasedListenerObjectCreator extends ObjectBasedListenerObjectCreator {

	private static final Logger log = LoggerFactory.getLogger(AbstractClassBasedListenerObjectCreator.class);

	protected Collection<Class<?>> excludedClasses;

	/**
	 * Calls {@link #addClass(Class)} on each given class.
	 *
	 * @param classes
	 */
	public AbstractClassBasedListenerObjectCreator(Class<?>... classes) {

		excludedClasses = new CopyOnWriteArrayList<>();
		for(Class<?> clazz : classes) {
			this.addClass(clazz);
		}
	}

	/**
	 * Calls {@link #addPackage(String, boolean)} on each given package name.
	 *
	 * @param excludedClasses	the classes to be excluded from scanning, can be null if not needed
	 * @param scanSubPackages	whether to scan the subpackages in the given packages
	 * @param packageNames		the packages to be scanned
	 */
	public AbstractClassBasedListenerObjectCreator(Collection<Class<?>> excludedClasses, boolean scanSubPackages, String... packageNames) {

		this.excludedClasses = new CopyOnWriteArrayList<>();
		if(excludedClasses != null) {
			this.excludedClasses.addAll(excludedClasses);
		}

		for(String packageName : packageNames) {
			this.addPackage(packageName, scanSubPackages);
		}
	}

	/**
	 * Adds 'excludedClass' to the list of classes that are excluded from scanning when package are scanned. When classes are manually scanned with {@link #addClass(Class)},
	 * excluding it beforehand has NO effect. Excluded classes are only for package scanning and have NO effect on manual class scanning.
	 *
	 * @param excludedClass
	 */
	public void excludeClass(Class<?> excludedClass) {
		excludedClasses.add(excludedClass);
	}

	/**
	 * Scans all public methods in the given {@link Class} and creates {@link ListenerObject}s for them if they have a {@link Listener} annotation, provided that
	 * {@link #getTargetObjectForClass(Class)} can provide a suitable execution target. Otherwise, the {@link Class} is ignored. The created {@link ListenerObject}s are added
	 * to the {@link Collection} of {@link ListenerObject}s that this instance keeps.
	 *
	 * @param clazz
	 */
	public void addClass(Class<?> clazz) {

		log.debug("Processing class '{}'", clazz.getName());
		Object targetObject = this.getTargetObjectForClass(clazz);
		if(targetObject != null) {
			log.debug("Found target object for class '{}', getting Listener methods", clazz.getName());
			Collection<ListenerObject> incompleteListenerObjects = this.getIncompleteListenerObjectsFromClass(clazz);
			for(ListenerObject listenerObject : incompleteListenerObjects) {
				listenerObject.setTarget(targetObject);
			}
			listenerObjects.addAll(incompleteListenerObjects);
			this.notifyObserversOnClassAdded(clazz);
		}
	}

	/**
	 * Scans all classes in the given package and calls {@link #addClass(Class)} on each of them if they're not contained in the collection of excluded classes.
	 *
	 * @param packageName		the package to be scanned
	 * @param scanSubPackages	whether to scan the subpackages in the given package
	 */
	public void addPackage(String packageName, boolean scanSubPackages) {

		log.debug("Scanning package '{}' for classes", packageName);
		Collection<Class<?>> classes;
		try {
			classes = this.getClasses(packageName, scanSubPackages);
		} catch(Exception e) {
			throw new RuntimeException("Error during reading of package '" + packageName + "'", e);
		}
		for(Class<?> clazz : classes) {
			if(!excludedClasses.contains(clazz)) {
				this.addClass(clazz);
			}
		}
	}

	/**
	 * This method gets the {@link ClassLoader} that is used to get the classes in a package. Please override it if you want to use a different {@link ClassLoader}.
	 * The default is the Context {@link ClassLoader}.
	 */
	protected ClassLoader getClassLoader() {
		return Thread.currentThread().getContextClassLoader();
	}

	/**
	 * Scans all classes accessible in the given package, using the {@link ClassLoader} associated with this {@link AbstractClassBasedListenerObjectCreator}.
	 *
	 * @param packageName		the package
	 * @param scanSubPackages	whether to scan the subpackages in the given package
	 * @return					the classes, or an empty {@link Collection} if none were found
	 * @throws ClassNotFoundException
	 * @throws IOException
	 */
	protected Collection<Class<?>> getClasses(String packageName, boolean scanSubPackages) throws ClassNotFoundException, IOException {

		String path = packageName.replace('.', '/');
		Enumeration<URL> resources = this.getClassLoader().getResources(path);

		Collection<Class<?>> classes = new ArrayList<>();
		while(resources.hasMoreElements()) {
			URL resource = resources.nextElement();
			String filename = URLDecoder.decode(resource.getFile(), StandardCharsets.UTF_8);
			log.debug("Processing resource '{}' with filename '{}'", resource, filename);

			if(resource.getProtocol().equals("jar") || resource.getProtocol().equals("zip")) {
				String zipFilename = filename.substring(5, filename.indexOf("!"));
				try (ZipFile zipFile = new ZipFile(zipFilename)) {
					classes.addAll(this.findClassesInZipFile(zipFile, path, scanSubPackages));
				}
			} else {
				File directory = new File(filename);
				classes.addAll(this.findClassesInDirectory(directory, packageName, scanSubPackages));
			}
		}
		return classes;
	}

	/**
	 * Method used to find all classes in a given zipFile.
	 *
	 * @param zipFile			the zipFile
	 * @param path				the package name in path format for classes found inside the zipFile
	 * @param scanSubPackages	whether to scan the subpackages in the given path
	 * @return					the classes, or an empty {@link Collection} if none were found
	 * @throws ClassNotFoundException
	 */
	protected Collection<Class<?>> findClassesInZipFile(ZipFile zipFile, String path, boolean scanSubPackages) throws ClassNotFoundException {

		log.debug("Scanning zipFile '{}' for classes in package '{}'", zipFile.getName(), path);
		Collection<Class<?>> classes = new ArrayList<>();
		Enumeration<ZipEntry> zipEntries = (Enumeration<ZipEntry>) zipFile.entries();
		while(zipEntries.hasMoreElements()) {
			String entryName = zipEntries.nextElement().getName();
			log.debug("Entry name: '{}'", entryName);
			Pattern p;
			if(scanSubPackages) {
				p = Pattern.compile("(" + path + "/[\\w/]+)\\.class");
			} else {
				p = Pattern.compile("(" + path + "/\\w+)\\.class");
			}
			Matcher m = p.matcher(entryName);
			if(m.matches()) {
				String className = m.group(1).replaceAll("/", ".");
				log.debug("Adding Class {}", className);
				classes.add(Class.forName(className));
			}
		}
		return classes;
	}

	/**
	 * Method used to find all classes in a given directory.
	 *
	 * @param directory			the directory
	 * @param packageName		the package name for classes found inside the directory
	 * @param scanSubPackages	whether to scan the subpackages in the given package
	 * @return					the classes, or an empty {@link Collection} if none were found
	 * @throws ClassNotFoundException
	 */
	protected Collection<Class<?>> findClassesInDirectory(File directory, String packageName, boolean scanSubPackages) throws ClassNotFoundException {

		log.debug("Scanning directory '{}' for classes in package '{}'", directory, packageName);
		Collection<Class<?>> classes = new ArrayList<>();
		if(!directory.exists()) {
			return classes;
		}
		File[] files = directory.listFiles();
		if (files != null) {
			for(File file : files) {
				if(file.isFile() && file.getName().endsWith(".class")) {
					classes.add(Class.forName(packageName + '.' + file.getName().substring(0, file.getName().length() - 6)));
				} else if(file.isDirectory() && scanSubPackages) {
					classes.addAll(this.findClassesInDirectory(file, packageName + "." + file.getName(), true));
				}
			}
		}
		return classes;
	}

	/**
	 * Gets a target execution {@link Object} for the given class to be used by a {@link ListenerObject} to execute its {@link Method}.
	 *
	 * @param clazz
	 * @return an {@link Object} that has the type 'clazz' and can be used as an execution target, or 'null' if no such {@link Object} can be found
	 */
	protected abstract Object getTargetObjectForClass(Class<?> clazz);
}