package com.dropbox.plugins.mypy_plugin; import com.dropbox.plugins.mypy_plugin.model.MypyError; import com.dropbox.plugins.mypy_plugin.model.MypyResult; import com.intellij.icons.AllIcons; import com.intellij.notification.Notification; import com.intellij.notification.NotificationType; import com.intellij.notification.Notifications; import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.editor.Document; import com.intellij.openapi.editor.Editor; import com.intellij.openapi.editor.LogicalPosition; import com.intellij.openapi.editor.ScrollType; import com.intellij.openapi.fileEditor.FileDocumentManager; import com.intellij.openapi.fileEditor.FileEditor; import com.intellij.openapi.fileEditor.TextEditor; import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx; import com.intellij.openapi.project.Project; import com.intellij.openapi.ui.JBMenuItem; import com.intellij.openapi.ui.JBPopupMenu; import com.intellij.openapi.ui.Messages; import com.intellij.openapi.vfs.LocalFileSystem; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.openapi.wm.ToolWindow; import com.intellij.openapi.wm.ToolWindowManager; import com.intellij.ui.JBColor; import com.intellij.ui.components.JBList; import com.intellij.ui.content.Content; import com.intellij.ui.content.ContentFactory; import icons.MypyIcons; import org.jetbrains.annotations.Nullable; import javax.swing.*; import java.awt.*; import java.awt.datatransfer.Clipboard; import java.awt.datatransfer.StringSelection; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.io.File; import java.io.IOException; import java.net.URISyntaxException; import java.net.URL; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.Set; import java.util.concurrent.Executors; import java.util.regex.Matcher; import java.util.regex.Pattern; import static java.lang.Integer.max; public final class MypyTerminal { private JPanel mypyToolWindowContent; private JBList<MypyError> errorsList; private JTextField mypyStatus; private JButton mypyRun; @SuppressWarnings("unused") private JScrollPane scroll; private int rightIndex; private final Project project; private ListCellRenderer<? super MypyError> defaultRenderer; private ListCellRenderer<MypyError> mypyRenderer; private MypyRunner runner; private ArrayList<String> errorFiles; private Set<String> collapsed; private HashMap<String, ArrayList<MypyError>> errorMap; final static int GRAY = 11579568; final static int DARK_GRAY = 7368816; private final static int LIGHT_GREEN = 13500365; private final static int LIGHT_RED = 16764365; final static int BLACK = 0; final static int WHITE = 16777215; final public static String ERROR_MARK = ": error:"; final public static String NOTE_MARK = ": note:"; final static String ERROR_RE = ".+" + ERROR_MARK + ".+"; final static String NOTE_RE = ".+" + NOTE_MARK + ".+"; public MypyTerminal (Project project) { this.project = project; } public MypyRunner getRunner() { return runner; } public JBList<MypyError> getErrorsList() { return errorsList; } public void toggleExpand(MypyError error) { String file = error.getFile(); if (collapsed.contains(file)) { collapsed.remove(file); } else { collapsed.add(file); } } void initUI(ToolWindow toolWindow) { errorsList.getEmptyText().setText(""); errorsList.setListData(new MypyError[] {}); runner = new MypyRunner(errorsList, project); rightIndex = 0; // List popup menu. JBPopupMenu popup = new JBPopupMenu(); JBMenuItem gotoItem = new JBMenuItem("Go to error"); gotoItem.addActionListener(e -> { int tot = MypyTerminal.this.errorsList.getModel().getSize(); int right = MypyTerminal.this.rightIndex; int index = MypyTerminal.this.errorsList.getSelectedIndex(); if ((right >= 0) & (right < tot)) { MypyTerminal.this.errorsList.setSelectedIndex(right); // If it was already selected, we need to trigger this manually. if (right == index) { MypyTerminal.this.openError(index); } } }); gotoItem.setIcon(AllIcons.Actions.RunToCursor); gotoItem.setDisabledIcon(AllIcons.Actions.RunToCursor); popup.add(gotoItem); JBMenuItem copyItem = new JBMenuItem("Copy error text"); copyItem.addActionListener(e -> { MypyError error = MypyTerminal.this.errorsList.getModel().getElementAt( MypyTerminal.this.rightIndex); StringSelection selection = new StringSelection(error.getRaw()); Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard(); clipboard.setContents(selection, selection); }); copyItem.setIcon(AllIcons.Actions.Copy); copyItem.setDisabledIcon(AllIcons.Actions.Copy); popup.add(copyItem); JBMenuItem copyAllItem = new JBMenuItem("Copy all errors"); copyAllItem.addActionListener(e -> { ArrayList<String> allErrors = new ArrayList<>(); int size = MypyTerminal.this.errorsList.getModel().getSize(); if (size == 0) { return; } for (int i = 0; i < size; i++) { MypyError err = MypyTerminal.this.errorsList.getModel().getElementAt(i); if (err.getLevel() == MypyError.HEADER) { continue; } allErrors.add(err.getRaw()); } String error = String.join("\n", allErrors); StringSelection selection = new StringSelection(error); Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard(); clipboard.setContents(selection, selection); }); popup.add(copyAllItem); JBMenuItem expandItem = new JBMenuItem("Expand"); expandItem.addActionListener(e -> { MypyError error = MypyTerminal.this.errorsList.getModel().getElementAt( MypyTerminal.this.rightIndex); if (error.getLevel() == MypyError.HEADER) { MypyTerminal.this.toggleExpand(error); MypyTerminal.this.renderList(); MypyTerminal.this.errorsList.setSelectedIndex(MypyTerminal.this.rightIndex); } }); popup.add(expandItem); JBMenuItem helpItem = new JBMenuItem("Help"); helpItem.addActionListener(e -> MypyHelp.show(project)); popup.add(helpItem); JSeparator sep = new JSeparator(); popup.add(sep); JBMenuItem configItem = new JBMenuItem("Configure plugin..."); configItem.addActionListener(e -> { MypyConfigDialog dialog = new MypyConfigDialog(project); dialog.show(); }); popup.add(configItem); // List selection listener. errorsList.addListSelectionListener(e -> { int index = MypyTerminal.this.errorsList.getSelectedIndex(); Rectangle rect = MypyTerminal.this.errorsList.getCellBounds(index, index); if (rect != null) { MypyTerminal.this.errorsList.scrollRectToVisible(rect); } MypyTerminal.this.openError(index); }); // List mouse listener. errorsList.addMouseListener(new MouseAdapter() { public void mouseClicked(MouseEvent e) { int index = MypyTerminal.this.errorsList.locationToIndex(e.getPoint()); if ((e.getButton() == MouseEvent.BUTTON2) | (e.getButton() == MouseEvent.BUTTON3) | (e.isControlDown())) { boolean active = !(MypyTerminal.this.runner.isRunning()); boolean isError = false; boolean isExpanded = false; boolean isHeader = false; if (index >= 0) { MypyError error = MypyTerminal.this.errorsList.getModel().getElementAt(index); isError = error.isError(); isExpanded = !error.isCollapsed(); isHeader = error.getLevel() == MypyError.HEADER; } gotoItem.setEnabled(active & isError); gotoItem.updateUI(); copyItem.setEnabled(active & !isHeader & (index >= 0)); copyItem.updateUI(); copyAllItem.setEnabled(active); copyAllItem.updateUI(); expandItem.setEnabled(active & isHeader); expandItem.setText(isExpanded ? "Collapse" : "Expand"); expandItem.updateUI(); configItem.updateUI(); helpItem.updateUI(); MypyTerminal.this.rightIndex = index; popup.updateUI(); popup.show(e.getComponent(), e.getX(), e.getY()); return; } if (e.isAltDown()) { String error = MypyTerminal.this.errorsList.getModel() .getElementAt(index).getMessage(); Pattern http = Pattern.compile("http://\\S+"); // TODO: Use better regex. Matcher matcher = http.matcher(error); if (matcher.find()) { String link = error.substring(matcher.start(0), matcher.end(0)); try { Desktop.getDesktop().browse(new URL(link).toURI()); } catch (URISyntaxException | IOException exc) { Messages.showMessageDialog(project, exc.getMessage(), "Plugin Exception:", Messages.getErrorIcon()); } } return; } if (e.getClickCount() >= 1) { if (index >= 0) { MypyError error = MypyTerminal.this.errorsList.getModel().getElementAt(index); boolean expandable = (error.getLevel() == MypyError.HEADER); if (expandable) { MypyTerminal.this.toggleExpand(error); MypyTerminal.this.renderList(); MypyTerminal.this.errorsList.setSelectedIndex(index); } else { int old = MypyTerminal.this.errorsList.getSelectedIndex(); if (old == index) { // manually trigger if selection didn't change MypyTerminal.this.openError(index); } else { MypyTerminal.this.errorsList.setSelectedIndex(index); } } } } } }); // Final strokes. mypyRun.addActionListener(e -> MypyTerminal.this.runMypyDaemonUIWrapper()); mypyRun.setIcon(MypyIcons.MYPY_SMALL); mypyRenderer = new MypyCellRenderer(); defaultRenderer = errorsList.getCellRenderer(); ContentFactory contentFactory = ContentFactory.SERVICE.getInstance(); Content content = contentFactory.createContent(mypyToolWindowContent, "", false); toolWindow.getContentManager().addContent(content); } public void runMypyDaemonUIWrapper() { runMypyDaemonUIWrapper(null, null); } public void runMypyDaemonUIWrapper(@Nullable String command, @Nullable VirtualFile vf) { setWaiting(); FileDocumentManager.getInstance().saveAllDocuments(); // Invoke mypy daemon runner script in a sub-thread, // it looks like UI is blocked on it otherwise. Executors.newSingleThreadExecutor().execute(() -> { Thread.currentThread().setName("MypyRunnerThread"); MypyResult result = MypyTerminal.this.runner.runMypyDaemon(command, vf); if (result == null) return; // Access UI is prohibited from non-dispatch thread. ApplicationManager.getApplication().invokeLater(() -> { MypyTerminal.this.setReady(result); ToolWindow tw = ToolWindowManager.getInstance(project).getToolWindow( MypyToolWindowFactory.MYPY_PLUGIN_ID); if (!tw.isVisible()) { String suffix = result.getErrCount() != 1 ? "s" : ""; NotificationType n_type = result.getRetCode() != 0 ? NotificationType.WARNING : NotificationType.INFORMATION; Notification completed = new Notification("Indexing", "Mypy Daemon", String.format("Type checking completed: %d error%s found", result.getErrCount(), suffix), n_type); Notifications.Bus.notify(completed); } if (result.getErrCount() == 0 & result.getNoteCount() == 0) { return; } if (result.getRetCode() != 0) { MypyTerminal.this.makeErrorMap(result); MypyTerminal.this.generateMarkers(result); MypyTerminal.this.collapsed = new HashSet<>(); MypyTerminal.this.renderList(); MypyTerminal.this.errorsList.setSelectedIndex(0); } }); }); } private void setWaiting() { errorsList.setForeground(new JBColor(new Color(GRAY), new Color(DARK_GRAY))); errorsList.setCellRenderer(defaultRenderer); mypyStatus.setText("Running..."); mypyStatus.setForeground(new JBColor(new Color(BLACK), new Color(GRAY))); mypyStatus.setBackground(new JBColor(new Color(WHITE), new Color(BLACK))); errorsList.setListData(new MypyError[] {}); errorsList.setPaintBusy(true); mypyRun.setText("Wait..."); mypyRun.setEnabled(false); } private void setReady(MypyResult result) { mypyRun.setText("Run"); mypyRun.setEnabled(true); errorsList.setPaintBusy(false); if (result == null) { // IO exception happened mypyStatus.setText("Internal problem..."); return; } if (result.getRetCode() == 0) { mypyStatus.setText("PASSED"); mypyStatus.setForeground(new JBColor(new Color(BLACK), new Color(100, 255, 100))); mypyStatus.setBackground(new JBColor(new Color(LIGHT_GREEN), new Color(BLACK))); } else { String suffix = result.getErrCount() != 1 ? "s" : ""; mypyStatus.setText(String.format("FAILED: %d error%s", result.getErrCount(), suffix)); mypyStatus.setForeground(new JBColor(new Color(BLACK), new Color(255, 100, 100))); mypyStatus.setBackground(new JBColor(new Color(LIGHT_RED), new Color(BLACK))); if (result.getErrCount() == 0 & result.getNoteCount() == 0) { // keep debug output return; } } // clear debug output errorsList.setListData(new MypyError[] {}); errorsList.setForeground(new JBColor(new Color(BLACK), new Color(GRAY))); errorsList.setCellRenderer(mypyRenderer); } private void makeErrorMap(MypyResult result) { HashMap<String, ArrayList<MypyError>> map = new HashMap<>(); ArrayList<MypyError> errors = result.getErrors(); ArrayList<String> files = new ArrayList<>(); for (MypyError next: errors) { String file = next.getFile(); if (!map.containsKey(file)) { map.put(file, new ArrayList<>()); } map.get(file).add(next); if (!files.contains(file)) { files.add(file); } } errorMap = map; errorFiles = files; } private void generateMarkers(MypyResult result) { for (MypyError error: result.getErrors()) { if (error.isError()) { String directory = project.getBaseDir().getPath(); String file = error.getFile(); int line = max(error.getLine() - 1, 0); VirtualFile vf = LocalFileSystem.getInstance().findFileByPath(directory + File.separator + file); if (vf != null) { Document document = FileDocumentManager.getInstance().getCachedDocument(vf); if (document != null) { error.marker = document.createRangeMarker(document.getLineStartOffset(line), document.getLineEndOffset(line)); } } } } } public void renderList() { ArrayList<MypyError> lines = new ArrayList<>(); for (String file: errorFiles) { boolean toggle = collapsed.contains(file); int errs = 0; for (MypyError error: errorMap.get(file)) { if (error.getLevel() == MypyError.ERROR) { errs++; } } MypyError title = new MypyError(file, MypyError.HEADER, errs); if (toggle) { title.toggle(); } lines.add(title); if (!collapsed.contains(file)) { lines.addAll(errorMap.get(file)); } } MypyError[] data = new MypyError[lines.size()]; data = lines.toArray(data); errorsList.setListData(data); } private void openError(int index) { if ((index >= errorsList.getModel().getSize()) | (index < 0)) { return; } if (runner.isRunning()) { return; } MypyError error = errorsList.getModel().getElementAt(index); String directory = project.getBaseDir().getPath(); if (error.isError()) { String file = error.getFile(); int line = max(error.getLine() - 1, 0); int column = max(error.getColumn() - 1, 0); VirtualFile vf = LocalFileSystem.getInstance().findFileByPath(directory + File.separator + file); // May be null if an error is shown in a file beyond repository // (e.g. typeshed or a deleted file because of a bug). if (vf != null) { FileEditor[] editors = FileEditorManagerEx.getInstanceEx(project).openFile(vf, true); if (editors[0] instanceof TextEditor) { Editor editor = ((TextEditor) editors[0]).getEditor(); if (error.marker == null) { // Try re-creating markers, likely the file was not in cache after the type check. // TODO: do this on document opening for all documents? Document document = FileDocumentManager.getInstance().getCachedDocument(vf); if (document != null) { for (MypyError e: errorMap.get(error.getFile())) { int errorLine = max(e.getLine() - 1, 0); e.marker = document.createRangeMarker(document.getLineStartOffset(errorLine), document.getLineEndOffset(errorLine)); } } } if (error.marker != null && error.marker.isValid()) { editor.getCaretModel().getPrimaryCaret().moveToOffset(error.marker.getStartOffset()); } else { LogicalPosition pos = new LogicalPosition(line, column); editor.getCaretModel().getPrimaryCaret().moveToLogicalPosition(pos); } editor.getScrollingModel().scrollToCaret(ScrollType.CENTER); if (error.marker != null && error.marker.isValid()) { editor.getSelectionModel().selectLineAtCaret(); } } } } } }