diff --git a/loofah/build.gradle.kts b/loofah/build.gradle.kts index 95bbce88e3b..52c441649ab 100644 --- a/loofah/build.gradle.kts +++ b/loofah/build.gradle.kts @@ -87,9 +87,10 @@ val fabricAppLaunch by sourceSets.register("applaunch") { // implementation (compile) dependencies spongeImpl.applyNamedDependencyOnOutput(commonProject, launch, this, project, this.implementationConfigurationName) spongeImpl.applyNamedDependencyOnOutput(commonProject, applaunch, this, project, this.implementationConfigurationName) - spongeImpl.applyNamedDependencyOnOutput(commonProject, fabricLaunch, this, project, this.implementationConfigurationName) spongeImpl.applyNamedDependencyOnOutput(project, this, fabricMain, project, fabricMain.implementationConfigurationName) + spongeImpl.applyNamedDependencyOnOutput(project, this, fabricLaunch, project, fabricLaunch.implementationConfigurationName) + configurations.named(implementationConfigurationName) { extendsFrom(gameManagedLibraries) @@ -179,6 +180,7 @@ dependencies { exclude("com.google.code.gson") } fabricBootstrapLibrariesConfig(apiLibs.configurate.yaml) + fabricBootstrapLibrariesConfig(apiLibs.guice) fabricLibrariesConfig("org.spongepowered:spongeapi:$apiVersion") { isTransitive = false } fabricLibrariesConfig(platform(apiLibs.adventure.bom)) fabricLibrariesConfig(apiLibs.adventure.api) diff --git a/loofah/src/applaunch/java/dk/nelind/loofah/applaunch/AppLaunchMain.java b/loofah/src/applaunch/java/dk/nelind/loofah/applaunch/AppLaunchMain.java index a946ac764ef..7bd5c7394dc 100644 --- a/loofah/src/applaunch/java/dk/nelind/loofah/applaunch/AppLaunchMain.java +++ b/loofah/src/applaunch/java/dk/nelind/loofah/applaunch/AppLaunchMain.java @@ -25,15 +25,15 @@ package dk.nelind.loofah.applaunch; import dk.nelind.loofah.applaunch.plugin.FabricPluginPlatform; -import dk.nelind.loofah.launch.FabricLaunch; import net.fabricmc.loader.api.entrypoint.PreLaunchEntrypoint; import org.spongepowered.common.applaunch.AppLaunch; -import org.spongepowered.common.launch.Launch; + +import java.lang.reflect.InvocationTargetException; public class AppLaunchMain implements PreLaunchEntrypoint { @Override public void onPreLaunch() { - FabricPluginPlatform pluginPlatform = AppLaunch.pluginPlatform(); + final FabricPluginPlatform pluginPlatform = AppLaunch.pluginPlatform(); pluginPlatform.discoverLocatorServices(); pluginPlatform.discoverLanguageServices(); @@ -41,6 +41,14 @@ public void onPreLaunch() { pluginPlatform.locatePluginResources(); pluginPlatform.createPluginCandidates(); - Launch.setInstance(new FabricLaunch(pluginPlatform)); + + // reflection call to avoid circular gradle task dependency hell + try { + Class.forName("dk.nelind.loofah.launch.FabricLaunch") + .getMethod("launch", FabricPluginPlatform.class) + .invoke(null, pluginPlatform); + } catch (IllegalAccessException | ClassNotFoundException | NoSuchMethodException | InvocationTargetException e) { + throw new RuntimeException(e); + } } } diff --git a/loofah/src/applaunch/java/dk/nelind/loofah/applaunch/plugin/FabricPluginPlatform.java b/loofah/src/applaunch/java/dk/nelind/loofah/applaunch/plugin/FabricPluginPlatform.java index 6f72576dac5..fc42c0723fb 100644 --- a/loofah/src/applaunch/java/dk/nelind/loofah/applaunch/plugin/FabricPluginPlatform.java +++ b/loofah/src/applaunch/java/dk/nelind/loofah/applaunch/plugin/FabricPluginPlatform.java @@ -32,10 +32,7 @@ import org.spongepowered.common.applaunch.config.core.SpongeConfigs; import org.spongepowered.common.applaunch.plugin.PluginPlatform; import org.spongepowered.common.applaunch.plugin.PluginPlatformConstants; -import org.spongepowered.plugin.PluginCandidate; -import org.spongepowered.plugin.PluginLanguageService; -import org.spongepowered.plugin.PluginResource; -import org.spongepowered.plugin.PluginResourceLocatorService; +import org.spongepowered.plugin.*; import org.spongepowered.plugin.blackboard.Keys; import org.spongepowered.plugin.builtin.StandardEnvironment; import org.spongepowered.plugin.builtin.jvm.JVMKeys; @@ -87,6 +84,10 @@ public static synchronized void bootstrap() { FabricPluginPlatform.bootstrapped = true; } + public Environment getStandardEnvironment() { + return this.standardEnvironment; + } + @Override public String version() { return this.standardEnvironment.blackboard().get(Keys.VERSION); diff --git a/loofah/src/applaunch/java/dk/nelind/loofah/applaunch/plugin/JavaPluginLanguageService.java b/loofah/src/applaunch/java/dk/nelind/loofah/applaunch/plugin/JavaPluginLanguageService.java index 84a108ab751..7799c181fed 100644 --- a/loofah/src/applaunch/java/dk/nelind/loofah/applaunch/plugin/JavaPluginLanguageService.java +++ b/loofah/src/applaunch/java/dk/nelind/loofah/applaunch/plugin/JavaPluginLanguageService.java @@ -24,7 +24,6 @@ */ package dk.nelind.loofah.applaunch.plugin; -import dk.nelind.loofah.launch.plugin.JavaPluginLoader; import org.spongepowered.plugin.Environment; import org.spongepowered.plugin.builtin.jvm.JVMPluginLanguageService; import org.spongepowered.plugin.metadata.Container; @@ -49,7 +48,8 @@ public String name() { @Override public String pluginLoader() { - return JavaPluginLoader.class.getName(); + // hard coded string to avoid circular gradle task dependency hell + return "dk.nelind.loofah.launch.plugin.JavaPluginLoader"; } @Override diff --git a/loofah/src/launch/java/dk/nelind/loofah/launch/FabricLaunch.java b/loofah/src/launch/java/dk/nelind/loofah/launch/FabricLaunch.java index ed31ac64253..acfae24b420 100644 --- a/loofah/src/launch/java/dk/nelind/loofah/launch/FabricLaunch.java +++ b/loofah/src/launch/java/dk/nelind/loofah/launch/FabricLaunch.java @@ -24,23 +24,67 @@ */ package dk.nelind.loofah.launch; +import com.google.common.collect.Lists; +import com.google.inject.Guice; import com.google.inject.Injector; +import com.google.inject.Module; import com.google.inject.Stage; +import dk.nelind.loofah.applaunch.plugin.FabricPluginPlatform; +import dk.nelind.loofah.launch.inject.FabricModule; import dk.nelind.loofah.launch.mapping.FabricMappingManager; +import dk.nelind.loofah.launch.plugin.FabricPluginManager; import net.fabricmc.api.EnvType; import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.SharedConstants; +import org.spongepowered.common.SpongeCommon; +import org.spongepowered.common.SpongeLifecycle; import org.spongepowered.common.applaunch.plugin.PluginPlatform; +import org.spongepowered.common.inject.SpongeCommonModule; +import org.spongepowered.common.inject.SpongeGuice; +import org.spongepowered.common.inject.SpongeModule; import org.spongepowered.common.launch.Launch; import org.spongepowered.common.launch.mapping.SpongeMappingManager; import org.spongepowered.common.launch.plugin.SpongePluginManager; import org.spongepowered.plugin.PluginContainer; +import java.util.List; + public class FabricLaunch extends Launch { - private final SpongeMappingManager mappingManager; + private final FabricMappingManager mappingManager; + private final FabricPluginManager pluginManager; public FabricLaunch(PluginPlatform pluginPlatform) { super(pluginPlatform); this.mappingManager = new FabricMappingManager(); + this.pluginManager = new FabricPluginManager(); + } + + public static void launch(FabricPluginPlatform pluginPlatform) { + FabricLaunch launch = new FabricLaunch(pluginPlatform); + Launch.setInstance(launch); + launch.bootstrap(); + } + + private void bootstrap() { + this.createPlatformPlugins(); + + // SpongeCommon bootstrap based on org.spongepowered.forge.mixin.core.server.BootstrapMixin_Forge + // and org.spongepowered.vanilla.launch.VanillaBootstrap + SharedConstants.tryDetectVersion(); + final Stage stage = SpongeGuice.getInjectorStage(this.injectionStage()); + SpongeCommon.logger().debug("Creating injector in stage '{}'", stage); + final Injector bootstrapInjector = this.createInjector(); + final SpongeLifecycle lifecycle = bootstrapInjector.getInstance(SpongeLifecycle.class); + this.setLifecycle(lifecycle); + lifecycle.establishFactories(); + lifecycle.establishBuilders(); + + this.logger().info("Loading Plugins"); + this.pluginManager.loadPlugins((FabricPluginPlatform) this.pluginPlatform); + } + + private void createPlatformPlugins() { + // TODO(loofah): create the platform plugins } @Override @@ -70,6 +114,11 @@ public PluginContainer platformPlugin() { @Override public Injector createInjector() { - return null; + final List modules = Lists.newArrayList( + new SpongeModule(), + new SpongeCommonModule(), + new FabricModule() + ); + return Guice.createInjector(this.injectionStage(), modules); } } diff --git a/loofah/src/launch/java/dk/nelind/loofah/launch/inject/FabricModule.java b/loofah/src/launch/java/dk/nelind/loofah/launch/inject/FabricModule.java new file mode 100644 index 00000000000..cf399764908 --- /dev/null +++ b/loofah/src/launch/java/dk/nelind/loofah/launch/inject/FabricModule.java @@ -0,0 +1,37 @@ +/* + * This file is part of Loofah, licensed under the MIT License (MIT). + * + * Copyright (c) Nelind + * Copyright (c) contributors + * + * 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 dk.nelind.loofah.launch.inject; + +import com.google.inject.AbstractModule; + +// TODO(loofah): finish platform injector +public class FabricModule extends AbstractModule { + @Override + protected void configure() { + //this.bind(Platform.class).to(); + //this.bind(EventManager.class).toProvider(); + //this.bind(SpongeCommandManager.class).to(); + } +} diff --git a/loofah/src/launch/java/dk/nelind/loofah/launch/plugin/FabricDummyPluginContainer.java b/loofah/src/launch/java/dk/nelind/loofah/launch/plugin/FabricDummyPluginContainer.java new file mode 100644 index 00000000000..dd092731b4f --- /dev/null +++ b/loofah/src/launch/java/dk/nelind/loofah/launch/plugin/FabricDummyPluginContainer.java @@ -0,0 +1,104 @@ +/* + * This file is part of Loofah, licensed under the MIT License (MIT). + * + * Copyright (c) Nelind + * Copyright (c) contributors + * + * 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 dk.nelind.loofah.launch.plugin; + +import org.apache.logging.log4j.Logger; +import org.spongepowered.common.applaunch.plugin.DummyPluginContainer; +import org.spongepowered.plugin.PluginContainer; +import org.spongepowered.plugin.metadata.PluginMetadata; + +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.Objects; +import java.util.Optional; +import java.util.StringJoiner; + +public final class FabricDummyPluginContainer implements PluginContainer, DummyPluginContainer { + + private final PluginMetadata metadata; + private final Logger logger; + private final Object instance; + + public FabricDummyPluginContainer(final PluginMetadata metadata, final Logger logger, final Object instance) { + this.metadata = metadata; + this.logger = logger; + this.instance = instance; + } + + @Override + public PluginMetadata metadata() { + return this.metadata; + } + + @Override + public Logger logger() { + return this.logger; + } + + @Override + public Object instance() { + return this.instance; + } + + @Override + public Optional locateResource(final URI relative) { + final ClassLoader classLoader = this.getClass().getClassLoader(); + final URL resolved = classLoader.getResource(relative.getPath()); + try { + if (resolved == null) { + return Optional.empty(); + } + return Optional.of(resolved.toURI()); + } catch (final URISyntaxException ignored) { + return Optional.empty(); + } + } + + @Override + public int hashCode() { + return Objects.hash(this.metadata().id()); + } + + @Override + public boolean equals(final Object that) { + if (that == this) { + return true; + } + + if (!(that instanceof PluginContainer)) { + return false; + } + + return this.metadata().id().equals(((PluginContainer) that).metadata().id()); + } + + @Override + public String toString() { + return new StringJoiner(", ", FabricDummyPluginContainer.class.getSimpleName() + "[", "]") + .add("metadata= " + this.metadata) + .toString(); + } +} diff --git a/loofah/src/launch/java/dk/nelind/loofah/launch/plugin/FabricPluginManager.java b/loofah/src/launch/java/dk/nelind/loofah/launch/plugin/FabricPluginManager.java new file mode 100644 index 00000000000..c64d6886ced --- /dev/null +++ b/loofah/src/launch/java/dk/nelind/loofah/launch/plugin/FabricPluginManager.java @@ -0,0 +1,181 @@ +/* + * This file is part of Loofah, licensed under the MIT License (MIT). + * + * Copyright (c) Nelind + * Copyright (c) contributors + * + * 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 dk.nelind.loofah.launch.plugin; + +import dk.nelind.loofah.applaunch.plugin.FabricPluginPlatform; +import dk.nelind.loofah.launch.plugin.resolver.DependencyResolver; +import dk.nelind.loofah.launch.plugin.resolver.ResolutionResult; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import org.apache.logging.log4j.Level; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.spongepowered.common.launch.Launch; +import org.spongepowered.common.launch.plugin.SpongePluginManager; +import org.spongepowered.common.util.PrettyPrinter; +import org.spongepowered.plugin.*; +import org.spongepowered.plugin.metadata.model.PluginDependency; + +import java.lang.reflect.InvocationTargetException; +import java.util.*; +import java.util.stream.Collectors; + +/** Adapted from {@link org.spongepowered.vanilla.launch.plugin.VanillaPluginManager} */ +public class FabricPluginManager implements SpongePluginManager { + private final Map plugins; + private final Map instancesToPlugins; + private final List sortedPlugins; + private final Map> locatedResources; + private final Map containerToResource; + + public FabricPluginManager() { + this.plugins = new Object2ObjectOpenHashMap<>(); + this.instancesToPlugins = new IdentityHashMap<>(); + this.sortedPlugins = new ArrayList<>(); + this.locatedResources = new Object2ObjectOpenHashMap<>(); + this.containerToResource = new Object2ObjectOpenHashMap<>(); + } + + @Override + public Optional fromInstance(final Object instance) { + return Optional.ofNullable(this.instancesToPlugins.get(Objects.requireNonNull(instance, "instance"))); + } + + @Override + public Optional plugin(final String id) { + return Optional.ofNullable(this.plugins.get(Objects.requireNonNull(id, "id"))); + } + + @Override + public Collection plugins() { + return Collections.unmodifiableCollection(this.sortedPlugins); + } + + @SuppressWarnings("unchecked") + public void loadPlugins(final FabricPluginPlatform platform) { + this.locatedResources.putAll(platform.getResources()); + + final Map, PluginLanguageService> pluginLanguageLookup = new HashMap<>(); + final Map, PluginLoader> pluginLoaders = new HashMap<>(); + + // Initialise the plugin language loaders. + for (final Map.Entry, List>> candidate : platform.getCandidates().entrySet()) { + final PluginLanguageService languageService = candidate.getKey(); + final String loaderClass = languageService.pluginLoader(); + try { + pluginLoaders.put(languageService, + (PluginLoader) Class.forName(loaderClass).getConstructor().newInstance()); + } catch (final InstantiationException | IllegalAccessException | ClassNotFoundException | NoSuchMethodException | + InvocationTargetException e) { + throw new RuntimeException(e); + } + candidate.getValue().forEach(x -> pluginLanguageLookup.put(x, languageService)); + } + + // Priority to platform plugins that will already exist here -- meaning the resolver will act upon them first + // and if someone decides to give a plugin an ID that is the same as a platform plugin, the resolver will effectively + // reject it. + final Set> resources = new LinkedHashSet<>(); + pluginLanguageLookup.keySet().stream().filter(x -> this.plugins.containsKey(x.metadata().id())).forEach(resources::add); + resources.addAll(pluginLanguageLookup.keySet()); + + final ResolutionResult resolutionResult = DependencyResolver.resolveAndSortCandidates(resources, platform.logger()); + final Map, String> failedInstances = new HashMap<>(); + final Map, String> consequentialFailedInstances = new HashMap<>(); + final ClassLoader launchClassloader = Launch.instance().getClass().getClassLoader(); + for (final PluginCandidate candidate : resolutionResult.sortedSuccesses()) { + final PluginContainer plugin = this.plugins.get(candidate.metadata().id()); + if (plugin != null) { + if (plugin instanceof FabricDummyPluginContainer) { + continue; + } + // If we get here, we screwed up - duplicate IDs should have been detected earlier. + // Place it in the resolution result... it'll then get picked up in the big error message + resolutionResult.duplicateIds().add(candidate.metadata().id()); + + // but this is our screw up, let's also make a big point of it + final PrettyPrinter prettyPrinter = new PrettyPrinter(120) + .add("ATTEMPTED TO CREATE PLUGIN WITH DUPLICATE PLUGIN ID").centre() + .hr() + .addWrapped("Loofah attempted to create a second plugin with ID '%s'. This is not allowed - all plugins must have a unique " + + "ID. Usually, Loofah will catch this earlier -- but in this case Loofah has validated two plugins with " + + "the same ID. Please report this error to Loofah devs.", + candidate.metadata().id()) + .add() + .add("Technical Details:") + .add("Plugins to load:", 4); + resolutionResult.sortedSuccesses().forEach(x -> prettyPrinter.add("*" + x.metadata().id(), 4)); + prettyPrinter.add().add("Detected Duplicate IDs:", 4); + resolutionResult.duplicateIds().forEach(x -> prettyPrinter.add("*" + x, 4)); + prettyPrinter.log(platform.logger(), Level.ERROR); + continue; + } + + // If a dependency failed to load, then we should bail on required dependencies too. + // This should work fine, we're sorted so all deps should be in place at this stage. + if (this.stillValid(candidate, consequentialFailedInstances)) { + final PluginLanguageService languageService = pluginLanguageLookup.get(candidate); + final PluginLoader pluginLoader = pluginLoaders.get(languageService); + try { + final PluginContainer container = pluginLoader.loadPlugin(platform.getStandardEnvironment(), candidate, launchClassloader); + this.addPlugin(container); + this.containerToResource.put(container, candidate.resource()); + } catch (final InvalidPluginException e) { + failedInstances.put(candidate, "Failed to construct: see stacktrace(s) above this message for details."); + platform.logger().error("Failed to construct plugin {}", candidate.metadata().id(), e); + } + } + } + + resolutionResult.printErrorsIfAny(failedInstances, consequentialFailedInstances, platform.logger()); + platform.logger().info("Loaded plugin(s): {}", this.sortedPlugins.stream().map(p -> p.metadata().id()).collect(Collectors.toList())); + } + + public void addPlugin(final PluginContainer plugin) { + this.plugins.put(plugin.metadata().id(), Objects.requireNonNull(plugin, "plugin")); + this.sortedPlugins.add(plugin); + + if (!(plugin instanceof FabricDummyPluginContainer)) { + this.instancesToPlugins.put(plugin.instance(), plugin); + } + } + + public Map> locatedResources() { + return Collections.unmodifiableMap(this.locatedResources); + } + + @Nullable + public PluginResource resource(final PluginContainer container) { + return this.containerToResource.get(container); + } + + private boolean stillValid(final PluginCandidate candidate, final Map, String> consequential) { + final Optional failedId = + candidate.metadata().dependencies().stream().filter(x -> !x.optional() && !this.plugins.containsKey(x.id())).findFirst(); + if (failedId.isPresent()) { + consequential.put(candidate, failedId.get().id()); + return false; + } + return true; + } +} diff --git a/loofah/src/launch/java/dk/nelind/loofah/launch/plugin/resolver/DependencyResolver.java b/loofah/src/launch/java/dk/nelind/loofah/launch/plugin/resolver/DependencyResolver.java new file mode 100644 index 00000000000..571a01bc655 --- /dev/null +++ b/loofah/src/launch/java/dk/nelind/loofah/launch/plugin/resolver/DependencyResolver.java @@ -0,0 +1,293 @@ +/* + * This file is part of Loofah, licensed under the MIT License (MIT). + * + * Copyright (c) Nelind + * Copyright (c) contributors + * + * 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 dk.nelind.loofah.launch.plugin.resolver; + +import org.apache.logging.log4j.Logger; +import org.apache.maven.artifact.versioning.ArtifactVersion; +import org.apache.maven.artifact.versioning.VersionRange; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.spongepowered.api.util.Tuple; +import org.spongepowered.plugin.PluginCandidate; +import org.spongepowered.plugin.PluginResource; +import org.spongepowered.plugin.metadata.model.PluginDependency; + +import java.util.*; +import java.util.stream.Collectors; + +/** Copied from {@link org.spongepowered.vanilla.launch.plugin.resolver.DependencyResolver}*/ +public final class DependencyResolver { + + public static ResolutionResult resolveAndSortCandidates(final Collection> candidates, + final Logger logger) { + final Map> nodes = new HashMap<>(); + final ResolutionResult resolutionResult = new ResolutionResult<>(); + for (final PluginCandidate candidate : candidates) { + final String id = candidate.metadata().id(); + // If we already have an entry, this is now a duplicate ID situation. + if (nodes.containsKey(id)) { + resolutionResult.duplicateIds().add(id); + } else { + nodes.put(id, new Node<>(candidate)); + } + } + + for (final Map.Entry> entry : nodes.entrySet()) { + // Attach deps, invalid deps will appear at this point. + final Node node = entry.getValue(); + for (final PluginDependency pd : node.candidate.metadata().dependencies()) { + final boolean isOptional = pd.optional(); + final Node dep = nodes.get(pd.id()); + + if (dep == null) { + if (isOptional) { + continue; // just move on to the next dep + } + node.invalid = true; + final String failure; + if (pd.version() != null) { + failure = String.format("%s version %s", pd.id(), pd.version()); + } else { + failure = pd.id(); + } + resolutionResult.missingDependencies().computeIfAbsent(entry.getValue().candidate, k -> new ArrayList<>()).add(failure); + node.checked = true; // no need to process this further + continue; + } + + if (!DependencyResolver.checkVersion(pd.version(), dep.candidate.metadata().version())) { + if (isOptional) { + continue; // just move on to the next dep + } + resolutionResult.versionMismatch().computeIfAbsent(entry.getValue().candidate, k -> new ArrayList<>()).add(Tuple.of(pd.version().toString(), dep.candidate)); + node.invalid = true; + node.checked = true; // no need to process this further. + } + + if (pd.loadOrder() == PluginDependency.LoadOrder.BEFORE) { + if (!pd.optional()) { + // Because of what we're about to do, we need to just make sure that + // if the "before" dep fails within here, then we still throw it out. + // Note, we can only do this for sorting, once sorted, if this loads + // but the before dep doesn't, well, it's on the plugin to solve, not + // us. + node.beforeRequiredDependency.add(node); + } + // don't bomb out the dep if this doesn't load - so set it to be optional. + // however, we otherwise treat it as an AFTER dep on the target dep + DependencyResolver.setDependency(dep, node, true); + } else { + DependencyResolver.setDependency(node, dep, pd.optional()); + } + } + } + + // Check for invalid deps + DependencyResolver.checkCyclic(nodes.values(), resolutionResult); + for (final Node node : nodes.values()) { + DependencyResolver.calculateSecondaryFailures(node, resolutionResult); + } + + // Now to sort them. + final List> original = nodes.values().stream().filter(x -> !x.invalid).collect(Collectors.toCollection(ArrayList::new)); + final List> toLoad = new ArrayList<>(original); + final LinkedHashSet> sorted = new LinkedHashSet<>(); + toLoad.stream().filter(x -> x.dependencies.isEmpty() && x.optionalDependencies.isEmpty()).forEach(sorted::add); + toLoad.removeIf(sorted::contains); + int size = toLoad.size(); + boolean excludeOptionals = false; + while (!toLoad.isEmpty()) { + boolean containsOptionalDeps = false; + for (final Node node : toLoad) { + if (sorted.containsAll(node.dependencies) && DependencyResolver.checkOptionalDependencies(excludeOptionals, sorted, node)) { + final boolean hasOptionalDeps = !node.optionalDependencies.isEmpty(); + containsOptionalDeps |= hasOptionalDeps; + sorted.add(node); + if (excludeOptionals && hasOptionalDeps) { + logger.warn("Plugin {} will be loaded before its optional dependencies: [ {} ]", + node.candidate.metadata().id(), + node.optionalDependencies.stream().map(x -> x.candidate.metadata().id()).collect(Collectors.joining(", "))); + } + } + } + toLoad.removeIf(sorted::contains); + if (toLoad.size() == size) { + // If we have excluded optionals then we need to re-do this cycle + // without them. + if (excludeOptionals || !containsOptionalDeps) { + // We have a problem + throw new IllegalStateException(String.format("Dependency resolver could not resolve order of all plugins.\n\n" + + "Attempted to sort %d plugins: [ %s ]\n" + + "Could not sort %d plugins: [ %s ]", + original.size(), + original.stream().map(x -> x.candidate.metadata().id()).collect(Collectors.joining(", ")), + toLoad.size(), + toLoad.stream().map(x -> x.candidate.metadata().id()).collect(Collectors.joining(", ")))); + } + logger.warn("Failed to resolve plugin load order due to failed dependency resolution, attempting to resolve order ignoring optional" + + " dependencies."); + excludeOptionals = true; + } else { + size = toLoad.size(); + excludeOptionals = false; + } + } + + final Collection> sortedSuccesses = resolutionResult.sortedSuccesses(); + for (final Node x : sorted) { + sortedSuccesses.add(x.candidate); + } + return resolutionResult; + } + + private static boolean checkOptionalDependencies( + final boolean excludeOptionals, final Collection> sorted, final Node node) { + if (excludeOptionals) { + // We need to make sure we filter out any deps that have "before" requirements - so we load those with all required deps met. + return node.optionalDependencies.stream().flatMap(x -> x.beforeRequiredDependency.stream()).distinct().allMatch(sorted::contains); + } + return sorted.containsAll(node.optionalDependencies); + } + + private static void setDependency(final Node before, final Node after, final boolean optional) { + if (optional) { + before.optionalDependencies.add(after); + } else { + before.dependencies.add(after); + } + } + + private static boolean checkVersion(final @Nullable VersionRange requestedVersion, final ArtifactVersion dependencyVersion) { + if (requestedVersion == null || !requestedVersion.hasRestrictions()) { + // we don't care which version + return true; + } + // Maven Artifact version resolution has a bug(?) where VersionRange#containsVersion() + // returns false if there are no restrictions when logically it should be true because + // theoretically all versions are included. Except in our case, the recommended version + // might be populated, yet no restrictions are in the VersionRange object, because we + // want a specific version, which should be a restriction. + // + // Further, VersionRange#hasRestrictions() returns true even if VersionRange#getRestrictions() + // is empty as it accounts for if there is a recommended version. Thus, we have to do the check + // on the recommended version first... which might be null, hence the Objects.equals check. + return Objects.equals(requestedVersion.getRecommendedVersion(), dependencyVersion) || requestedVersion.containsVersion(dependencyVersion); + } + + private static void checkCyclic(final Collection> nodes, final ResolutionResult resolutionResult) { + for (final Node node : nodes) { + if (!node.checked) { + final LinkedHashSet> nodeSet = new LinkedHashSet<>(); + nodeSet.add(node); + DependencyResolver.checkCyclic(node, resolutionResult, nodeSet); + } + } + } + + private static void checkCyclic(final Node node, final ResolutionResult resolutionResult, + final LinkedHashSet> dependencyPath) { + if (node.invalid) { + return; + } + + // We're doing depth first. + for (final Node dependency : node.dependencies) { + // We've already done this. Consequential failures will be handled later. + if (dependency.checked) { + continue; + } + + if (!dependencyPath.add(dependency)) { + // This is a cyclic dep, so we need to break out. + dependency.checked = true; + node.invalid = true; + // We create the dependency path for printing later. + boolean append = false; + final List> candidatePath = new LinkedList<>(); + for (final Node depInCycle : dependencyPath) { + append |= depInCycle == dependency; + // all candidates from here are in the loop. + if (append) { + candidatePath.add(depInCycle.candidate); + depInCycle.invalid = true; + } + } + + // We'll only care about the one. + for (final PluginCandidate dep : candidatePath) { + resolutionResult.cyclicDependency().put(dep, candidatePath); + } + } else { + DependencyResolver.checkCyclic(dependency, resolutionResult, dependencyPath); + // this should be at the bottom of this list so remove it again. + dependencyPath.remove(dependency); + } + } + } + + private static boolean calculateSecondaryFailures(final Node node, final ResolutionResult resolutionResult) { + if (node.secondaryChecked) { + return node.invalid; + } + + node.secondaryChecked = true; + if (node.invalid) { + return true; + } else if (node.dependencies.isEmpty() && node.beforeRequiredDependency.isEmpty()) { + return false; + } + + for (final Node depNode : node.dependencies) { + if (DependencyResolver.calculateSecondaryFailures(depNode, resolutionResult)) { + node.invalid = true; + resolutionResult.cascadedFailure().computeIfAbsent(node.candidate, k -> new HashSet<>()).add(depNode.candidate); + } + } + + for (final Node depNode : node.beforeRequiredDependency) { + if (DependencyResolver.calculateSecondaryFailures(depNode, resolutionResult)) { + node.invalid = true; + resolutionResult.cascadedFailure().computeIfAbsent(node.candidate, k -> new HashSet<>()).add(depNode.candidate); + } + } + return node.invalid; + } + + static class Node { + + final PluginCandidate candidate; + final Set> beforeRequiredDependency = new HashSet<>(); + final Set> dependencies = new HashSet<>(); + final Set> optionalDependencies = new HashSet<>(); + boolean invalid = false; + boolean checked = false; + boolean secondaryChecked = false; + + public Node(final PluginCandidate candidate) { + this.candidate = candidate; + } + + } + +} diff --git a/loofah/src/launch/java/dk/nelind/loofah/launch/plugin/resolver/ResolutionResult.java b/loofah/src/launch/java/dk/nelind/loofah/launch/plugin/resolver/ResolutionResult.java new file mode 100644 index 00000000000..82f3f3a7a1c --- /dev/null +++ b/loofah/src/launch/java/dk/nelind/loofah/launch/plugin/resolver/ResolutionResult.java @@ -0,0 +1,181 @@ +/* + * This file is part of Loofah, licensed under the MIT License (MIT). + * + * Copyright (c) Nelind + * Copyright (c) contributors + * + * 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 dk.nelind.loofah.launch.plugin.resolver; + +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.Logger; +import org.spongepowered.api.util.Tuple; +import org.spongepowered.common.util.PrettyPrinter; +import org.spongepowered.plugin.PluginCandidate; +import org.spongepowered.plugin.PluginResource; + +import java.util.*; +import java.util.stream.Collectors; + +/** Copied from */ +public final class ResolutionResult { + + private final LinkedHashSet> sortedSuccesses; + private final Collection duplicateIds; + private final Map, Collection> missingDependencies; + private final Map, Collection>>> versionMismatch; + private final Map, Collection>> cyclicDependency; + private final Map, Collection>> cascadedFailure; + + public ResolutionResult() { + this.sortedSuccesses = new LinkedHashSet<>(); + this.duplicateIds = new HashSet<>(); + this.missingDependencies = new HashMap<>(); + this.versionMismatch = new HashMap<>(); + this.cyclicDependency = new HashMap<>(); + this.cascadedFailure = new HashMap<>(); + } + + public Collection> sortedSuccesses() { + return this.sortedSuccesses; + } + + public Collection duplicateIds() { + return this.duplicateIds; + } + + public Map, Collection> missingDependencies() { + return this.missingDependencies; + } + + public Map, Collection>>> versionMismatch() { + return this.versionMismatch; + } + + public Map, Collection>> cyclicDependency() { + return this.cyclicDependency; + } + + public Map, Collection>> cascadedFailure() { + return this.cascadedFailure; + } + + public void printErrorsIfAny( + final Map, String > failedInstance, + final Map, String> consequentialFailedInstance, + final Logger logger) { + final int noOfFailures = this.numberOfFailures() + failedInstance.size() + consequentialFailedInstance.size(); + if (noOfFailures == 0) { + return; + } + + final PrettyPrinter errorPrinter = new PrettyPrinter(120); + errorPrinter.add("SPONGE PLUGINS FAILED TO LOAD").centre().hr() + .addWrapped("%d plugin(s) have unfulfilled or cyclic dependencies or failed to load. Your game will continue to load without" + + " these plugins.", + noOfFailures); + + if (!this.duplicateIds.isEmpty()) { + errorPrinter.add(); + errorPrinter.add("The following plugins IDs were duplicated - some plugins will not have been loaded:"); + for (final String id : this.duplicateIds) { + errorPrinter.add(" * %s", id); + } + } + + if (!this.missingDependencies.isEmpty()) { + errorPrinter.add(); + errorPrinter.add("The following plugins are missing dependencies:"); + for (final Map.Entry, Collection> entry : this.missingDependencies.entrySet()) { + errorPrinter.add(" * %s requires [ %s ]", + entry.getKey().metadata().id(), + String.join(", ", entry.getValue())); + } + } + + if (!this.versionMismatch.isEmpty()) { + errorPrinter.add(); + errorPrinter.add("The following plugins require different version(s) of dependencies you have installed:"); + for (final Map.Entry, Collection>>> entry : this.versionMismatch.entrySet()) { + final PluginCandidate candidate = entry.getKey(); + final Collection>> mismatchedDeps = entry.getValue(); + final String errorString = mismatchedDeps.stream() + .map(x -> String.format("%s version %s (currently version %s)", + x.second().metadata().id(), x.first(), x.second().metadata().version())) + .collect(Collectors.joining(", ")); + errorPrinter.add(" * %s requires [ %s ]", + candidate.metadata().id(), + errorString); + } + } + + if (!this.cyclicDependency.isEmpty()) { + errorPrinter.add(); + errorPrinter.add("The following plugins were found to have cyclic dependencies:"); + for (final Map.Entry, Collection>> node : this.cyclicDependency.entrySet()) { + errorPrinter.add(" * %s has dependency cycle [ ... -> %s -> ... ]", + node.getKey().metadata().id(), + node.getValue().stream().map(x -> x.metadata().id()).collect(Collectors.joining(" -> "))); + } + } + + if (!failedInstance.isEmpty()) { + errorPrinter.add(); + errorPrinter.add("The following plugins threw exceptions when being created (report these to the plugin authors):"); + for (final Map.Entry, String> node : failedInstance.entrySet()) { + errorPrinter.add(" * %s with the error message \"%s\"", + node.getKey().metadata().id(), + node.getValue()); + } + } + + if (!this.cascadedFailure.isEmpty() || !consequentialFailedInstance.isEmpty()) { + final Map, String> mergedFailures = new HashMap<>(consequentialFailedInstance); + for (final Map.Entry, Collection>> entry : this.cascadedFailure.entrySet()) { + final String error = entry.getValue().stream().map(x -> x.metadata().id()).collect(Collectors.joining(", ")); + mergedFailures.merge(entry.getKey(), error, (old, incoming) -> old + ", " + incoming); + } + + errorPrinter.add(); + errorPrinter.add("The following plugins are not loading because they depend on plugins that will not load:"); + for (final Map.Entry, String> node : mergedFailures.entrySet()) { + errorPrinter.add(" * %s depends on [ %s ]", + node.getKey().metadata().id(), + // nothing wrong with this plugin other than the other plugins, + // so we just list all the plugins that failed + node.getValue()); + } + } + + errorPrinter.add().hr().addWrapped("DO NOT REPORT THIS TO SPONGE. These errors are not Sponge errors, they are plugin loading errors. Seek " + + "support from the authors of the plugins listed above if you need help getting these plugins to load.").add(); + errorPrinter.addWrapped("Your game will continue to start without the %d plugins listed above. Other plugins will continue to load, " + + "however you may wish to stop your game and fix these issues. For any missing dependencies, you " + + "may be able to find them at https://ore.spongepowered.org/. For any plugins that have cyclic dependencies or threw " + + "exceptions, it is likely a bug in the plugin.", noOfFailures); + + errorPrinter.log(logger, Level.ERROR); + } + + private int numberOfFailures() { + return this.missingDependencies.size() + this.versionMismatch.size() + this.cyclicDependency.size() + this.cascadedFailure.size(); + } + +} diff --git a/loofah/src/main/java/dk/nelind/loofah/main/Loofah.java b/loofah/src/main/java/dk/nelind/loofah/main/Loofah.java index dbb40c2b69e..0a5f3c096db 100644 --- a/loofah/src/main/java/dk/nelind/loofah/main/Loofah.java +++ b/loofah/src/main/java/dk/nelind/loofah/main/Loofah.java @@ -29,6 +29,6 @@ public class Loofah implements ModInitializer { @Override public void onInitialize() { - + /** Hook into lifecycle based on {@link org.spongepowered.forge.SpongeForgeMod} */ } }