diff --git a/src/main/java/net/fabricmc/loom/api/LoomGradleExtensionAPI.java b/src/main/java/net/fabricmc/loom/api/LoomGradleExtensionAPI.java index 485c4e70..69c44a1a 100644 --- a/src/main/java/net/fabricmc/loom/api/LoomGradleExtensionAPI.java +++ b/src/main/java/net/fabricmc/loom/api/LoomGradleExtensionAPI.java @@ -84,6 +84,16 @@ public interface LoomGradleExtensionAPI { void mixin(Action action); + /** + * Optionally register and configure a {@link ModSettings} object. The name should match the modid. + * This is generally only required when the mod spans across multiple classpath directories, such as when using split sourcesets. + */ + @ApiStatus.Experimental + void mods(Action> action); + + @ApiStatus.Experimental + NamedDomainObjectContainer getMods(); + @ApiStatus.Experimental // TODO: move this from LoomGradleExtensionAPI to LoomGradleExtension once getRefmapName & setRefmapName is removed. MixinExtensionAPI getMixin(); diff --git a/src/main/java/net/fabricmc/loom/api/ModSettings.java b/src/main/java/net/fabricmc/loom/api/ModSettings.java new file mode 100644 index 00000000..471f7d65 --- /dev/null +++ b/src/main/java/net/fabricmc/loom/api/ModSettings.java @@ -0,0 +1,55 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2022 FabricMC + * + * 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 net.fabricmc.loom.api; + +import javax.inject.Inject; + +import org.gradle.api.Named; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.tasks.SourceSet; +import org.jetbrains.annotations.ApiStatus; + +/** + * A {@link Named} object for setting mod-related values. The {@linkplain Named#getName() name} should match the mod id. + */ +@ApiStatus.Experimental +public abstract class ModSettings implements Named { + /** + * List of classpath directories, used to populate the `fabric.classPathGroups` Fabric Loader system property. + */ + public abstract ListProperty getModSourceSets(); + + @Inject + public ModSettings() { + getModSourceSets().finalizeValueOnRead(); + } + + /** + * Mark a {@link SourceSet} output directories part of the named mod. + */ + public void sourceSet(SourceSet sourceSet) { + getModSourceSets().add(sourceSet); + } +} diff --git a/src/main/java/net/fabricmc/loom/configuration/ide/RunConfig.java b/src/main/java/net/fabricmc/loom/configuration/ide/RunConfig.java index 7979b1b2..cd834b1e 100644 --- a/src/main/java/net/fabricmc/loom/configuration/ide/RunConfig.java +++ b/src/main/java/net/fabricmc/loom/configuration/ide/RunConfig.java @@ -50,6 +50,7 @@ import org.w3c.dom.Node; import net.fabricmc.loom.LoomGradleExtension; import net.fabricmc.loom.configuration.InstallerData; import net.fabricmc.loom.configuration.ide.idea.IdeaSyncTask; +import net.fabricmc.loom.configuration.ide.idea.IdeaUtils; import net.fabricmc.loom.configuration.providers.BundleMetadata; import net.fabricmc.loom.util.Constants; @@ -101,16 +102,6 @@ public class RunConfig { return e; } - private static String getIdeaModuleName(Project project, SourceSet srcs) { - String module = project.getName() + "." + srcs.getName(); - - while ((project = project.getParent()) != null) { - module = project.getName() + "." + module; - } - - return module; - } - private static void populate(Project project, LoomGradleExtension extension, RunConfig runConfig, String environment) { runConfig.configName += extension.isRootProject() ? "" : " (" + project.getPath() + ")"; runConfig.eclipseProjectName = project.getExtensions().getByType(EclipseModel.class).getProject().getName(); @@ -166,7 +157,7 @@ public class RunConfig { RunConfig runConfig = new RunConfig(); runConfig.configName = configName; populate(project, extension, runConfig, environment); - runConfig.ideaModuleName = getIdeaModuleName(project, sourceSet); + runConfig.ideaModuleName = IdeaUtils.getIdeaModuleName(project, sourceSet); runConfig.runDirIdeaUrl = "file://$PROJECT_DIR$/" + runDir; runConfig.runDir = runDir; runConfig.sourceSet = sourceSet; diff --git a/src/main/java/net/fabricmc/loom/configuration/ide/idea/IdeaUtils.java b/src/main/java/net/fabricmc/loom/configuration/ide/idea/IdeaUtils.java index e1aa3ea4..fd1e26ab 100644 --- a/src/main/java/net/fabricmc/loom/configuration/ide/idea/IdeaUtils.java +++ b/src/main/java/net/fabricmc/loom/configuration/ide/idea/IdeaUtils.java @@ -26,6 +26,9 @@ package net.fabricmc.loom.configuration.ide.idea; import java.util.Objects; +import org.gradle.api.Project; +import org.gradle.api.tasks.SourceSet; + public class IdeaUtils { public static boolean isIdeaSync() { return Boolean.parseBoolean(System.getProperty("idea.sync.active", "false")); @@ -42,4 +45,14 @@ public class IdeaUtils { final int minor = Integer.parseInt(split[1]); return major > 2021 || (major == 2021 && minor >= 3); } + + public static String getIdeaModuleName(Project project, SourceSet srcs) { + String module = project.getName() + "." + srcs.getName(); + + while ((project = project.getParent()) != null) { + module = project.getName() + "." + module; + } + + return module; + } } diff --git a/src/main/java/net/fabricmc/loom/extension/LoomGradleExtensionApiImpl.java b/src/main/java/net/fabricmc/loom/extension/LoomGradleExtensionApiImpl.java index aa6365ec..490fa4d1 100644 --- a/src/main/java/net/fabricmc/loom/extension/LoomGradleExtensionApiImpl.java +++ b/src/main/java/net/fabricmc/loom/extension/LoomGradleExtensionApiImpl.java @@ -39,6 +39,7 @@ import org.gradle.api.tasks.SourceSet; import net.fabricmc.loom.api.InterfaceInjectionExtensionAPI; import net.fabricmc.loom.api.LoomGradleExtensionAPI; import net.fabricmc.loom.api.MixinExtensionAPI; +import net.fabricmc.loom.api.ModSettings; import net.fabricmc.loom.api.decompilers.DecompilerOptions; import net.fabricmc.loom.api.mappings.intermediate.IntermediateMappingsProvider; import net.fabricmc.loom.api.mappings.layered.spec.LayeredMappingSpecBuilder; @@ -77,6 +78,7 @@ public abstract class LoomGradleExtensionApiImpl implements LoomGradleExtensionA private final NamedDomainObjectContainer runConfigs; private final NamedDomainObjectContainer decompilers; + private final NamedDomainObjectContainer mods; protected LoomGradleExtensionApiImpl(Project project, LoomFiles directories) { this.jarProcessors = project.getObjects().listProperty(JarProcessor.class) @@ -106,6 +108,7 @@ public abstract class LoomGradleExtensionApiImpl implements LoomGradleExtensionA this.runConfigs = project.container(RunConfigSettings.class, baseName -> new RunConfigSettings(project, baseName)); this.decompilers = project.getObjects().domainObjectContainer(DecompilerOptions.class); + this.mods = project.getObjects().domainObjectContainer(ModSettings.class); this.minecraftJarConfiguration = project.getObjects().property(MinecraftJarConfiguration.class).convention(MinecraftJarConfiguration.MERGED); this.minecraftJarConfiguration.finalizeValueOnRead(); @@ -282,6 +285,16 @@ public abstract class LoomGradleExtensionApiImpl implements LoomGradleExtensionA return interfaceInjectionExtension; } + @Override + public void mods(Action> action) { + action.execute(getMods()); + } + + @Override + public NamedDomainObjectContainer getMods() { + return mods; + } + // This is here to ensure that LoomGradleExtensionApiImpl compiles without any unimplemented methods private final class EnsureCompile extends LoomGradleExtensionApiImpl { private EnsureCompile() { diff --git a/src/main/java/net/fabricmc/loom/task/launch/GenerateDLIConfigTask.java b/src/main/java/net/fabricmc/loom/task/launch/GenerateDLIConfigTask.java index 38d2637f..3e078131 100644 --- a/src/main/java/net/fabricmc/loom/task/launch/GenerateDLIConfigTask.java +++ b/src/main/java/net/fabricmc/loom/task/launch/GenerateDLIConfigTask.java @@ -42,6 +42,7 @@ import org.gradle.api.tasks.TaskAction; import net.fabricmc.loom.configuration.providers.minecraft.MinecraftVersionMeta; import net.fabricmc.loom.configuration.providers.minecraft.mapped.MappedMinecraftProvider; import net.fabricmc.loom.task.AbstractLoomTask; +import net.fabricmc.loom.util.gradle.SourceSetHelper; public abstract class GenerateDLIConfigTask extends AbstractLoomTask { @TaskAction @@ -74,6 +75,10 @@ public abstract class GenerateDLIConfigTask extends AbstractLoomTask { launchConfig.property("fabric.gameJarPath", getGameJarPath("common")); } + if (!getExtension().getMods().isEmpty()) { + launchConfig.property("fabric.classPathGroups", getClassPathGroups()); + } + final boolean plainConsole = getProject().getGradle().getStartParameter().getConsoleOutput() == ConsoleOutput.Plain; final boolean ansiSupportedIDE = new File(getProject().getRootDir(), ".vscode").exists() || new File(getProject().getRootDir(), ".idea").exists() @@ -103,6 +108,19 @@ public abstract class GenerateDLIConfigTask extends AbstractLoomTask { }; } + /** + * See: https://github.com/FabricMC/fabric-loader/pull/585. + */ + private String getClassPathGroups() { + return getExtension().getMods().stream() + .map(modSettings -> + SourceSetHelper.getClasspath(modSettings, getProject()).stream() + .map(File::getAbsolutePath) + .collect(Collectors.joining(File.pathSeparator)) + ) + .collect(Collectors.joining(File.pathSeparator+File.pathSeparator)); + } + public static class LaunchConfig { private final Map> values = new HashMap<>(); diff --git a/src/main/java/net/fabricmc/loom/util/gradle/SourceSetHelper.java b/src/main/java/net/fabricmc/loom/util/gradle/SourceSetHelper.java new file mode 100644 index 00000000..4630bde6 --- /dev/null +++ b/src/main/java/net/fabricmc/loom/util/gradle/SourceSetHelper.java @@ -0,0 +1,167 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2022 FabricMC + * + * 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 net.fabricmc.loom.util.gradle; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import javax.xml.xpath.XPath; +import javax.xml.xpath.XPathExpressionException; +import javax.xml.xpath.XPathFactory; + +import org.gradle.api.Project; +import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.SourceSetOutput; +import org.intellij.lang.annotations.Language; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.VisibleForTesting; +import org.xml.sax.InputSource; + +import net.fabricmc.loom.api.ModSettings; +import net.fabricmc.loom.configuration.ide.idea.IdeaUtils; + +public final class SourceSetHelper { + @VisibleForTesting + @Language("xpath") + public static final String IDEA_OUTPUT_XPATH = "/project/component[@name='ProjectRootManager']/output/@url"; + + private SourceSetHelper() { + } + + public static List getClasspath(ModSettings modSettings, Project project) { + return modSettings.getModSourceSets().get().stream() + .flatMap(sourceSet -> getClasspath(sourceSet, project).stream()) + .toList(); + } + + public static List getClasspath(SourceSet sourceSet, Project project) { + final List classpath = getGradleClasspath(sourceSet); + + classpath.addAll(getIdeaClasspath(sourceSet, project)); + classpath.addAll(getEclipseClasspath(sourceSet, project)); + classpath.addAll(getVscodeClasspath(sourceSet, project)); + + return classpath; + } + + private static List getGradleClasspath(SourceSet sourceSet) { + final SourceSetOutput output = sourceSet.getOutput(); + final File resources = output.getResourcesDir(); + + final List classpath = new ArrayList<>(); + + classpath.addAll(output.getClassesDirs().getFiles()); + + if (resources != null) { + classpath.add(resources); + } + + return classpath; + } + + @VisibleForTesting + public static List getIdeaClasspath(SourceSet sourceSet, Project project) { + final File projectDir = project.getRootDir(); + final File dotIdea = new File(projectDir, ".idea"); + + if (!dotIdea.exists()) { + return Collections.emptyList(); + } + + final File miscXml = new File(dotIdea, "misc.xml"); + + if (!miscXml.exists()) { + return Collections.emptyList(); + } + + String outputDirUrl = evaluateXpath(miscXml, IDEA_OUTPUT_XPATH); + + if (outputDirUrl == null) { + return Collections.emptyList(); + } + + outputDirUrl = outputDirUrl.replace("$PROJECT_DIR$", projectDir.getAbsolutePath()); + outputDirUrl = outputDirUrl.replaceAll("^file:", ""); + + final File productionDir = new File(outputDirUrl, "production"); + final File outputDir = new File(productionDir, IdeaUtils.getIdeaModuleName(project, sourceSet)); + + return Collections.singletonList(outputDir); + } + + @Nullable + private static String evaluateXpath(File file, @Language("xpath") String expression) { + final XPath xpath = XPathFactory.newInstance().newXPath(); + + try (FileInputStream fis = new FileInputStream(file)) { + return xpath.evaluate(expression, new InputSource(fis)); + } catch (XPathExpressionException e) { + return null; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @VisibleForTesting + public static List getEclipseClasspath(SourceSet sourceSet, Project project) { + // Somewhat of a guess, I'm unsure if this is correct for multi-project builds + final File projectDir = project.getProjectDir(); + final File classpath = new File(projectDir, ".classpath"); + + if (!classpath.exists()) { + return Collections.emptyList(); + } + + return getBinDirClasspath(projectDir, sourceSet); + } + + @VisibleForTesting + public static List getVscodeClasspath(SourceSet sourceSet, Project project) { + // Somewhat of a guess, I'm unsure if this is correct for multi-project builds + final File projectDir = project.getProjectDir(); + final File dotVscode = new File(projectDir, ".vscode"); + + if (!dotVscode.exists()) { + return Collections.emptyList(); + } + + return getBinDirClasspath(projectDir, sourceSet); + } + + private static List getBinDirClasspath(File projectDir, SourceSet sourceSet) { + final File binDir = new File(projectDir, "bin"); + + if (!binDir.exists()) { + return Collections.emptyList(); + } + + return Collections.singletonList(new File(binDir, sourceSet.getName())); + } +} diff --git a/src/test/groovy/net/fabricmc/loom/test/unit/SourceSetHelperTest.groovy b/src/test/groovy/net/fabricmc/loom/test/unit/SourceSetHelperTest.groovy new file mode 100644 index 00000000..22499db5 --- /dev/null +++ b/src/test/groovy/net/fabricmc/loom/test/unit/SourceSetHelperTest.groovy @@ -0,0 +1,115 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2022 FabricMC + * + * 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 net.fabricmc.loom.test.unit + +import net.fabricmc.loom.util.gradle.SourceSetHelper +import org.gradle.api.Project +import org.gradle.api.tasks.SourceSet +import org.intellij.lang.annotations.Language +import spock.lang.Shared +import spock.lang.Specification + +class SourceSetHelperTest extends Specification { + @Shared + private static File projectDir = File.createTempDir() + + def "idea classpath"() { + given: + def miscXml = new File(projectDir, ".idea/misc.xml") + miscXml.parentFile.mkdirs() + miscXml.text = MISC_XML + + def mockProject = Mock(Project) + def mockSourceSet = Mock(SourceSet) + + mockProject.getName() >> "UnitTest" + mockProject.getRootDir() >> projectDir + mockSourceSet.getName() >> "main" + + when: + def result = SourceSetHelper.getIdeaClasspath(mockSourceSet, mockProject) + + then: + result.size() == 1 + !result[0].toString().startsWith("file:") + + println(result[0].toString()) + } + + def "eclipse classpath"() { + given: + def classpath = new File(projectDir, ".classpath") + classpath.createNewFile() + + def binDir = new File(projectDir, "bin") + binDir.mkdirs() + + def mockProject = Mock(Project) + def mockSourceSet = Mock(SourceSet) + + mockProject.getName() >> "UnitTest" + mockProject.getProjectDir() >> projectDir + mockSourceSet.getName() >> "main" + + when: + def result = SourceSetHelper.getEclipseClasspath(mockSourceSet, mockProject) + + then: + result.size() == 1 + println(result[0].toString()) + } + + def "vscode classpath"() { + given: + def dotVscode = new File(projectDir, ".vscode") + dotVscode.mkdirs() + + def binDir = new File(projectDir, "bin") + binDir.mkdirs() + + def mockProject = Mock(Project) + def mockSourceSet = Mock(SourceSet) + + mockProject.getName() >> "UnitTest" + mockProject.getProjectDir() >> projectDir + mockSourceSet.getName() >> "main" + + when: + def result = SourceSetHelper.getVscodeClasspath(mockSourceSet, mockProject) + + then: + result.size() == 1 + println(result[0].toString()) + } + + @Language("xml") + private static String MISC_XML = """ + + + + + +""" +}