From 951a5c8118b9b06c64a101d142a744eb656d8544 Mon Sep 17 00:00:00 2001 From: shartte Date: Sun, 13 Oct 2024 12:02:58 +0200 Subject: [PATCH] Refactor IDE integration (#169) --- .../internal/EclipseIntegration.java | 197 +++++++++ .../moddevgradle/internal/IdeIntegration.java | 109 +++++ .../internal/IntelliJIntegration.java | 278 +++++++++++++ .../IntelliJOutputDirectoryValueSource.java | 122 ++++++ .../moddevgradle/internal/ModDevPlugin.java | 391 ++---------------- .../internal/NoIdeIntegration.java | 18 + .../moddevgradle/internal/RunUtils.java | 164 +------- .../internal/VsCodeIntegration.java | 79 ++++ .../internal/utils/IdeDetection.java | 31 -- 9 files changed, 847 insertions(+), 542 deletions(-) create mode 100644 src/main/java/net/neoforged/moddevgradle/internal/EclipseIntegration.java create mode 100644 src/main/java/net/neoforged/moddevgradle/internal/IdeIntegration.java create mode 100644 src/main/java/net/neoforged/moddevgradle/internal/IntelliJIntegration.java create mode 100644 src/main/java/net/neoforged/moddevgradle/internal/IntelliJOutputDirectoryValueSource.java create mode 100644 src/main/java/net/neoforged/moddevgradle/internal/NoIdeIntegration.java create mode 100644 src/main/java/net/neoforged/moddevgradle/internal/VsCodeIntegration.java diff --git a/src/main/java/net/neoforged/moddevgradle/internal/EclipseIntegration.java b/src/main/java/net/neoforged/moddevgradle/internal/EclipseIntegration.java new file mode 100644 index 00000000..86e400d6 --- /dev/null +++ b/src/main/java/net/neoforged/moddevgradle/internal/EclipseIntegration.java @@ -0,0 +1,197 @@ +package net.neoforged.moddevgradle.internal; + +import net.neoforged.elc.configs.GradleLaunchConfig; +import net.neoforged.elc.configs.JavaApplicationLaunchConfig; +import net.neoforged.elc.configs.LaunchConfig; +import net.neoforged.elc.configs.LaunchGroup; +import net.neoforged.moddevgradle.dsl.ModModel; +import net.neoforged.moddevgradle.dsl.RunModel; +import net.neoforged.moddevgradle.internal.utils.ExtensionUtils; +import org.gradle.api.GradleException; +import org.gradle.api.Project; +import org.gradle.api.file.Directory; +import org.gradle.api.file.RegularFile; +import org.gradle.api.provider.Property; +import org.gradle.api.provider.Provider; +import org.gradle.api.provider.SetProperty; +import org.gradle.api.tasks.TaskProvider; +import org.gradle.plugins.ide.eclipse.EclipsePlugin; +import org.gradle.plugins.ide.eclipse.model.Classpath; +import org.gradle.plugins.ide.eclipse.model.EclipseModel; +import org.gradle.plugins.ide.eclipse.model.Library; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.xml.stream.XMLStreamException; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Provides integration with Eclipse Buildship and VSCode extensions based on it. + */ +sealed class EclipseIntegration extends IdeIntegration permits VsCodeIntegration { + private static final Logger LOG = LoggerFactory.getLogger(EclipseIntegration.class); + + protected final EclipseModel eclipseModel; + + protected EclipseIntegration(Project project) { + super(project); + this.eclipseModel = getOrCreateEclipseModel(project); + LOG.debug("Configuring Eclipse model for Eclipse project '{}'.", eclipseModel.getProject().getName()); + } + + /** + * Attach a source artifact to a binary artifact if the IDE supports it. + * + * @param jarToSourceJarMapping Maps a classpath location containing classes to their source location. + * Locations are usually JAR files but may be folders. + */ + @Override + public void attachSources(Map, Provider> jarToSourceJarMapping) { + + var fileClasspath = eclipseModel.getClasspath().getFile(); + fileClasspath.whenMerged((Classpath classpath) -> { + for (var mapping : jarToSourceJarMapping.entrySet()) { + var classesPath = mapping.getKey().get().getAsFile(); + var sourcesPath = mapping.getValue().get().getAsFile(); + + for (var entry : classpath.getEntries()) { + if (entry instanceof Library library && classesPath.equals(new File(library.getPath()))) { + library.setSourcePath(classpath.fileReference(sourcesPath)); + } + } + } + }); + } + + @Override + protected void registerProjectSyncTask(TaskProvider task) { + // Make sure our post-sync task runs on Eclipse + eclipseModel.synchronizationTasks(task); + } + + @Override + public void configureRuns(Map> prepareRunTasks, + Iterable runs) { + // Set up runs if running under buildship and in VS Code + project.afterEvaluate(ignored -> { + for (var run : runs) { + var prepareTask = prepareRunTasks.get(run).get(); + addEclipseLaunchConfiguration(project, run, prepareTask); + } + }); + } + + @Override + public void configureTesting(SetProperty loadedMods, + Property testedMod, + Provider runArgsDir, + File gameDirectory, + Provider programArgsFile, + Provider vmArgsFile) { + // Eclipse has no concept of JUnit run templates. We cannot configure VM args or similar for all JUnit runs. + } + + private static EclipseModel getOrCreateEclipseModel(Project project) { + // Set up stuff for Eclipse + var eclipseModel = ExtensionUtils.findExtension(project, "eclipse", EclipseModel.class); + if (eclipseModel == null) { + project.getPlugins().apply(EclipsePlugin.class); + eclipseModel = ExtensionUtils.findExtension(project, "eclipse", EclipseModel.class); + if (eclipseModel == null) { + throw new GradleException("Even after applying the Eclipse plugin, no 'eclipse' extension was present!"); + } + } + return eclipseModel; + } + + private void addEclipseLaunchConfiguration(Project project, + RunModel run, + PrepareRun prepareTask) { + if (!prepareTask.getEnabled()) { + LOG.info("Not creating Eclipse run {} since its prepare task {} is disabled", run, prepareTask); + return; + } + + // Grab the eclipse model so we can extend it. -> Done on the root project so that the model is available to all subprojects. + // And so that post sync tasks are only run once for all subprojects. + + var runIdeName = run.getIdeName().get(); + var launchConfigName = runIdeName; + var eclipseProjectName = Objects.requireNonNullElse(eclipseModel.getProject().getName(), project.getName()); + + // If the user wants to run tasks before the actual execution, we create a launch group to facilitate that + if (!run.getTasksBefore().isEmpty()) { + // Rename the main launch to "Run " ... + launchConfigName = "Run " + runIdeName; + + // Creates a launch config to run the preparation tasks + var prepareRunConfig = GradleLaunchConfig.builder(eclipseProjectName) + .tasks(run.getTasksBefore().stream().map(task -> task.get().getPath()).toArray(String[]::new)) + .build(); + var prepareRunLaunchName = "Prepare " + runIdeName; + writeEclipseLaunchConfig(project, prepareRunLaunchName, prepareRunConfig); + + // This is the launch group that will first launch Gradle, and then the game + var withGradleTasksConfig = LaunchGroup.builder() + .entry(LaunchGroup.entry(prepareRunLaunchName) + .enabled(true) + .adoptIfRunning(false) + .mode(LaunchGroup.Mode.RUN) + // See https://github.com/eclipse/buildship/issues/1272 + // for why we cannot just wait for termination + .action(LaunchGroup.Action.delay(2))) + .entry(LaunchGroup.entry(launchConfigName) + .enabled(true) + .adoptIfRunning(false) + .mode(LaunchGroup.Mode.INHERIT) + .action(LaunchGroup.Action.none())) + .build(); + writeEclipseLaunchConfig(project, runIdeName, withGradleTasksConfig); + } + + // This is the actual main launch configuration that launches the game + var config = JavaApplicationLaunchConfig.builder(eclipseProjectName) + .vmArgs( + RunUtils.escapeJvmArg(RunUtils.getArgFileParameter(prepareTask.getVmArgsFile().get())), + RunUtils.escapeJvmArg(getModFoldersProvider(project, run.getLoadedMods(), null).getArgument()) + ) + .args(RunUtils.escapeJvmArg(RunUtils.getArgFileParameter(prepareTask.getProgramArgsFile().get()))) + .envVar(run.getEnvironment().get()) + .workingDirectory(run.getGameDirectory().get().getAsFile().getAbsolutePath()) + .build(RunUtils.DEV_LAUNCH_MAIN_CLASS); + writeEclipseLaunchConfig(project, launchConfigName, config); + } + + protected static ModFoldersProvider getModFoldersProvider(Project project, + Provider> modsProvider, + @Nullable Provider testedMod) { + var folders = RunUtils.buildModFolders(project, modsProvider, testedMod, (sourceSet, output) -> { + output.from(RunUtils.findSourceSetProject(project, sourceSet).getProjectDir().toPath() + .resolve("bin") + .resolve(sourceSet.getName())); + }); + + var modFoldersProvider = project.getObjects().newInstance(ModFoldersProvider.class); + modFoldersProvider.getModFolders().set(folders); + return modFoldersProvider; + } + + private static void writeEclipseLaunchConfig(Project project, String name, LaunchConfig config) { + var file = project.file(".eclipse/configurations/" + name + ".launch"); + file.getParentFile().mkdirs(); + try (var writer = new FileWriter(file, false)) { + config.write(writer); + } catch (IOException e) { + throw new UncheckedIOException("Failed to write launch file: " + file, e); + } catch (XMLStreamException e) { + throw new RuntimeException("Failed to write launch file: " + file, e); + } + } +} diff --git a/src/main/java/net/neoforged/moddevgradle/internal/IdeIntegration.java b/src/main/java/net/neoforged/moddevgradle/internal/IdeIntegration.java new file mode 100644 index 00000000..c7a3955d --- /dev/null +++ b/src/main/java/net/neoforged/moddevgradle/internal/IdeIntegration.java @@ -0,0 +1,109 @@ +package net.neoforged.moddevgradle.internal; + +import net.neoforged.moddevgradle.dsl.ModModel; +import net.neoforged.moddevgradle.dsl.RunModel; +import net.neoforged.moddevgradle.internal.utils.ExtensionUtils; +import net.neoforged.moddevgradle.internal.utils.IdeDetection; +import org.gradle.api.Project; +import org.gradle.api.Task; +import org.gradle.api.file.Directory; +import org.gradle.api.file.RegularFile; +import org.gradle.api.provider.Property; +import org.gradle.api.provider.Provider; +import org.gradle.api.provider.SetProperty; +import org.gradle.api.tasks.TaskProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.util.Map; + +sealed abstract class IdeIntegration permits IntelliJIntegration, EclipseIntegration, NoIdeIntegration { + private static final Logger LOG = LoggerFactory.getLogger(IdeIntegration.class); + + /** + * A task we attach other tasks to that should run when the IDE reloads the projects. + */ + private final TaskProvider ideSyncTask; + + protected final Project project; + + public IdeIntegration(Project project) { + this.project = project; + this.ideSyncTask = project.getTasks().register("neoForgeIdeSync", task -> { + task.setGroup(ModDevPlugin.INTERNAL_TASK_GROUP); + task.setDescription("A utility task that is used to create necessary files when the Gradle project is synchronized with the IDE project."); + }); + this.registerProjectSyncTask(ideSyncTask); + } + + public static IdeIntegration of(Project project) { + var ideIntegration = ExtensionUtils.findExtension(project, "mdgInternalIdeIntegration", IdeIntegration.class); + if (ideIntegration == null) { + ideIntegration = createForProject(project); + project.getExtensions().add(IdeIntegration.class, "mdgInternalIdeIntegration", ideIntegration); + } + return ideIntegration; + } + + private static IdeIntegration createForProject(Project project) { + if (IdeDetection.isVsCode()) { + // VSCode internally uses Eclipse and as such, we need to prioritize it over the pure Eclipse integration + LOG.debug("Activating VSCode integration for project {}.", project.getPath()); + return new VsCodeIntegration(project); + } else if (IdeDetection.isEclipse()) { + LOG.debug("Activating Eclipse integration for project {}.", project.getPath()); + return new EclipseIntegration(project); + } else if (IdeDetection.isIntelliJSync()) { + LOG.debug("Activating IntelliJ integration for project {}.", project.getPath()); + return new IntelliJIntegration(project); + } else { + return new NoIdeIntegration(project); + } + } + + /** + * Attach a source artifact to a binary artifact if the IDE supports it. + * + * @param jarToSourceJarMapping Maps a classpath location containing classes to their source location. + * Locations are usually JAR files but may be folders. + * @see #shouldUseCombinedSourcesAndClassesArtifact() This method will not work if the IDE doesn't support attaching sources. + */ + void attachSources(Map, Provider> jarToSourceJarMapping) { + } + + /** + * Only IntelliJ needs the combined artifact. + * We also use this model when Gradle tasks are being run from IntelliJ, not only if IntelliJ is reloading + * the project. + * This prevents the classpath during debugging from the classpath detected by IntelliJ itself. + * For Eclipse, we can attach the sources via the Eclipse project model. + */ + boolean shouldUseCombinedSourcesAndClassesArtifact() { + return false; + } + + /** + * Registers a task to be run when the IDE reloads the Gradle project. + */ + public final void runTaskOnProjectSync(TaskProvider task) { + ideSyncTask.configure(ideSyncTask -> ideSyncTask.dependsOn(task)); + } + + /** + * To be implemented by specific IDE integrations to register a task to be run on reload with the IDE. + */ + protected abstract void registerProjectSyncTask(TaskProvider task); + + void configureRuns(Map> prepareRunTasks, Iterable runs) { + } + + void configureTesting(SetProperty loadedMods, + Property testedMod, + Provider runArgsDir, + File gameDirectory, + Provider programArgsFile, + Provider vmArgsFile) { + } + +} diff --git a/src/main/java/net/neoforged/moddevgradle/internal/IntelliJIntegration.java b/src/main/java/net/neoforged/moddevgradle/internal/IntelliJIntegration.java new file mode 100644 index 00000000..4de3015d --- /dev/null +++ b/src/main/java/net/neoforged/moddevgradle/internal/IntelliJIntegration.java @@ -0,0 +1,278 @@ +package net.neoforged.moddevgradle.internal; + +import net.neoforged.moddevgradle.dsl.ModModel; +import net.neoforged.moddevgradle.dsl.RunModel; +import net.neoforged.moddevgradle.internal.utils.ExtensionUtils; +import net.neoforged.moddevgradle.internal.utils.FileUtils; +import net.neoforged.moddevgradle.internal.utils.IdeDetection; +import net.neoforged.moddevgradle.internal.utils.StringUtils; +import org.gradle.api.GradleException; +import org.gradle.api.Project; +import org.gradle.api.file.Directory; +import org.gradle.api.file.RegularFile; +import org.gradle.api.logging.Logging; +import org.gradle.api.plugins.ExtensionAware; +import org.gradle.api.provider.Property; +import org.gradle.api.provider.Provider; +import org.gradle.api.provider.SetProperty; +import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.TaskProvider; +import org.gradle.internal.DefaultTaskExecutionRequest; +import org.gradle.plugins.ide.idea.IdeaPlugin; +import org.gradle.plugins.ide.idea.model.IdeaModel; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.gradle.ext.Application; +import org.jetbrains.gradle.ext.BeforeRunTask; +import org.jetbrains.gradle.ext.IdeaExtPlugin; +import org.jetbrains.gradle.ext.JUnit; +import org.jetbrains.gradle.ext.ModuleRef; +import org.jetbrains.gradle.ext.ProjectSettings; +import org.jetbrains.gradle.ext.RunConfigurationContainer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.inject.Inject; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +final class IntelliJIntegration extends IdeIntegration { + private static final Logger LOG = LoggerFactory.getLogger(IntelliJIntegration.class); + + private final IdeaModel rootIdeaModel; + + IntelliJIntegration(Project project) { + super(project); + + // While the IDEA model on the root project is the only sensible place to adjust IntelliJ project-wide settings + // such as run configurations. + var rootProject = project.getRootProject(); + + // Force apply during IJ sync to be able to set IntelliJ settings + rootProject.getPlugins().apply(IdeaPlugin.class); + + this.rootIdeaModel = ExtensionUtils.getExtension(rootProject, "idea", IdeaModel.class); + + // idea-ext doesn't seem to do anything if no idea model is present anyway + if (!rootProject.getPlugins().hasPlugin(IdeaExtPlugin.class)) { + rootProject.getPlugins().apply(IdeaExtPlugin.class); + } + } + + @Override + public void attachSources(Map, Provider> jarToSourceJarMapping) { + // IntelliJ does not have a mechanism for us to attach the source artifacts + } + + @Override + public void configureRuns(Map> prepareRunTasks, Iterable runs) { + + // IDEA Sync has no real notion of tasks or providers or similar + project.afterEvaluate(ignored -> { + + var runConfigurations = getIntelliJRunConfigurations(); + + if (runConfigurations == null) { + LOG.debug("Failed to find IntelliJ run configuration container. Not adding run configurations."); + } else { + var outputDirectory = IntelliJOutputDirectoryValueSource.getIntellijOutputDirectory(project); + + for (var run : runs) { + var prepareTask = prepareRunTasks.get(run).get(); + if (!prepareTask.getEnabled()) { + LOG.info("Not creating IntelliJ run {} since its prepare task {} is disabled", run, prepareTask); + continue; + } + addIntelliJRunConfiguration(project, runConfigurations, outputDirectory, run, prepareTask); + } + } + }); + } + + @Override + protected void registerProjectSyncTask(TaskProvider task) { + // Since this does not just configure a data model but actually runs an additional task, we only do this + // when IntelliJ is actually reloading the Gradle project right now. + if (!IdeDetection.isIntelliJSync()) { + return; + } + + project.afterEvaluate(ignored -> { + // Also run the sync task directly as part of the sync. (Thanks Loom). + var startParameter = project.getGradle().getStartParameter(); + var taskRequests = new ArrayList<>(startParameter.getTaskRequests()); + + taskRequests.add(new DefaultTaskExecutionRequest(List.of(task.getName()))); + startParameter.setTaskRequests(taskRequests); + + }); + } + + @Override + public void configureTesting(SetProperty loadedMods, + Property testedMod, + Provider runArgsDir, + File gameDirectory, + Provider programArgsFile, + Provider vmArgsFile) { + // Write out a separate file that has IDE specific VM args, which include the definition of the output directories. + // For JUnit we have to write this to a separate file due to the Run parameters being shared among all projects. + var intellijVmArgsFile = runArgsDir.map(dir -> dir.file("intellijVmArgs.txt")); + + var outputDirectory = IntelliJOutputDirectoryValueSource.getIntellijOutputDirectory(project); + var ideSpecificVmArgs = RunUtils.escapeJvmArg(getModFoldersProvider(project, outputDirectory, loadedMods, testedMod).getArgument()); + try { + var vmArgsFilePath = intellijVmArgsFile.get().getAsFile().toPath(); + Files.createDirectories(vmArgsFilePath.getParent()); + // JVM args generally expect platform encoding + FileUtils.writeStringSafe(vmArgsFilePath, ideSpecificVmArgs, StringUtils.getNativeCharset()); + } catch (IOException e) { + throw new GradleException("Failed to write VM args file for IntelliJ unit tests", e); + } + + // Configure IntelliJ default JUnit parameters, which are used when the user configures IJ to run tests natively + // IMPORTANT: This affects *all projects*, not just this one. We have to use $MODULE_WORKING_DIR$ to make it work. + var intelliJRunConfigurations = getIntelliJRunConfigurations(); + if (intelliJRunConfigurations != null) { + intelliJRunConfigurations.defaults(JUnit.class, jUnitDefaults -> { + // $MODULE_WORKING_DIR$ is documented here: https://www.jetbrains.com/help/idea/absolute-path-variables.html + jUnitDefaults.setWorkingDirectory("$MODULE_WORKING_DIR$/" + ModDevPlugin.JUNIT_GAME_DIR); + jUnitDefaults.setVmParameters( + // The FML JUnit plugin uses this system property to read a file containing the program arguments needed to launch + // NOTE: IntelliJ does not support $MODULE_WORKING_DIR$ in VM Arguments + // See https://youtrack.jetbrains.com/issue/IJPL-14230/Add-macro-support-for-VM-options-field-e.g.-expand-ModuleFileDir-properly + // As a workaround, we just use paths relative to the working directory. + RunUtils.escapeJvmArg("-Dfml.junit.argsfile=" + buildRelativePath(programArgsFile, gameDirectory)) + + " " + + RunUtils.escapeJvmArg("@" + buildRelativePath(vmArgsFile, gameDirectory)) + + " " + + RunUtils.escapeJvmArg("@" + buildRelativePath(intellijVmArgsFile, gameDirectory)) + ); + }); + } + } + + @Nullable + private RunConfigurationContainer getIntelliJRunConfigurations() { + // The idea and idea-ext plugins are required on the root-project level to add run configurations + if (rootIdeaModel == null) { + return null; + } + + // It's unclear when idea is present but the project is null, but guard against it + if (rootIdeaModel.getProject() == null) { + return null; + } + + var projectSettings = ((ExtensionAware) rootIdeaModel.getProject()).getExtensions().getByType(ProjectSettings.class); + + return ExtensionUtils.findExtension((ExtensionAware) projectSettings, "runConfigurations", RunConfigurationContainer.class); + } + + private static void addIntelliJRunConfiguration(Project project, + RunConfigurationContainer runConfigurations, + @Nullable Function outputDirectory, + RunModel run, + PrepareRun prepareTask) { + var appRun = new Application(run.getIdeName().get(), project); + var sourceSets = ExtensionUtils.getSourceSets(project); + var sourceSet = run.getSourceSet().get(); + // Validate that the source set is part of this project + if (!sourceSets.contains(sourceSet)) { + throw new GradleException("Cannot use source set from another project for run " + run.getName()); + } + appRun.setModuleName(getIntellijModuleName(project, sourceSet)); + appRun.setWorkingDirectory(run.getGameDirectory().get().getAsFile().getAbsolutePath()); + appRun.setEnvs(run.getEnvironment().get()); + + appRun.setJvmArgs( + RunUtils.escapeJvmArg(RunUtils.getArgFileParameter(prepareTask.getVmArgsFile().get())) + + " " + + RunUtils.escapeJvmArg(getModFoldersProvider(project, outputDirectory, run.getLoadedMods(), null).getArgument()) + ); + appRun.setMainClass(RunUtils.DEV_LAUNCH_MAIN_CLASS); + appRun.setProgramParameters(RunUtils.escapeJvmArg(RunUtils.getArgFileParameter(prepareTask.getProgramArgsFile().get()))); + + if (!run.getTasksBefore().isEmpty()) { + // This is slightly annoying. + // idea-ext does not expose the ability to run multiple gradle tasks at once, but the IDE model is capable of it. + class GradleTasks extends BeforeRunTask { + @Inject + GradleTasks(String nameParam) { + type = "gradleTask"; + name = nameParam; + } + + @SuppressWarnings("unchecked") + @Override + public Map toMap() { + var result = (Map) super.toMap(); + result.put("projectPath", project.getProjectDir().getAbsolutePath().replaceAll("\\\\", "/")); + var tasks = run.getTasksBefore().stream().map(task -> task.get().getPath()).collect(Collectors.joining(" ")); + result.put("taskName", tasks); + return result; + } + } + appRun.getBeforeRun().add(new GradleTasks("Prepare")); + } + + runConfigurations.add(appRun); + } + + private static String buildRelativePath(Provider file, File workingDirectory) { + return workingDirectory.toPath().relativize(file.get().getAsFile().toPath()).toString().replace("\\", "/"); + } + + @Override + public boolean shouldUseCombinedSourcesAndClassesArtifact() { + return true; + } + + private static ModFoldersProvider getModFoldersProvider(Project project, + @Nullable Function outputDirectory, + Provider> modsProvider, + @Nullable Provider testedMod) { + Provider> folders; + if (outputDirectory != null) { + folders = RunUtils.buildModFolders(project, modsProvider, testedMod, (sourceSet, output) -> { + var sourceSetDir = outputDirectory.apply(RunUtils.findSourceSetProject(project, sourceSet)).toPath().resolve(getIdeaOutName(sourceSet)); + output.from(sourceSetDir.resolve("classes"), sourceSetDir.resolve("resources")); + }); + } else { + folders = RunUtils.getModFoldersForGradle(project, modsProvider, testedMod); + } + + var modFoldersProvider = project.getObjects().newInstance(ModFoldersProvider.class); + modFoldersProvider.getModFolders().set(folders); + return modFoldersProvider; + } + + private static String getIdeaOutName(final SourceSet sourceSet) { + return sourceSet.getName().equals(SourceSet.MAIN_SOURCE_SET_NAME) ? "production" : sourceSet.getName(); + } + + /** + * Convert a project and source set to an IntelliJ module name. + * Do not use {@link ModuleRef} as it does not correctly handle projects with a space in their name! + */ + private static String getIntellijModuleName(Project project, SourceSet sourceSet) { + var moduleName = new StringBuilder(); + // The `replace` call here is our bug fix compared to ModuleRef! + // The actual IDEA logic is more complicated, but this should cover the majority of use cases. + // See https://github.com/JetBrains/intellij-community/blob/a32fd0c588a6da11fd6d5d2fb0362308da3206f3/plugins/gradle/src/org/jetbrains/plugins/gradle/service/project/GradleProjectResolverUtil.java#L205 + // which calls https://github.com/JetBrains/intellij-community/blob/a32fd0c588a6da11fd6d5d2fb0362308da3206f3/platform/util-rt/src/com/intellij/util/PathUtilRt.java#L120 + moduleName.append(project.getRootProject().getName().replace(" ", "_")); + if (project != project.getRootProject()) { + moduleName.append(project.getPath().replaceAll(":", ".")); + } + moduleName.append("."); + moduleName.append(sourceSet.getName()); + return moduleName.toString(); + } +} diff --git a/src/main/java/net/neoforged/moddevgradle/internal/IntelliJOutputDirectoryValueSource.java b/src/main/java/net/neoforged/moddevgradle/internal/IntelliJOutputDirectoryValueSource.java new file mode 100644 index 00000000..efd3995e --- /dev/null +++ b/src/main/java/net/neoforged/moddevgradle/internal/IntelliJOutputDirectoryValueSource.java @@ -0,0 +1,122 @@ +package net.neoforged.moddevgradle.internal; + +import org.gradle.api.Project; +import org.gradle.api.provider.Property; +import org.gradle.api.provider.ValueSource; +import org.gradle.api.provider.ValueSourceParameters; +import org.intellij.lang.annotations.Language; +import org.jetbrains.annotations.Nullable; +import org.xml.sax.InputSource; + +import javax.inject.Inject; +import javax.xml.xpath.XPathExpressionException; +import javax.xml.xpath.XPathFactory; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.function.Function; + +/** + * Checks the IntelliJ project files for the setting that determines whether 1) the build is delegated + * and 2) the configured output directory. + *

+ * We need to know where the IDE would place its own compiled output files. + * Delegated builds use Gradles output directories, while non-delegated builds default to subdirectories of {@code out/}. + */ +abstract class IntelliJOutputDirectoryValueSource implements ValueSource { + // TODO: Loom has unit tests for this... Probably a good idea! + @Language("xpath") + private static final String IDEA_DELEGATED_BUILD_XPATH = "/project/component[@name='GradleSettings']/option[@name='linkedExternalProjectsSettings']/GradleProjectSettings/option[@name='delegatedBuild']/@value"; + @Language("xpath") + private static final String IDEA_OUTPUT_XPATH = "/project/component[@name='ProjectRootManager']/output/@url"; + + interface Params extends ValueSourceParameters { + Property getProjectDir(); + } + + @Inject + public IntelliJOutputDirectoryValueSource() { + } + + /** + * Returns a function that maps a project to the configured output directory, + * only if "Build and run using" is set to "IDEA". + * In other cases, returns {@code null}. + */ + @Nullable + static Function getIntellijOutputDirectory(Project project) { + var outputDirSetting = project.getProviders().of(IntelliJOutputDirectoryValueSource.class, spec -> { + spec.getParameters().getProjectDir().set(getRootGradleProjectDir(project).getAbsolutePath()); + }); + + if (!outputDirSetting.isPresent()) { + return null; + } + + var outputDirTemplate = outputDirSetting.get(); + return p -> new File(outputDirTemplate.replace("$PROJECT_DIR$", p.getProjectDir().getAbsolutePath())); + } + + @Override + public @Nullable String obtain() { + var gradleProjectDir = new File(getParameters().getProjectDir().get()); + + // Check if an IntelliJ project exists at the given Gradle projects directory + var ideaDir = new File(gradleProjectDir, ".idea"); + if (!ideaDir.exists()) { + return null; + } + + // Check if IntelliJ is configured to build with Gradle. + var gradleXml = new File(ideaDir, "gradle.xml"); + var delegatedBuild = evaluateXPath(gradleXml, IDEA_DELEGATED_BUILD_XPATH); + if (!"false".equals(delegatedBuild)) { + return null; + } + + // Find configured output path + var miscXml = new File(ideaDir, "misc.xml"); + String outputDirUrl = evaluateXPath(miscXml, IDEA_OUTPUT_XPATH); + if (outputDirUrl == null) { + // Apparently IntelliJ defaults to out/ now? + outputDirUrl = "file://$PROJECT_DIR$/out"; + } + + // The output dir can start with something like "//C:\"; File can handle it. + return outputDirUrl.replaceAll("^file:", ""); + } + + @Nullable + private static String evaluateXPath(File file, @Language("xpath") String expression) { + try (var fis = new FileInputStream(file)) { + String result = XPathFactory.newInstance().newXPath().evaluate(expression, new InputSource(fis)); + return result.isBlank() ? null : result; + } catch (FileNotFoundException | XPathExpressionException ignored) { + return null; + } catch (IOException e) { + throw new UncheckedIOException("Failed to evaluate xpath " + expression + " on file " + file, e); + } + } + + /** + * Tries to find the Gradle project home that most likely contains the IntelliJ project. + * In composite build scenarios, the composite root has a higher chance of being the IntelliJ directory, + * while otherwise it's the root project. + */ + private static File getRootGradleProjectDir(Project project) { + // Always try the root of a composite build first, since it has the highest chance + var root = project.getGradle().getParent(); + if (root != null) { + while (root.getParent() != null) { + root = root.getParent(); + } + + return root.getRootProject().getProjectDir(); + } + + // As a fallback or in case of not using composite builds, try the root project folder + return project.getRootDir(); + } +} diff --git a/src/main/java/net/neoforged/moddevgradle/internal/ModDevPlugin.java b/src/main/java/net/neoforged/moddevgradle/internal/ModDevPlugin.java index 6a100350..803c9cc1 100644 --- a/src/main/java/net/neoforged/moddevgradle/internal/ModDevPlugin.java +++ b/src/main/java/net/neoforged/moddevgradle/internal/ModDevPlugin.java @@ -1,8 +1,5 @@ package net.neoforged.moddevgradle.internal; -import net.neoforged.elc.configs.GradleLaunchConfig; -import net.neoforged.elc.configs.JavaApplicationLaunchConfig; -import net.neoforged.elc.configs.LaunchGroup; import net.neoforged.minecraftdependencies.MinecraftDependenciesPlugin; import net.neoforged.minecraftdependencies.MinecraftDistribution; import net.neoforged.moddevgradle.dsl.DataFileCollection; @@ -10,23 +7,14 @@ import net.neoforged.moddevgradle.dsl.NeoForgeExtension; import net.neoforged.moddevgradle.dsl.RunModel; import net.neoforged.moddevgradle.internal.utils.ExtensionUtils; -import net.neoforged.moddevgradle.internal.utils.FileUtils; import net.neoforged.moddevgradle.internal.utils.IdeDetection; -import net.neoforged.moddevgradle.internal.utils.StringUtils; import net.neoforged.moddevgradle.tasks.JarJar; import net.neoforged.nfrtgradle.CreateMinecraftArtifacts; import net.neoforged.nfrtgradle.DownloadAssets; import net.neoforged.nfrtgradle.NeoFormRuntimePlugin; -import net.neoforged.vsclc.BatchedLaunchWriter; -import net.neoforged.vsclc.attribute.ConsoleType; -import net.neoforged.vsclc.attribute.PathLike; -import net.neoforged.vsclc.attribute.ShortCmdBehaviour; -import net.neoforged.vsclc.writer.WritingMode; -import org.gradle.api.GradleException; import org.gradle.api.Named; import org.gradle.api.Plugin; import org.gradle.api.Project; -import org.gradle.api.Task; import org.gradle.api.artifacts.ConfigurablePublishArtifact; import org.gradle.api.artifacts.Configuration; import org.gradle.api.artifacts.ExternalModuleDependency; @@ -41,7 +29,6 @@ import org.gradle.api.file.Directory; import org.gradle.api.file.RegularFile; import org.gradle.api.model.ObjectFactory; -import org.gradle.api.plugins.ExtensionAware; import org.gradle.api.plugins.JavaLibraryPlugin; import org.gradle.api.plugins.JavaPlugin; import org.gradle.api.plugins.JavaPluginExtension; @@ -51,38 +38,20 @@ import org.gradle.api.tasks.TaskProvider; import org.gradle.api.tasks.bundling.AbstractArchiveTask; import org.gradle.api.tasks.testing.Test; -import org.gradle.internal.DefaultTaskExecutionRequest; import org.gradle.jvm.toolchain.JavaLanguageVersion; import org.gradle.jvm.toolchain.JavaToolchainService; -import org.gradle.plugins.ide.eclipse.EclipsePlugin; -import org.gradle.plugins.ide.eclipse.model.EclipseModel; -import org.gradle.plugins.ide.eclipse.model.Library; -import org.gradle.plugins.ide.idea.model.IdeaModel; -import org.gradle.plugins.ide.idea.model.IdeaProject; -import org.jetbrains.annotations.Nullable; -import org.jetbrains.gradle.ext.Application; -import org.jetbrains.gradle.ext.BeforeRunTask; -import org.jetbrains.gradle.ext.IdeaExtPlugin; -import org.jetbrains.gradle.ext.JUnit; -import org.jetbrains.gradle.ext.ProjectSettings; -import org.jetbrains.gradle.ext.RunConfigurationContainer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.event.Level; import javax.inject.Inject; import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.util.ArrayList; import java.util.HashMap; import java.util.IdentityHashMap; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.function.Consumer; import java.util.function.Function; -import java.util.stream.Collectors; /** * The main plugin class. @@ -94,10 +63,10 @@ public class ModDevPlugin implements Plugin { * This must be relative to the project directory since we can only set this to the same project-relative * directory across all subprojects due to IntelliJ limitations. */ - private static final String JUNIT_GAME_DIR = "build/minecraft-junit"; + static final String JUNIT_GAME_DIR = "build/minecraft-junit"; private static final String TASK_GROUP = "mod development"; - private static final String INTERNAL_TASK_GROUP = "mod development/internal"; + static final String INTERNAL_TASK_GROUP = "mod development/internal"; /** * Name of the configuration in which we place the required dependencies to develop mods for use in the runtime-classpath. @@ -139,6 +108,8 @@ public void apply(Project project) { var layout = project.getLayout(); var tasks = project.getTasks(); + var ideIntegration = IdeIntegration.of(project); + // We use this directory to store intermediate files used during moddev var modDevBuildDir = layout.getBuildDirectory().dir("moddev"); @@ -255,7 +226,7 @@ public void apply(Project project) { // For IntelliJ, we attach a combined sources+classes artifact which enables an "Attach Sources..." link for IJ users // Otherwise, attaching sources is a pain for IJ users. Provider minecraftClassesArtifact; - if (shouldUseCombinedSourcesAndClassesArtifact()) { + if (ideIntegration.shouldUseCombinedSourcesAndClassesArtifact()) { minecraftClassesArtifact = createArtifacts.map(task -> project.files(task.getCompiledWithSourcesArtifact())); } else { minecraftClassesArtifact = createArtifacts.map(task -> project.files(task.getCompiledArtifact())); @@ -310,13 +281,6 @@ public void apply(Project project) { })); }); - var ideSyncTask = tasks.register("neoForgeIdeSync", task -> { - task.setGroup(INTERNAL_TASK_GROUP); - task.setDescription("A utility task that is used to create necessary files when the Gradle project is synchronized with the IDE project."); - task.dependsOn(createArtifacts); - task.dependsOn(extension.getIdeSyncTasks()); - }); - var additionalClasspath = configurations.create("additionalRuntimeClasspath", spec -> { spec.setDescription("Contains dependencies of every run, that should not be considered boot classpath modules."); spec.setCanBeResolved(true); @@ -393,7 +357,7 @@ public void apply(Project project) { task.getGameLogLevel().set(run.getLogLevel()); }); prepareRunTasks.put(run, prepareRunTask); - ideSyncTask.configure(task -> task.dependsOn(prepareRunTask)); + ideIntegration.runTaskOnProjectSync(prepareRunTask); var createLaunchScriptTask = tasks.register(InternalModelHelper.nameOfRun(run, "create", "launchScript"), CreateLaunchScriptTask.class, task -> { task.setGroup(INTERNAL_TASK_GROUP); @@ -418,9 +382,9 @@ public void apply(Project project) { task.getVmArgsFile().set(prepareRunTask.get().getVmArgsFile().map(d -> d.getAsFile().getAbsolutePath())); task.getProgramArgsFile().set(prepareRunTask.get().getProgramArgsFile().map(d -> d.getAsFile().getAbsolutePath())); task.getEnvironment().set(run.getEnvironment()); - task.getModFolders().set(RunUtils.getGradleModFoldersProvider(project, run.getLoadedMods(), false)); + task.getModFolders().set(RunUtils.getGradleModFoldersProvider(project, run.getLoadedMods(), null)); }); - ideSyncTask.configure(task -> task.dependsOn(createLaunchScriptTask)); + ideIntegration.runTaskOnProjectSync(createLaunchScriptTask); tasks.register(InternalModelHelper.nameOfRun(run, "run", ""), RunGameTask.class, task -> { task.setGroup(TASK_GROUP); @@ -442,7 +406,7 @@ public void apply(Project project) { task.dependsOn(prepareRunTask); task.dependsOn(run.getTasksBefore()); - task.getJvmArgumentProviders().add(RunUtils.getGradleModFoldersProvider(project, run.getLoadedMods(), false)); + task.getJvmArgumentProviders().add(RunUtils.getGradleModFoldersProvider(project, run.getLoadedMods(), null)); }); }); @@ -453,15 +417,20 @@ public void apply(Project project) { modDevBuildDir, userDevConfigOnly, downloadAssets, - ideSyncTask, createArtifacts, neoForgeModDevLibrariesDependency, minecraftClassesArtifact ); - configureIntelliJModel(project, ideSyncTask, extension, prepareRunTasks); - - configureEclipseModel(project, ideSyncTask, createArtifacts, extension, prepareRunTasks); + // For IDEs that support it, link the source/binary artifacts if we use separated ones + if (!ideIntegration.shouldUseCombinedSourcesAndClassesArtifact()) { + ideIntegration.attachSources( + Map.of( + createArtifacts.get().getCompiledArtifact(), + createArtifacts.get().getSourcesArtifact() + ) + ); + } } private static Provider getNeoFormDataDependencyNotation(NeoForgeExtension extension) { @@ -577,14 +546,17 @@ private void setupTesting(Project project, Provider modDevDir, Configuration userDevConfigOnly, TaskProvider downloadAssets, - TaskProvider ideSyncTask, TaskProvider createArtifacts, Provider neoForgeModDevLibrariesDependency, Provider minecraftClassesArtifact) { var extension = ExtensionUtils.getExtension(project, NeoForgeExtension.NAME, NeoForgeExtension.class); var unitTest = extension.getUnitTest(); + var loadedMods = unitTest.getLoadedMods(); + var testedMod = unitTest.getTestedMod(); var gameDirectory = new File(project.getProjectDir(), JUNIT_GAME_DIR); + var ideIntegration = IdeIntegration.of(project); + var tasks = project.getTasks(); var configurations = project.getConfigurations(); var dependencyFactory = project.getDependencyFactory(); @@ -669,7 +641,7 @@ private void setupTesting(Project project, }); // Ensure the test files are written on sync so that users who use IDE-only tests can run them - ideSyncTask.configure(task -> task.dependsOn(prepareTask)); + ideIntegration.runTaskOnProjectSync(prepareTask); var testTask = tasks.named(JavaPlugin.TEST_TASK_NAME, Test.class, task -> { task.dependsOn(prepareTask); @@ -679,53 +651,16 @@ private void setupTesting(Project project, task.systemProperty("fml.junit.argsfile", programArgsFile.get().getAsFile().getAbsolutePath()); task.jvmArgs(RunUtils.getArgFileParameter(vmArgsFile.get())); - var modFoldersProvider = RunUtils.getGradleModFoldersProvider(project, unitTest.getLoadedMods(), true); + var modFoldersProvider = RunUtils.getGradleModFoldersProvider(project, loadedMods, testedMod); task.getJvmArgumentProviders().add(modFoldersProvider); }); project.afterEvaluate(p -> { // Test tasks don't have a provider-based property for working directory, so we need to afterEvaluate it. testTask.configure(task -> task.setWorkingDir(gameDirectory)); - - // Write out a separate file that has IDE specific VM args, which include the definition of the output directories. - // For JUnit we have to write this to a separate file due to the Run parameters being shared among all projects. - var intellijVmArgsFile = runArgsDir.map(dir -> dir.file("intellijVmArgs.txt")); - var outputDirectory = RunUtils.getIntellijOutputDirectory(project); - var ideSpecificVmArgs = RunUtils.escapeJvmArg(RunUtils.getIdeaModFoldersProvider(project, outputDirectory, unitTest.getLoadedMods(), true).getArgument()); - try { - var vmArgsFilePath = intellijVmArgsFile.get().getAsFile().toPath(); - Files.createDirectories(vmArgsFilePath.getParent()); - // JVM args generally expect platform encoding - FileUtils.writeStringSafe(vmArgsFilePath, ideSpecificVmArgs, StringUtils.getNativeCharset()); - } catch (IOException e) { - throw new GradleException("Failed to write VM args file for IntelliJ unit tests", e); - } - - // Configure IntelliJ default JUnit parameters, which are used when the user configures IJ to run tests natively - // IMPORTANT: This affects *all projects*, not just this one. We have to use $MODULE_WORKING_DIR$ to make it work. - var intelliJRunConfigurations = getIntelliJRunConfigurations(p); - if (intelliJRunConfigurations != null) { - intelliJRunConfigurations.defaults(JUnit.class, jUnitDefaults -> { - // $MODULE_WORKING_DIR$ is documented here: https://www.jetbrains.com/help/idea/absolute-path-variables.html - jUnitDefaults.setWorkingDirectory("$MODULE_WORKING_DIR$/" + JUNIT_GAME_DIR); - jUnitDefaults.setVmParameters( - // The FML JUnit plugin uses this system property to read a file containing the program arguments needed to launch - // NOTE: IntelliJ does not support $MODULE_WORKING_DIR$ in VM Arguments - // See https://youtrack.jetbrains.com/issue/IJPL-14230/Add-macro-support-for-VM-options-field-e.g.-expand-ModuleFileDir-properly - // As a workaround, we just use paths relative to the working directory. - RunUtils.escapeJvmArg("-Dfml.junit.argsfile=" + buildRelativePath(programArgsFile, gameDirectory)) - + " " - + RunUtils.escapeJvmArg("@" + buildRelativePath(vmArgsFile, gameDirectory)) - + " " - + RunUtils.escapeJvmArg("@" + buildRelativePath(intellijVmArgsFile, gameDirectory)) - ); - }); - } }); - } - private static String buildRelativePath(Provider file, File workingDirectory) { - return workingDirectory.toPath().relativize(file.get().getAsFile().toPath()).toString().replace("\\", "/"); + ideIntegration.configureTesting(loadedMods, testedMod, runArgsDir, gameDirectory, programArgsFile, vmArgsFile); } private static void setupJarJar(Project project) { @@ -742,282 +677,6 @@ private static void setupJarJar(Project project) { }); } - private static void addIntelliJRunConfiguration(Project project, - RunConfigurationContainer runConfigurations, - @Nullable Function outputDirectory, - RunModel run, - PrepareRun prepareTask) { - var appRun = new Application(run.getIdeName().get(), project); - var sourceSets = ExtensionUtils.getSourceSets(project); - var sourceSet = run.getSourceSet().get(); - // Validate that the source set is part of this project - if (!sourceSets.contains(sourceSet)) { - throw new GradleException("Cannot use source set from another project for run " + run.getName()); - } - appRun.setModuleName(RunUtils.getIntellijModuleName(project, sourceSet)); - appRun.setWorkingDirectory(run.getGameDirectory().get().getAsFile().getAbsolutePath()); - appRun.setEnvs(run.getEnvironment().get()); - - appRun.setJvmArgs( - RunUtils.escapeJvmArg(RunUtils.getArgFileParameter(prepareTask.getVmArgsFile().get())) - + " " - + RunUtils.escapeJvmArg(RunUtils.getIdeaModFoldersProvider(project, outputDirectory, run.getLoadedMods(), false).getArgument()) - ); - appRun.setMainClass(RunUtils.DEV_LAUNCH_MAIN_CLASS); - appRun.setProgramParameters(RunUtils.escapeJvmArg(RunUtils.getArgFileParameter(prepareTask.getProgramArgsFile().get()))); - - if (!run.getTasksBefore().isEmpty()) { - // This is slightly annoying. - // idea-ext does not expose the ability to run multiple gradle tasks at once, but the IDE model is capable of it. - class GradleTasks extends BeforeRunTask { - @Inject - GradleTasks(String nameParam) { - type = "gradleTask"; - name = nameParam; - } - - @SuppressWarnings("unchecked") - @Override - public Map toMap() { - var result = (Map) super.toMap(); - result.put("projectPath", project.getProjectDir().getAbsolutePath().replaceAll("\\\\", "/")); - var tasks = run.getTasksBefore().stream().map(task -> task.get().getPath()).collect(Collectors.joining(" ")); - result.put("taskName", tasks); - return result; - } - } - appRun.getBeforeRun().add(new GradleTasks("Prepare")); - } - - runConfigurations.add(appRun); - } - - private static void configureIntelliJModel(Project project, TaskProvider ideSyncTask, NeoForgeExtension extension, Map> prepareRunTasks) { - var rootProject = project.getRootProject(); - - if (!rootProject.getPlugins().hasPlugin(IdeaExtPlugin.class)) { - rootProject.getPlugins().apply(IdeaExtPlugin.class); - } - - // IDEA Sync has no real notion of tasks or providers or similar - project.afterEvaluate(ignored -> { - var settings = getIntelliJProjectSettings(rootProject); - if (settings != null && IdeDetection.isIntelliJSync()) { - // Also run the sync task directly as part of the sync. (Thanks Loom). - var startParameter = project.getGradle().getStartParameter(); - var taskRequests = new ArrayList<>(startParameter.getTaskRequests()); - - taskRequests.add(new DefaultTaskExecutionRequest(List.of(ideSyncTask.getName()))); - startParameter.setTaskRequests(taskRequests); - } - - var runConfigurations = getIntelliJRunConfigurations(rootProject); // TODO: Consider making this a value source - - if (runConfigurations == null) { - LOG.debug("Failed to find IntelliJ run configuration container. Not adding run configurations."); - } else { - var outputDirectory = RunUtils.getIntellijOutputDirectory(project); - - for (var run : extension.getRuns()) { - var prepareTask = prepareRunTasks.get(run).get(); - if (!prepareTask.getEnabled()) { - LOG.info("Not creating IntelliJ run {} since its prepare task {} is disabled", run, prepareTask); - continue; - } - addIntelliJRunConfiguration(project, runConfigurations, outputDirectory, run, prepareTask); - } - } - }); - } - - @Nullable - private static IdeaProject getIntelliJProject(Project project) { - var ideaModel = ExtensionUtils.findExtension(project, "idea", IdeaModel.class); - if (ideaModel != null) { - return ideaModel.getProject(); - } - return null; - } - - @Nullable - private static ProjectSettings getIntelliJProjectSettings(Project project) { - var ideaProject = getIntelliJProject(project); - if (ideaProject != null) { - return ((ExtensionAware) ideaProject).getExtensions().getByType(ProjectSettings.class); - } - return null; - } - - @Nullable - private static RunConfigurationContainer getIntelliJRunConfigurations(Project project) { - var projectSettings = getIntelliJProjectSettings(project); - if (projectSettings != null) { - return ExtensionUtils.findExtension((ExtensionAware) projectSettings, "runConfigurations", RunConfigurationContainer.class); - } - return null; - } - - private static void configureEclipseModel(Project project, - TaskProvider ideSyncTask, - TaskProvider createArtifacts, - NeoForgeExtension extension, - Map> prepareRunTasks) { - - // Set up stuff for Eclipse - var eclipseModel = ExtensionUtils.findExtension(project, "eclipse", EclipseModel.class); - if (eclipseModel == null) { - // If we detect running under Eclipse or VSCode, we apply the Eclipse plugin - if (!IdeDetection.isEclipse() && !IdeDetection.isVsCode()) { - LOG.info("No Eclipse project model found, and not running under Eclipse or VSCode. Skipping Eclipse model configuration."); - return; - } - - project.getPlugins().apply(EclipsePlugin.class); - eclipseModel = ExtensionUtils.findExtension(project, "eclipse", EclipseModel.class); - if (eclipseModel == null) { - LOG.error("Even after applying the Eclipse plugin, no 'eclipse' extension was present!"); - return; - } - } - - LOG.debug("Configuring Eclipse model for Eclipse project '{}'.", eclipseModel.getProject().getName()); - - // Make sure our post-sync task runs on Eclipse - eclipseModel.synchronizationTasks(ideSyncTask); - - // When using separate artifacts for classes and sources, link them - if (!shouldUseCombinedSourcesAndClassesArtifact()) { - var fileClasspath = eclipseModel.getClasspath().getFile(); - fileClasspath.whenMerged((org.gradle.plugins.ide.eclipse.model.Classpath classpath) -> { - var classesPath = createArtifacts.get().getCompiledArtifact().get().getAsFile(); - var sourcesPath = createArtifacts.get().getSourcesArtifact().get().getAsFile(); - - for (var entry : classpath.getEntries()) { - if (entry instanceof Library library && classesPath.equals(new File(library.getPath()))) { - library.setSourcePath(classpath.fileReference(sourcesPath)); - } - } - }); - } - - // Set up runs if running under buildship and in VS Code - if (IdeDetection.isVsCode()) { - project.afterEvaluate(ignored -> { - var launchWriter = new BatchedLaunchWriter(WritingMode.MODIFY_CURRENT); - - for (var run : extension.getRuns()) { - var prepareTask = prepareRunTasks.get(run).get(); - addVscodeLaunchConfiguration(project, run, prepareTask, launchWriter); - } - - try { - launchWriter.writeToLatestJson(project.getRootDir().toPath()); - } catch (final IOException e) { - throw new RuntimeException("Failed to write VSCode launch files", e); - } - }); - } else if (IdeDetection.isEclipse()) { - project.afterEvaluate(ignored -> { - for (var run : extension.getRuns()) { - var prepareTask = prepareRunTasks.get(run).get(); - addEclipseLaunchConfiguration(project, run, prepareTask); - } - }); - } - } - - private static void addEclipseLaunchConfiguration(Project project, - RunModel run, - PrepareRun prepareTask) { - if (!prepareTask.getEnabled()) { - LOG.info("Not creating Eclipse run {} since its prepare task {} is disabled", run, prepareTask); - return; - } - - // Grab the eclipse model so we can extend it. -> Done on the root project so that the model is available to all subprojects. - // And so that post sync tasks are only run once for all subprojects. - var model = project.getExtensions().getByType(EclipseModel.class); - - var runIdeName = run.getIdeName().get(); - var launchConfigName = runIdeName; - var eclipseProjectName = Objects.requireNonNullElse(model.getProject().getName(), project.getName()); - - // If the user wants to run tasks before the actual execution, we create a launch group to facilitate that - if (!run.getTasksBefore().isEmpty()) { - // Rename the main launch to "Run " ... - launchConfigName = "Run " + runIdeName; - - // Creates a launch config to run the preparation tasks - var prepareRunConfig = GradleLaunchConfig.builder(eclipseProjectName) - .tasks(run.getTasksBefore().stream().map(task -> task.get().getPath()).toArray(String[]::new)) - .build(); - var prepareRunLaunchName = "Prepare " + runIdeName; - RunUtils.writeEclipseLaunchConfig(project, prepareRunLaunchName, prepareRunConfig); - - // This is the launch group that will first launch Gradle, and then the game - var withGradleTasksConfig = LaunchGroup.builder() - .entry(LaunchGroup.entry(prepareRunLaunchName) - .enabled(true) - .adoptIfRunning(false) - .mode(LaunchGroup.Mode.RUN) - // See https://github.com/eclipse/buildship/issues/1272 - // for why we cannot just wait for termination - .action(LaunchGroup.Action.delay(2))) - .entry(LaunchGroup.entry(launchConfigName) - .enabled(true) - .adoptIfRunning(false) - .mode(LaunchGroup.Mode.INHERIT) - .action(LaunchGroup.Action.none())) - .build(); - RunUtils.writeEclipseLaunchConfig(project, runIdeName, withGradleTasksConfig); - } - - // This is the actual main launch configuration that launches the game - var config = JavaApplicationLaunchConfig.builder(eclipseProjectName) - .vmArgs( - RunUtils.escapeJvmArg(RunUtils.getArgFileParameter(prepareTask.getVmArgsFile().get())), - RunUtils.escapeJvmArg(RunUtils.getEclipseModFoldersProvider(project, run.getLoadedMods(), false).getArgument()) - ) - .args(RunUtils.escapeJvmArg(RunUtils.getArgFileParameter(prepareTask.getProgramArgsFile().get()))) - .envVar(run.getEnvironment().get()) - .workingDirectory(run.getGameDirectory().get().getAsFile().getAbsolutePath()) - .build(RunUtils.DEV_LAUNCH_MAIN_CLASS); - RunUtils.writeEclipseLaunchConfig(project, launchConfigName, config); - - } - - private static void addVscodeLaunchConfiguration(Project project, - RunModel run, - PrepareRun prepareTask, - BatchedLaunchWriter launchWriter) { - if (!prepareTask.getEnabled()) { - LOG.info("Not creating VSCode run {} since its prepare task {} is disabled", run, prepareTask); - return; - } - - var model = project.getExtensions().getByType(EclipseModel.class); - var runIdeName = run.getIdeName().get(); - var eclipseProjectName = Objects.requireNonNullElse(model.getProject().getName(), project.getName()); - - // If the user wants to run tasks before the actual execution, we attach them to autoBuildTasks - // Missing proper support - https://github.com/microsoft/vscode-java-debug/issues/1106 - if (!run.getTasksBefore().isEmpty()) { - model.autoBuildTasks(run.getTasksBefore().toArray()); - } - - launchWriter.createGroup("Mod Development - " + project.getName(), WritingMode.REMOVE_EXISTING) - .createLaunchConfiguration() - .withName(runIdeName) - .withProjectName(eclipseProjectName) - .withArguments(List.of(RunUtils.getArgFileParameter(prepareTask.getProgramArgsFile().get()))) - .withAdditionalJvmArgs(List.of(RunUtils.getArgFileParameter(prepareTask.getVmArgsFile().get()), - RunUtils.getEclipseModFoldersProvider(project, run.getLoadedMods(), false).getArgument())) - .withMainClass(RunUtils.DEV_LAUNCH_MAIN_CLASS) - .withShortenCommandLine(ShortCmdBehaviour.NONE) - .withConsoleType(ConsoleType.INTERNAL_CONSOLE) - .withCurrentWorkingDirectory(PathLike.ofNio(run.getGameDirectory().get().getAsFile().toPath())); - } - record DataFileCollectionWrapper(DataFileCollection extension, Configuration configuration) { } diff --git a/src/main/java/net/neoforged/moddevgradle/internal/NoIdeIntegration.java b/src/main/java/net/neoforged/moddevgradle/internal/NoIdeIntegration.java new file mode 100644 index 00000000..e6b9d683 --- /dev/null +++ b/src/main/java/net/neoforged/moddevgradle/internal/NoIdeIntegration.java @@ -0,0 +1,18 @@ +package net.neoforged.moddevgradle.internal; + +import org.gradle.api.Project; +import org.gradle.api.tasks.TaskProvider; + +/** + * This implementation of {@link IdeIntegration} is used when no IDE was detected to host Gradle. + */ +final class NoIdeIntegration extends IdeIntegration { + public NoIdeIntegration(Project project) { + super(project); + } + + @Override + protected void registerProjectSyncTask(TaskProvider task) { + // No IDE + } +} diff --git a/src/main/java/net/neoforged/moddevgradle/internal/RunUtils.java b/src/main/java/net/neoforged/moddevgradle/internal/RunUtils.java index 8dead3cf..f6501eb9 100644 --- a/src/main/java/net/neoforged/moddevgradle/internal/RunUtils.java +++ b/src/main/java/net/neoforged/moddevgradle/internal/RunUtils.java @@ -1,12 +1,9 @@ package net.neoforged.moddevgradle.internal; -import net.neoforged.elc.configs.LaunchConfig; import net.neoforged.moddevgradle.dsl.InternalModelHelper; import net.neoforged.moddevgradle.dsl.ModModel; -import net.neoforged.moddevgradle.dsl.NeoForgeExtension; import net.neoforged.moddevgradle.dsl.RunModel; import net.neoforged.moddevgradle.internal.utils.ExtensionUtils; -import net.neoforged.moddevgradle.internal.utils.IdeDetection; import net.neoforged.moddevgradle.internal.utils.OperatingSystem; import org.gradle.api.GradleException; import org.gradle.api.InvalidUserCodeException; @@ -22,21 +19,13 @@ import org.gradle.api.tasks.Nested; import org.gradle.api.tasks.SourceSet; import org.gradle.process.CommandLineArgumentProvider; -import org.intellij.lang.annotations.Language; import org.jetbrains.annotations.Nullable; -import org.jetbrains.gradle.ext.ModuleRef; import org.slf4j.event.Level; -import org.xml.sax.InputSource; import javax.inject.Inject; -import javax.xml.stream.XMLStreamException; -import javax.xml.xpath.XPathExpressionException; -import javax.xml.xpath.XPathFactory; import java.io.BufferedInputStream; import java.io.File; import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.FileWriter; import java.io.IOException; import java.io.UncheckedIOException; import java.nio.file.Files; @@ -47,7 +36,6 @@ import java.util.Properties; import java.util.Set; import java.util.function.BiConsumer; -import java.util.function.Function; import java.util.stream.Collectors; final class RunUtils { @@ -155,7 +143,7 @@ public static void writeLog4j2Configuration(Level rootLevel, Path destination) t - + @@ -163,7 +151,7 @@ public static void writeLog4j2Configuration(Level rootLevel, Path destination) t - + @@ -205,13 +193,13 @@ public static String getArgFileParameter(RegularFile argFile) { return "@" + argFile.getAsFile().getAbsolutePath(); } - public static ModFoldersProvider getGradleModFoldersProvider(Project project, Provider> modsProvider, boolean includeUnitTests) { + public static ModFoldersProvider getGradleModFoldersProvider(Project project, Provider> modsProvider, Provider testedMod) { var modFoldersProvider = project.getObjects().newInstance(ModFoldersProvider.class); - modFoldersProvider.getModFolders().set(getModFoldersForGradle(project, modsProvider, includeUnitTests)); + modFoldersProvider.getModFolders().set(getModFoldersForGradle(project, modsProvider, testedMod)); return modFoldersProvider; } - private static Project findSourceSetProject(Project someProject, SourceSet sourceSet) { + public static Project findSourceSetProject(Project someProject, SourceSet sourceSet) { for (var s : ExtensionUtils.getSourceSets(someProject)) { if (s == sourceSet) { return someProject; @@ -231,69 +219,25 @@ private static Project findSourceSetProject(Project someProject, SourceSet sourc throw new IllegalArgumentException("Could not find project for source set " + someProject); } - public static ModFoldersProvider getIdeaModFoldersProvider(Project project, - @Nullable Function outputDirectory, - Provider> modsProvider, - boolean includeUnitTests) { - Provider> folders; - if (outputDirectory != null) { - folders = buildModFolders(project, modsProvider, includeUnitTests, (sourceSet, output) -> { - var sourceSetDir = outputDirectory.apply(findSourceSetProject(project, sourceSet)).toPath().resolve(getIdeaOutName(sourceSet)); - output.from(sourceSetDir.resolve("classes"), sourceSetDir.resolve("resources")); - }); - } else { - folders = getModFoldersForGradle(project, modsProvider, includeUnitTests); - } - - var modFoldersProvider = project.getObjects().newInstance(ModFoldersProvider.class); - modFoldersProvider.getModFolders().set(folders); - return modFoldersProvider; - } - - public static void writeEclipseLaunchConfig(Project project, String name, LaunchConfig config) { - var file = project.file(".eclipse/configurations/" + name + ".launch"); - file.getParentFile().mkdirs(); - try (var writer = new FileWriter(file, false)) { - config.write(writer); - } catch (IOException e) { - throw new UncheckedIOException("Failed to write launch file: " + file, e); - } catch (XMLStreamException e) { - throw new RuntimeException("Failed to write launch file: " + file, e); - } - } - - public static ModFoldersProvider getEclipseModFoldersProvider(Project project, - Provider> modsProvider, - boolean includeUnitTests) { - var folders = buildModFolders(project, modsProvider, includeUnitTests, (sourceSet, output) -> { - output.from(findSourceSetProject(project, sourceSet).getProjectDir().toPath() - .resolve("bin") - .resolve(sourceSet.getName())); - }); - - var modFoldersProvider = project.getObjects().newInstance(ModFoldersProvider.class); - modFoldersProvider.getModFolders().set(folders); - return modFoldersProvider; - } - - private static String getIdeaOutName(final SourceSet sourceSet) { - return sourceSet.getName().equals(SourceSet.MAIN_SOURCE_SET_NAME) ? "production" : sourceSet.getName(); - } - - private static Provider> getModFoldersForGradle(Project project, Provider> modsProvider, boolean includeUnitTests) { - return buildModFolders(project, modsProvider, includeUnitTests, (sourceSet, output) -> { + public static Provider> getModFoldersForGradle(Project project, + Provider> modsProvider, + @Nullable Provider testedMod) { + return buildModFolders(project, modsProvider, testedMod, (sourceSet, output) -> { output.from(sourceSet.getOutput()); }); } - private static Provider> buildModFolders(Project project, Provider> modsProvider, boolean includeUnitTests, BiConsumer outputFolderResolver) { - var extension = ExtensionUtils.findExtension(project, NeoForgeExtension.NAME, NeoForgeExtension.class); - var testedModProvider = extension.getUnitTest().getTestedMod() - .filter(m -> includeUnitTests) - .map(Optional::of) - .orElse(Optional.empty()); + public static Provider> buildModFolders(Project project, + Provider> modsProvider, + @Nullable Provider testedModProvider, + BiConsumer outputFolderResolver) { + // Convert it to optional to ensure zip will be called even if no mod under test is present. + if (testedModProvider == null) { + testedModProvider = project.provider(() -> null); + } + var optionalTestedModProvider = testedModProvider.map(Optional::of).orElse(Optional.empty()); - return modsProvider.zip(testedModProvider, ((mods, testedMod) -> { + return modsProvider.zip(optionalTestedModProvider, ((mods, testedMod) -> { if (testedMod.isPresent()) { if (!mods.contains(testedMod.get())) { throw new InvalidUserCodeException("The tested mod (%s) must be included in the mods loaded for unit testing (%s)." @@ -328,76 +272,6 @@ private static Provider> buildModFolders(Project project, })); } - // TODO: Loom has unit tests for this... Probably a good idea! - @Language("xpath") - public static final String IDEA_DELEGATED_BUILD_XPATH = "/project/component[@name='GradleSettings']/option[@name='linkedExternalProjectsSettings']/GradleProjectSettings/option[@name='delegatedBuild']/@value"; - @Language("xpath") - public static final String IDEA_OUTPUT_XPATH = "/project/component[@name='ProjectRootManager']/output/@url"; - - /** - * Returns a function that maps a project to the configured output directory, - * only if "Build and run using" is set to "IDEA". - * In other cases, returns {@code null}. - */ - @Nullable - static Function getIntellijOutputDirectory(Project someProject) { - var ideaDir = IdeDetection.getIntellijProjectDir(someProject); - if (ideaDir == null) { - return null; - } - - // Check if IntelliJ is configured to build with Gradle. - var gradleXml = new File(ideaDir, "gradle.xml"); - var delegatedBuild = evaluateXPath(gradleXml, IDEA_DELEGATED_BUILD_XPATH); - if (!"false".equals(delegatedBuild)) { - return null; - } - - // Find configured output path - var miscXml = new File(ideaDir, "misc.xml"); - String outputDirUrl = evaluateXPath(miscXml, IDEA_OUTPUT_XPATH); - if (outputDirUrl == null) { - // Apparently IntelliJ defaults to out/ now? - outputDirUrl = "file://$PROJECT_DIR$/out"; - } - - // The output dir can start with something like "//C:\"; File can handle it. - outputDirUrl = outputDirUrl.replaceAll("^file:", ""); - - var outputDirTemplate = outputDirUrl; - return p -> new File(outputDirTemplate.replace("$PROJECT_DIR$", p.getProjectDir().getAbsolutePath())); - } - - @Nullable - private static String evaluateXPath(File file, @Language("xpath") String expression) { - try (var fis = new FileInputStream(file)) { - String result = XPathFactory.newInstance().newXPath().evaluate(expression, new InputSource(fis)); - return result.isBlank() ? null : result; - } catch (FileNotFoundException | XPathExpressionException ignored) { - return null; - } catch (IOException e) { - throw new UncheckedIOException("Failed to evaluate xpath " + expression + " on file " + file, e); - } - } - - /** - * Convert a project and source set to an IntelliJ module name. - * Do not use {@link ModuleRef} as it does not correctly handle projects with a space in their name! - */ - public static String getIntellijModuleName(Project project, SourceSet sourceSet) { - var moduleName = new StringBuilder(); - // The `replace` call here is our bug fix compared to ModuleRef! - // The actual IDEA logic is more complicated, but this should cover the majority of use cases. - // See https://github.com/JetBrains/intellij-community/blob/a32fd0c588a6da11fd6d5d2fb0362308da3206f3/plugins/gradle/src/org/jetbrains/plugins/gradle/service/project/GradleProjectResolverUtil.java#L205 - // which calls https://github.com/JetBrains/intellij-community/blob/a32fd0c588a6da11fd6d5d2fb0362308da3206f3/platform/util-rt/src/com/intellij/util/PathUtilRt.java#L120 - moduleName.append(project.getRootProject().getName().replace(" ", "_")); - if (project != project.getRootProject()) { - moduleName.append(project.getPath().replaceAll(":", ".")); - } - moduleName.append("."); - moduleName.append(sourceSet.getName()); - return moduleName.toString(); - } } record AssetProperties(String assetIndex, String assetsRoot) { diff --git a/src/main/java/net/neoforged/moddevgradle/internal/VsCodeIntegration.java b/src/main/java/net/neoforged/moddevgradle/internal/VsCodeIntegration.java new file mode 100644 index 00000000..53080395 --- /dev/null +++ b/src/main/java/net/neoforged/moddevgradle/internal/VsCodeIntegration.java @@ -0,0 +1,79 @@ +package net.neoforged.moddevgradle.internal; + +import net.neoforged.moddevgradle.dsl.RunModel; +import net.neoforged.vsclc.BatchedLaunchWriter; +import net.neoforged.vsclc.attribute.ConsoleType; +import net.neoforged.vsclc.attribute.PathLike; +import net.neoforged.vsclc.attribute.ShortCmdBehaviour; +import net.neoforged.vsclc.writer.WritingMode; +import org.gradle.api.Project; +import org.gradle.api.tasks.TaskProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Provides integration with Eclipse Buildship and VSCode extensions based on it. + */ +final class VsCodeIntegration extends EclipseIntegration { + private static final Logger LOG = LoggerFactory.getLogger(VsCodeIntegration.class); + + VsCodeIntegration(Project project) { + super(project); + } + + @Override + public void configureRuns(Map> prepareRunTasks, + Iterable runs) { + // Set up runs if running under buildship and in VS Code + project.afterEvaluate(ignored -> { + var launchWriter = new BatchedLaunchWriter(WritingMode.MODIFY_CURRENT); + + for (var run : runs) { + var prepareTask = prepareRunTasks.get(run).get(); + addVscodeLaunchConfiguration(project, run, prepareTask, launchWriter); + } + + try { + launchWriter.writeToLatestJson(project.getRootDir().toPath()); + } catch (final IOException e) { + throw new RuntimeException("Failed to write VSCode launch files", e); + } + }); + } + + private void addVscodeLaunchConfiguration(Project project, + RunModel run, + PrepareRun prepareTask, + BatchedLaunchWriter launchWriter) { + if (!prepareTask.getEnabled()) { + LOG.info("Not creating VSCode run {} since its prepare task {} is disabled", run, prepareTask); + return; + } + + var runIdeName = run.getIdeName().get(); + var eclipseProjectName = Objects.requireNonNullElse(eclipseModel.getProject().getName(), project.getName()); + + // If the user wants to run tasks before the actual execution, we attach them to autoBuildTasks + // Missing proper support - https://github.com/microsoft/vscode-java-debug/issues/1106 + if (!run.getTasksBefore().isEmpty()) { + eclipseModel.autoBuildTasks(run.getTasksBefore().toArray()); + } + + launchWriter.createGroup("Mod Development - " + project.getName(), WritingMode.REMOVE_EXISTING) + .createLaunchConfiguration() + .withName(runIdeName) + .withProjectName(eclipseProjectName) + .withArguments(List.of(RunUtils.getArgFileParameter(prepareTask.getProgramArgsFile().get()))) + .withAdditionalJvmArgs(List.of(RunUtils.getArgFileParameter(prepareTask.getVmArgsFile().get()), + getModFoldersProvider(project, run.getLoadedMods(), null).getArgument())) + .withMainClass(RunUtils.DEV_LAUNCH_MAIN_CLASS) + .withShortenCommandLine(ShortCmdBehaviour.NONE) + .withConsoleType(ConsoleType.INTERNAL_CONSOLE) + .withCurrentWorkingDirectory(PathLike.ofNio(run.getGameDirectory().get().getAsFile().toPath())); + } +} diff --git a/src/main/java/net/neoforged/moddevgradle/internal/utils/IdeDetection.java b/src/main/java/net/neoforged/moddevgradle/internal/utils/IdeDetection.java index dedd2edc..afe93244 100644 --- a/src/main/java/net/neoforged/moddevgradle/internal/utils/IdeDetection.java +++ b/src/main/java/net/neoforged/moddevgradle/internal/utils/IdeDetection.java @@ -1,12 +1,8 @@ package net.neoforged.moddevgradle.internal.utils; -import org.gradle.api.Project; -import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.File; - /** * Utilities for trying to detect in which IDE Gradle is running. */ @@ -89,31 +85,4 @@ public static boolean isVsCode() { return false; } - /** - * Try to find the IntelliJ project directory that belongs to this Gradle project. - * There are scenarios where this is impossible, since IntelliJ allows adding - * Gradle builds to IntelliJ projects in a completely different directory. - */ - @Nullable - public static File getIntellijProjectDir(Project project) { - // Always try the root of a composite build first, since it has the highest chance - var root = project.getGradle().getParent(); - if (root != null) { - while (root.getParent() != null) { - root = root.getParent(); - } - - return getIntellijProjectDir(root.getRootProject().getProjectDir()); - } - - // As a fallback or in case of not using composite builds, try the root project folder - return getIntellijProjectDir(project.getRootDir()); - } - - @Nullable - private static File getIntellijProjectDir(File gradleProjectDir) { - var ideaDir = new File(gradleProjectDir, ".idea"); - return ideaDir.exists() ? ideaDir : null; - } - }