// Copyright 2016 Pants project contributors (see CONTRIBUTORS.md). // Licensed under the Apache License, Version 2.0 (see LICENSE). package com.twitter.intellij.pants.execution; import com.intellij.execution.BeforeRunTask; import com.intellij.execution.CommonProgramRunConfigurationParameters; import com.intellij.execution.ExecutionException; import com.intellij.execution.RunManager; import com.intellij.execution.RunnerAndConfigurationSettings; import com.intellij.execution.configurations.GeneralCommandLine; import com.intellij.execution.configurations.RunConfiguration; import com.intellij.execution.configurations.RunProfileWithCompileBeforeLaunchOption; import com.intellij.execution.filters.Filter; import com.intellij.execution.filters.OpenFileHyperlinkInfo; import com.intellij.execution.impl.RunManagerImpl; import com.intellij.execution.process.CapturingAnsiEscapesAwareProcessHandler; import com.intellij.execution.process.CapturingProcessHandler; import com.intellij.execution.process.ProcessAdapter; import com.intellij.execution.process.ProcessEvent; import com.intellij.execution.runners.ExecutionEnvironment; import com.intellij.execution.ui.ConsoleView; import com.intellij.execution.ui.ConsoleViewContentType; import com.intellij.notification.Notification; import com.intellij.notification.NotificationType; import com.intellij.notification.Notifications; import com.intellij.openapi.actionSystem.DataContext; import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.application.ModalityState; import com.intellij.openapi.externalSystem.service.execution.ExternalSystemBeforeRunTask; import com.intellij.openapi.externalSystem.service.execution.ExternalSystemBeforeRunTaskProvider; import com.intellij.openapi.fileEditor.FileDocumentManager; import com.intellij.openapi.module.Module; import com.intellij.openapi.module.ModuleManager; import com.intellij.openapi.project.Project; import com.intellij.openapi.util.Key; import com.intellij.openapi.util.Pair; import com.intellij.openapi.vfs.LocalFileSystem; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.openapi.wm.ex.ToolWindowManagerEx; import com.twitter.intellij.pants.PantsBundle; import com.twitter.intellij.pants.file.FileChangeTracker; import com.twitter.intellij.pants.metrics.PantsExternalMetricsListenerManager; import com.twitter.intellij.pants.model.PantsOptions; import com.twitter.intellij.pants.settings.PantsSettings; import com.twitter.intellij.pants.ui.PantsConsoleManager; import com.twitter.intellij.pants.util.PantsConstants; import com.twitter.intellij.pants.util.PantsUtil; import icons.PantsIcons; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.plugins.scala.testingSupport.test.AbstractTestRunConfiguration; import javax.swing.Icon; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; /** * PantsMakeBeforeRun creates a custom Make process `PantsCompile` to replace IntelliJ's default Make process whenever a new configuration * is added under a pants project via {@link PantsMakeBeforeRun#replaceDefaultMakeWithPantsMake}, so the time to launch pants is minimized. * <p/> * Motivation: By default, IntelliJ's Make process is invoked before any JUnit/Scala/Application run which has unnecessary (for Pants) * long steps to scan the entire project to assist external builders' incremental compile. */ public class PantsMakeBeforeRun extends ExternalSystemBeforeRunTaskProvider { public static final Key<ExternalSystemBeforeRunTask> ID = Key.create("Pants.BeforeRunTask"); public static final String ERROR_TAG = "[error]"; private static ConcurrentHashMap<Project, Process> runningPantsProcesses = new ConcurrentHashMap<>(); public static boolean hasActivePantsProcess(@NotNull Project project) { return runningPantsProcesses.containsKey(project); } public PantsMakeBeforeRun(@NotNull Project project) { super(PantsConstants.SYSTEM_ID, project, ID); } public static void replaceDefaultMakeWithPantsMake(@NotNull Project project, @NotNull RunnerAndConfigurationSettings settings) { RunManager runManager = RunManager.getInstance(project); if (!(runManager instanceof RunManagerImpl)) { return; } RunManagerImpl runManagerImpl = (RunManagerImpl) runManager; RunConfiguration runConfiguration = settings.getConfiguration(); Optional<VirtualFile> buildRoot = PantsUtil.findBuildRoot(project); /** /** * Scala related run/test configuration inherit {@link AbstractTestRunConfiguration} */ if (runConfiguration instanceof AbstractTestRunConfiguration) { if (buildRoot.isPresent()) { ((AbstractTestRunConfiguration) runConfiguration).setWorkingDirectory(buildRoot.get().getPath()); } } /** * JUnit, Application, etc configuration inherit {@link CommonProgramRunConfigurationParameters} */ else if (runConfiguration instanceof CommonProgramRunConfigurationParameters) { if (buildRoot.isPresent()) { ((CommonProgramRunConfigurationParameters) runConfiguration).setWorkingDirectory(buildRoot.get().getPath()); } } /** * If neither applies (e.g. Pants or pytest configuration), do not continue. */ else { return; } /** * Every time a new configuration is created, 'Make' is by default added to the "Before launch" tasks. * Therefore we want to overwrite it with {@link PantsMakeBeforeRun}. */ BeforeRunTask pantsMakeTask = new ExternalSystemBeforeRunTask(ID, PantsConstants.SYSTEM_ID); pantsMakeTask.setEnabled(true); runManagerImpl.setBeforeRunTasks(runConfiguration, Collections.singletonList(pantsMakeTask), false); } public static void terminatePantsProcess(Project project) { Process process = runningPantsProcesses.get(project); if (process != null) { process.destroy(); runningPantsProcesses.remove(project, process); } } @Override public String getName() { return "PantsCompile"; } @Override public String getDescription(ExternalSystemBeforeRunTask task) { return "PantsCompile"; } @Nullable @Override public ExternalSystemBeforeRunTask createTask(RunConfiguration runConfiguration) { return new ExternalSystemBeforeRunTask(ID, PantsConstants.SYSTEM_ID); } @Nullable @Override public Icon getIcon() { return PantsIcons.Icon; } @Override public boolean canExecuteTask( RunConfiguration configuration, ExternalSystemBeforeRunTask beforeRunTask ) { return true; } @Override public boolean executeTask( final DataContext context, RunConfiguration configuration, ExecutionEnvironment env, ExternalSystemBeforeRunTask beforeRunTask ) { Project currentProject = configuration.getProject(); prepareIDE(currentProject); Set<String> targetAddressesToCompile = PantsUtil.filterGenTargets(getTargetAddressesToCompile(configuration)); Pair<Boolean, Optional<String>> result = executeTask(currentProject, targetAddressesToCompile, false); return result.getFirst(); } public Pair<Boolean, Optional<String>> executeTask(Project project) { return executeTask(project, getTargetAddressesToCompile(ModuleManager.getInstance(project).getModules()), false); } public Pair<Boolean, Optional<String>> executeTask(@NotNull Module[] modules) { if (modules.length == 0) { return Pair.create(false, Optional.empty()); } return executeTask(modules[0].getProject(), getTargetAddressesToCompile(modules), false); } /** * @param currentProject: current project * @param targetAddressesToCompile: set of target addresses to compile * @param useCleanAll: whether to use clean-all first in Pants * @return whether the execution is successful, additional message along with the execution * in a Pair object. */ public Pair<Boolean, Optional<String>> executeTask(Project currentProject, Set<String> targetAddressesToCompile, boolean useCleanAll) { // If project has not changed since last Compile, return immediately. if (!FileChangeTracker.shouldRecompileThenReset(currentProject, targetAddressesToCompile)) { PantsExternalMetricsListenerManager.getInstance().logIsPantsNoopCompile(true); return Pair.create(true, Optional.of(PantsConstants.NOOP_COMPILE)); } prepareIDE(currentProject); if (targetAddressesToCompile.isEmpty()) { showPantsMakeTaskMessage("No target found in configuration.\n", ConsoleViewContentType.SYSTEM_OUTPUT, currentProject); return Pair.create(true, Optional.empty()); } Optional<VirtualFile> pantsExecutable = PantsUtil.findPantsExecutable(currentProject); if (!pantsExecutable.isPresent()) { return Pair.create( false, Optional.of( PantsBundle.message("pants.error.no.pants.executable.by.path", currentProject.getProjectFilePath()) ) ); } final GeneralCommandLine commandLine = PantsUtil.defaultCommandLine(pantsExecutable.get().getPath()); showPantsMakeTaskMessage("Checking Pants options...\n", ConsoleViewContentType.SYSTEM_OUTPUT, currentProject); Optional<PantsOptions> pantsOptional = PantsOptions.getPantsOptions(currentProject); if (!pantsOptional.isPresent()) { showPantsMakeTaskMessage("Pants Options not found.\n", ConsoleViewContentType.ERROR_OUTPUT, currentProject); return Pair.create(false, Optional.empty()); } PantsOptions pantsOptions = pantsOptional.get(); /* Global options section. */ commandLine.addParameter(PantsConstants.PANTS_CLI_OPTION_NO_COLORS); if (useCleanAll) { commandLine.addParameter("clean-all"); if (pantsOptions.supportsAsyncCleanAll()) { commandLine.addParameter(PantsConstants.PANTS_CLI_OPTION_ASYNC_CLEAN_ALL); } } if (pantsOptions.supportsManifestJar()) { commandLine.addParameter(PantsConstants.PANTS_CLI_OPTION_EXPORT_CLASSPATH_MANIFEST_JAR); } PantsSettings settings = PantsSettings.getInstance(currentProject); if (settings.isUseIdeaProjectJdk()) { try { commandLine.addParameter(PantsUtil.getJvmDistributionPathParameter(PantsUtil.getJdkPathFromIntelliJCore())); } catch (Exception e) { showPantsMakeTaskMessage(e.getMessage(), ConsoleViewContentType.ERROR_OUTPUT, currentProject); return Pair.create(false, Optional.empty()); } } /* Goals and targets section. */ commandLine.addParameters("export-classpath", "compile"); for (String targetAddress : targetAddressesToCompile) { commandLine.addParameter(targetAddress); } final Process process; try { process = commandLine.createProcess(); } catch (ExecutionException e) { showPantsMakeTaskMessage(e.getMessage(), ConsoleViewContentType.ERROR_OUTPUT, currentProject); return Pair.create(false, Optional.empty()); } final CapturingProcessHandler processHandler = new CapturingAnsiEscapesAwareProcessHandler(process, commandLine.getCommandLineString()); final List<String> output = new ArrayList<>(); processHandler.addProcessListener(new ProcessAdapter() { @Override public void onTextAvailable(ProcessEvent event, Key outputType) { super.onTextAvailable(event, outputType); showPantsMakeTaskMessage(event.getText(), ConsoleViewContentType.NORMAL_OUTPUT, currentProject); output.add(event.getText()); } }); runningPantsProcesses.put(currentProject, process); processHandler.runProcess(); runningPantsProcesses.remove(currentProject, process); final boolean success = process.exitValue() == 0; // Mark project dirty if compile failed. if (!success) { FileChangeTracker.markDirty(currentProject); } else { FileChangeTracker.addManifestJarIntoSnapshot(currentProject); } notifyCompileResult(success); // Sync files as generated sources may have changed after Pants compile. PantsUtil.synchronizeFiles(); String finalOutString = String.join("", output); Pair<Boolean, Optional<String>> result = Pair.create(success, Optional.of(finalOutString)); return result; } private void notifyCompileResult(final boolean success) { /* Show pop up notification about pants compile result. */ ApplicationManager.getApplication().invokeLater(new Runnable() { @Override public void run() { String message = success ? "Pants compile succeeded." : "Pants compile failed."; NotificationType type = success ? NotificationType.INFORMATION : NotificationType.ERROR; Notification start = new Notification(PantsConstants.PANTS, "Compile message", message, type); Notifications.Bus.notify(start); } }); } private void prepareIDE(Project project) { ApplicationManager.getApplication().invokeAndWait(new Runnable() { @Override public void run() { /* Clear message window. */ ConsoleView executionConsole = PantsConsoleManager.getOrMakeNewConsole(project); executionConsole.getComponent().setVisible(true); executionConsole.clear(); ToolWindowManagerEx.getInstance(project).getToolWindow(PantsConstants.PANTS_CONSOLE_NAME).activate(null); /* Force cached changes to disk. */ FileDocumentManager.getInstance().saveAllDocuments(); project.save(); } }, ModalityState.NON_MODAL); } @NotNull protected Set<String> getTargetAddressesToCompile(RunConfiguration configuration) { /* Scala run configurations */ if (configuration instanceof AbstractTestRunConfiguration) { Module module = ((AbstractTestRunConfiguration) configuration).getModule(); return getTargetAddressesToCompile(new Module[]{module}); } /* JUnit, Application run configurations */ else if (configuration instanceof RunProfileWithCompileBeforeLaunchOption) { RunProfileWithCompileBeforeLaunchOption config = (RunProfileWithCompileBeforeLaunchOption) configuration; Module[] targetModules = config.getModules(); return getTargetAddressesToCompile(targetModules); } else { return Collections.emptySet(); } } @NotNull private Set<String> getTargetAddressesToCompile(Module[] targetModules) { if (targetModules.length == 0) { return Collections.emptySet(); } Set<String> result = new HashSet<>(); for (Module targetModule : targetModules) { result.addAll(PantsUtil.getNonGenTargetAddresses(targetModule)); } return result; } private void showPantsMakeTaskMessage(String message, ConsoleViewContentType type, Project project) { ConsoleView executionConsole = PantsConsoleManager.getOrMakeNewConsole(project); // Create a filter that monitors console outputs, and turns them into a hyperlink if applicable. Filter filter = new Filter() { @Nullable @Override public Result applyFilter(String line, int entireLength) { Optional<ParseResult> result = ParseResult.parseErrorLocation(line, ERROR_TAG); if (result.isPresent()) { OpenFileHyperlinkInfo linkInfo = new OpenFileHyperlinkInfo( project, result.get().getFile(), result.get().getLineNumber() - 1, // line number needs to be 0 indexed result.get().getColumnNumber() - 1 // column number needs to be 0 indexed ); int startHyperlink = entireLength - line.length() + line.indexOf(ERROR_TAG); return new Result( startHyperlink, entireLength, linkInfo, null // TextAttributes, going with default hence null ); } return null; } }; ApplicationManager.getApplication().invokeLater(new Runnable() { @Override public void run() { executionConsole.addMessageFilter(filter); executionConsole.print(message, type); } }, ModalityState.NON_MODAL); } /** * Encapsulate the result of parsed data. */ static class ParseResult { private VirtualFile file; private int lineNumber; private int columnNumber; /** * This function parses Pants output against known file and tag, * and returns (file, line number, column number) * encapsulated in `ParseResult` object if the output contains valid information. * * @param line original Pants output * @param tag known tag. e.g. [error] * @return `ParseResult` instance */ public static Optional<ParseResult> parseErrorLocation(String line, String tag) { if (!line.contains(tag)) { return Optional.empty(); } String[] splitByColon = line.split(":"); if (splitByColon.length < 3) { return Optional.empty(); } try { // filePath path is between tag and first colon String filePath = splitByColon[0].substring(splitByColon[0].indexOf(tag) + tag.length()).trim(); VirtualFile virtualFile = LocalFileSystem.getInstance().findFileByPath(filePath); if (virtualFile == null) { return Optional.empty(); } // line number is between first and second colon int lineNumber = Integer.valueOf(splitByColon[1]); // column number is between second and third colon int columnNumber = Integer.valueOf(splitByColon[2]); return Optional.of(new ParseResult(virtualFile, lineNumber, columnNumber)); } catch (NumberFormatException e) { return Optional.empty(); } } private ParseResult(VirtualFile file, int lineNumber, int columnNumber) { this.file = file; this.lineNumber = lineNumber; this.columnNumber = columnNumber; } @Override public boolean equals(Object obj) { if (obj == null) { return false; } if (getClass() != obj.getClass()) { return false; } ParseResult other = (ParseResult) obj; return Objects.equals(file, other.file) && Objects.equals(lineNumber, other.lineNumber) && Objects.equals(columnNumber, other.columnNumber); } public VirtualFile getFile() { return file; } public int getLineNumber() { return lineNumber; } public int getColumnNumber() { return columnNumber; } } }