a2c56c075c327520e0ca00cc75c038fd417abfa2
[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.PyTokenTypes;
53 import com.jetbrains.python.PythonFileType;
54 import com.jetbrains.python.PythonHelper;
55 import com.jetbrains.python.PythonLanguage;
56 import com.jetbrains.python.codeInsight.imports.OptimizeImportsQuickFix;
57 import com.jetbrains.python.formatter.PyCodeStyleSettings;
58 import com.jetbrains.python.inspections.PyPep8Inspection;
59 import com.jetbrains.python.inspections.quickfix.ReformatFix;
60 import com.jetbrains.python.inspections.quickfix.RemoveTrailingBlankLinesFix;
61 import com.jetbrains.python.psi.*;
62 import com.jetbrains.python.psi.impl.PyFileImpl;
63 import com.jetbrains.python.psi.impl.PyPsiUtils;
64 import com.jetbrains.python.sdk.PreferredSdkComparator;
65 import com.jetbrains.python.sdk.PySdkUtil;
66 import com.jetbrains.python.sdk.PythonSdkType;
67 import com.jetbrains.python.sdk.flavors.PythonSdkFlavor;
68 import org.jetbrains.annotations.NotNull;
69 import org.jetbrains.annotations.Nullable;
70
71 import java.io.File;
72 import java.util.ArrayList;
73 import java.util.Arrays;
74 import java.util.Collections;
75 import java.util.List;
76 import java.util.regex.Matcher;
77 import java.util.regex.Pattern;
78
79 /**
80  * @author yole
81  */
82 public class Pep8ExternalAnnotator extends ExternalAnnotator<Pep8ExternalAnnotator.State, Pep8ExternalAnnotator.Results> {
83   // Taken directly from the sources of pycodestyle.py
84   private static final String DEFAULT_IGNORED_ERRORS = "E121,E123,E126,E226,E24,E704,W503";
85   private static final Logger LOG = Logger.getInstance(Pep8ExternalAnnotator.class);
86   private static final Pattern E303_LINE_COUNT_PATTERN = Pattern.compile(".*\\((\\d+)\\)$");
87
88   public static class Problem {
89     private final int myLine;
90     private final int myColumn;
91     private final String myCode;
92     private final String myDescription;
93
94     public Problem(int line, int column, @NotNull String code, @NotNull String description) {
95       myLine = line;
96       myColumn = column;
97       myCode = code;
98       myDescription = description;
99     }
100
101     public int getLine() {
102       return myLine;
103     }
104
105     public int getColumn() {
106       return myColumn;
107     }
108
109     @NotNull
110     public String getCode() {
111       return myCode;
112     }
113
114     @NotNull
115     public String getDescription() {
116       return myDescription;
117     }
118   }
119
120   public static class State {
121     private final String interpreterPath;
122     private final String fileText;
123     private final HighlightDisplayLevel level;
124     private final List<String> ignoredErrors;
125     private final int margin;
126
127     public State(String interpreterPath, String fileText, HighlightDisplayLevel level,
128                  List<String> ignoredErrors, int margin) {
129       this.interpreterPath = interpreterPath;
130       this.fileText = fileText;
131       this.level = level;
132       this.ignoredErrors = ignoredErrors;
133       this.margin = margin;
134     }
135   }
136
137   public static class Results {
138     public final List<Problem> problems = new ArrayList<>();
139     private final HighlightDisplayLevel level;
140
141     public Results(HighlightDisplayLevel level) {
142       this.level = level;
143     }
144   }
145
146   private boolean myReportedMissingInterpreter;
147
148   @Nullable
149   @Override
150   public State collectInformation(@NotNull PsiFile file) {
151     VirtualFile vFile = file.getVirtualFile();
152     if (vFile == null || vFile.getFileType() != PythonFileType.INSTANCE) {
153       return null;
154     }
155     Sdk sdk = PythonSdkType.findLocalCPython(ModuleUtilCore.findModuleForPsiElement(file));
156     if (sdk == null) {
157       if (!myReportedMissingInterpreter) {
158         myReportedMissingInterpreter = true;
159         reportMissingInterpreter();
160       }
161       return null;
162     }
163     final String homePath = sdk.getHomePath();
164     if (homePath == null) {
165       if (!myReportedMissingInterpreter) {
166         myReportedMissingInterpreter = true;
167         LOG.info("Could not find home path for interpreter " + homePath);
168       }
169       return null;
170     }
171     final InspectionProfile profile = InspectionProjectProfileManager.getInstance(file.getProject()).getCurrentProfile();
172     final HighlightDisplayKey key = HighlightDisplayKey.find(PyPep8Inspection.INSPECTION_SHORT_NAME);
173     if (!profile.isToolEnabled(key, file)) {
174       return null;
175     }
176     if (file instanceof PyFileImpl && !((PyFileImpl)file).isAcceptedFor(PyPep8Inspection.class)) {
177       return null;
178     }
179     final PyPep8Inspection inspection = (PyPep8Inspection)profile.getUnwrappedTool(PyPep8Inspection.KEY.toString(), file);
180     final CodeStyleSettings currentSettings = CodeStyleSettingsManager.getInstance(file.getProject()).getCurrentSettings();
181
182     final List<String> ignoredErrors = Lists.newArrayList(inspection.ignoredErrors);
183     if (!currentSettings.getCustomSettings(PyCodeStyleSettings.class).SPACE_AFTER_NUMBER_SIGN) {
184       ignoredErrors.add("E262"); // Block comment should start with a space
185       ignoredErrors.add("E265"); // Inline comment should start with a space
186     }
187
188     if (!currentSettings.getCustomSettings(PyCodeStyleSettings.class).SPACE_BEFORE_NUMBER_SIGN) {
189       ignoredErrors.add("E261"); // At least two spaces before inline comment
190     }
191
192     final int margin = currentSettings.getRightMargin(file.getLanguage());
193     return new State(homePath, file.getText(), profile.getErrorLevel(key, file), ignoredErrors, margin);
194   }
195
196   private static void reportMissingInterpreter() {
197     LOG.info("Found no suitable interpreter to run pycodestyle.py. Available interpreters are: [");
198     List<Sdk> allSdks = PythonSdkType.getAllSdks();
199     Collections.sort(allSdks, PreferredSdkComparator.INSTANCE);
200     for (Sdk sdk : allSdks) {
201       LOG.info("  Path: " + sdk.getHomePath() + "; Flavor: " + PythonSdkFlavor.getFlavor(sdk) + "; Remote: " + PythonSdkType.isRemote(sdk));
202     }
203     LOG.info("]");
204   }
205
206   @Nullable
207   @Override
208   public Results doAnnotate(State collectedInfo) {
209     if (collectedInfo == null) return null;
210     ArrayList<String> options = Lists.newArrayList();
211
212     if (!collectedInfo.ignoredErrors.isEmpty()) {
213       options.add("--ignore=" + DEFAULT_IGNORED_ERRORS + "," + StringUtil.join(collectedInfo.ignoredErrors, ","));
214     }
215     options.add("--max-line-length=" + collectedInfo.margin);
216     options.add("-");
217
218     GeneralCommandLine cmd = PythonHelper.PYCODESTYLE.newCommandLine(collectedInfo.interpreterPath, options);
219
220     ProcessOutput output = PySdkUtil.getProcessOutput(cmd, new File(collectedInfo.interpreterPath).getParent(),
221                                                       ImmutableMap.of("PYTHONBUFFERED", "1"),
222                                                       10000,
223                                                       collectedInfo.fileText.getBytes(), false);
224
225     Results results = new Results(collectedInfo.level);
226     if (output.isTimeout()) {
227       LOG.info("Timeout running pycodestyle.py");
228     }
229     else if (output.getStderrLines().isEmpty()) {
230       for (String line : output.getStdoutLines()) {
231         final Problem problem = parseProblem(line);
232         if (problem != null) {
233           results.problems.add(problem);
234         }
235       }
236     }
237     else if (((ApplicationInfoImpl) ApplicationInfo.getInstance()).isEAP()) {
238       LOG.info("Error running pycodestyle.py: " + output.getStderr());
239     }
240     return results;
241   }
242
243   @Override
244   public void apply(@NotNull PsiFile file, Results annotationResult, @NotNull AnnotationHolder holder) {
245     if (annotationResult == null || !file.isValid()) return;
246     final String text = file.getText();
247     Project project = file.getProject();
248     final Document document = PsiDocumentManager.getInstance(project).getDocument(file);
249
250     for (Problem problem : annotationResult.problems) {
251       final int line = problem.myLine - 1;
252       final int column = problem.myColumn - 1;
253       int offset;
254       if (document != null) {
255         offset = line >= document.getLineCount() ? document.getTextLength() - 1 : document.getLineStartOffset(line) + column;
256       }
257       else {
258         offset = StringUtil.lineColToOffset(text, line, column);
259       }
260       PsiElement problemElement = file.findElementAt(offset);
261       // E3xx - blank lines warnings
262       if (!(problemElement instanceof PsiWhiteSpace) && problem.myCode.startsWith("E3")) {
263         final PsiElement elementBefore = file.findElementAt(Math.max(0, offset - 1));
264         if (elementBefore instanceof PsiWhiteSpace) {
265           problemElement = elementBefore;
266         }
267       }
268       // W292 no newline at end of file
269       if (problemElement == null && document != null && offset == document.getTextLength() && problem.myCode.equals("W292")) {
270         problemElement = file.findElementAt(Math.max(0, offset - 1));
271       }
272
273       if (ignoreDueToSettings(project, problem, problemElement) || ignoredDueToProblemSuppressors(project, problem, file, problemElement)) {
274         continue;
275       }
276
277       if (problemElement != null) {
278         // TODO Remove: a workaround until the bundled pycodestyle.py supports Python 3.6 variable annotations by itself
279         if (problem.myCode.equals("E701") &&
280             problemElement.getNode().getElementType() == PyTokenTypes.COLON &&
281             problemElement.getParent() instanceof PyAnnotation) {
282           continue;
283         }
284         
285         TextRange problemRange = problemElement.getTextRange();
286         // Multi-line warnings are shown only in the gutter and it's not the desired behavior from the usability point of view.
287         // So we register it only on that line where pycodestyle.py found the problem originally.
288         if (crossesLineBoundary(document, text, problemRange)) {
289           final int lineEndOffset;
290           if (document != null) {
291             lineEndOffset = line >= document.getLineCount() ? document.getTextLength() - 1 : document.getLineEndOffset(line);
292           }
293           else {
294             lineEndOffset = StringUtil.lineColToOffset(text, line + 1, 0) - 1;
295           }
296           if (offset > lineEndOffset) {
297             // PSI/document don't match, don't try to highlight random places
298             continue;
299           }
300           problemRange = new TextRange(offset, lineEndOffset);
301         }
302         final Annotation annotation;
303         final boolean inInternalMode = ApplicationManager.getApplication().isInternal();
304         final String message = "PEP 8: " + (inInternalMode ? problem.myCode + " " : "") + problem.myDescription;
305         if (annotationResult.level == HighlightDisplayLevel.ERROR) {
306           annotation = holder.createErrorAnnotation(problemRange, message);
307         }
308         else if (annotationResult.level == HighlightDisplayLevel.WARNING) {
309           annotation = holder.createWarningAnnotation(problemRange, message);
310         }
311         else {
312           annotation = holder.createWeakWarningAnnotation(problemRange, message);
313         }
314         if (problem.myCode.equals("E401")) {
315           annotation.registerUniversalFix(new OptimizeImportsQuickFix(), null, null);
316         }
317         else if (problem.myCode.equals("W391")) {
318           annotation.registerUniversalFix(new RemoveTrailingBlankLinesFix(), null, null);
319         }
320         else {
321           annotation.registerUniversalFix(new ReformatFix(), null, null);
322         }
323         annotation.registerFix(new IgnoreErrorFix(problem.myCode));
324         annotation.registerFix(new CustomEditInspectionToolsSettingsAction(HighlightDisplayKey.find(PyPep8Inspection.INSPECTION_SHORT_NAME),
325                                                                            () -> "Edit inspection profile setting"));
326       }
327     }
328   }
329
330   private static boolean ignoredDueToProblemSuppressors(@NotNull Project project,
331                                                         @NotNull Problem problem,
332                                                         @NotNull PsiFile file,
333                                                         @Nullable PsiElement element) {
334     final Pep8ProblemSuppressor[] suppressors = Pep8ProblemSuppressor.EP_NAME.getExtensions();
335     return Arrays.stream(suppressors).anyMatch(p -> p.isProblemSuppressed(problem, file, element));
336   }
337
338   private static boolean crossesLineBoundary(@Nullable Document document, String text, TextRange problemRange) {
339     int start = problemRange.getStartOffset();
340     int end = problemRange.getEndOffset();
341     if (document != null) {
342       return document.getLineNumber(start) != document.getLineNumber(end);
343     }
344     return StringUtil.offsetToLineNumber(text, start) != StringUtil.offsetToLineNumber(text, end);
345   }
346
347   private static boolean ignoreDueToSettings(Project project, Problem problem, @Nullable PsiElement element) {
348     final EditorSettingsExternalizable editorSettings = EditorSettingsExternalizable.getInstance();
349     if (!editorSettings.getStripTrailingSpaces().equals(EditorSettingsExternalizable.STRIP_TRAILING_SPACES_NONE)) {
350       // ignore trailing spaces errors if they're going to disappear after save
351       if (problem.myCode.equals("W291") || problem.myCode.equals("W293")) {
352         return true;
353       }
354     }
355
356     final CodeStyleSettings codeStyleSettings = CodeStyleSettingsManager.getSettings(project);
357     final CommonCodeStyleSettings commonSettings = codeStyleSettings.getCommonSettings(PythonLanguage.getInstance());
358     final PyCodeStyleSettings pySettings = codeStyleSettings.getCustomSettings(PyCodeStyleSettings.class);
359     
360     if (element instanceof PsiWhiteSpace) {
361       // E303 too many blank lines (num)
362       if (problem.myCode.equals("E303")) {
363         final Matcher matcher = E303_LINE_COUNT_PATTERN.matcher(problem.myDescription);
364         if (matcher.matches()) {
365           final int reportedBlanks = Integer.parseInt(matcher.group(1));
366           final PsiElement nonWhitespaceAfter = PyPsiUtils.getNextNonWhitespaceSibling(element);
367           final PsiElement nonWhitespaceBefore = PyPsiUtils.getPrevNonWhitespaceSibling(element);
368           final boolean classNearby = nonWhitespaceBefore instanceof PyClass || nonWhitespaceAfter instanceof PyClass;
369           final boolean functionNearby = nonWhitespaceBefore instanceof PyFunction || nonWhitespaceAfter instanceof PyFunction;
370           if (functionNearby || classNearby) {
371             if (PyUtil.isTopLevel(element)) {
372               if (reportedBlanks <= pySettings.BLANK_LINES_AROUND_TOP_LEVEL_CLASSES_FUNCTIONS) {
373                 return true;
374               }
375             }
376             else {
377               // Blanks around classes have priority over blanks around functions as defined in Python spacing builder
378               if (classNearby && reportedBlanks <= commonSettings.BLANK_LINES_AROUND_CLASS ||
379                   functionNearby && reportedBlanks <= commonSettings.BLANK_LINES_AROUND_METHOD) {
380                 return true;
381               }
382             }
383           }
384         }
385       }
386       
387       if (problem.myCode.equals("W191") && codeStyleSettings.useTabCharacter(PythonFileType.INSTANCE)) {
388         return true;
389       }
390         
391       // E251 unexpected spaces around keyword / parameter equals
392       // Note that E222 (multiple spaces after operator) is not suppressed, though. 
393       if (problem.myCode.equals("E251") &&
394           (element.getParent() instanceof PyParameter && pySettings.SPACE_AROUND_EQ_IN_NAMED_PARAMETER ||
395            element.getParent() instanceof PyKeywordArgument && pySettings.SPACE_AROUND_EQ_IN_KEYWORD_ARGUMENT)) {
396         return true;
397       }
398     }
399     return false;
400   }
401
402   private static final Pattern PROBLEM_PATTERN = Pattern.compile(".+:(\\d+):(\\d+): ([EW]\\d{3}) (.+)");
403
404   @Nullable
405   private static Problem parseProblem(String s) {
406     Matcher m = PROBLEM_PATTERN.matcher(s);
407     if (m.matches()) {
408       int line = Integer.parseInt(m.group(1));
409       int column = Integer.parseInt(m.group(2));
410       return new Problem(line, column, m.group(3), m.group(4));
411     }
412     if (((ApplicationInfoImpl) ApplicationInfo.getInstance()).isEAP()) {
413       LOG.info("Failed to parse problem line from pycodestyle.py: " + s);
414     }
415     return null;
416   }
417
418   private static class IgnoreErrorFix implements IntentionAction {
419     private final String myCode;
420
421     public IgnoreErrorFix(String code) {
422       myCode = code;
423     }
424
425     @NotNull
426     @Override
427     public String getText() {
428       return "Ignore errors like this";
429     }
430
431     @NotNull
432     @Override
433     public String getFamilyName() {
434       return getText();
435     }
436
437     @Override
438     public boolean isAvailable(@NotNull Project project, Editor editor, PsiFile file) {
439       return true;
440     }
441
442     @Override
443     public void invoke(@NotNull Project project, Editor editor, final PsiFile file) throws IncorrectOperationException {
444       InspectionProjectProfileManager.getInstance(project).getCurrentProfile().modifyProfile(model -> {
445         PyPep8Inspection tool = (PyPep8Inspection)model.getUnwrappedTool(PyPep8Inspection.INSPECTION_SHORT_NAME, file);
446         if (!tool.ignoredErrors.contains(myCode)) {
447           tool.ignoredErrors.add(myCode);
448         }
449       });
450     }
451
452     @Override
453     public boolean startInWriteAction() {
454       return false;
455     }
456   }
457 }