package com.haskforce.highlighting.annotation.external; import com.haskforce.features.intentions.AddLanguagePragma; import com.haskforce.features.intentions.AddTypeSignature; import com.haskforce.features.intentions.RemoveForall; import com.haskforce.highlighting.annotation.HaskellAnnotationHolder; import com.haskforce.highlighting.annotation.HaskellProblem; import com.haskforce.highlighting.annotation.Problems; import com.haskforce.highlighting.annotation.external.GhcModUtil.GhcVersionValidation; import com.haskforce.settings.ToolKey; import com.haskforce.ui.tools.HaskellToolsConsole; import com.haskforce.utils.ExecUtil; import com.haskforce.utils.NotificationUtil; import com.haskforce.utils.EitherUtil; import com.intellij.execution.configurations.GeneralCommandLine; import com.intellij.execution.configurations.ParametersList; import com.intellij.lang.annotation.Annotation; import com.intellij.notification.NotificationType; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.editor.VisualPosition; import com.intellij.openapi.module.Module; import com.intellij.openapi.project.Project; import com.intellij.openapi.util.Pair; import com.intellij.openapi.util.TextRange; import com.intellij.openapi.util.io.FileUtil; import com.intellij.openapi.util.text.StringUtil; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.psi.PsiFile; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import scala.util.Either; import java.io.File; import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * Interface + encapsulation of details concerning ghc-mod communication and annotation. */ public class GhcMod { @SuppressWarnings("UnusedDeclaration") private static final Logger LOG = Logger.getInstance(GhcMod.class); // Map of module -> errorMessage. Useful to ensure we don't output the same error multiple times. private static Map<Module, String> errorState = new HashMap<Module, String>(0); private static Map<Project, GhcVersionValidation> ghcVersionValidationMap = new HashMap<Project, GhcVersionValidation>(0); @Nullable public static String getPath(@NotNull Project project) { return GhcModUtil.changedPathIfStack(project, ToolKey.GHC_MOD_KEY.getPath(project)); } @NotNull public static String getFlags(@NotNull Project project) { return GhcModUtil.changedFlagsIfStack( project, ToolKey.GHC_MOD_KEY.getPath(project), ToolKey.GHC_MOD_KEY.getFlags(project) ); } @Nullable public static Problems check(@NotNull Module module, @NotNull String workingDirectory, @NotNull String file) { final String stdout = simpleExec(module, workingDirectory, getFlags(module.getProject()), "check", file); return stdout == null ? new Problems() : handleCheck(module, stdout); } @Nullable public static Problems handleCheck(@NotNull Module module, @NotNull String stdout) { final Problems problems = parseProblems(module, new Scanner(stdout)); if (problems == null) { // parseProblems should have returned something, so let's just dump the output to the user. displayError(module, stdout); return null; } else if (problems.size() == 1) { final Problem problem = (Problem)problems.get(0); if (problem.startLine == 0 && problem.startColumn == 0) { displayError(module, problem.message); return null; } } // Clear the errorState since ghc-mod was successful. errorState.remove(module); return problems; } @Nullable public static String[] list(@NotNull Module module, @NotNull String workingDirectory) { return simpleExecToLines(module, workingDirectory, getFlags(module.getProject()), "list"); } public static void displayError(@NotNull Module module, @NotNull String message) { if (!message.equals(errorState.get(module))) { errorState.put(module, message); NotificationUtil.displayToolsNotification(NotificationType.ERROR, module.getProject(), "ghc-mod error", message); } } @Nullable public static String simpleExec(@NotNull Module module, @NotNull String workingDirectory, @NotNull String ghcModFlags, @NotNull String command, String... params) { final String ghcModPath = getPath(module.getProject()); final String stdout; if (ghcModPath == null || (stdout = exec(module.getProject(), workingDirectory, ghcModPath, command, ghcModFlags, params)) == null || stdout.length() == 0) { return null; } return stdout; } @Nullable public static String[] simpleExecToLines(@NotNull Module module, @NotNull String workingDirectory, @NotNull String ghcModFlags, @NotNull String command, String... params) { final String result = simpleExec(module, workingDirectory, ghcModFlags, command, params); return result == null ? null : StringUtil.splitByLines(result); } @Nullable public static String exec(@NotNull Project project, @NotNull String workingDirectory, @NotNull String ghcModPath, @NotNull String command, @NotNull String ghcModFlags, String... params) { if (!validateGhcVersion(project, ghcModPath, ghcModFlags)) return null; GeneralCommandLine commandLine = new GeneralCommandLine(ghcModPath); GhcModUtil.updateEnvironment(project, commandLine.getEnvironment()); ParametersList parametersList = commandLine.getParametersList(); parametersList.addParametersString(ghcModFlags); parametersList.add(command); parametersList.addAll(params); // 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); HaskellToolsConsole toolConsole = HaskellToolsConsole.get(project); toolConsole.writeInput(ToolKey.GHC_MOD_KEY, "Using working directory: " + workingDirectory); toolConsole.writeInput(ToolKey.GHC_MOD_KEY, commandLine.getCommandLineString()); Either<ExecUtil.ExecError, String> result = ExecUtil.readCommandLine(commandLine); if (result.isLeft()) { //noinspection ThrowableResultOfMethodCallIgnored ExecUtil.ExecError e = EitherUtil.unsafeGetLeft(result); toolConsole.writeError(ToolKey.GHC_MOD_KEY, e.getMessage()); NotificationUtil.displayToolsNotification( NotificationType.ERROR, project, "ghc-mod", e.getMessage() ); return null; } String out = EitherUtil.unsafeGetRight(result); toolConsole.writeOutput(ToolKey.GHC_MOD_KEY, out); return out; } private static boolean validateGhcVersion(@NotNull Project project, @NotNull String ghcModPath, @NotNull String ghcModFlags) { GhcVersionValidation v = ghcVersionValidationMap.get(project); GhcVersionValidation newV = GhcModUtil.validateGhcVersion(v, project, ghcModPath, ghcModFlags); ghcVersionValidationMap.put(project, newV); return newV == GhcVersionValidation.VALID; } /** Similar to parseProblems(Scanner), except also resolves absolute file paths. */ @Nullable public static Problems parseProblems(@NotNull Module module, @NotNull Scanner scanner) { List<Problem> problems = parseProblems(scanner); Problems result = new Problems(); if (problems == null) return null; for (Problem problem : problems) { String absPath = inferAbsolutePath(module, problem.file); if (absPath != null) { result.add(new Problem(absPath, problem.startLine, problem.startColumn, problem.message)); } else { result.add(problem); } } return result; } /** Continually parses from scanner until end of input, returning a list of problems. */ @Nullable public static List<Problem> parseProblems(@NotNull Scanner scanner) { List<Problem> result = new ArrayList<Problem>(); Problem problem; while (scanner.hasNext()) { problem = parseProblem(scanner); if (problem != null) { result.add(problem); } } // We only call this function if ghc-mod returned errors, so if we couldn't parse a result something // bad happened. We'll check for a null return value in handleCheck. return result.size() == 0 ? null : result; } private static final Pattern IN_A_STMT_REGEX = Pattern.compile("\nIn a stmt.*"); private static final Pattern USE_V_REGEX = Pattern.compile("\nUse -v.*"); /** Parses a single problem from the scanner. */ public static Problem parseProblem(@NotNull Scanner scanner) { scanner.useDelimiter(":"); if (!scanner.hasNext()) { return null; } String file = scanner.next(); if (!scanner.hasNext()) { return null; } if (!scanner.hasNextInt()) { // We're probably parsing something like C:\path\to\file.hs file += ':' + scanner.next(); if (!scanner.hasNextInt()) { return null; } } final int startLine = scanner.nextInt(); if (!scanner.hasNextInt()) { return null; } final int startColumn = scanner.nextInt(); scanner.skip(":"); scanner.useDelimiter("\n"); if (!scanner.hasNext()) { return null; } // Remove "In a stmt..." text and set newlines. String message = scanner.next().replace('\0', '\n'); // Remove "In a stmt ..." message = IN_A_STMT_REGEX.matcher(message).replaceAll(""); // Remove "Use -v ..." message = USE_V_REGEX.matcher(message).replaceAll(""); // Remove newlines from filename. file = file.trim(); return new Problem(file, startLine, startColumn, message); } /** Infers the absolute path given a relative one and its enclosing module. */ @Nullable private static String inferAbsolutePath(@NotNull Module module, @NotNull String path) { File file = new File(path); if (file.exists()) return file.getAbsolutePath(); final VirtualFile moduleFile = module.getModuleFile(); if (moduleFile == null) return null; final String inferredPath = FileUtil.join(moduleFile.getParent().getCanonicalPath(), path); if (new File(inferredPath).exists()) return inferredPath; return null; } @Nullable public static String type(@NotNull Module module, @NotNull String workDir, @NotNull String canonicalPath, VisualPosition startPosition, @NotNull VisualPosition stopPosition) { final String stdout = simpleExec(module, workDir, getFlags(module.getProject()), "type" , canonicalPath, String.valueOf(startPosition.line), String.valueOf(startPosition.column)); if (stdout == null) return "Type info not found"; try { return 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-mod type` command:\n\n" + stdout); return null; } } public static class Problem extends HaskellProblem { public String file; public String message; public boolean isError; public Problem(String file, int startLine, int startColumn, String message) { this.file = file; this.startLine = startLine; this.startColumn = startColumn; this.message = message; this.isError = !message.startsWith("Warning: "); if (this.isError) { this.message = message; } else { this.message = message.substring("Warning: ".length()); } } abstract static class RegisterFixHandler { abstract public void apply(Matcher matcher, Annotation annotation, Problem problem); } /** * Intentions are identified using regex against the message received from ghc-mod. * The first regex match wins; all others will be ignored. * The RegisterFixHandler is used as an anonymous class so you can determine which fix, or fixes, to register. */ static final List<Pair<Pattern, RegisterFixHandler>> fixHandlers; static { fixHandlers = new ArrayList<>(Arrays.<Pair<Pattern, RegisterFixHandler>>asList( Pair.create(Pattern.compile("^Top-level binding with no type signature"), new RegisterFixHandler() { @Override public void apply(Matcher matcher, Annotation annotation, Problem problem) { annotation.registerFix(new AddTypeSignature(problem)); } }), Pair.create(Pattern.compile("^Illegal symbol '.' in type"), new RegisterFixHandler() { @Override public void apply(Matcher matcher, Annotation annotation, Problem problem) { annotation.registerFix(new AddLanguagePragma("RankNTypes")); annotation.registerFix(new RemoveForall(problem)); } }), Pair.create(Pattern.compile(" -X([A-Z][A-Za-z0-9]+)"), new RegisterFixHandler() { @Override public void apply(Matcher matcher, Annotation annotation, Problem problem) { annotation.registerFix(new AddLanguagePragma(matcher.group(1))); } }) )); } public void registerFix(@NotNull Annotation annotation) { for (Pair<Pattern, RegisterFixHandler> p : fixHandlers) { final Matcher matcher = p.first.matcher(message); if (matcher.find()) { p.second.apply(matcher, annotation, this); // Bail out on first match. return; } } } public static final Pattern WHITESPACE_REGEX = Pattern.compile("\\s"); /** * Create an annotation from this problem and add it to the annotation holder. */ @Override public void createAnnotations(@NotNull PsiFile psiFile, @NotNull HaskellAnnotationHolder holder) { final String text = psiFile.getText(); final int offsetStart = getOffsetStart(text); if (offsetStart == -1) { return; } // TODO: There is probably a better way to compare these two file paths. // The problem might not be ours; ignore this problem in that case. // Note that Windows paths end up with different slashes, so getPresentableUrl() normalizes them. final VirtualFile vFile = psiFile.getVirtualFile(); if (!(file.equals(vFile.getCanonicalPath()) || file.equals(vFile.getPresentableUrl()))) { return; } // Since we don't have ending regions from ghc-mod, highlight until the first whitespace. Matcher matcher = WHITESPACE_REGEX.matcher(text.substring(offsetStart)); final int offsetEnd = matcher.find() ? offsetStart + matcher.start() : text.length(); final TextRange range = TextRange.create(offsetStart, offsetEnd); final Annotation annotation; if (isError) { annotation = holder.createErrorAnnotation(range, message); } else { annotation = holder.createWeakWarningAnnotation(range, message); } if (annotation == null) return; registerFix(annotation); } } }