package com.haskforce.highlighting.annotation.external; import com.haskforce.actions.RestartGhcModi; import com.haskforce.codeInsight.BrowseItem; import com.haskforce.highlighting.annotation.Problems; import com.haskforce.highlighting.annotation.external.GhcModUtil.GhcVersionValidation; import com.haskforce.settings.SettingsChangeNotifier; import com.haskforce.settings.ToolKey; import com.haskforce.settings.ToolSettings; import com.haskforce.ui.tools.HaskellToolsConsole; import com.haskforce.utils.ExecUtil; import com.haskforce.utils.HtmlUtils; import com.haskforce.utils.NotificationUtil; import com.haskforce.utils.SystemUtil; import com.intellij.execution.ExecutionException; import com.intellij.execution.configurations.GeneralCommandLine; import com.intellij.execution.configurations.ParametersList; import com.intellij.notification.NotificationType; import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.editor.VisualPosition; import com.intellij.openapi.module.Module; import com.intellij.openapi.module.ModuleComponent; import com.intellij.openapi.module.ModuleUtilCore; import com.intellij.openapi.project.Project; import com.intellij.openapi.util.text.StringUtil; import com.intellij.psi.PsiElement; import com.intellij.xml.util.XmlUtil; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import scala.Option; import java.io.*; import java.util.*; import java.util.concurrent.*; import java.util.regex.Pattern; /** * Process wrapper for GhcModi. Implements ModuleComponent so destruction of processes coincides with closing projects. */ public class GhcModi implements ModuleComponent, SettingsChangeNotifier { public static Option<GhcModi> get(PsiElement element) { final Module module = ModuleUtilCore.findModuleForPsiElement(element); if (module == null) return Option.apply(null); return get(module); } public static Option<GhcModi> get(@NotNull Module module) { final GhcModi ghcModi = module.getComponent(GhcModi.class); if (ghcModi.isConfigured()) return Option.apply(ghcModi); return Option.apply(null); } @SuppressWarnings("UnusedDeclaration") private static final Logger LOG = Logger.getInstance(GhcModi.class); public @NotNull final Module module; public @NotNull String workingDirectory; public @Nullable String path; public @NotNull String flags; private @Nullable Process process; private @Nullable BufferedReader input; private @Nullable BufferedWriter output; private ExecutorService executorService = Executors.newSingleThreadExecutor(); private boolean enabled = true; private GhcVersionValidation ghcVersionValidation = GhcVersionValidation.PENDING_VALIDATION; private final HaskellToolsConsole toolConsole; // Keep track of error messages so we don't output the same ones multiple times. public static final Pattern TYPE_SPLIT_REGEX = Pattern.compile(" :: "); public static Problems getFutureProblems(@NotNull Project project, @NotNull Future<Problems> problemsFuture) { return getFuture(project, problemsFuture); } public static BrowseItem[] getFutureBrowseItems(@NotNull Project project, @NotNull Future<BrowseItem[]> browseItemsFuture) { return getFuture(project, browseItemsFuture); } public static String getFutureType(@NotNull Project project, @NotNull Future<String> typeFuture) { return getFuture(project, typeFuture); } @Nullable public static <T> T getFuture(@NotNull Project project, @NotNull Future<T> future) { long timeout = ToolKey.getGhcModiTimeout(project); try { return future.get(timeout, TimeUnit.MILLISECONDS); } catch (InterruptedException e) { LOG.warn(e); displayError(project, "ghc-modi was interrupted: " + e); } catch (java.util.concurrent.ExecutionException e) { LOG.warn(e); displayError(project, "ghc-modi execution was aborted: " + e); } catch (TimeoutException e) { String msg = "ghc-modi took too long to respond (waited " + timeout + " milliseconds)"; LOG.warn(msg, e); HaskellToolsConsole.get(project).writeError(ToolKey.GHC_MODI_KEY, msg); } return null; } public boolean isConfigured() { return path != null; } /** * Checks the module with ghc-modi and returns Problems to be annotated in the source. */ @Nullable public Future<Problems> check(final @NotNull String file) { return handleGhcModiCall(new GhcModiCallable<Problems>() { @Override public Problems call() throws GhcModiError { return unsafeCheck(file); } }); } @Nullable public Problems syncCheck(final @NotNull String file) { return runSync(new GhcModiCallable<Problems>() { @Override public Problems call() throws GhcModiError { return unsafeCheck(file); } }); } @Nullable private Problems unsafeCheck(final @NotNull String file) throws GhcModiError { final String stdout = simpleExec("check " + file); return stdout == null ? new Problems() : handleCheck(module, file, stdout); } @Nullable public String[] syncLang() { return runSync(new GhcModiCallable<String[]>() { @Override public String[] call() throws GhcModiError { return unsafeLang(); } }); } @NotNull private String[] unsafeLang() throws GhcModiError { return simpleExecToLinesOrEmpty("lang"); } @Nullable public String[] syncFlag() { return runSync(new GhcModiCallable<String[]>() { @Override public String[] call() throws GhcModiError { return unsafeFlag(); } }); } @NotNull private String[] unsafeFlag() throws GhcModiError { return simpleExecToLinesOrEmpty("flag"); } @Nullable public String[] syncList() { return runSync(new GhcModiCallable<String[]>() { @Override public String[] call() throws GhcModiError { return unsafeList(); } }); } @NotNull private String[] unsafeList() throws GhcModiError { return simpleExecToLinesOrEmpty("list"); } public Future<String> type(final @NotNull String canonicalPath, @NotNull final VisualPosition startPosition, @NotNull final VisualPosition stopPosition) { return handleGhcModiCall(new GhcModiCallable<String>(){ @Override public String call() throws GhcModiError { final String command = "type " + canonicalPath + ' ' + startPosition.line + ' ' + startPosition.column; final String stdout = simpleExec(command); try { return stdout == null ? "Type info not found" : GhcModUtil.handleTypeInfo(startPosition, stopPosition, stdout); } catch (GhcModUtil.TypeInfoParseException e) { NotificationUtil.displayToolsNotification( NotificationType.ERROR, module.getProject(), "Type Info Error", "There was an error when executing the ghc-modi `type` command:\n\n" + stdout); return null; } } }); } @Nullable private static Problems handleCheck(@NotNull Module module, @NotNull String file, @NotNull String stdout) throws GhcModiError { final Problems problems = GhcMod.parseProblems(module, new Scanner(stdout)); if (problems == null) { // parseProblems should have returned something, so let's just dump the output to the user. throw new CheckParseError(stdout); } else if (problems.size() == 1) { final GhcMod.Problem problem = (GhcMod.Problem)problems.get(0); if (problem.startLine == 0 && problem.startColumn == 0) { throw new CheckError(file, problem.message); } } return problems; } /** * Returns an array of browse information for a given module. */ @Nullable public Future<BrowseItem[]> browse(@NotNull final String module) { return handleGhcModiCall(new GhcModiCallable<BrowseItem[]>() { @Override public BrowseItem[] call() throws GhcModiError { return unsafeBrowse(module); } }); } @Nullable public BrowseItem[] syncBrowse(@NotNull final String module) { return runSync(new GhcModiCallable<BrowseItem[]>() { @Override public BrowseItem[] call() throws GhcModiError { return unsafeBrowse(module); } }); } @NotNull private BrowseItem[] unsafeBrowse(@NotNull final String module) throws GhcModiError { String[] lines = simpleExecToLines("browse -d " + module); if (lines == null) return new BrowseItem[0]; BrowseItem[] result = new BrowseItem[lines.length]; for (int i = 0; i < lines.length; ++i) { final String[] parts = TYPE_SPLIT_REGEX.split(lines[i], 2); //noinspection ObjectAllocationInLoop result[i] = new BrowseItem(parts[0], module, parts.length == 2 ? parts[1] : ""); } return result; } /** * A wrapper to exec that checks stdout and returns null if there was no output. */ @Nullable public String simpleExec(@NotNull String command) throws GhcModiError { final String stdout = exec(command); if (stdout == null || stdout.length() == 0) { return null; } return stdout; } /** * Same as simpleExec, except returns an array of Strings for each line in the output. */ @Nullable public String[] simpleExecToLines(@NotNull String command) throws GhcModiError { final String result = simpleExec(command); return result == null ? null : StringUtil.splitByLines(result); } @NotNull public String[] simpleExecToLinesOrEmpty(@NotNull String command) throws GhcModiError { final String[] result = simpleExecToLines(command); return result == null ? new String[] {} : result; } /** * Lazily spawns a process if needed and executes the given command. If this fails, returns null and displays * the error to the user. If the path to ghc-modi is not set, this simply returns null with no error message. */ @Nullable public synchronized String exec(@NotNull String command) throws GhcModiError { if (!enabled) { return null; } if (path == null) { return null; } if (!validateGhcVersion()) { return null; } if (process == null) { spawnProcess(); } if (output == null) { throw new InitError("Output stream was unexpectedly null."); } if (input == null) { throw new InitError("Input stream was unexpectedly null."); } return interact(command, input, output); } private void spawnProcess() throws GhcModiError { GeneralCommandLine commandLine = new GeneralCommandLine( GhcModUtil.changedPathIfStack(module.getProject(), path) ); GhcModUtil.updateEnvironment(module.getProject(), commandLine.getEnvironment()); ParametersList parametersList = commandLine.getParametersList(); parametersList.addParametersString( GhcModUtil.changedFlagsIfStack(module.getProject(), path, flags) ); // setWorkDirectory is deprecated but is needed to work with IntelliJ 13 which does not have withWorkDirectory. commandLine.setWorkDirectory(workingDirectory); // Make sure we can actually see the errors. commandLine.setRedirectErrorStream(true); toolConsole.writeInput(ToolKey.GHC_MODI_KEY, "Using working directory: " + workingDirectory); toolConsole.writeInput(ToolKey.GHC_MODI_KEY, "Starting ghc-modi process: " + commandLine.getCommandLineString()); try { process = commandLine.createProcess(); } catch (ExecutionException e) { toolConsole.writeError(ToolKey.GHC_MODI_KEY, "Failed to initialize process"); throw new InitError(e.toString()); } input = new BufferedReader(new InputStreamReader(process.getInputStream())); output = new BufferedWriter(new OutputStreamWriter(process.getOutputStream())); } private boolean validateGhcVersion() { if (path == null) throw new RuntimeException("Unexpected GhcModi.path == null"); ghcVersionValidation = GhcModUtil.validateGhcVersion(ghcVersionValidation, module.getProject(), path, flags); return ghcVersionValidation == GhcVersionValidation.VALID; } /** * Kills the existing process and closes input and output if they exist. */ private synchronized void kill() { ghcVersionValidation = GhcVersionValidation.PENDING_VALIDATION; if (process != null) process.destroy(); process = null; try { if (input != null) input.close(); } catch (IOException e) { /* Ignored */ } input = null; try { if (output != null) output.close(); } catch (IOException e) { /* Ignored */ } output = null; } @Nullable private String interact(@NotNull String command, @NotNull BufferedReader input, @NotNull BufferedWriter output) throws GhcModiError { toolConsole.writeInput(ToolKey.GHC_MODI_KEY, command); write(command, input, output); try { final String result = read(command, input); toolConsole.writeOutput(ToolKey.GHC_MODI_KEY, result); return result; } catch (InterruptedException e) { final ExecError err = new ExecError(command, e.toString()); toolConsole.writeError(ToolKey.GHC_MODI_KEY, err.message); throw err; } catch (IOException e) { final ExecError err = new ExecError(command, e.toString()); toolConsole.writeError(ToolKey.GHC_MODI_KEY, err.message); throw err; } } private void write(@NotNull String command, @NotNull BufferedReader input, @NotNull BufferedWriter output) throws GhcModiError { try { output.write(command); output.newLine(); output.flush(); } catch (IOException e) { final String messagePrefix = "Failed to write command to ghc-modi: "; String message = null; try { // Attempt to read error from ghc-modi. message = read(command, input); } catch (IOException ignored) { // Ignored } catch (InterruptedException ignored) { // Ignored } if (message == null || message.trim().isEmpty()) message = e.toString(); throw new ExecError(command, messagePrefix + message); } } @Nullable private String read(@NotNull String command, @NotNull BufferedReader input) throws GhcModiError, IOException, InterruptedException { StringBuilder builder = new StringBuilder(0); String line; for (;;) { line = input.readLine(); if (line == null || line.equals("OK")) { break; } if (line.startsWith("NG")) { throw new ExecError(command, line); } builder.append(line).append(SystemUtil.LINE_SEPARATOR); } return builder.toString(); } /** * Restarts the ghc-modi process and runs the check command on file to ensure the process starts successfully. */ public synchronized void restart() { kill(); setEnabled(true); } private void setEnabled(boolean enabled) { this.enabled = enabled; } @Nullable private <T> T runSync(final GhcModiCallable<T> callable) { try { return callable.call(); } catch (final GhcModiError e) { final String messagePrefix; if (e.killProcess) { kill(); setEnabled(false); messagePrefix = KILLING_MESSAGE_PREFIX; } else { messagePrefix = ""; } ApplicationManager.getApplication().invokeLater(new Runnable() { @Override public void run() { displayError(messagePrefix + e.message, true); } }); return null; } } public static String KILLING_MESSAGE_PREFIX = "Killing ghc-modi due to process failure.<br/><br/>You can restart it using " + "<b>" + XmlUtil.escape(RestartGhcModi.MENU_PATH) + "</b><br/><br/>"; @Nullable private synchronized <T> Future<T> handleGhcModiCall(final GhcModiCallable<T> callable) { return executorService.submit(new Callable<T>() { @Override public T call() { return runSync(callable); } }); } private void displayError(@NotNull String message, boolean stripHtmlTags) { displayError(module.getProject(), message, stripHtmlTags); } private void displayError(@NotNull String message) { displayError(message, false); } private static void displayError(@NotNull Project project, @NotNull String message, boolean stripHtmlTags) { HaskellToolsConsole.get(project).writeError(ToolKey.GHC_MODI_KEY, stripHtmlTags ? HtmlUtils.stripTags(message) : message ); NotificationUtil.displayToolsNotification(NotificationType.ERROR, project, "ghc-modi error", message); } private static void displayError(@NotNull Project project, @NotNull String message) { displayError(project, message, false); } interface GhcModiCallable<V> extends Callable<V> { V call() throws GhcModiError; } static abstract class GhcModiError extends Exception { // Using error to index errors since message might have extra information. final @NotNull String error; final @NotNull String message; final boolean killProcess; GhcModiError(@NotNull String error, @NotNull String message, boolean killProcess) { this.error = error; this.message = message; this.killProcess = killProcess; } @NotNull @Override public String getMessage() { return message; } } static class InitError extends GhcModiError { InitError(@NotNull String error) { super(error, "Initializing ghc-modi failed with error: " + error, true); } } static class ExecError extends GhcModiError { ExecError(@NotNull String command, @NotNull String error) { super(error, "Executing ghc-modi command '" + command + "' failed with error: " + error, true); } } static class CheckParseError extends GhcModiError { CheckParseError(@NotNull String stdout) { super(stdout, "Unable to parse problems from ghc-modi: " + stdout, false); } } static class CheckError extends GhcModiError { CheckError(@NotNull String file, @NotNull String error) { super(error, "Error checking file '" + file + "' with ghc-modi: " + error, false); } } /** * Private constructor used during module component initialization. */ public GhcModi(@NotNull Module module) { this.module = module; this.path = lookupPath(); this.flags = lookupFlags(); this.workingDirectory = lookupWorkingDirectory(); // Ensure that we are notified of changes to the settings. module.getProject().getMessageBus().connect().subscribe(SettingsChangeNotifier.GHC_MODI_TOPIC, this); toolConsole = HaskellToolsConsole.get(module.getProject()); } @Override public void onSettingsChanged(@NotNull ToolSettings settings) { this.path = settings.getPath(); this.flags = settings.getFlags(); toolConsole.writeError(ToolKey.GHC_MODI_KEY, "Settings changed, reloading ghc-modi"); kill(); try { spawnProcess(); } catch (GhcModiError e) { displayError(e.message); } } @Nullable private String lookupPath() { return ToolKey.GHC_MODI_KEY.getPath(module.getProject()); } @NotNull private String lookupFlags() { return ToolKey.GHC_MODI_KEY.getFlags(module.getProject()); } @NotNull private String lookupWorkingDirectory() { return ExecUtil.guessWorkDir(module); } // Implemented methods for ModuleComponent. @Override public void projectOpened() { // No need to do anything here. } @Override public void projectClosed() { } @Override public void moduleAdded() { // No need to do anything here. } @Override public void initComponent() { // No need to do anything here. } @Override public void disposeComponent() { executorService.shutdownNow(); kill(); } @NotNull @Override public String getComponentName() { return "GhcModi"; } }