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