diff --git a/.gitignore b/.gitignore index f1478b6..4310615 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,8 @@ **/.DS_Store -.idea/workspace.xml -.idea/modules.xml -.idea/misc.xml -.idea/gradle.xml -.idea/libraries/ -.idea/kotlinc.xml -.idea/shelf/ +.idea/* +!.idea/codeStyles/ +!.idea/copyright/ +!.idea/checkstyle-idea.xml **/*.iml .gradle/ **/build/ diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index 60bc341..0000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/dictionaries/leinardi.xml b/.idea/dictionaries/leinardi.xml deleted file mode 100644 index f394628..0000000 --- a/.idea/dictionaries/leinardi.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - leinardi - pylint - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 2f62a92..23e18bd 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -2,5 +2,10 @@ \ No newline at end of file diff --git a/.idea/uiDesigner.xml b/.idea/uiDesigner.xml deleted file mode 100644 index e96534f..0000000 --- a/.idea/uiDesigner.xml +++ /dev/null @@ -1,124 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 8306744..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/build.gradle b/build.gradle index 06179a3..42fe626 100644 --- a/build.gradle +++ b/build.gradle @@ -33,7 +33,7 @@ buildscript { plugins { id 'org.jetbrains.intellij' version '0.4.17' - id 'net.ltgt.errorprone' version '0.0.16' + id 'net.ltgt.errorprone' version '2.0.2' id 'idea' id 'java' id 'checkstyle' @@ -45,11 +45,10 @@ checkstyle { ignoreFailures = false // Whether this task will ignore failures and continue running the build. configFile rootProject.file('config/checkstyle/checkstyle.xml') // The Checkstyle configuration file to use. - toolVersion = '8.29' // The version of Checkstyle you want to be used + toolVersion = '9.1' // The version of Checkstyle you want to be used } -def hasPyCharm = project.hasProperty('pycharmPath') -def hasPythonPlugin = project.hasProperty('pythonPlugin') +def hasPycharmPath = project.hasProperty('pycharmPath') def props = new Properties() rootProject.file('src/main/resources/com/leinardi/pycharm/mypy/MypyBundle.properties') .withInputStream { @@ -69,16 +68,18 @@ sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 intellij { - version ideaVersion + version ideVersion pluginName props.getProperty('plugin.name').toLowerCase().replace(' ', '-') downloadSources Boolean.valueOf(downloadIdeaSources) updateSinceUntilBuild = true - if (hasPyCharm) { + if (hasPycharmPath) { alternativeIdePath pycharmPath } - if (hasPythonPlugin) { - plugins += [pythonPlugin] - } + plugins += [pythonPlugin] +} + +runIde { + systemProperties.put("idea.log.debug.categories", "#com.leinardi.pycharm.mypy") } patchPluginXml { @@ -86,7 +87,6 @@ patchPluginXml { sinceBuild project.property('sinceBuild') untilBuild project.property('untilBuild') pluginDescription props.getProperty('plugin.Mypy-PyCharm.description') - changeNotes getChangelogHtml() } publishPlugin { @@ -99,7 +99,7 @@ repositories { jcenter() maven { url "https://plugins.gradle.org/m2/" } maven { url 'https://dl.bintray.com/jetbrains/intellij-plugin-service' } - if (hasPyCharm) { + if (hasPycharmPath) { flatDir { dirs "$pycharmPath/lib" } @@ -107,10 +107,10 @@ repositories { } dependencies { - if (hasPyCharm) { + if (hasPycharmPath) { compileOnly name: 'pycharm' } - errorprone 'com.google.errorprone:error_prone_core:2.3.1' + errorprone 'com.google.errorprone:error_prone_core:2.10.0' } def getChangelogHtml() { diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml index daba2b1..fdef6c0 100644 --- a/config/checkstyle/checkstyle.xml +++ b/config/checkstyle/checkstyle.xml @@ -15,13 +15,13 @@ --> + "-//Checkstyle//DTD Checkstyle Configuration 1.3//EN" + "https://checkstyle.org/dtds/configuration_1_3.dtd"> - + diff --git a/gradle.properties b/gradle.properties index 41f45fa..e0ad87e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,17 +13,14 @@ # See the License for the specific language governing permissions and # limitations under the License. # -version=0.11.2 -ideaVersion=2018.1.8 -sinceBuild=181.5684 +version=0.12.0-alpha2 +ideVersion=PC-2021.2.3 +pythonPlugin=PythonCore:212.5457.59 +sinceBuild=192.7142.36 untilBuild= downloadIdeaSources=true publishUsername=leinardi publishChannels=Stable ####################################################################################################################### -# Uncomment one of the following settings: either pycharmPath or pythonPlugin -####################################################################################################################### -# Run Mypy plugin inside PyCharm installed into the following path -pycharmPath=/home/leinardi/pycharm-community -# Run Mypy plugin inside IntelliJ IDEA with the Python plugin. The IDE and the plugin will be downloaded automatically -pythonPlugin=PythonCore:2018.1.181.5087.50 +# To run PyCharm from a custom path, uncomment and change the following line +# pycharmPath=/home/leinardi/pycharm-community diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b57e667..312cb77 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -16,6 +16,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/main/java/com/leinardi/pycharm/mypy/MypyInspection.java b/src/main/java/com/leinardi/pycharm/mypy/MypyAnnotator.java similarity index 52% rename from src/main/java/com/leinardi/pycharm/mypy/MypyInspection.java rename to src/main/java/com/leinardi/pycharm/mypy/MypyAnnotator.java index c550117..05db8bc 100644 --- a/src/main/java/com/leinardi/pycharm/mypy/MypyInspection.java +++ b/src/main/java/com/leinardi/pycharm/mypy/MypyAnnotator.java @@ -16,12 +16,15 @@ package com.leinardi.pycharm.mypy; -import com.intellij.codeInspection.InspectionManager; -import com.intellij.codeInspection.LocalInspectionTool; -import com.intellij.codeInspection.ProblemDescriptor; +import com.intellij.codeInsight.daemon.HighlightDisplayKey; +import com.intellij.codeInspection.InspectionProfile; +import com.intellij.lang.annotation.AnnotationHolder; +import com.intellij.lang.annotation.ExternalAnnotator; +import com.intellij.lang.annotation.HighlightSeverity; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.progress.ProcessCanceledException; import com.intellij.openapi.project.Project; +import com.intellij.profile.codeInspection.InspectionProjectProfileManager; import com.intellij.psi.PsiFile; import com.leinardi.pycharm.mypy.checker.Problem; import com.leinardi.pycharm.mypy.checker.ScanFiles; @@ -38,16 +41,44 @@ import java.util.Map; import static com.leinardi.pycharm.mypy.MypyBundle.message; -import static com.leinardi.pycharm.mypy.util.Async.asyncResultOf; import static com.leinardi.pycharm.mypy.util.Notifications.showException; import static com.leinardi.pycharm.mypy.util.Notifications.showWarning; import static java.util.Collections.singletonList; -import static java.util.Optional.ofNullable; -public class MypyInspection extends LocalInspectionTool { +/** + * Using the `ExternalAnnotator` API instead of `LocalInspectionTool`, because the former has better behavior with + * long-running expensive checkers like mypy. Following multiple successive changes to a file, `LocalInspectionTool` + * can invoke the checker for each modification from multiple threads in parallel, which can bog down the system + * (see https://github.com/leinardi/mypy-pycharm/issues/43). + * `ExternalAnnotator` cancels the previous running check (if any) before running the next one. + *

+ * Modeled after `com.jetbrains.python.validation.Pep8ExternalAnnotator` + *

+ * IDE calls methods in three phases: + * 1. `State collectInformation(PsiFile)`: preparation + * 2. `Results doAnnotate(State)`: called in the background. + * 3. `void apply(PsiFile, State, AnnotationHolder)`: apply the annotations to the editor. + */ +public class MypyAnnotator extends ExternalAnnotator { + /* Inner classes storing intermediate results */ + static class State { + PsiFile file; + + public State(PsiFile file) { + this.file = file; + } + } + + static class Results { + List issues; + + public Results(List issues) { + this.issues = issues; + } + } - private static final Logger LOG = Logger.getInstance(MypyInspection.class); - private static final List NO_PROBLEMS_FOUND = Collections.emptyList(); + private static final Logger LOG = Logger.getInstance(MypyAnnotator.class); + private static final Results NO_PROBLEMS_FOUND = new Results(Collections.emptyList()); private static final String ERROR_MESSAGE_INVALID_SYNTAX = "invalid syntax"; private MypyPlugin plugin(final Project project) { @@ -58,20 +89,32 @@ private MypyPlugin plugin(final Project project) { return mypyPlugin; } + /** + * Integration with `MypyBatchInspection` + */ @Override - public ProblemDescriptor[] checkFile(@NotNull final PsiFile psiFile, - @NotNull final InspectionManager manager, - final boolean isOnTheFly) { - return asProblemDescriptors(asyncResultOf(() -> inspectFile(psiFile, manager), NO_PROBLEMS_FOUND), - manager); + public String getPairedBatchInspectionShortName() { + return MypyBatchInspection.INSPECTION_SHORT_NAME; } @Nullable - public List inspectFile(@NotNull final PsiFile psiFile, - @NotNull final InspectionManager manager) { - LOG.debug("Inspection has been invoked."); + @Override + public State collectInformation(@NotNull PsiFile file) { + LOG.debug("Mypy collectInformation " + file.getName() + + " modified=" + file.getModificationStamp() + + " thread=" + Thread.currentThread().getName() + ); - final MypyPlugin plugin = plugin(manager.getProject()); + return new State(file); + } + + @Nullable + @Override + public Results doAnnotate(State state) { + PsiFile psiFile = state.file; + Project project = psiFile.getProject(); + final MypyPlugin plugin = plugin(project); + long startTime = System.currentTimeMillis(); if (!MypyRunner.checkMypyAvailable(plugin.getProject())) { LOG.debug("Scan failed: Mypy not available."); @@ -91,7 +134,10 @@ public List inspectFile(@NotNull final PsiFile psiFile, if (map.isEmpty()) { return NO_PROBLEMS_FOUND; } - return map.get(psiFile); + + long duration = System.currentTimeMillis() - startTime; + LOG.debug("Mypy scan completed: " + psiFile.getName() + " in " + duration + " ms"); + return new Results(map.get(psiFile)); } catch (ProcessCanceledException | AssertionError e) { LOG.debug("Process cancelled when scanning: " + psiFile.getName()); @@ -102,7 +148,7 @@ public List inspectFile(@NotNull final PsiFile psiFile, return NO_PROBLEMS_FOUND; } catch (Throwable e) { - handlePluginException(e, psiFile, manager.getProject()); + handlePluginException(e, psiFile, project); return NO_PROBLEMS_FOUND; } finally { @@ -110,6 +156,26 @@ public List inspectFile(@NotNull final PsiFile psiFile, } } + @Override + public void apply(@NotNull PsiFile file, Results results, @NotNull AnnotationHolder holder) { + if (results == null || !file.isValid()) { + return; + } + + LOG.debug("Applying " + results.issues.size() + " annotations for " + file.getName()); + + // Get severity from inspection profile + final InspectionProfile profile = + InspectionProjectProfileManager.getInstance(file.getProject()).getCurrentProfile(); + final HighlightDisplayKey key = HighlightDisplayKey.find(MypyBatchInspection.INSPECTION_SHORT_NAME); + HighlightSeverity severity = profile.getErrorLevel(key, file).getSeverity(); + + for (Problem problem : results.issues) { + LOG.debug(" " + problem.getLine() + ": " + problem.getMessage()); + problem.createAnnotation(holder, severity); + } + } + private void handlePluginException(final Throwable e, final @NotNull PsiFile psiFile, final @NotNull Project project) { @@ -125,13 +191,4 @@ private void handlePluginException(final Throwable e, showException(project, e); } } - - @NotNull - private ProblemDescriptor[] asProblemDescriptors(final List results, final InspectionManager manager) { - return ofNullable(results) - .map(problems -> problems.stream() - .map(problem -> problem.toProblemDescriptor(manager)) - .toArray(ProblemDescriptor[]::new)) - .orElse(ProblemDescriptor.EMPTY_ARRAY); - } } diff --git a/src/main/java/com/leinardi/pycharm/mypy/MypyBatchInspection.java b/src/main/java/com/leinardi/pycharm/mypy/MypyBatchInspection.java new file mode 100644 index 0000000..3dc242c --- /dev/null +++ b/src/main/java/com/leinardi/pycharm/mypy/MypyBatchInspection.java @@ -0,0 +1,21 @@ +package com.leinardi.pycharm.mypy; + +import com.intellij.codeInspection.LocalInspectionTool; +import com.intellij.codeInspection.ex.ExternalAnnotatorBatchInspection; +import org.jetbrains.annotations.NotNull; + +/** + * By itself, the `MypyAnnotator` class does not provide support for the explicit "Inspect code" feature. + * + * This class uses `ExternalAnnotatorBatchInspection` middleware to provides that functionality. + * + * Modeled after `com.jetbrains.python.inspections.PyPep8Inspection` + */ +public class MypyBatchInspection extends LocalInspectionTool implements ExternalAnnotatorBatchInspection { + public static final String INSPECTION_SHORT_NAME = "Mypy"; + + @Override + public @NotNull String getShortName() { + return INSPECTION_SHORT_NAME; + } +} diff --git a/src/main/java/com/leinardi/pycharm/mypy/checker/Problem.java b/src/main/java/com/leinardi/pycharm/mypy/checker/Problem.java index 90cebb3..ff4c275 100644 --- a/src/main/java/com/leinardi/pycharm/mypy/checker/Problem.java +++ b/src/main/java/com/leinardi/pycharm/mypy/checker/Problem.java @@ -16,9 +16,9 @@ package com.leinardi.pycharm.mypy.checker; -import com.intellij.codeInspection.InspectionManager; -import com.intellij.codeInspection.ProblemDescriptor; -import com.intellij.codeInspection.ProblemHighlightType; +import com.intellij.lang.annotation.AnnotationBuilder; +import com.intellij.lang.annotation.AnnotationHolder; +import com.intellij.lang.annotation.HighlightSeverity; import com.intellij.psi.PsiElement; import com.leinardi.pycharm.mypy.MypyBundle; import com.leinardi.pycharm.mypy.mpapi.SeverityLevel; @@ -52,11 +52,15 @@ public Problem(final PsiElement target, this.suppressErrors = suppressErrors; } - @NotNull - public ProblemDescriptor toProblemDescriptor(final InspectionManager inspectionManager) { - return inspectionManager.createProblemDescriptor(target, - MypyBundle.message("inspection.message", getMessage()), - null, problemHighlightType(), false, afterEndOfLine); + public void createAnnotation(@NotNull AnnotationHolder holder, @NotNull HighlightSeverity severity) { + String message = MypyBundle.message("inspection.message", getMessage()); + AnnotationBuilder annotation = holder + .newAnnotation(severity, message) + .range(target.getTextRange()); + if (isAfterEndOfLine()) { + annotation = annotation.afterEndOfLine(); + } + annotation.create(); } public SeverityLevel getSeverityLevel() { @@ -83,10 +87,6 @@ public boolean isSuppressErrors() { return suppressErrors; } - private ProblemHighlightType problemHighlightType() { - return ProblemHighlightType.GENERIC_ERROR_OR_WARNING; - } - @Override public String toString() { return new ToStringBuilder(this) diff --git a/src/main/java/com/leinardi/pycharm/mypy/mpapi/MypyRunner.java b/src/main/java/com/leinardi/pycharm/mypy/mpapi/MypyRunner.java index 348be80..4d4c0f0 100644 --- a/src/main/java/com/leinardi/pycharm/mypy/mpapi/MypyRunner.java +++ b/src/main/java/com/leinardi/pycharm/mypy/mpapi/MypyRunner.java @@ -38,6 +38,7 @@ import com.leinardi.pycharm.mypy.util.FileTypes; import com.leinardi.pycharm.mypy.util.Notifications; import org.jdesktop.swingx.util.OS; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.io.BufferedReader; @@ -279,7 +280,7 @@ private static List runMypy(Project project, Set filesToScan, Str if (daemon) { cmd.addParameter("run"); cmd.addParameter("--"); - cmd.addParameter("``--show-column-numbers"); + cmd.addParameter("--show-column-numbers"); } else { cmd.addParameter("--show-column-numbers"); } @@ -302,8 +303,10 @@ private static List runMypy(Project project, Set filesToScan, Str cmd.setWorkDirectory(project.getBasePath()); final Process process; try { + LOG.info("Running command: " + cmd.getCommandLineString()); process = cmd.createProcess(); InputStream inputStream = process.getInputStream(); + assert (inputStream != null); //TODO check stderr for errors // process.waitFor(); return parseMypyOutput(inputStream); @@ -319,7 +322,8 @@ private static List runMypy(Project project, Set filesToScan, Str } } - private static List parseMypyOutput(InputStream inputStream) throws IOException { + @NotNull + public static List parseMypyOutput(@NotNull InputStream inputStream) throws IOException { ArrayList issues = new ArrayList<>(); BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream, UTF_8)); String rawLine; @@ -333,7 +337,7 @@ private static List parseMypyOutput(InputStream inputStream) throws IOExc String path = splitPosition[0].trim(); int line = splitPosition.length > 1 ? Integer.parseInt(splitPosition[1].trim()) : 1; int column = splitPosition.length > 2 ? Integer.parseInt(splitPosition[2].trim()) : 1; - String[] splitError = rawLine.substring(typeIndexStart).split(":", -1); + String[] splitError = rawLine.substring(typeIndexStart).split(":", 2); SeverityLevel severityLevel = SeverityLevel.valueOf(splitError[0].trim().toUpperCase()); String message = splitError[1].trim(); issues.add(new Issue(path, line, column, severityLevel, message)); diff --git a/src/main/java/com/leinardi/pycharm/mypy/toolwindow/TogglableTreeNode.java b/src/main/java/com/leinardi/pycharm/mypy/toolwindow/TogglableTreeNode.java index b68a240..03c9e3b 100644 --- a/src/main/java/com/leinardi/pycharm/mypy/toolwindow/TogglableTreeNode.java +++ b/src/main/java/com/leinardi/pycharm/mypy/toolwindow/TogglableTreeNode.java @@ -18,8 +18,8 @@ import javax.swing.tree.DefaultMutableTreeNode; import javax.swing.tree.TreeNode; -import java.util.Collections; import java.util.List; +import java.util.stream.Collectors; /** * Tree node with togglable visibility. @@ -44,9 +44,10 @@ public void setVisible(final boolean visible) { this.visible = visible; } - @SuppressWarnings("unchecked") List getAllChildren() { - return Collections.unmodifiableList(children); + return children.stream() + .map(child -> (TogglableTreeNode) child) + .collect(Collectors.toList()); } @Override diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index c8d0d96..0969546 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -15,9 +15,9 @@ --> - com.leinardi.pycharm.mypy - Mypy - Roberto Leinardi + com.leinardi.pycharm.mypy.experimental + Mypy (experimental) + Marti Raudsepp @@ -46,11 +46,16 @@ - + + This plugin provides both real-time \ - and on-demand scanning of Python files with Mypy from within the PyCharm IDE.

+plugin.Mypy-PyCharm.description=\ +

Experimental fork of the original Mypy plugin by Roberto Leinardi with various improvements.

\ +

This plugin provides both real-time and on-demand scanning of Python files with the Mypy type checker from \ + within the PyCharm and IntelliJ IDEs.

plugin.notification.alerts=Mypy Alerts plugin.notification.logging=Mypy Logging plugin.notification.unable-to-run-mypy.subtitle=Unable to run Mypy diff --git a/src/test/java/com/leinardi/pycharm/mypy/mpapi/MypyRunnerTest.java b/src/test/java/com/leinardi/pycharm/mypy/mpapi/MypyRunnerTest.java new file mode 100644 index 0000000..5b1148e --- /dev/null +++ b/src/test/java/com/leinardi/pycharm/mypy/mpapi/MypyRunnerTest.java @@ -0,0 +1,26 @@ +package com.leinardi.pycharm.mypy.mpapi; + +import org.junit.Assert; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; + +public class MypyRunnerTest { + private static InputStream stringToStream(String s) { + return new ByteArrayInputStream(s.getBytes(StandardCharsets.UTF_8)); + } + + @Test + public void testParseWithColon() throws IOException { + String message = "Dict entry 0 has incompatible type \"int\": \"str\"; expected \"int\": \"int\" [dict-item]"; + String input = "path/testfile.py:1:22: error: " + message + "\n"; + Issue parsed = new Issue("path/testfile.py", 1, 22, SeverityLevel.ERROR, message); + + List results = MypyRunner.parseMypyOutput(stringToStream(input)); + Assert.assertArrayEquals(results.toArray(), new Issue[]{parsed}); + } +}