do not set shortcuts in keymap
[idea/community.git] / python / src / com / jetbrains / python / validation / Pep8ExternalAnnotator.java
1 /*
2  * Copyright 2000-2016 JetBrains s.r.o.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  * http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.jetbrains.python.validation;
17
18 import com.google.common.collect.ImmutableMap;
19 import com.google.common.collect.Lists;
20 import com.intellij.codeHighlighting.HighlightDisplayLevel;
21 import com.intellij.codeInsight.daemon.HighlightDisplayKey;
22 import com.intellij.codeInsight.intention.IntentionAction;
23 import com.intellij.codeInspection.InspectionProfile;
24 import com.intellij.codeInspection.ex.CustomEditInspectionToolsSettingsAction;
25 import com.intellij.execution.configurations.GeneralCommandLine;
26 import com.intellij.execution.process.ProcessOutput;
27 import com.intellij.lang.annotation.Annotation;
28 import com.intellij.lang.annotation.AnnotationHolder;
29 import com.intellij.lang.annotation.ExternalAnnotator;
30 import com.intellij.openapi.application.ApplicationInfo;
31 import com.intellij.openapi.application.ApplicationManager;
32 import com.intellij.openapi.application.impl.ApplicationInfoImpl;
33 import com.intellij.openapi.diagnostic.Logger;
34 import com.intellij.openapi.editor.Document;
35 import com.intellij.openapi.editor.Editor;
36 import com.intellij.openapi.editor.ex.EditorSettingsExternalizable;
37 import com.intellij.openapi.module.ModuleUtilCore;
38 import com.intellij.openapi.project.Project;
39 import com.intellij.openapi.projectRoots.Sdk;
40 import com.intellij.openapi.util.TextRange;
41 import com.intellij.openapi.util.text.StringUtil;
42 import com.intellij.openapi.vfs.VirtualFile;
43 import com.intellij.profile.codeInspection.InspectionProjectProfileManager;
44 import com.intellij.psi.PsiDocumentManager;
45 import com.intellij.psi.PsiElement;
46 import com.intellij.psi.PsiFile;
47 import com.intellij.psi.PsiWhiteSpace;
48 import com.intellij.psi.codeStyle.CodeStyleSettings;
49 import com.intellij.psi.codeStyle.CodeStyleSettingsManager;
50 import com.intellij.psi.codeStyle.CommonCodeStyleSettings;
51 import com.intellij.util.IncorrectOperationException;
52 import com.jetbrains.python.PythonFileType;
53 import com.jetbrains.python.PythonHelper;
54 import com.jetbrains.python.PythonLanguage;
55 import com.jetbrains.python.codeInsight.imports.OptimizeImportsQuickFix;
56 import com.jetbrains.python.formatter.PyCodeStyleSettings;
57 import com.jetbrains.python.inspections.PyPep8Inspection;
58 import com.jetbrains.python.inspections.quickfix.ReformatFix;
59 import com.jetbrains.python.inspections.quickfix.RemoveTrailingBlankLinesFix;
60 import com.jetbrains.python.psi.*;
61 import com.jetbrains.python.psi.impl.PyPsiUtils;
62 import com.jetbrains.python.sdk.PreferredSdkComparator;
63 import com.jetbrains.python.sdk.PySdkUtil;
64 import com.jetbrains.python.sdk.PythonSdkType;
65 import com.jetbrains.python.sdk.flavors.PythonSdkFlavor;
66 import org.jetbrains.annotations.NotNull;
67 import org.jetbrains.annotations.Nullable;
68
69 import java.io.File;
70 import java.util.ArrayList;
71 import java.util.Collections;
72 import java.util.List;
73 import java.util.regex.Matcher;
74 import java.util.regex.Pattern;
75
76 /**
77  * @author yole
78  */
79 public class Pep8ExternalAnnotator extends ExternalAnnotator<Pep8ExternalAnnotator.State, Pep8ExternalAnnotator.Results> {
80   // Taken directly from the sources of pycodestyle.py
81   private static final String DEFAULT_IGNORED_ERRORS = "E121,E123,E126,E226,E24,E704,W503";
82   private static final Logger LOG = Logger.getInstance(Pep8ExternalAnnotator.class);
83   private static final Pattern E303_LINE_COUNT_PATTERN = Pattern.compile(".*\\((\\d+)\\)$");
84
85   public static class Problem {
86     private final int myLine;
87     private final int myColumn;
88     private final String myCode;
89     private final String myDescription;
90
91     public Problem(int line, int column, String code, String description) {
92       myLine = line;
93       myColumn = column;
94       myCode = code;
95       myDescription = description;
96     }
97   }
98
99   public static class State {
100     private final String interpreterPath;
101     private final String fileText;
102     private final HighlightDisplayLevel level;
103     private final List<String> ignoredErrors;
104     private final int margin;
105
106     public State(String interpreterPath, String fileText, HighlightDisplayLevel level,
107                  List<String> ignoredErrors, int margin) {
108       this.interpreterPath = interpreterPath;
109       this.fileText = fileText;
110       this.level = level;
111       this.ignoredErrors = ignoredErrors;
112       this.margin = margin;
113     }
114   }
115
116   public static class Results {
117     public final List<Problem> problems = new ArrayList<>();
118     private final HighlightDisplayLevel level;
119
120     public Results(HighlightDisplayLevel level) {
121       this.level = level;
122     }
123   }
124
125   private boolean myReportedMissingInterpreter;
126
127   @Nullable
128   @Override
129   public State collectInformation(@NotNull PsiFile file) {
130     VirtualFile vFile = file.getVirtualFile();
131     if (vFile == null || vFile.getFileType() != PythonFileType.INSTANCE) {
132       return null;
133     }
134     Sdk sdk = PythonSdkType.findLocalCPython(ModuleUtilCore.findModuleForPsiElement(file));
135     if (sdk == null) {
136       if (!myReportedMissingInterpreter) {
137         myReportedMissingInterpreter = true;
138         reportMissingInterpreter();
139       }
140       return null;
141     }
142     final String homePath = sdk.getHomePath();
143     if (homePath == null) {
144       if (!myReportedMissingInterpreter) {
145         myReportedMissingInterpreter = true;
146         LOG.info("Could not find home path for interpreter " + homePath);
147       }
148       return null;
149     }
150     final InspectionProfile profile = InspectionProjectProfileManager.getInstance(file.getProject()).getCurrentProfile();
151     final HighlightDisplayKey key = HighlightDisplayKey.find(PyPep8Inspection.INSPECTION_SHORT_NAME);
152     if (!profile.isToolEnabled(key)) {
153       return null;
154     }
155     final PyPep8Inspection inspection = (PyPep8Inspection)profile.getUnwrappedTool(PyPep8Inspection.KEY.toString(), file);
156     final CodeStyleSettings currentSettings = CodeStyleSettingsManager.getInstance(file.getProject()).getCurrentSettings();
157
158     final List<String> ignoredErrors = Lists.newArrayList(inspection.ignoredErrors);
159     if (!currentSettings.getCustomSettings(PyCodeStyleSettings.class).SPACE_AFTER_NUMBER_SIGN) {
160       ignoredErrors.add("E262"); // Block comment should start with a space
161       ignoredErrors.add("E265"); // Inline comment should start with a space
162     }
163
164     if (!currentSettings.getCustomSettings(PyCodeStyleSettings.class).SPACE_BEFORE_NUMBER_SIGN) {
165       ignoredErrors.add("E261"); // At least two spaces before inline comment
166     }
167
168     final int margin = currentSettings.getRightMargin(file.getLanguage());
169     return new State(homePath, file.getText(), profile.getErrorLevel(key, file), ignoredErrors, margin);
170   }
171
172   private static void reportMissingInterpreter() {
173     LOG.info("Found no suitable interpreter to run pycodestyle.py. Available interpreters are: [");
174     List<Sdk> allSdks = PythonSdkType.getAllSdks();
175     Collections.sort(allSdks, PreferredSdkComparator.INSTANCE);
176     for (Sdk sdk : allSdks) {
177       LOG.info("  Path: " + sdk.getHomePath() + "; Flavor: " + PythonSdkFlavor.getFlavor(sdk) + "; Remote: " + PythonSdkType.isRemote(sdk));
178     }
179     LOG.info("]");
180   }
181
182   @Nullable
183   @Override
184   public Results doAnnotate(State collectedInfo) {
185     if (collectedInfo == null) return null;
186     ArrayList<String> options = Lists.newArrayList();
187
188     if (!collectedInfo.ignoredErrors.isEmpty()) {
189       options.add("--ignore=" + DEFAULT_IGNORED_ERRORS + "," + StringUtil.join(collectedInfo.ignoredErrors, ","));
190     }
191     options.add("--max-line-length=" + collectedInfo.margin);
192     options.add("-");
193
194     GeneralCommandLine cmd = PythonHelper.PYCODESTYLE.newCommandLine(collectedInfo.interpreterPath, options);
195
196     ProcessOutput output = PySdkUtil.getProcessOutput(cmd, new File(collectedInfo.interpreterPath).getParent(),
197                                                       ImmutableMap.of("PYTHONBUFFERED", "1"),
198                                                       10000,
199                                                       collectedInfo.fileText.getBytes(), false);
200
201     Results results = new Results(collectedInfo.level);
202     if (output.isTimeout()) {
203       LOG.info("Timeout running pycodestyle.py");
204     }
205     else if (output.getStderrLines().isEmpty()) {
206       for (String line : output.getStdoutLines()) {
207         final Problem problem = parseProblem(line);
208         if (problem != null) {
209           results.problems.add(problem);
210         }
211       }
212     }
213     else if (((ApplicationInfoImpl) ApplicationInfo.getInstance()).isEAP()) {
214       LOG.info("Error running pycodestyle.py: " + output.getStderr());
215     }
216     return results;
217   }
218
219   @Override
220   public void apply(@NotNull PsiFile file, Results annotationResult, @NotNull AnnotationHolder holder) {
221     if (annotationResult == null) return;
222     PyPsiUtils.assertValid(file);
223     final String text = file.getText();
224     Project project = file.getProject();
225     final Document document = PsiDocumentManager.getInstance(project).getDocument(file);
226
227     for (Problem problem : annotationResult.problems) {
228       final int line = problem.myLine - 1;
229       final int column = problem.myColumn - 1;
230       int offset;
231       if (document != null) {
232         offset = line >= document.getLineCount() ? document.getTextLength() - 1 : document.getLineStartOffset(line) + column;
233       }
234       else {
235         offset = StringUtil.lineColToOffset(text, line, column);
236       }
237       PsiElement problemElement = file.findElementAt(offset);
238       // E3xx - blank lines warnings
239       if (!(problemElement instanceof PsiWhiteSpace) && problem.myCode.startsWith("E3")) {
240         final PsiElement elementBefore = file.findElementAt(Math.max(0, offset - 1));
241         if (elementBefore instanceof PsiWhiteSpace) {
242           problemElement = elementBefore;
243         }
244       }
245       // W292 no newline at end of file
246       if (problemElement == null && document != null && offset == document.getTextLength() && problem.myCode.equals("W292")) {
247         problemElement = file.findElementAt(Math.max(0, offset - 1));
248       }
249
250       if (ignoreDueToSettings(project, problem, problemElement)) {
251         continue;
252       }
253
254       if (problemElement != null) {
255         TextRange problemRange = problemElement.getTextRange();
256         // Multi-line warnings are shown only in the gutter and it's not the desired behavior from the usability point of view.
257         // So we register it only on that line where pycodestyle.py found the problem originally.
258         if (crossesLineBoundary(document, text, problemRange)) {
259           final int lineEndOffset;
260           if (document != null) {
261             lineEndOffset = line >= document.getLineCount() ? document.getTextLength() - 1 : document.getLineEndOffset(line);
262           }
263           else {
264             lineEndOffset = StringUtil.lineColToOffset(text, line + 1, 0) - 1;
265           }
266           if (offset > lineEndOffset) {
267             // PSI/document don't match, don't try to highlight random places
268             continue;
269           }
270           problemRange = new TextRange(offset, lineEndOffset);
271         }
272         final Annotation annotation;
273         final boolean inInternalMode = ApplicationManager.getApplication().isInternal();
274         final String message = "PEP 8: " + (inInternalMode ? problem.myCode + " " : "") + problem.myDescription;
275         if (annotationResult.level == HighlightDisplayLevel.ERROR) {
276           annotation = holder.createErrorAnnotation(problemRange, message);
277         }
278         else if (annotationResult.level == HighlightDisplayLevel.WARNING) {
279           annotation = holder.createWarningAnnotation(problemRange, message);
280         }
281         else {
282           annotation = holder.createWeakWarningAnnotation(problemRange, message);
283         }
284         if (problem.myCode.equals("E401")) {
285           annotation.registerUniversalFix(new OptimizeImportsQuickFix(), null, null);
286         }
287         else if (problem.myCode.equals("W391")) {
288           annotation.registerUniversalFix(new RemoveTrailingBlankLinesFix(), null, null);
289         }
290         else {
291           annotation.registerUniversalFix(new ReformatFix(), null, null);
292         }
293         annotation.registerFix(new IgnoreErrorFix(problem.myCode));
294         annotation.registerFix(new CustomEditInspectionToolsSettingsAction(HighlightDisplayKey.find(PyPep8Inspection.INSPECTION_SHORT_NAME),
295                                                                            () -> "Edit inspection profile setting"));
296       }
297     }
298   }
299
300   private static boolean crossesLineBoundary(@Nullable Document document, String text, TextRange problemRange) {
301     int start = problemRange.getStartOffset();
302     int end = problemRange.getEndOffset();
303     if (document != null) {
304       return document.getLineNumber(start) != document.getLineNumber(end);
305     }
306     return StringUtil.offsetToLineNumber(text, start) != StringUtil.offsetToLineNumber(text, end);
307   }
308
309   private static boolean ignoreDueToSettings(Project project, Problem problem, @Nullable PsiElement element) {
310     final EditorSettingsExternalizable editorSettings = EditorSettingsExternalizable.getInstance();
311     if (!editorSettings.getStripTrailingSpaces().equals(EditorSettingsExternalizable.STRIP_TRAILING_SPACES_NONE)) {
312       // ignore trailing spaces errors if they're going to disappear after save
313       if (problem.myCode.equals("W291") || problem.myCode.equals("W293")) {
314         return true;
315       }
316     }
317
318     final CodeStyleSettings codeStyleSettings = CodeStyleSettingsManager.getSettings(project);
319     final CommonCodeStyleSettings commonSettings = codeStyleSettings.getCommonSettings(PythonLanguage.getInstance());
320     final PyCodeStyleSettings pySettings = codeStyleSettings.getCustomSettings(PyCodeStyleSettings.class);
321     
322     if (element instanceof PsiWhiteSpace) {
323       // E303 too many blank lines (num)
324       if (problem.myCode.equals("E303")) {
325         final Matcher matcher = E303_LINE_COUNT_PATTERN.matcher(problem.myDescription);
326         if (matcher.matches()) {
327           final int reportedBlanks = Integer.parseInt(matcher.group(1));
328           final PsiElement nonWhitespaceAfter = PyPsiUtils.getNextNonWhitespaceSibling(element);
329           final PsiElement nonWhitespaceBefore = PyPsiUtils.getPrevNonWhitespaceSibling(element);
330           final boolean classNearby = nonWhitespaceBefore instanceof PyClass || nonWhitespaceAfter instanceof PyClass;
331           final boolean functionNearby = nonWhitespaceBefore instanceof PyFunction || nonWhitespaceAfter instanceof PyFunction;
332           if (functionNearby || classNearby) {
333             if (PyUtil.isTopLevel(element)) {
334               if (reportedBlanks <= pySettings.BLANK_LINES_AROUND_TOP_LEVEL_CLASSES_FUNCTIONS) {
335                 return true;
336               }
337             }
338             else {
339               // Blanks around classes have priority over blanks around functions as defined in Python spacing builder
340               if (classNearby && reportedBlanks <= commonSettings.BLANK_LINES_AROUND_CLASS ||
341                   functionNearby && reportedBlanks <= commonSettings.BLANK_LINES_AROUND_METHOD) {
342                 return true;
343               }
344             }
345           }
346         }
347       }
348       
349       if (problem.myCode.equals("W191") && codeStyleSettings.useTabCharacter(PythonFileType.INSTANCE)) {
350         return true;
351       }
352         
353       // E251 unexpected spaces around keyword / parameter equals
354       // Note that E222 (multiple spaces after operator) is not suppressed, though. 
355       if (problem.myCode.equals("E251") &&
356           (element.getParent() instanceof PyParameter && pySettings.SPACE_AROUND_EQ_IN_NAMED_PARAMETER ||
357            element.getParent() instanceof PyKeywordArgument && pySettings.SPACE_AROUND_EQ_IN_KEYWORD_ARGUMENT)) {
358         return true;
359       }
360     }
361     return false;
362   }
363
364   private static final Pattern PROBLEM_PATTERN = Pattern.compile(".+:(\\d+):(\\d+): ([EW]\\d{3}) (.+)");
365
366   @Nullable
367   private static Problem parseProblem(String s) {
368     Matcher m = PROBLEM_PATTERN.matcher(s);
369     if (m.matches()) {
370       int line = Integer.parseInt(m.group(1));
371       int column = Integer.parseInt(m.group(2));
372       return new Problem(line, column, m.group(3), m.group(4));
373     }
374     if (((ApplicationInfoImpl) ApplicationInfo.getInstance()).isEAP()) {
375       LOG.info("Failed to parse problem line from pycodestyle.py: " + s);
376     }
377     return null;
378   }
379
380   private static class IgnoreErrorFix implements IntentionAction {
381     private final String myCode;
382
383     public IgnoreErrorFix(String code) {
384       myCode = code;
385     }
386
387     @NotNull
388     @Override
389     public String getText() {
390       return "Ignore errors like this";
391     }
392
393     @NotNull
394     @Override
395     public String getFamilyName() {
396       return getText();
397     }
398
399     @Override
400     public boolean isAvailable(@NotNull Project project, Editor editor, PsiFile file) {
401       return true;
402     }
403
404     @Override
405     public void invoke(@NotNull Project project, Editor editor, final PsiFile file) throws IncorrectOperationException {
406       InspectionProjectProfileManager.getInstance(project).getCurrentProfile().modifyProfile(model -> {
407         PyPep8Inspection tool = (PyPep8Inspection)model.getUnwrappedTool(PyPep8Inspection.INSPECTION_SHORT_NAME, file);
408         if (!tool.ignoredErrors.contains(myCode)) {
409           tool.ignoredErrors.add(myCode);
410         }
411       });
412     }
413
414     @Override
415     public boolean startInWriteAction() {
416       return false;
417     }
418   }
419 }