From 8f33ac4c5c7031501b0ee58fa69a18d103a5851c Mon Sep 17 00:00:00 2001 From: Morgan Bartholomew Date: Thu, 11 Jun 2026 22:12:06 +1000 Subject: [PATCH] PY-90092 lsp-tools: inline lsp plugin into community IJ-MR-207996 GitOrigin-RevId: 8a8619c679d0991f0a3c8a1cf3c425e3d1eaa4c4 --- .idea/modules.xml | 7 + build/bazel-generated-file-list.txt | 7 + .../DevKitSplitModeInspectionExclusions.json | 23 +- python/BUILD.bazel | 2 + python/intellij.python.community.tests.iml | 1 + python/pluginCore/plugin-content.yaml | 21 + .../pluginCore/resources/META-INF/plugin.xml | 7 + python/python-lsp-core/BUILD.bazel | 106 ++++ python/python-lsp-core/OWNERSHIP | 2 + python/python-lsp-core/README.MD | 101 ++++ .../intellij.python.lsp.core.iml | 37 ++ .../resources/intellij.python.lsp.core.xml | 30 + .../messages/PyLspCoreBundle.properties | 10 + .../lsp/core/NotebookLanguageResolver.kt | 25 + .../python/lsp/core/PyLspCoreBundle.kt | 18 + .../python/lsp/core/PyLspSupportedFile.kt | 53 ++ .../lsp/core/PyLspToolIntegrationProvider.kt | 427 ++++++++++++++ .../python/lsp/core/listener/PyLspListener.kt | 11 + .../type/LspCollectSupportedTypesVisitor.kt | 135 +++++ .../core/type/LspIsSupportedTypesVisitor.kt | 123 ++++ .../python/lsp/core/type/LspPyTypeResolver.kt | 19 + .../lsp/core/type/LspTypeEvalContext.kt | 110 ++++ .../lsp/core/type/PyStringTypeResolver.kt | 36 ++ .../typeEngine/BuiltInPyTypeEngineProvider.kt | 24 + .../typeEngine/PyTypeEngineConfigurable.kt | 75 +++ .../typeEngine/PyTypeEngineProjectSettings.kt | 39 ++ .../core/typeEngine/PyTypeEngineProvider.kt | 34 ++ .../lsp/core/typeEngine/PyTypeEngineType.kt | 10 + .../typeEngine/PyTypeEngineUsageCollector.kt | 44 ++ .../lsp/core/typeEngine/PyTypeEngineUtils.kt | 21 + .../utils/PyLspServerModificationTracker.kt | 13 + python/python-lsp-impl/BUILD.bazel | 82 +++ .../intellij.python.lsp.impl.iml | 29 + .../resources/intellij.python.lsp.impl.xml | 19 + .../lsp/impl/PyLspToolAdvertiserService.kt | 199 +++++++ python/python-pyrefly/BUILD.bazel | 111 ++++ python/python-pyrefly/OWNERSHIP | 2 + .../pyrefly/icons/PythonPyreflyIcons.java | 17 + .../intellij.python.pyrefly.iml | 44 ++ .../resources/icons/pyrefly.svg | 2 + .../resources/icons/pyrefly_dark.svg | 2 + .../resources/intellij.python.pyrefly.xml | 32 ++ .../messages/PyreflyBundle.properties | 2 + .../intellij/python/pyrefly/PyreflyBundle.kt | 18 + .../python/pyrefly/PyreflyConfiguration.kt | 25 + .../pyrefly/PyreflyDetailConfigurable.kt | 12 + .../intellij/python/pyrefly/PyreflyPyTool.kt | 47 ++ .../pyrefly/PyreflyPyTypeEngineProvider.kt | 71 +++ .../python/pyrefly/PyreflyUsageCollector.kt | 50 ++ .../intellij/python/pyrefly/PyreflyUtil.kt | 11 + .../python/pyrefly/lsp/PyreflyLsp4jServer.kt | 79 +++ .../pyrefly/lsp/PyreflyLspClientDescriptor.kt | 115 ++++ .../lsp/PyreflyLspIntegrationProvider.kt | 9 + .../PyreflyExternalPyTypeResolverProvider.kt | 50 ++ .../PyreflyOffsetDetectorVisitor.kt | 66 +++ .../typeProvider/PyreflyTypeEvalContext.kt | 430 ++++++++++++++ .../typeProvider/PyreflyTypeResolver.kt | 54 ++ .../PyreflyTypeResolverFileCache.kt | 69 +++ python/python-pyright/BUILD.bazel | 107 ++++ python/python-pyright/OWNERSHIP | 2 + .../pyright/icons/PythonPyrightIcons.java | 18 + .../intellij.python.pyright.iml | 42 ++ .../resources/icons/basedpyright.png | Bin 0 -> 151 bytes .../resources/icons/basedpyright_dark.png | Bin 0 -> 153 bytes .../resources/icons/pyright.png | Bin 0 -> 8459 bytes .../resources/icons/pyright_dark.png | Bin 0 -> 8459 bytes .../resources/intellij.python.pyright.xml | 20 + .../messages/PyrightBundle.properties | 2 + .../pyright/BasedpyrightConfigurable.kt | 32 ++ .../python/pyright/BasedpyrightPyTool.kt | 38 ++ .../intellij/python/pyright/PyrightBundle.kt | 18 + .../python/pyright/PyrightConfigurable.kt | 31 ++ .../python/pyright/PyrightLspClient.kt | 89 +++ .../intellij/python/pyright/PyrightPyTool.kt | 38 ++ .../intellij/python/pyright/PyrightUtil.kt | 14 + python/python-ruff/BUILD.bazel | 125 +++++ python/python-ruff/OWNERSHIP | 2 + .../python/ruff/icons/PythonRuffIcons.java | 17 + python/python-ruff/intellij.python.ruff.iml | 51 ++ python/python-ruff/resources/icons/ruff.svg | 4 + .../python-ruff/resources/icons/ruff_dark.svg | 4 + .../RuffSuppressionCodes/preview.py | 3 + .../inlayProviders/RuffTomlCodes/preview.toml | 5 + .../resources/intellij.python.ruff.xml | 46 ++ .../resources/messages/RuffBundle.properties | 15 + .../com/intellij/python/ruff/RuffBundle.kt | 18 + .../intellij/python/ruff/RuffConfigurable.kt | 52 ++ .../python/ruff/RuffImportOptimizer.kt | 96 ++++ .../com/intellij/python/ruff/RuffPyTool.kt | 64 +++ .../com/intellij/python/ruff/RuffService.kt | 242 ++++++++ .../src/com/intellij/python/ruff/RuffUtil.kt | 13 + .../python/ruff/codeinsight/PatternUtil.kt | 22 + .../RuffConfigExternalAnnotator.kt | 158 ++++++ .../RuffConfigOptionCompletionContributor.kt | 133 +++++ ...igOptionCompletionDocumentationProvider.kt | 38 ++ .../RuffConfigOptionDocumentationProvider.kt | 173 ++++++ .../RuffConfigOptionGotoDeclarationHandler.kt | 42 ++ .../ruff/codeinsight/RuffDocumentationUtil.kt | 107 ++++ .../RuffErrorCodeCompletionContributor.kt | 126 +++++ ...rrorCodeCompletionDocumentationProvider.kt | 26 + .../RuffErrorCodeDocumentationProvider.kt | 49 ++ .../RuffErrorCodeGotoDeclarationHandler.kt | 122 ++++ .../RuffSuppressionInlayHintsProvider.kt | 58 ++ .../codeinsight/RuffTomlInlayHintsProvider.kt | 58 ++ .../codeinsight/RuffTooltipLinkHandler.kt | 56 ++ .../RuffDisableRuleForFileIntentionAction.kt | 61 ++ .../actions/RuffDisableRuleIntentionAction.kt | 270 +++++++++ .../python/ruff/server/RuffLspClient.kt | 116 ++++ python/python-ty/BUILD.bazel | 119 ++++ python/python-ty/OWNERSHIP | 2 + .../python/ty/icons/PythonTYIcons.java | 17 + python/python-ty/intellij.python.ty.iml | 48 ++ python/python-ty/resources/icons/ty.png | Bin 0 -> 2015 bytes python/python-ty/resources/icons/ty_dark.png | Bin 0 -> 2015 bytes .../resources/intellij.python.ty.xml | 26 + .../resources/messages/TyBundle.properties | 8 + .../src/com/intellij/python/ty/TyBundle.kt | 18 + .../com/intellij/python/ty/TyConfigurable.kt | 29 + .../com/intellij/python/ty/TyLsp4jServer.kt | 19 + .../src/com/intellij/python/ty/TyLspClient.kt | 44 ++ .../src/com/intellij/python/ty/TyPyTool.kt | 43 ++ .../src/com/intellij/python/ty/TyService.kt | 7 + .../intellij/python/ty/TyUsageCollector.kt | 25 + .../src/com/intellij/python/ty/TyUtil.kt | 164 ++++++ .../TyExternalPyTypeResolverProvider.kt | 39 ++ .../python/ty/typeProvider/TyPyTypeEngine.kt | 91 +++ .../python/ty/typeProvider/TyTypeContext.kt | 53 ++ .../python/ty/typeProvider/TyTypeResolver.kt | 27 + python/python-type-engine/BUILD.bazel | 125 +++++ .../intellij.python.typeEngine.iml | 51 ++ .../resources/intellij.python.typeEngine.xml | 54 ++ .../messages/TypeEngineBundle.properties | 8 + ...PyChooserTypeEngineConfigurableProvider.kt | 18 + .../typeEngine/PyTypeEngineStatusBarWidget.kt | 224 ++++++++ .../typeEngine/RestartLspServersListener.kt | 139 +++++ .../python/typeEngine/TypeEngineBundle.kt | 18 + .../typeEngine/TypeInferenceCoroutine.kt | 10 + .../python/lsp/core/PyLspSupportedFileTest.kt | 40 ++ .../LspCollectSupportedTypesVisitorTest.kt | 116 ++++ .../type/LspIsSupportedTypesVisitorTest.kt | 135 +++++ .../python/utils/PyStringTypeResolverTest.kt | 526 ++++++++++++++++++ 141 files changed, 8283 insertions(+), 1 deletion(-) create mode 100644 python/python-lsp-core/BUILD.bazel create mode 100644 python/python-lsp-core/OWNERSHIP create mode 100644 python/python-lsp-core/README.MD create mode 100644 python/python-lsp-core/intellij.python.lsp.core.iml create mode 100644 python/python-lsp-core/resources/intellij.python.lsp.core.xml create mode 100644 python/python-lsp-core/resources/messages/PyLspCoreBundle.properties create mode 100644 python/python-lsp-core/src/com/intellij/python/lsp/core/NotebookLanguageResolver.kt create mode 100644 python/python-lsp-core/src/com/intellij/python/lsp/core/PyLspCoreBundle.kt create mode 100644 python/python-lsp-core/src/com/intellij/python/lsp/core/PyLspSupportedFile.kt create mode 100644 python/python-lsp-core/src/com/intellij/python/lsp/core/PyLspToolIntegrationProvider.kt create mode 100644 python/python-lsp-core/src/com/intellij/python/lsp/core/listener/PyLspListener.kt create mode 100644 python/python-lsp-core/src/com/intellij/python/lsp/core/type/LspCollectSupportedTypesVisitor.kt create mode 100644 python/python-lsp-core/src/com/intellij/python/lsp/core/type/LspIsSupportedTypesVisitor.kt create mode 100644 python/python-lsp-core/src/com/intellij/python/lsp/core/type/LspPyTypeResolver.kt create mode 100644 python/python-lsp-core/src/com/intellij/python/lsp/core/type/LspTypeEvalContext.kt create mode 100644 python/python-lsp-core/src/com/intellij/python/lsp/core/type/PyStringTypeResolver.kt create mode 100644 python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/BuiltInPyTypeEngineProvider.kt create mode 100644 python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/PyTypeEngineConfigurable.kt create mode 100644 python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/PyTypeEngineProjectSettings.kt create mode 100644 python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/PyTypeEngineProvider.kt create mode 100644 python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/PyTypeEngineType.kt create mode 100644 python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/PyTypeEngineUsageCollector.kt create mode 100644 python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/PyTypeEngineUtils.kt create mode 100644 python/python-lsp-core/src/com/intellij/python/lsp/core/utils/PyLspServerModificationTracker.kt create mode 100644 python/python-lsp-impl/BUILD.bazel create mode 100644 python/python-lsp-impl/intellij.python.lsp.impl.iml create mode 100644 python/python-lsp-impl/resources/intellij.python.lsp.impl.xml create mode 100644 python/python-lsp-impl/src/com/intellij/python/lsp/impl/PyLspToolAdvertiserService.kt create mode 100644 python/python-pyrefly/BUILD.bazel create mode 100644 python/python-pyrefly/OWNERSHIP create mode 100644 python/python-pyrefly/gen/com/intellij/python/pyrefly/icons/PythonPyreflyIcons.java create mode 100644 python/python-pyrefly/intellij.python.pyrefly.iml create mode 100644 python/python-pyrefly/resources/icons/pyrefly.svg create mode 100644 python/python-pyrefly/resources/icons/pyrefly_dark.svg create mode 100644 python/python-pyrefly/resources/intellij.python.pyrefly.xml create mode 100644 python/python-pyrefly/resources/messages/PyreflyBundle.properties create mode 100644 python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyBundle.kt create mode 100644 python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyConfiguration.kt create mode 100644 python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyDetailConfigurable.kt create mode 100644 python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyPyTool.kt create mode 100644 python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyPyTypeEngineProvider.kt create mode 100644 python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyUsageCollector.kt create mode 100644 python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyUtil.kt create mode 100644 python/python-pyrefly/src/com/intellij/python/pyrefly/lsp/PyreflyLsp4jServer.kt create mode 100644 python/python-pyrefly/src/com/intellij/python/pyrefly/lsp/PyreflyLspClientDescriptor.kt create mode 100644 python/python-pyrefly/src/com/intellij/python/pyrefly/lsp/PyreflyLspIntegrationProvider.kt create mode 100644 python/python-pyrefly/src/com/intellij/python/pyrefly/typeProvider/PyreflyExternalPyTypeResolverProvider.kt create mode 100644 python/python-pyrefly/src/com/intellij/python/pyrefly/typeProvider/PyreflyOffsetDetectorVisitor.kt create mode 100644 python/python-pyrefly/src/com/intellij/python/pyrefly/typeProvider/PyreflyTypeEvalContext.kt create mode 100644 python/python-pyrefly/src/com/intellij/python/pyrefly/typeProvider/PyreflyTypeResolver.kt create mode 100644 python/python-pyrefly/src/com/intellij/python/pyrefly/typeProvider/PyreflyTypeResolverFileCache.kt create mode 100644 python/python-pyright/BUILD.bazel create mode 100644 python/python-pyright/OWNERSHIP create mode 100644 python/python-pyright/gen/com/intellij/python/pyright/icons/PythonPyrightIcons.java create mode 100644 python/python-pyright/intellij.python.pyright.iml create mode 100644 python/python-pyright/resources/icons/basedpyright.png create mode 100644 python/python-pyright/resources/icons/basedpyright_dark.png create mode 100644 python/python-pyright/resources/icons/pyright.png create mode 100644 python/python-pyright/resources/icons/pyright_dark.png create mode 100644 python/python-pyright/resources/intellij.python.pyright.xml create mode 100644 python/python-pyright/resources/messages/PyrightBundle.properties create mode 100644 python/python-pyright/src/com/intellij/python/pyright/BasedpyrightConfigurable.kt create mode 100644 python/python-pyright/src/com/intellij/python/pyright/BasedpyrightPyTool.kt create mode 100644 python/python-pyright/src/com/intellij/python/pyright/PyrightBundle.kt create mode 100644 python/python-pyright/src/com/intellij/python/pyright/PyrightConfigurable.kt create mode 100644 python/python-pyright/src/com/intellij/python/pyright/PyrightLspClient.kt create mode 100644 python/python-pyright/src/com/intellij/python/pyright/PyrightPyTool.kt create mode 100644 python/python-pyright/src/com/intellij/python/pyright/PyrightUtil.kt create mode 100644 python/python-ruff/BUILD.bazel create mode 100644 python/python-ruff/OWNERSHIP create mode 100644 python/python-ruff/gen/com/intellij/python/ruff/icons/PythonRuffIcons.java create mode 100644 python/python-ruff/intellij.python.ruff.iml create mode 100644 python/python-ruff/resources/icons/ruff.svg create mode 100644 python/python-ruff/resources/icons/ruff_dark.svg create mode 100644 python/python-ruff/resources/inlayProviders/RuffSuppressionCodes/preview.py create mode 100644 python/python-ruff/resources/inlayProviders/RuffTomlCodes/preview.toml create mode 100644 python/python-ruff/resources/intellij.python.ruff.xml create mode 100644 python/python-ruff/resources/messages/RuffBundle.properties create mode 100644 python/python-ruff/src/com/intellij/python/ruff/RuffBundle.kt create mode 100644 python/python-ruff/src/com/intellij/python/ruff/RuffConfigurable.kt create mode 100644 python/python-ruff/src/com/intellij/python/ruff/RuffImportOptimizer.kt create mode 100644 python/python-ruff/src/com/intellij/python/ruff/RuffPyTool.kt create mode 100644 python/python-ruff/src/com/intellij/python/ruff/RuffService.kt create mode 100644 python/python-ruff/src/com/intellij/python/ruff/RuffUtil.kt create mode 100644 python/python-ruff/src/com/intellij/python/ruff/codeinsight/PatternUtil.kt create mode 100644 python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffConfigExternalAnnotator.kt create mode 100644 python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffConfigOptionCompletionContributor.kt create mode 100644 python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffConfigOptionCompletionDocumentationProvider.kt create mode 100644 python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffConfigOptionDocumentationProvider.kt create mode 100644 python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffConfigOptionGotoDeclarationHandler.kt create mode 100644 python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffDocumentationUtil.kt create mode 100644 python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffErrorCodeCompletionContributor.kt create mode 100644 python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffErrorCodeCompletionDocumentationProvider.kt create mode 100644 python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffErrorCodeDocumentationProvider.kt create mode 100644 python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffErrorCodeGotoDeclarationHandler.kt create mode 100644 python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffSuppressionInlayHintsProvider.kt create mode 100644 python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffTomlInlayHintsProvider.kt create mode 100644 python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffTooltipLinkHandler.kt create mode 100644 python/python-ruff/src/com/intellij/python/ruff/codeinsight/actions/RuffDisableRuleForFileIntentionAction.kt create mode 100644 python/python-ruff/src/com/intellij/python/ruff/codeinsight/actions/RuffDisableRuleIntentionAction.kt create mode 100644 python/python-ruff/src/com/intellij/python/ruff/server/RuffLspClient.kt create mode 100644 python/python-ty/BUILD.bazel create mode 100644 python/python-ty/OWNERSHIP create mode 100644 python/python-ty/gen/com/intellij/python/ty/icons/PythonTYIcons.java create mode 100644 python/python-ty/intellij.python.ty.iml create mode 100644 python/python-ty/resources/icons/ty.png create mode 100644 python/python-ty/resources/icons/ty_dark.png create mode 100644 python/python-ty/resources/intellij.python.ty.xml create mode 100644 python/python-ty/resources/messages/TyBundle.properties create mode 100644 python/python-ty/src/com/intellij/python/ty/TyBundle.kt create mode 100644 python/python-ty/src/com/intellij/python/ty/TyConfigurable.kt create mode 100644 python/python-ty/src/com/intellij/python/ty/TyLsp4jServer.kt create mode 100644 python/python-ty/src/com/intellij/python/ty/TyLspClient.kt create mode 100644 python/python-ty/src/com/intellij/python/ty/TyPyTool.kt create mode 100644 python/python-ty/src/com/intellij/python/ty/TyService.kt create mode 100644 python/python-ty/src/com/intellij/python/ty/TyUsageCollector.kt create mode 100644 python/python-ty/src/com/intellij/python/ty/TyUtil.kt create mode 100644 python/python-ty/src/com/intellij/python/ty/typeProvider/TyExternalPyTypeResolverProvider.kt create mode 100644 python/python-ty/src/com/intellij/python/ty/typeProvider/TyPyTypeEngine.kt create mode 100644 python/python-ty/src/com/intellij/python/ty/typeProvider/TyTypeContext.kt create mode 100644 python/python-ty/src/com/intellij/python/ty/typeProvider/TyTypeResolver.kt create mode 100644 python/python-type-engine/BUILD.bazel create mode 100644 python/python-type-engine/intellij.python.typeEngine.iml create mode 100644 python/python-type-engine/resources/intellij.python.typeEngine.xml create mode 100644 python/python-type-engine/resources/messages/TypeEngineBundle.properties create mode 100644 python/python-type-engine/src/com/intellij/python/typeEngine/PyChooserTypeEngineConfigurableProvider.kt create mode 100644 python/python-type-engine/src/com/intellij/python/typeEngine/PyTypeEngineStatusBarWidget.kt create mode 100644 python/python-type-engine/src/com/intellij/python/typeEngine/RestartLspServersListener.kt create mode 100644 python/python-type-engine/src/com/intellij/python/typeEngine/TypeEngineBundle.kt create mode 100644 python/python-type-engine/src/com/intellij/python/typeEngine/TypeInferenceCoroutine.kt create mode 100644 python/testSrc/com/intellij/python/lsp/core/PyLspSupportedFileTest.kt create mode 100644 python/testSrc/com/intellij/python/lsp/core/type/LspCollectSupportedTypesVisitorTest.kt create mode 100644 python/testSrc/com/intellij/python/lsp/core/type/LspIsSupportedTypesVisitorTest.kt create mode 100644 python/testSrc/com/intellij/python/utils/PyStringTypeResolverTest.kt diff --git a/.idea/modules.xml b/.idea/modules.xml index 979b61f53bc8..74daeaa4c82a 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -1794,6 +1794,8 @@ + + @@ -1808,10 +1810,13 @@ + + + @@ -1832,6 +1837,8 @@ + + diff --git a/build/bazel-generated-file-list.txt b/build/bazel-generated-file-list.txt index 4662f00537a0..150bbd3bc3f8 100644 --- a/build/bazel-generated-file-list.txt +++ b/build/bazel-generated-file-list.txt @@ -1593,6 +1593,8 @@ python/python-external-index python/python-features-trainer python/python-grazie python/python-hatch +python/python-lsp-core +python/python-lsp-impl python/python-markdown python/python-packaging python/python-parser @@ -1604,11 +1606,14 @@ python/python-process-output/frontend python/python-psi-api python/python-psi-impl python/python-pyproject +python/python-pyrefly +python/python-pyright python/python-pytools python/python-pytools-ui python/python-pytools/tests python/python-requirements python/python-restructuredtext +python/python-ruff python/python-sdk python/python-sdk-configurator/backend python/python-sdk-configurator/common @@ -1623,6 +1628,8 @@ python/python-test-env/junit4 python/python-test-env/junit5 python/python-test-env/plain python/python-test-env/uv +python/python-ty +python/python-type-engine python/python-uv/backend python/python-uv/common python/python-uv/tests diff --git a/plugins/devkit/devkit-core/resources/remotedevInspectionData/DevKitSplitModeInspectionExclusions.json b/plugins/devkit/devkit-core/resources/remotedevInspectionData/DevKitSplitModeInspectionExclusions.json index 9df7912ffab7..245718b55be2 100644 --- a/plugins/devkit/devkit-core/resources/remotedevInspectionData/DevKitSplitModeInspectionExclusions.json +++ b/plugins/devkit/devkit-core/resources/remotedevInspectionData/DevKitSplitModeInspectionExclusions.json @@ -1 +1,22 @@ -{ "exclusions": [] } +{ + "exclusions": [ + { + "inspection": "SplitModeApiUsage", + "file": "community/python/python-type-engine/src/com/intellij/python/typeEngine/PyTypeEngineStatusBarWidget.kt", + "line": 49, + "reason": "PY-90092: Python LSP/type-engine tools were moved from PyCharm Pro into PyCharm Community. The type engine status bar widget is monolith-only UI that has not been split into frontend/backend yet." + }, + { + "inspection": "SplitModeApiUsage", + "file": "community/python/python-type-engine/src/com/intellij/python/typeEngine/PyTypeEngineStatusBarWidget.kt", + "line": 132, + "reason": "PY-90092: JBPopupFactory usage in the monolith-only Python type engine status bar widget (not yet split for remote dev)." + }, + { + "inspection": "SplitModeXmlApiUsage", + "file": "community/python/python-type-engine/resources/intellij.python.typeEngine.xml", + "line": 42, + "reason": "PY-90092: statusBarWidgetFactory registration for the monolith-only Python type engine widget (not yet split for remote dev)." + } + ] +} diff --git a/python/BUILD.bazel b/python/BUILD.bazel index 77050ae7f6da..defb4106fce7 100644 --- a/python/BUILD.bazel +++ b/python/BUILD.bazel @@ -1409,6 +1409,8 @@ jvm_library( "//plugins/toml/core:core_test_lib", "//python/python-poetry/backend", "//python/python-poetry/backend:backend_test_lib", + "//python/python-lsp-core:lsp-core", + "//python/python-lsp-core:lsp-core_test_lib", ], ) ### auto-generated section `build intellij.python.community.tests` end diff --git a/python/intellij.python.community.tests.iml b/python/intellij.python.community.tests.iml index bddb9f532b4d..ed5c01b84263 100644 --- a/python/intellij.python.community.tests.iml +++ b/python/intellij.python.community.tests.iml @@ -141,6 +141,7 @@ + \ No newline at end of file diff --git a/python/pluginCore/plugin-content.yaml b/python/pluginCore/plugin-content.yaml index ace7e214d03b..61a7a29fcdbf 100644 --- a/python/pluginCore/plugin-content.yaml +++ b/python/pluginCore/plugin-content.yaml @@ -116,6 +116,12 @@ - name: lib/modules/intellij.python.langInjection.jar contentModules: - name: intellij.python.langInjection +- name: lib/modules/intellij.python.lsp.core.jar + contentModules: + - name: intellij.python.lsp.core +- name: lib/modules/intellij.python.lsp.impl.jar + contentModules: + - name: intellij.python.lsp.impl - name: lib/modules/intellij.python.markdown.jar contentModules: - name: intellij.python.markdown @@ -161,6 +167,12 @@ - name: lib/modules/intellij.python.pyproject.jar contentModules: - name: intellij.python.pyproject +- name: lib/modules/intellij.python.pyrefly.jar + contentModules: + - name: intellij.python.pyrefly +- name: lib/modules/intellij.python.pyright.jar + contentModules: + - name: intellij.python.pyright - name: lib/modules/intellij.python.pytools.jar contentModules: - name: intellij.python.pytools @@ -170,6 +182,9 @@ - name: lib/modules/intellij.python.requirements.jar contentModules: - name: intellij.python.requirements +- name: lib/modules/intellij.python.ruff.jar + contentModules: + - name: intellij.python.ruff - name: lib/modules/intellij.python.sdk.jar contentModules: - name: intellij.python.sdk @@ -200,6 +215,12 @@ - name: lib/modules/intellij.python.terminal.shared.jar contentModules: - name: intellij.python.terminal.shared +- name: lib/modules/intellij.python.ty.jar + contentModules: + - name: intellij.python.ty +- name: lib/modules/intellij.python.typeEngine.jar + contentModules: + - name: intellij.python.typeEngine - name: lib/modules/intellij.python.uv.backend.jar contentModules: - name: intellij.python.uv.backend diff --git a/python/pluginCore/resources/META-INF/plugin.xml b/python/pluginCore/resources/META-INF/plugin.xml index 4d165a606ce9..0cc86d0f9eef 100644 --- a/python/pluginCore/resources/META-INF/plugin.xml +++ b/python/pluginCore/resources/META-INF/plugin.xml @@ -94,6 +94,13 @@ The Python plug-in provides smart editing for Python scripts. The feature set of + + + + + + + diff --git a/python/python-lsp-core/BUILD.bazel b/python/python-lsp-core/BUILD.bazel new file mode 100644 index 000000000000..66335a1febb3 --- /dev/null +++ b/python/python-lsp-core/BUILD.bazel @@ -0,0 +1,106 @@ +load("@rules_jvm//:jvm.bzl", "jvm_library") + +### auto-generated section `build intellij.python.lsp.core` start + +jvm_library( + name = "lsp-core", + srcs = glob( + [ + "src/**/*.kt", + "src/**/*.java", + "src/**/*.form", + ], + allow_empty = True, + ), + module_name = "intellij.python.lsp.core", + resource_strip_prefix = "resources", + resources = glob( + ["resources/**/*"], + ), + visibility = ["//visibility:public"], + exports = [ + # do not sort, + "@lib//:eclipse-lsp4j", + "@lib//:eclipse-lsp4j-jsonrpc", + ], + deps = [ + # do not sort, + "@lib//:kotlin-stdlib", + "//libraries/guava", + "@lib//:eclipse-lsp4j", + "@lib//:eclipse-lsp4j-jsonrpc", + "//platform/core-api:core", + "//platform/core-ui", + "//platform/editor-ui-api:editor-ui", + "//platform/lang-impl", + "//platform/platform-impl:ide-impl", + "//platform/lsp", + "//platform/projectModel-api:projectModel", + "//platform/analysis-api:analysis", + "//platform/lang-api:lang", + "//python:python-community-impl", + "//python/python-core-impl:community-core-impl", + "//python/python-sdk:sdk", + "//platform/observable", + "//platform/ide-core", + "//platform/statistics", + "//platform/core-impl", + "//platform/util:util-ui", + "//python/python-pytools-ui:pytools-ui", + "//python/python-pytools:pytools", + "//python/python-exec-service:community-execService", + "//platform/lsp-impl", + ], +) + +jvm_library( + name = "lsp-core_test_lib", + testonly = True, + srcs = glob( + [], + allow_empty = True, + ), + module_name = "intellij.python.lsp.core", + visibility = ["//visibility:public"], + exports = [ + # do not sort, + "@lib//:eclipse-lsp4j", + "@lib//:eclipse-lsp4j-jsonrpc", + ], + runtime_deps = [ + # do not sort, + ":lsp-core", + "//libraries/guava:guava_test_lib", + "//platform/core-api:core_test_lib", + "//platform/core-ui:core-ui_test_lib", + "//platform/editor-ui-api:editor-ui_test_lib", + "//platform/lang-impl:lang-impl_test_lib", + "//platform/platform-impl:ide-impl_test_lib", + "//platform/lsp:lsp_test_lib", + "//platform/projectModel-api:projectModel_test_lib", + "//platform/analysis-api:analysis_test_lib", + "//platform/lang-api:lang_test_lib", + "//python:python-community-impl_test_lib", + "//python/python-core-impl:community-core-impl_test_lib", + "//python/python-sdk:sdk_test_lib", + "//platform/observable:observable_test_lib", + "//platform/ide-core:ide-core_test_lib", + "//platform/statistics:statistics_test_lib", + "//platform/core-impl:core-impl_test_lib", + "//platform/util:util-ui_test_lib", + "//python/python-pytools-ui:pytools-ui_test_lib", + "//python/python-pytools:pytools_test_lib", + "//python/python-exec-service:community-execService_test_lib", + "//platform/lsp-impl:lsp-impl_test_lib", + ], +) +### auto-generated section `build intellij.python.lsp.core` end + +### auto-generated section `iml intellij.python.lsp.core` start +exports_files( + [ + "intellij.python.lsp.core.iml", + ], + visibility = ["//visibility:public"], +) +### auto-generated section `iml intellij.python.lsp.core` end diff --git a/python/python-lsp-core/OWNERSHIP b/python/python-lsp-core/OWNERSHIP new file mode 100644 index 000000000000..e5e207deb924 --- /dev/null +++ b/python/python-lsp-core/OWNERSHIP @@ -0,0 +1,2 @@ +owner 'PyCharm Codeinsight' + diff --git a/python/python-lsp-core/README.MD b/python/python-lsp-core/README.MD new file mode 100644 index 000000000000..7726529835cb --- /dev/null +++ b/python/python-lsp-core/README.MD @@ -0,0 +1,101 @@ +## Updated: 2025-12-26 + +Today we have major LSP providers for Python type checking: +- [pyrefly](https://github.com/facebook/pyrefly) +- [ty](https://github.com/astral-sh/ty) + +# Overview + +## Pyrefly +Pyrefly is Meta’s new Rust-based Python type checker, replacing Pyre — Meta’s previous Python type checker written in OCaml. +The expectation is that Pyrefly should be faster, more portable, and more capable compared to Pyre. + +One key thing the Pyrefly team made very clear this year is that they want to be truly open source. Pyre was also technically open source, but it was more of a “we built this for our needs, but here’s the source code if you want it”. In contrast, one of the foundational goals of Pyrefly is to be more engaged with the needs of the open-source community. + +## ty +ty is also a Rust-based Python type checker currently under development by Astral, the team behind uv and ruff. The project was formerly known as Red-Knot, but now has its official name: ty. Compared to Meta, Astral has been much quieter about its announcement: just a soft launch on GitHub, a brief 30-minute presentation, and a couple of blog articles and podcasts here and there. + + +## Comparison + +Both pyrefly and ty are written in Rust, both are incremental (albeit implemented slightly differently: see details below), and both are powered under the hood by Ruff for AST parsing. Also, both have first-class support for command-line type checking and LSP/IDE integration. + +However, other than the fact that they are both fast Python type checkers, that’s where the similarities end. +The primary difference lies in their goals. Pyrefly tries to be as aggressive as possible when typing — inferring as much as possible so that even code with absolutely no explicit types can have some amount of typing guarantees. + +ty, on the other hand, follows a different mantra: the gradual guarantee. The principal idea is that in a well-typed program, removing a type annotation should not cause a type error. In other words: you shouldn’t need to add new types to working code to resolve type errors. + +Example with difference: +```python + +def foo(imp: Any): + return str(imp) + +a = foo(123) + +# ✅ pyrefly | Revealed type: str +# ➖ ty | Revealed type: `Unknown` +# ➖ mypy | Revealed type is "Any" +# ✅ pyright | Type of "a" is "str" +reveal_type(a) + +# ✅ pyrefly | ERROR: `+` is not supported between `str` and `Literal[1]` +# ➖ ty | < No Error > +# ➖ mypy | < No Error > +# ✅ pyright | ERROR: Operator "+" not supported for types "str" and "Literal[1]" +a + 1 +``` + + +## Experiments + +I decided to test and implement type inference based on pyrefly. To resolve types I use the following algorithm: +- Filter PyTypedElements to determine whether they should be sent to LSP type evaluation or can be resolved instantly. For instance, for PyClass and PyStringLiteral we don't need to send them to LSP because it's sufficient to resolve the type from the builtin type provider. +- I created a special cache for each file that persists between document modifications and stores calculated types there. +- The first 10 requests are sent to LSP directly without batching because we have completion that should run really fast. Please note that our limitation is to not use completion from LSP and instead use builtin with LSP type provider. After the first 10 requests, I collect all filtered types from the file and send them to LSP as a batch request. +- LSP returns types as strings with type hints. We use special code that converts these strings to PyTypes. The code is not ideal and some types cannot be converted from string to PyType. + +## Test Environment +For testing I used a project from https://packages.jetbrains.team/files/p/pyqa/pycharm-test-data/pandasExample.zip and measured how much time it takes to infer types for all PyTypedElements in the file. +I tested both the builtin type provider and the pyrefly type provider. I ran the task in single-threaded mode (one by one) and in multithreaded mode with 8 parallel threads. +After the first run, I modified the file by adding `a=2\n` to the end and checked types again. + +## Results + +| LSP Provider | First Run (ms) | Second Run (ms) | +|---------------------|----------------|-----------------| +| builtin (single) | 4040 | 2400 | +| pyrefly (single) | 4010 | 1200 | +| builtin (8 thread) | 2200 | 1200 | +| pyrefly (8 threads) | 3051 | 608 | + +The following durations are for single-threaded Pyrefly: + +| Operation | Total Duration First Run (ms) | Second Run (ms) | +|-----------------------|--------------------------------|-----------------| +| LSP Request | 3000 | 1789 | +| String Type -> PyType | 557 | 166 | +| Total | 4010 | 1200 | + +Please note that in my test I had 2 LSP requests because I inferred types for pandas_example.py and the file pandas/typing.py was processed by the String PyType to PyType resolver. + + + +# Tested Hypotheses: +- Sending LSP requests without batching (1 type per request): This has significant overhead and is ~10x slower compared to batching. +- Sending all LSP requests in a single batch: This is faster overall, but results in very long response times for large files. For instance, a 3k-line file has a completion response time of 3 seconds. Not all files need types for all PSI elements, so storing them all creates significant memory and time overhead, especially for libraries. +- Sending LSP requests both individually and in batch simultaneously: There is some internal blocking, and the response for a single type can take longer than the batch response. + +# Risks +- The rest of our codeinsight was created and tested using our current type inference engine. Replacing this could cause issues for anything that relies on typing. These could include refactorings, resolve, inspections. To mitigate this, we should make our codeinsight tests (most of which are unit tests) are compatible with external type inference. +- Memory consumption: The WebStorm team is dealing with a heavy TS server. We should be prepared for the same data to be cached twice: once inside the LSP and again inside the IDE. +- We should invest in converting LSP string type representations into PyType instances. Ideally, we wouldn't use our old type engine. + +# Distribution of external type inference binaries +- We have a fork of Ty at the JB Account on GitHub. This fork contains an action that builds the distribution of the patched Ty. +- Pyrefly has already accepted our provideType() method. Therefore, we can install Pyrefly using the Python package manager. + +# Helpful Links: +- Test project for validating type resolution on large pandas file: https://packages.jetbrains.team/files/p/pyqa/pycharm-test-data/pandasExample.zip +- My MR branch: https://code.jetbrains.team/p/ij/repositories/ultimate/reviews/186102/files?repo=ultimate +- My MR to pyrefly: https://github.com/facebook/pyrefly/pull/1918 diff --git a/python/python-lsp-core/intellij.python.lsp.core.iml b/python/python-lsp-core/intellij.python.lsp.core.iml new file mode 100644 index 000000000000..0cc37a4232d3 --- /dev/null +++ b/python/python-lsp-core/intellij.python.lsp.core.iml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/python/python-lsp-core/resources/intellij.python.lsp.core.xml b/python/python-lsp-core/resources/intellij.python.lsp.core.xml new file mode 100644 index 000000000000..a8c5f8a4038e --- /dev/null +++ b/python/python-lsp-core/resources/intellij.python.lsp.core.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/python/python-lsp-core/resources/messages/PyLspCoreBundle.properties b/python/python-lsp-core/resources/messages/PyLspCoreBundle.properties new file mode 100644 index 000000000000..5df412975bfc --- /dev/null +++ b/python/python-lsp-core/resources/messages/PyLspCoreBundle.properties @@ -0,0 +1,10 @@ +comment.multimodule.not.warning=You can configure PyCharm type engine only for a single-module project now. + +display.name=Type Engine +engine.label=Type engine: +project.default=Project default +pycharm.description=A smart IDE type engine optimized for real world Python, including Django templates, SQLAlchemy models, and more +pyrefly.description=Pyrefly provides fast and accurate type checking, with support for Django and Pydantic +ty.description=Currently in Beta, ty provides fast and accurate type checking designed to work well with untyped code. Supports advanced type analysis features. +open.settings=Type Engine Settings\u2026 +pyrefly.package.install.description=If Pyrefly is not already installed, the latest version will be installed automatically after you apply these settings. diff --git a/python/python-lsp-core/src/com/intellij/python/lsp/core/NotebookLanguageResolver.kt b/python/python-lsp-core/src/com/intellij/python/lsp/core/NotebookLanguageResolver.kt new file mode 100644 index 000000000000..f0aa3c7da1ff --- /dev/null +++ b/python/python-lsp-core/src/com/intellij/python/lsp/core/NotebookLanguageResolver.kt @@ -0,0 +1,25 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.lsp.core + +import com.intellij.lang.Language +import com.intellij.openapi.extensions.ExtensionPointName +import com.intellij.openapi.vfs.VirtualFile +import org.jetbrains.annotations.ApiStatus + +/** + * Resolves the kernel language of a notebook file without forcing python-lsp-core to + * depend on any concrete notebook framework. Integration modules register implementations. + */ +@ApiStatus.Internal +interface NotebookLanguageResolver { + /** + * Returns the kernel language of [file] if this resolver recognises it as a + * notebook, or `null` otherwise. + */ + fun resolveLanguage(file: VirtualFile): Language? + + companion object { + val EP_NAME: ExtensionPointName = + ExtensionPointName.create("com.intellij.python.lsp.notebookLanguageResolver") + } +} diff --git a/python/python-lsp-core/src/com/intellij/python/lsp/core/PyLspCoreBundle.kt b/python/python-lsp-core/src/com/intellij/python/lsp/core/PyLspCoreBundle.kt new file mode 100644 index 000000000000..06117bea4423 --- /dev/null +++ b/python/python-lsp-core/src/com/intellij/python/lsp/core/PyLspCoreBundle.kt @@ -0,0 +1,18 @@ +package com.intellij.python.lsp.core + +import com.intellij.DynamicBundle +import org.jetbrains.annotations.Nls +import org.jetbrains.annotations.NonNls +import org.jetbrains.annotations.PropertyKey + +object PyLspCoreBundle { + private val myDynamicBundle = DynamicBundle(this::class.java, BUNDLE) + + @NonNls + const val BUNDLE: String = "messages.PyLspCoreBundle" + + @Nls + fun message(@PropertyKey(resourceBundle = BUNDLE) key: String, vararg params: Any): String { + return myDynamicBundle.getMessage(key, *params) + } +} \ No newline at end of file diff --git a/python/python-lsp-core/src/com/intellij/python/lsp/core/PyLspSupportedFile.kt b/python/python-lsp-core/src/com/intellij/python/lsp/core/PyLspSupportedFile.kt new file mode 100644 index 000000000000..b40b72f3ba3f --- /dev/null +++ b/python/python-lsp-core/src/com/intellij/python/lsp/core/PyLspSupportedFile.kt @@ -0,0 +1,53 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.lsp.core + +import com.intellij.openapi.util.registry.Registry +import com.intellij.openapi.vfs.VirtualFile +import com.jetbrains.python.PythonFileType +import com.jetbrains.python.PythonLanguage +import com.jetbrains.python.pyi.PyiFileType +import org.jetbrains.annotations.ApiStatus + +/** + * Predicate shared by [PyLspToolDescriptor.isSupportedFile] and + * [PyLspToolIntegrationProvider.fileOpened]. + * + * Accepts: + * - regular Python sources ([PythonFileType]) + * - Python stub files ([PyiFileType]) + * - when [notebookSupported] is `true`, notebooks whose kernel language resolves + * to Python via [NotebookLanguageResolver]. + * + * [notebookSupported] is a per-descriptor knob: a python LSP tool that can serve + * notebooks opts in by overriding its `supportsNotebooks` property to `true`. + */ +@ApiStatus.Internal +fun isPythonFile(file: VirtualFile, notebookSupported: Boolean = false): Boolean = + isPythonFile( + file, + notebookSupported, + NotebookLanguageResolver.EP_NAME.extensionList, + ) + +/** + * Test helper: pass explicit [NotebookLanguageResolver] implementations instead of relying on the EP. + */ +@ApiStatus.Internal +fun isPythonFile( + file: VirtualFile, + notebookSupported: Boolean, + resolvers: List, +): Boolean { + if (isPythonSourceFile(file)) return true + if (!notebookSupported) return false + if (!Registry.`is`("jupyter.lsp.enabled", false)) return false + return isPythonNotebookLanguage(file, resolvers) +} + +@ApiStatus.Internal +fun isPythonSourceFile(file: VirtualFile): Boolean = + file.fileType is PythonFileType || file.fileType is PyiFileType + +@ApiStatus.Internal +fun isPythonNotebookLanguage(file: VirtualFile, resolvers: List): Boolean = + resolvers.any { it.resolveLanguage(file)?.isKindOf(PythonLanguage.INSTANCE) == true } diff --git a/python/python-lsp-core/src/com/intellij/python/lsp/core/PyLspToolIntegrationProvider.kt b/python/python-lsp-core/src/com/intellij/python/lsp/core/PyLspToolIntegrationProvider.kt new file mode 100644 index 000000000000..b5c5f7a4b4c0 --- /dev/null +++ b/python/python-lsp-core/src/com/intellij/python/lsp/core/PyLspToolIntegrationProvider.kt @@ -0,0 +1,427 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.lsp.core + +import com.intellij.codeInsight.completion.CompletionParameters +import com.intellij.codeInsight.intention.CustomizableIntentionAction +import com.intellij.codeInsight.intention.FileModifier +import com.intellij.codeInsight.intention.IntentionAction +import com.intellij.codeInsight.intention.IntentionActionWithOptions +import com.intellij.codeInsight.intention.preview.IntentionPreviewInfo +import com.intellij.codeInspection.util.IntentionName +import com.intellij.execution.ExecutionException +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.lang.annotation.HighlightSeverity +import com.intellij.modcommand.ModCommandAction +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.markup.EffectType +import com.intellij.openapi.editor.markup.TextAttributes +import com.intellij.openapi.module.Module +import com.intellij.openapi.module.ModuleUtilCore +import com.intellij.openapi.progress.runBlockingMaybeCancellable +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.roots.ModuleRootManager +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.NlsSafe +import com.intellij.openapi.util.text.HtmlChunk +import com.intellij.openapi.util.text.buildChildren +import com.intellij.openapi.util.text.buildHtml +import com.intellij.openapi.util.text.plus +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.platform.lsp.api.LspClient +import com.intellij.platform.lsp.api.LspClientDescriptor +import com.intellij.platform.lsp.api.LspClientManager +import com.intellij.platform.lsp.api.LspIntegrationProvider +import com.intellij.platform.lsp.api.LspIntegrationProvider.LspClientStarter +import com.intellij.platform.lsp.api.LspServerListener +import com.intellij.platform.lsp.api.customization.LspCodeLensCustomizer +import com.intellij.platform.lsp.api.customization.LspCodeLensDisabled +import com.intellij.platform.lsp.api.customization.LspCompletionCustomizer +import com.intellij.platform.lsp.api.customization.LspCompletionSupport +import com.intellij.platform.lsp.api.customization.LspCustomization +import com.intellij.platform.lsp.api.customization.LspDiagnosticsCustomizer +import com.intellij.platform.lsp.api.customization.LspDiagnosticsDisabled +import com.intellij.platform.lsp.api.customization.LspDiagnosticsSupport +import com.intellij.platform.lsp.api.customization.LspHoverCustomizer +import com.intellij.platform.lsp.api.customization.LspHoverDisabled +import com.intellij.platform.lsp.api.customization.LspHoverSupport +import com.intellij.platform.lsp.api.customization.LspInlayHintSupport +import com.intellij.platform.lsp.api.customization.LspOptimizeImportsCustomizer +import com.intellij.platform.lsp.api.customization.LspOptimizeImportsDisabled +import com.intellij.platform.lsp.api.lsWidget.LspClientWidgetItem +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.python.community.execService.asGeneralCommandLine +import com.intellij.python.pytools.PyTool +import com.intellij.python.pytools.getExecutableWithBaseArgs +import com.intellij.python.pytools.isEnabledOn +import com.intellij.python.pytools.lsp.PyLspTool +import com.intellij.python.pytools.lsp.PyLspToolSettings +import com.intellij.python.pytools.ui.configuration.PyExternalToolsConfigurable +import com.intellij.python.pytools.ui.getInstalledToolPackage +import com.intellij.ui.JBColor +import com.jetbrains.python.onFailure +import com.jetbrains.python.packaging.common.PythonPackageManagementListener +import com.jetbrains.python.packaging.management.PythonPackageManager +import com.jetbrains.python.sdk.ModuleOrProject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.eclipse.lsp4j.Diagnostic +import org.eclipse.lsp4j.DiagnosticSeverity +import org.eclipse.lsp4j.ExecuteCommandParams +import org.eclipse.lsp4j.InitializeResult +import org.jetbrains.annotations.Nls +import java.util.Collections +import javax.swing.Icon + +abstract class PyLspToolIntegrationProvider : LspIntegrationProvider { + private val listenerConnectedForProjects: MutableSet = Collections.synchronizedSet(HashSet()) + + override fun fileOpened(project: Project, file: VirtualFile, clientStarter: LspClientStarter) { + // Find the module for this file + val module = ModuleUtilCore.findModuleForFile(file, project) ?: return + val moduleOrProject = ModuleOrProject.ModuleAndProject(module) + + val descriptor = getDescriptor(module) + if (!descriptor.isSupportedFile(file)) + return + if (!descriptor.pyTool.isEnabledOn(project)) + return + descriptor.supportProvider = this + if (listenerConnectedForProjects.add(project)) { + val listenerDisposable = Disposer.newDisposable("Python LSP package listener") + Disposer.register(project, listenerDisposable) + Disposer.register(listenerDisposable) { + listenerConnectedForProjects.remove(project) + } + subscribeOnChanges(descriptor.pyTool, project, listenerDisposable) + } + + runBlockingMaybeCancellable { + descriptor.pyTool.getExecutableWithBaseArgs(moduleOrProject, descriptor.executableName) + }.onFailure { return } + + clientStarter.ensureClientStarted(descriptor) + } + + override fun createWidgetItem(lspClient: LspClient, currentFile: VirtualFile?): LspClientWidgetItem? { + return object : LspClientWidgetItem( + lspClient = lspClient, + currentFile = currentFile, + icon = getIcon(lspClient), + settingsPageClass = PyExternalToolsConfigurable::class.java + ) { + override val itemLabel: @NlsSafe String + get() = presentableName(lspClient) + versionPostfix + rootPostfix + } + } + + abstract fun getDescriptor(module: Module): PyLspToolDescriptor + + fun getIcon(lspClient: LspClient): Icon = (lspClient.descriptor as PyLspToolDescriptor).pyTool.icon + + fun presentableName(lspClient: LspClient): @NlsSafe String = lspClient.initializeResult?.serverInfo?.name + ?: lspClient.descriptor.presentableName + + protected open fun subscribeOnChanges(pyTool: PyTool, project: Project, parentDisposable: Disposable) { + project.messageBus.connect(parentDisposable) + .subscribe(PythonPackageManager.PACKAGE_MANAGEMENT_TOPIC, LspPackageListener(pyTool, project)) + } + + inner class LspPackageListener(val pyTool: PyTool, val project: Project) : PythonPackageManagementListener { + var wasThisToolInstalled: Boolean? = null + + override fun packagesChanged(sdk: Sdk) { + val lspServerManager = LspClientManager.getInstance(project) + val manager = PythonPackageManager.forSdk(project, sdk) + val isInstalled = manager.getInstalledToolPackage(pyTool) != null + if (isInstalled != wasThisToolInstalled) { + val providerClass = this@PyLspToolIntegrationProvider::class.java + if (isInstalled) { + lspServerManager.startClientsIfNeeded(providerClass) + } + else { + lspServerManager.stopClients(providerClass) + } + } + wasThisToolInstalled = isInstalled + } + } + +} + +/** + * Base class for Python LSP tool descriptors that can be either module-scoped or project-scoped. + * + * When a [module] is provided, the LSP server will only serve files within that module's content roots. + * When [module] is null, it falls back to project-wide behavior using all project base directories. + */ +abstract class PyLspToolDescriptor( + val module: Module, + val pyTool: PyLspTool<*>, +) : LspClientDescriptor(module.project, pyTool.presentableName, *ModuleRootManager.getInstance(module).contentRoots) { + /** + * Whether this python LSP tool can serve Jupyter notebooks. Defaults to `true` because all current Python LSP tools support notebooks. + */ + open val supportsNotebooks: Boolean get() = true + + override fun isSupportedFile(file: VirtualFile): Boolean = + isPythonFile(file, notebookSupported = supportsNotebooks) && isFileInModule(file) + + protected fun isFileInModule(file: VirtualFile): Boolean = + ModuleUtilCore.findModuleForFile(file, project) == module + + abstract val toolConfig: PyLspToolSettings + + open val executableName: String get() = pyTool.packageName.name + + abstract fun lspArguments(): List + + override fun createCommandLine(): GeneralCommandLine { + val moduleOrProject = ModuleOrProject.ModuleAndProject(module) + val executable = runBlockingMaybeCancellable { + pyTool.getExecutableWithBaseArgs(moduleOrProject, executableName) + } + val (binary, baseArgs) = executable.getOr { throw ExecutionException(it.error.message) } + val cmd = binary.asGeneralCommandLine().getOr { throw ExecutionException(it.error.message) } + .withParameters(*baseArgs.toTypedArray(), *lspArguments().toTypedArray()) + return cmd + } + + lateinit var supportProvider: PyLspToolIntegrationProvider + + private val registeredActionIds = mutableListOf() + + override val lspServerListener: PyLspToolDescriptorLspServerListener = PyLspToolDescriptorLspServerListener() + + open inner class PyLspToolDescriptorLspServerListener : LspServerListener { + override fun serverInitialized(params: InitializeResult) { + val actionManager = ActionManager.getInstance() + val commandProvider = params.capabilities.executeCommandProvider + + if (commandProvider == null) return + // workaround for IJPL-196574 + commandProvider.commands.forEach { command -> + val actionId = "LSP.Command.$presentableName.$command" + if (actionManager.getAction(actionId) != null) { + // workaround for PY-86023 + actionManager.unregisterAction(actionId) + } + if (command in commandDescriptions && commandDescriptions[command] == null) return@forEach + val text = "${params.serverInfo.name}: " + (commandDescriptions[command] ?: command) + val action = object : AnAction(text) { + override fun actionPerformed(e: AnActionEvent) { + val lspServerManager = LspClientManager.getInstance(project) + + lspServerManager + .getClients(supportProvider::class.java) + .firstOrNull() + ?.let { server -> + project.service().cs.launch { + server.sendRequest { + it.workspaceService.executeCommand(ExecuteCommandParams(command, null)) + } + } + } + } + } + actionManager.registerAction(actionId, action) + registeredActionIds.add(actionId) + } + } + + override fun serverStopped(shutdownNormally: Boolean) { + val actionManager = ActionManager.getInstance() + registeredActionIds.forEach { actionId -> + actionManager.unregisterAction(actionId) + } + registeredActionIds.clear() + } + } + + // workaround for IJPL-196574 + open val commandDescriptions: Map = emptyMap() + + override val lspCustomization: PyLspToolCustomization = PyLspToolCustomization(toolConfig, pyTool, project) +} + +open class PyLspToolCustomization( + val toolConfig: PyLspToolSettings, + private val pyTool: PyTool, + private val project: Project, +) : LspCustomization() { + val Diagnostic.presentableCode: String? + get() = code?.get()?.toString()?.let { codeCustomizer(it) } + + open fun quickFixMessage(text: @IntentionName String): @IntentionName String = text + + open fun quickFixOptions(diagnostic: Diagnostic): List = emptyList() + + override val completionCustomizer: LspCompletionCustomizer = object : LspCompletionSupport() { + override fun shouldRunCodeCompletion(parameters: CompletionParameters): Boolean = + pyTool.isEnabledOn(project) && toolConfig.completions == true + } + + protected open val diagnosticsSupport: PyLspToolDiagnosticsSupport = PyLspToolDiagnosticsSupport() + + // instead of using `shouldAskServerForDiagnostics` we also want to avoid `publishDiagnostics` + final override val diagnosticsCustomizer: LspDiagnosticsCustomizer + get() = if (pyTool.isEnabledOn(project) && toolConfig.inspections) diagnosticsSupport else LspDiagnosticsDisabled + + override val optimizeImportsCustomizer: LspOptimizeImportsCustomizer = LspOptimizeImportsDisabled + + override val inlayHintCustomizer: LspInlayHintSupport = object : LspInlayHintSupport() { + override fun shouldAskServerForInlayHints(file: VirtualFile): Boolean = + pyTool.isEnabledOn(project) && toolConfig.inlayHints == true + } + + override val hoverCustomizer: LspHoverCustomizer + get() = if (pyTool.isEnabledOn(project) && toolConfig.documentation == true) LspHoverSupport() else LspHoverDisabled + + override val codeLensCustomizer: LspCodeLensCustomizer = LspCodeLensDisabled + + open inner class PyLspToolDiagnosticsSupport : LspDiagnosticsSupport() { + + override fun getMessage(diagnostic: Diagnostic): String { + val messageLines = super.getMessage(diagnostic).split("\n") + // source is nullable + val firstLine = + messageLines[0] + " ${diagnostic.source.orEmpty()}" + diagnostic.presentableCode?.let { "($it)" }.orEmpty() // NON-NLS + if (messageLines.size == 1) return firstLine + return firstLine + "\n" + messageLines.drop(1).joinToString("\n", prefix = "\n") // NON-NLS + } + + override fun getTooltip(diagnostic: Diagnostic): String { + val code = diagnostic.presentableCode + val source = diagnostic.source + val style = "color: gray" + // workaround for IJPL-196845 + val codeLink = when { + diagnostic.codeDescription?.href != null && code != null -> HtmlChunk.link(diagnostic.codeDescription?.href!!, code) + code != null -> HtmlChunk.span(style).buildChildren { append(HtmlChunk.text(code)) } + else -> null + } + val suffix = when { + source != null && codeLink != null && diagnostic.codeDescription?.href != null -> { + // "source(code)" with link + HtmlChunk.span(style).buildChildren { + append(HtmlChunk.text("$source(")) + } + + codeLink + + HtmlChunk.span(style).buildChildren { + append(HtmlChunk.text(")")) + } + } + source != null && code != null -> { + // "source(code)" without link + HtmlChunk.span(style).buildChildren { + append(HtmlChunk.text("$source($code)")) + } + } + source != null -> { + // "source" + HtmlChunk.span(style).buildChildren { + append(HtmlChunk.text(source)) + } + } + else -> { + // "code" or null (empty) + codeLink + } + } + + val tooltip = super.getTooltip(diagnostic) + // workaround for leading spaces getting removed + val fixed = tooltip.replace(Regex("\n +")) { m -> + val spaces = m.value.substring(1) + "\n" + "\u00A0".repeat(spaces.length) + } + // workaround for IJPL-196664 + return buildHtml { + append(HtmlChunk.text(fixed)) + if (suffix != null) { + append(HtmlChunk.nbsp() + suffix) + } + } + } + + // workaround for IJPL-196573 + override fun getHighlightSeverity(diagnostic: Diagnostic): HighlightSeverity? = + when (diagnostic.severity) { + DiagnosticSeverity.Hint -> HighlightSeverity.INFORMATION + else -> super.getHighlightSeverity(diagnostic) + } + + // workaround for IJPL-196573 + override fun getEnforcedTextAttributes(diagnostic: Diagnostic): TextAttributes? = + when (diagnostic.severity) { + DiagnosticSeverity.Information -> TextAttributes().apply { + effectType = EffectType.WAVE_UNDERSCORE + effectColor = JBColor.BLUE + } + DiagnosticSeverity.Hint -> TextAttributes().apply { + effectType = EffectType.BOLD_DOTTED_LINE + effectColor = JBColor.GRAY + } + else -> null + } + } + + open inner class PyLspAction(val diagnostic: Diagnostic, val action: IntentionAction) : IntentionAction by action, + CustomizableIntentionAction, + IntentionActionWithOptions { + override fun getText(): @IntentionName String { + return quickFixMessage(action.text) + } + + override fun isShowSubmenu(): Boolean { + return true + } + + override fun getOptions(): List { + return quickFixOptions(diagnostic) + } + + override fun asIntention(): IntentionAction { + return this + } + + override fun getCombiningPolicy(): IntentionActionWithOptions.CombiningPolicy { + // ideally this would be `InspectionOptionsOnly`, but at this time it not investigated + return IntentionActionWithOptions.CombiningPolicy.IntentionOptionsOnly + } + + // + override fun generatePreview(project: Project, editor: Editor, psiFile: PsiFile): IntentionPreviewInfo { + return action.generatePreview(project, editor, psiFile) + } + + override fun asModCommandAction(): ModCommandAction? { + return action.asModCommandAction() + } + + override fun getElementToMakeWritable(currentFile: PsiFile): PsiElement? { + return action.getElementToMakeWritable(currentFile) + } + + override fun getFileModifierForPreview(target: PsiFile): FileModifier? { + return action.getFileModifierForPreview(target) + } + + override fun isDumbAware(): Boolean { + return action.isDumbAware + } + // + } + + open fun codeCustomizer(@Nls code: String): @Nls String = code +} + +@Service(Service.Level.PROJECT) +class PyLspService(val cs: CoroutineScope) \ No newline at end of file diff --git a/python/python-lsp-core/src/com/intellij/python/lsp/core/listener/PyLspListener.kt b/python/python-lsp-core/src/com/intellij/python/lsp/core/listener/PyLspListener.kt new file mode 100644 index 000000000000..52c85d0c0f9d --- /dev/null +++ b/python/python-lsp-core/src/com/intellij/python/lsp/core/listener/PyLspListener.kt @@ -0,0 +1,11 @@ +package com.intellij.python.lsp.core.listener + +import com.intellij.util.messages.Topic + +interface PyLspListener { + fun onTypeSettingsChange() + + companion object { + val TOPIC: Topic = Topic.create("Topic.PyLspListener", PyLspListener::class.java) + } +} \ No newline at end of file diff --git a/python/python-lsp-core/src/com/intellij/python/lsp/core/type/LspCollectSupportedTypesVisitor.kt b/python/python-lsp-core/src/com/intellij/python/lsp/core/type/LspCollectSupportedTypesVisitor.kt new file mode 100644 index 000000000000..2d4cefea6b6e --- /dev/null +++ b/python/python-lsp-core/src/com/intellij/python/lsp/core/type/LspCollectSupportedTypesVisitor.kt @@ -0,0 +1,135 @@ +package com.intellij.python.lsp.core.type + +import com.jetbrains.python.psi.PyBinaryExpression +import com.jetbrains.python.psi.PyCallExpression +import com.jetbrains.python.psi.PyCapturePattern +import com.jetbrains.python.psi.PyClassPattern +import com.jetbrains.python.psi.PyDecorator +import com.jetbrains.python.psi.PyDictLiteralExpression +import com.jetbrains.python.psi.PyFunction +import com.jetbrains.python.psi.PyLambdaExpression +import com.jetbrains.python.psi.PyListLiteralExpression +import com.jetbrains.python.psi.PyLiteralPattern +import com.jetbrains.python.psi.PyMappingPattern +import com.jetbrains.python.psi.PyNamedParameter +import com.jetbrains.python.psi.PyPrefixExpression +import com.jetbrains.python.psi.PyRecursiveElementVisitor +import com.jetbrains.python.psi.PyReferenceExpression +import com.jetbrains.python.psi.PySequencePattern +import com.jetbrains.python.psi.PySetCompExpression +import com.jetbrains.python.psi.PySetLiteralExpression +import com.jetbrains.python.psi.PySubscriptionExpression +import com.jetbrains.python.psi.PyTargetExpression +import com.jetbrains.python.psi.PyTypedElement +import com.jetbrains.python.psi.PyWildcardPattern +import com.jetbrains.python.psi.PyYieldExpression +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Internal +class LspCollectSupportedTypesVisitor: PyRecursiveElementVisitor() { + val result: MutableList = mutableListOf() + + override fun visitPyMappingPattern(node: PyMappingPattern) { + result += node + super.visitPyMappingPattern(node) + } + + override fun visitPySequencePattern(node: PySequencePattern) { + result += node + super.visitPySequencePattern(node) + } + + override fun visitPyBinaryExpression(node: PyBinaryExpression) { + result += node + super.visitPyBinaryExpression(node) + } + + override fun visitPyCallExpression(node: PyCallExpression) { + result += node + super.visitPyCallExpression(node) + } + + override fun visitPyCapturePattern(node: PyCapturePattern) { + result += node + super.visitPyCapturePattern(node) + } + + override fun visitPyClassPattern(node: PyClassPattern) { + result += node + super.visitPyClassPattern(node) + } + + override fun visitPyDecorator(node: PyDecorator) { + result += node + super.visitPyDecorator(node) + } + + override fun visitPyDictLiteralExpression(node: PyDictLiteralExpression) { + result += node + super.visitPyDictLiteralExpression(node) + } + + override fun visitPyFunction(node: PyFunction) { + result += node + super.visitPyFunction(node) + } + + override fun visitPyLambdaExpression(node: PyLambdaExpression) { + result += node + super.visitPyLambdaExpression(node) + } + + override fun visitPyListLiteralExpression(node: PyListLiteralExpression) { + result += node + super.visitPyListLiteralExpression(node) + } + + override fun visitPyLiteralPattern(node: PyLiteralPattern) { + result += node + super.visitPyLiteralPattern(node) + } + + override fun visitPyNamedParameter(node: PyNamedParameter) { + result += node + super.visitPyNamedParameter(node) + } + + override fun visitPyPrefixExpression(node: PyPrefixExpression) { + result += node + super.visitPyPrefixExpression(node) + } + + override fun visitPyReferenceExpression(node: PyReferenceExpression) { + result += node + super.visitPyReferenceExpression(node) + } + + override fun visitPySetCompExpression(node: PySetCompExpression) { + result += node + super.visitPySetCompExpression(node) + } + + override fun visitPySetLiteralExpression(node: PySetLiteralExpression) { + result += node + super.visitPySetLiteralExpression(node) + } + + override fun visitPySubscriptionExpression(node: PySubscriptionExpression) { + result += node + super.visitPySubscriptionExpression(node) + } + override fun visitPyTargetExpression(node: PyTargetExpression) { + result += node + super.visitPyTargetExpression(node) + } + + override fun visitWildcardPattern(node: PyWildcardPattern) { + result += node + super.visitWildcardPattern(node) + } + + override fun visitPyYieldExpression(node: PyYieldExpression) { + result += node + super.visitPyYieldExpression(node) + } +} \ No newline at end of file diff --git a/python/python-lsp-core/src/com/intellij/python/lsp/core/type/LspIsSupportedTypesVisitor.kt b/python/python-lsp-core/src/com/intellij/python/lsp/core/type/LspIsSupportedTypesVisitor.kt new file mode 100644 index 000000000000..52306f97616e --- /dev/null +++ b/python/python-lsp-core/src/com/intellij/python/lsp/core/type/LspIsSupportedTypesVisitor.kt @@ -0,0 +1,123 @@ +package com.intellij.python.lsp.core.type + +import com.jetbrains.python.psi.PyBinaryExpression +import com.jetbrains.python.psi.PyCallExpression +import com.jetbrains.python.psi.PyCapturePattern +import com.jetbrains.python.psi.PyClassPattern +import com.jetbrains.python.psi.PyDecorator +import com.jetbrains.python.psi.PyDictLiteralExpression +import com.jetbrains.python.psi.PyElementVisitor +import com.jetbrains.python.psi.PyFunction +import com.jetbrains.python.psi.PyGeneratorExpression +import com.jetbrains.python.psi.PyLambdaExpression +import com.jetbrains.python.psi.PyListLiteralExpression +import com.jetbrains.python.psi.PyLiteralPattern +import com.jetbrains.python.psi.PyMappingPattern +import com.jetbrains.python.psi.PyNamedParameter +import com.jetbrains.python.psi.PyPrefixExpression +import com.jetbrains.python.psi.PyReferenceExpression +import com.jetbrains.python.psi.PySequencePattern +import com.jetbrains.python.psi.PySetCompExpression +import com.jetbrains.python.psi.PySetLiteralExpression +import com.jetbrains.python.psi.PySubscriptionExpression +import com.jetbrains.python.psi.PyTargetExpression +import com.jetbrains.python.psi.PyTupleExpression +import com.jetbrains.python.psi.PyWildcardPattern +import com.jetbrains.python.psi.PyYieldExpression + +class LspIsSupportedTypesVisitor : PyElementVisitor() { + var isSupported = false + private set + + override fun visitPyMappingPattern(node: PyMappingPattern) { + isSupported = true + } + + override fun visitPySequencePattern(node: PySequencePattern) { + isSupported = true + } + + override fun visitPyBinaryExpression(node: PyBinaryExpression) { + isSupported = true + } + + override fun visitPyCallExpression(node: PyCallExpression) { + isSupported = true + } + + override fun visitPyCapturePattern(node: PyCapturePattern) { + isSupported = true + } + + override fun visitPyClassPattern(node: PyClassPattern) { + isSupported = true + } + + override fun visitPyDecorator(node: PyDecorator) { + isSupported = true + } + + override fun visitPyDictLiteralExpression(node: PyDictLiteralExpression) { + isSupported = true + } + + override fun visitPyFunction(node: PyFunction) { + isSupported = true + } + + override fun visitPyLambdaExpression(node: PyLambdaExpression) { + isSupported = true + } + + override fun visitPyListLiteralExpression(node: PyListLiteralExpression) { + isSupported = true + } + + override fun visitPyLiteralPattern(node: PyLiteralPattern) { + isSupported = true + } + + override fun visitPyNamedParameter(node: PyNamedParameter) { + isSupported = true + } + + override fun visitPyTupleExpression(node: PyTupleExpression) { + isSupported = true + } + + override fun visitPyPrefixExpression(node: PyPrefixExpression) { + isSupported = true + } + + override fun visitPyReferenceExpression(node: PyReferenceExpression) { + isSupported = true + } + + override fun visitPySetCompExpression(node: PySetCompExpression) { + isSupported = true + } + + override fun visitPySetLiteralExpression(node: PySetLiteralExpression) { + isSupported = true + } + + override fun visitPySubscriptionExpression(node: PySubscriptionExpression) { + isSupported = true + } + + override fun visitPyTargetExpression(node: PyTargetExpression) { + isSupported = true + } + + override fun visitWildcardPattern(node: PyWildcardPattern) { + isSupported = true + } + + override fun visitPyYieldExpression(node: PyYieldExpression) { + isSupported = true + } + + override fun visitPyGeneratorExpression(node: PyGeneratorExpression) { + isSupported = true + } +} \ No newline at end of file diff --git a/python/python-lsp-core/src/com/intellij/python/lsp/core/type/LspPyTypeResolver.kt b/python/python-lsp-core/src/com/intellij/python/lsp/core/type/LspPyTypeResolver.kt new file mode 100644 index 000000000000..e6a2528d1e7e --- /dev/null +++ b/python/python-lsp-core/src/com/intellij/python/lsp/core/type/LspPyTypeResolver.kt @@ -0,0 +1,19 @@ +package com.intellij.python.lsp.core.type + +import com.jetbrains.python.psi.PyExpressionCodeFragment +import com.jetbrains.python.psi.PyTypedElement +import com.jetbrains.python.psi.types.engine.PyTypeEngine +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Internal +interface LspPyTypeResolver : PyTypeEngine { + override fun isSupportedForResolve(pyTypedElement: PyTypedElement): Boolean { + val realFile = pyTypedElement.containingFile?.originalFile ?: return false + if (realFile is PyExpressionCodeFragment) + return false + + val isSupportedTypesVisitor = LspIsSupportedTypesVisitor() + pyTypedElement.accept(isSupportedTypesVisitor) + return isSupportedTypesVisitor.isSupported + } +} \ No newline at end of file diff --git a/python/python-lsp-core/src/com/intellij/python/lsp/core/type/LspTypeEvalContext.kt b/python/python-lsp-core/src/com/intellij/python/lsp/core/type/LspTypeEvalContext.kt new file mode 100644 index 000000000000..99abc9aeff38 --- /dev/null +++ b/python/python-lsp-core/src/com/intellij/python/lsp/core/type/LspTypeEvalContext.kt @@ -0,0 +1,110 @@ +package com.intellij.python.lsp.core.type + +import com.google.common.util.concurrent.Striped +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.util.Ref +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.jetbrains.python.psi.PyTypedElement +import com.jetbrains.python.psi.types.PyType +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.annotations.Unmodifiable +import java.util.WeakHashMap +import java.util.concurrent.ConcurrentSkipListMap +import java.util.concurrent.ConcurrentSkipListSet +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import kotlin.concurrent.withLock + +@ApiStatus.Internal +abstract class LspTypeEvalContext(val psiFile: PsiFile) { + /** + * Context should be available only for the same timestamp of the file. + */ + private val psiFileTimestamp: Long = psiFile.modificationStamp + + private val stringTypeLocks = Striped.lock(64) + private val requestedTypesCount = AtomicInteger(0) + private val isFullLoaded: AtomicBoolean = AtomicBoolean(false) + private val cacheElementToStringType: WeakHashMap = WeakHashMap() + private val cacheStringTypeToType: ConcurrentSkipListMap> = ConcurrentSkipListMap() + private val unresolvedSet: ConcurrentSkipListSet = ConcurrentSkipListSet() + + protected abstract fun requestTypes(pyTypedElements: @Unmodifiable Collection): List? + + open fun provideType(element: PyTypedElement, isUserInitiated: Boolean): Ref? { + val stringType = cacheElementToStringType[element] ?: resolveTypeByLsp(element, isUserInitiated) + if (stringType == null) + return null + + return stringTypeLocks.get(stringType).withLock { + if (stringType in unresolvedSet) { + return@withLock null + } + val cachedResolved = cacheStringTypeToType[stringType] + if (cachedResolved != null) { + return@withLock cachedResolved + } + val resolvedType = resolveStringType(element, stringType) + if (resolvedType != null) { + cacheStringTypeToType[stringType] = resolvedType + } + else { + unresolvedSet.add(stringType) + } + + return@withLock resolvedType + } + } + + protected abstract fun resolveStringType(element: PyTypedElement, stringType: String): Ref? + + private fun resolveTypeByLsp(element: PyTypedElement, isUserInitiated: Boolean): String? = + if (!isUserInitiated && requestedTypesCount.incrementAndGet() >= FULL_LOAD_THRESHOLD && !ApplicationManager.getApplication().isDispatchThread) { + synchronized(this@LspTypeEvalContext) { + loadAllTypes(element) + } + cacheElementToStringType[element] + } + else { + synchronized(element) { + loadSingleType(element) + } + } + + private fun loadAllTypes(element: PyTypedElement) { + if (isFullLoaded.get()) + return + val requestedTypes = collectElementsForCalculation(element) - cacheElementToStringType.keys + if (requestedTypes.isEmpty()) return + + val contents = requestTypes(requestedTypes) + if (contents == null) { + thisLogger().warn("Failed to load all types for ${psiFile.name} Current stamp ${psiFile.modificationStamp} calculating: ${psiFileTimestamp}") + return + } + + cacheElementToStringType.putAll(requestedTypes zip contents) + isFullLoaded.set(true) + } + + private fun loadSingleType(typedElement: PyTypedElement): String? { + val types = requestTypes(listOf(typedElement)) + if (types.isNullOrEmpty()) return null + val resolvedType = types.single() ?: "" + cacheElementToStringType[typedElement] = resolvedType + return resolvedType + } + + companion object { + private const val FULL_LOAD_THRESHOLD: Int = 10 + + fun collectElementsForCalculation(element: PsiElement): List { + val psiFile = element.containingFile ?: return emptyList() + val visitor = LspCollectSupportedTypesVisitor() + psiFile.acceptChildren(visitor) + return visitor.result + } + } +} diff --git a/python/python-lsp-core/src/com/intellij/python/lsp/core/type/PyStringTypeResolver.kt b/python/python-lsp-core/src/com/intellij/python/lsp/core/type/PyStringTypeResolver.kt new file mode 100644 index 000000000000..30d97008d1f8 --- /dev/null +++ b/python/python-lsp-core/src/com/intellij/python/lsp/core/type/PyStringTypeResolver.kt @@ -0,0 +1,36 @@ +package com.intellij.python.lsp.core.type + +import com.intellij.openapi.util.Ref +import com.intellij.psi.impl.source.resolve.FileContextUtil +import com.intellij.psi.util.QualifiedName +import com.jetbrains.python.codeInsight.typeRepresentation.psi.PyTypeRepresentationFile +import com.jetbrains.python.codeInsight.typing.PyTypingTypeProvider +import com.jetbrains.python.psi.PyTypedElement +import com.jetbrains.python.psi.impl.PyBuiltinCache +import com.jetbrains.python.psi.types.PyType +import com.jetbrains.python.psi.types.TypeEvalContext + +object PyStringTypeResolver { + fun resolvePyType(anchor: PyTypedElement, stringType: String): Ref? { + if (stringType.isBlank()) + return null + + val codeInsightFallback = TypeEvalContext.externalContext(anchor.project) + val qualifiedName = QualifiedName.fromDottedString(stringType) + // Shortcut for builtins classes + if (qualifiedName.matchesPrefix(QualifiedName.fromComponents("builtins"))) { + val builtinClassName = qualifiedName.removeHead(1) + if (builtinClassName.componentCount == 1) { + val builtinClass = PyBuiltinCache.getInstance(anchor).getObjectType(builtinClassName.toString()) + if (builtinClass != null) { + return Ref(builtinClass) + } + } + } + + val file = FileContextUtil.getContextFile(anchor) ?: return null + + val typeExpression = PyTypeRepresentationFile(stringType, file).type ?: return null + return PyTypingTypeProvider.getType(typeExpression, codeInsightFallback, true) + } +} \ No newline at end of file diff --git a/python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/BuiltInPyTypeEngineProvider.kt b/python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/BuiltInPyTypeEngineProvider.kt new file mode 100644 index 000000000000..9086a9e57126 --- /dev/null +++ b/python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/BuiltInPyTypeEngineProvider.kt @@ -0,0 +1,24 @@ +package com.intellij.python.lsp.core.typeEngine + +import com.intellij.openapi.observable.properties.PropertyGraph +import com.intellij.openapi.project.Project +import com.intellij.python.lsp.core.PyLspCoreBundle +import com.intellij.ui.dsl.builder.Panel +import com.intellij.ui.dsl.builder.RowsRange + +internal class BuiltInPyTypeEngineProvider : PyTypeEngineProvider { + override val pyTypeEngineType: PyTypeEngineType + get() = PyTypeEngineType.PYCHARM + + override fun updateLspServers(project: Project) {} + + override fun isSupported(project: Project): Boolean = true + + override fun Panel.createConfigurableContent(project: Project, propertyGraph: PropertyGraph): RowsRange { + return rowsRange { + row { + comment(PyLspCoreBundle.message("pycharm.description")) + } + } + } +} diff --git a/python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/PyTypeEngineConfigurable.kt b/python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/PyTypeEngineConfigurable.kt new file mode 100644 index 000000000000..fb990771d450 --- /dev/null +++ b/python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/PyTypeEngineConfigurable.kt @@ -0,0 +1,75 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.lsp.core.typeEngine + +import com.intellij.icons.AllIcons +import com.intellij.openapi.components.service +import com.intellij.openapi.observable.properties.PropertyGraph +import com.intellij.openapi.observable.util.transform +import com.intellij.openapi.options.SearchableConfigurable +import com.intellij.openapi.options.UiDslUnnamedConfigurable +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.modules +import com.intellij.python.lsp.core.PyLspCoreBundle +import com.intellij.ui.dsl.builder.Panel +import com.intellij.ui.dsl.builder.toMutableProperty +import com.intellij.ui.dsl.builder.toNullableProperty +import com.jetbrains.python.psi.types.PyTypeEngineSettingsModificationTracker + +class PyTypeEngineConfigurable( + private val project: Project, +) : UiDslUnnamedConfigurable.Simple(), SearchableConfigurable { + private val settings: PyTypeEngineProjectSettings + get() = project.service() + + private val propertyGraph = PropertyGraph() + private val selectedTypeEngine = propertyGraph.property(PyTypeEngineType.PYCHARM) + + private val availableOptions: List + get() = PyTypeEngineProvider.getSupportedTypes(project) + + private var previousTypeEngine: PyTypeEngineType = settings.typeEngine + + init { + val initialOption = settings.typeEngine + selectedTypeEngine.set(initialOption) + } + + override fun getId(): String = "pycharm.type.engine" + override fun getDisplayName(): String = PyLspCoreBundle.message("display.name") + override fun getDisplayNameFast(): String = PyLspCoreBundle.message("display.name") + override fun getHelpTopic(): String = "reference.settings.python.type.engine" + + override fun Panel.createContent() { + val isSingleModule = project.modules.size == 1 + if (!isSingleModule) { + row { + icon(AllIcons.General.Information).commentRight(PyLspCoreBundle.message("comment.multimodule.not.warning")) + } + + return + } + + row(PyLspCoreBundle.message("engine.label")) { + segmentedButton(availableOptions) { text = it.displayName } + .bind(settings::typeEngine.toMutableProperty().toNullableProperty()) + .whenItemSelected { selectedTypeEngine.set(it) } + } + + PyTypeEngineProvider.EP.extensionsIfPointIsRegistered.filter { it.isSupported(project) }.forEach { provider -> + provider.apply { + val isVisible = selectedTypeEngine.transform { it == provider.pyTypeEngineType } + createConfigurableContent(project, propertyGraph).visibleIf(isVisible) + } + } + + onApply { + val newEngine = settings.typeEngine + if (newEngine != previousTypeEngine) { + PyTypeEngineUsageCollector.logEngineChanged(project, newEngine) + previousTypeEngine = newEngine + } + // Invalidate TypeEvalContext cache so that editor errors reflect the new type engine settings + PyTypeEngineSettingsModificationTracker.getInstance(project).incModificationCount() + } + } +} \ No newline at end of file diff --git a/python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/PyTypeEngineProjectSettings.kt b/python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/PyTypeEngineProjectSettings.kt new file mode 100644 index 000000000000..2d0e0213d503 --- /dev/null +++ b/python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/PyTypeEngineProjectSettings.kt @@ -0,0 +1,39 @@ +package com.intellij.python.lsp.core.typeEngine + +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.python.lsp.core.listener.PyLspListener +import com.intellij.util.xmlb.XmlSerializerUtil + + +@Service(Service.Level.PROJECT) +@State(name = "PyTypeEngineProjectSettings", storages = [Storage("pyTypeEngine.xml")]) +class PyTypeEngineProjectSettings(val project: Project) : PersistentStateComponent { + private var state: PyTypeEngineSettingsState = PyTypeEngineSettingsState() + + var typeEngine: PyTypeEngineType + get() = state.typeEngine + set(value) { + if (value == state.typeEngine) return + state.typeEngine = value + project.messageBus.syncPublisher(PyLspListener.TOPIC).onTypeSettingsChange() + } + + override fun getState(): PyTypeEngineSettingsState = state + + override fun loadState(state: PyTypeEngineSettingsState): Unit = XmlSerializerUtil.copyBean(state, this.state) + + + companion object { + fun getInstance(project: Project): PyTypeEngineProjectSettings = project.service() + } +} + + +data class PyTypeEngineSettingsState( + var typeEngine: PyTypeEngineType = PyTypeEngineType.PYCHARM, +) diff --git a/python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/PyTypeEngineProvider.kt b/python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/PyTypeEngineProvider.kt new file mode 100644 index 000000000000..d97776a321e8 --- /dev/null +++ b/python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/PyTypeEngineProvider.kt @@ -0,0 +1,34 @@ +package com.intellij.python.lsp.core.typeEngine + +import com.intellij.openapi.extensions.ExtensionPointName +import com.intellij.openapi.observable.properties.PropertyGraph +import com.intellij.openapi.project.Project +import com.intellij.ui.dsl.builder.Panel +import com.intellij.ui.dsl.builder.RowsRange + +interface PyTypeEngineProvider { + val pyTypeEngineType: PyTypeEngineType + + fun isSupported(project: Project): Boolean + + fun updateLspServers(project: Project) + + fun Panel.createConfigurableContent(project: Project, propertyGraph: PropertyGraph): RowsRange + + companion object { + internal val EP = ExtensionPointName.create("com.intellij.python.lsp.typeEngineConfigurable") + + fun getSupportedTypes(project: Project): List { + return EP.extensionsIfPointIsRegistered.mapNotNull { + if (!it.isSupported(project)) return@mapNotNull null + return@mapNotNull it.pyTypeEngineType + } + } + + fun updateLspServers(project: Project) { + EP.extensionsIfPointIsRegistered.forEach { + it.updateLspServers(project) + } + } + } +} diff --git a/python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/PyTypeEngineType.kt b/python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/PyTypeEngineType.kt new file mode 100644 index 000000000000..7509c705c2b8 --- /dev/null +++ b/python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/PyTypeEngineType.kt @@ -0,0 +1,10 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.lsp.core.typeEngine + +import org.jetbrains.annotations.Nls + +enum class PyTypeEngineType(val displayName: @Nls String, val packageName: String) { + PYCHARM("Built-in", ""), + PYREFLY("Pyrefly", "pyrefly"), + TY("ty", "ty"); +} diff --git a/python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/PyTypeEngineUsageCollector.kt b/python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/PyTypeEngineUsageCollector.kt new file mode 100644 index 000000000000..62644a7540d3 --- /dev/null +++ b/python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/PyTypeEngineUsageCollector.kt @@ -0,0 +1,44 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.lsp.core.typeEngine + +import com.intellij.internal.statistic.eventLog.EventLogGroup +import com.intellij.internal.statistic.eventLog.events.EventFields +import com.intellij.internal.statistic.service.fus.collectors.CounterUsagesCollector +import com.intellij.openapi.project.Project + +object PyTypeEngineUsageCollector : CounterUsagesCollector() { + private val GROUP = EventLogGroup("python.type.engine", 2) + + // Event fields + private val TYPE_ENGINE = EventFields.Enum("type_engine") + + // Events + private val ENGINE_CHANGED = GROUP.registerVarargEvent( + "engine.changed", + TYPE_ENGINE, + ) + + private val SETTINGS_OPENED = GROUP.registerEvent("settings.opened") + + private val STATUS_BAR_WIDGET_CLICKED = GROUP.registerEvent("statusbar.widget.clicked") + + override fun getGroup(): EventLogGroup = GROUP + + fun logEngineChanged( + project: Project?, + newEngine: PyTypeEngineType, + ) { + ENGINE_CHANGED.log( + project, + TYPE_ENGINE.with(newEngine), + ) + } + + fun logSettingsOpened(project: Project?) { + SETTINGS_OPENED.log(project) + } + + fun logStatusBarWidgetClicked(project: Project?) { + STATUS_BAR_WIDGET_CLICKED.log(project) + } +} diff --git a/python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/PyTypeEngineUtils.kt b/python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/PyTypeEngineUtils.kt new file mode 100644 index 000000000000..91675c2ad864 --- /dev/null +++ b/python/python-lsp-core/src/com/intellij/python/lsp/core/typeEngine/PyTypeEngineUtils.kt @@ -0,0 +1,21 @@ +package com.intellij.python.lsp.core.typeEngine + +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.modules +import com.intellij.openapi.util.registry.Registry +import com.jetbrains.python.sdk.isReadOnly +import com.jetbrains.python.sdk.pythonSdk +import com.jetbrains.python.statistics.InterpreterTarget +import com.jetbrains.python.statistics.executionType + +object PyTypeEngineUtils { + fun isExternalTypeEngineSupported(project: Project): Boolean { + if (!Registry.`is`("pycharm.type.engine", true)) + return false + + val module = project.modules.singleOrNull() ?: return false + val pythonSdk = module.pythonSdk ?: return false + + return !pythonSdk.isReadOnly && pythonSdk.executionType == InterpreterTarget.LOCAL + } +} diff --git a/python/python-lsp-core/src/com/intellij/python/lsp/core/utils/PyLspServerModificationTracker.kt b/python/python-lsp-core/src/com/intellij/python/lsp/core/utils/PyLspServerModificationTracker.kt new file mode 100644 index 000000000000..df19b5fb4b32 --- /dev/null +++ b/python/python-lsp-core/src/com/intellij/python/lsp/core/utils/PyLspServerModificationTracker.kt @@ -0,0 +1,13 @@ +package com.intellij.python.lsp.core.utils + +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.SimpleModificationTracker + +@Service(Service.Level.PROJECT) +class PyLspServerModificationTracker: SimpleModificationTracker() { + companion object { + fun getInstance(project: Project): PyLspServerModificationTracker = project.service() + } +} \ No newline at end of file diff --git a/python/python-lsp-impl/BUILD.bazel b/python/python-lsp-impl/BUILD.bazel new file mode 100644 index 000000000000..17f88bfedec7 --- /dev/null +++ b/python/python-lsp-impl/BUILD.bazel @@ -0,0 +1,82 @@ +load("@rules_jvm//:jvm.bzl", "jvm_library") + +### auto-generated section `build intellij.python.lsp.impl` start + +jvm_library( + name = "lsp-impl", + srcs = glob( + [ + "src/**/*.kt", + "src/**/*.java", + "src/**/*.form", + ], + allow_empty = True, + ), + module_name = "intellij.python.lsp.impl", + resource_strip_prefix = "resources", + resources = glob( + ["resources/**/*"], + ), + visibility = ["//visibility:public"], + deps = [ + # do not sort, + "@lib//:kotlin-stdlib", + "//platform/core-api:core", + "//platform/platform-api:ide", + "//platform/ide-core", + "//platform/platform-impl:ide-impl", + "//platform/editor-ui-api:editor-ui", + "//platform/projectModel-api:projectModel", + "//platform/lsp", + "//python/python-pytools:pytools", + "//python:python-community-impl", + "//python/python-sdk:sdk", + "//python/python-lsp-core:lsp-core", + "//python/python-ruff:ruff", + "//python/python-pyright:pyright", + "//python/python-pyrefly:pyrefly", + "//python/python-ty:ty", + "//python/python-pytools-ui:pytools-ui", + ], +) + +jvm_library( + name = "lsp-impl_test_lib", + testonly = True, + srcs = glob( + [], + allow_empty = True, + ), + module_name = "intellij.python.lsp.impl", + visibility = ["//visibility:public"], + runtime_deps = [ + # do not sort, + ":lsp-impl", + "//platform/core-api:core_test_lib", + "//platform/platform-api:ide_test_lib", + "//platform/ide-core:ide-core_test_lib", + "//platform/platform-impl:ide-impl_test_lib", + "//platform/editor-ui-api:editor-ui_test_lib", + "//platform/projectModel-api:projectModel_test_lib", + "//platform/lsp:lsp_test_lib", + "//python/python-pytools:pytools_test_lib", + "//python:python-community-impl_test_lib", + "//python/python-sdk:sdk_test_lib", + "//python/python-lsp-core:lsp-core_test_lib", + "//python/python-ruff:ruff_test_lib", + "//python/python-pyright:pyright_test_lib", + "//python/python-pyrefly:pyrefly_test_lib", + "//python/python-ty:ty_test_lib", + "//python/python-pytools-ui:pytools-ui_test_lib", + ], +) +### auto-generated section `build intellij.python.lsp.impl` end + +### auto-generated section `iml intellij.python.lsp.impl` start +exports_files( + [ + "intellij.python.lsp.impl.iml", + ], + visibility = ["//visibility:public"], +) +### auto-generated section `iml intellij.python.lsp.impl` end diff --git a/python/python-lsp-impl/intellij.python.lsp.impl.iml b/python/python-lsp-impl/intellij.python.lsp.impl.iml new file mode 100644 index 000000000000..16bba9ef8b7d --- /dev/null +++ b/python/python-lsp-impl/intellij.python.lsp.impl.iml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/python/python-lsp-impl/resources/intellij.python.lsp.impl.xml b/python/python-lsp-impl/resources/intellij.python.lsp.impl.xml new file mode 100644 index 000000000000..2da67edabd23 --- /dev/null +++ b/python/python-lsp-impl/resources/intellij.python.lsp.impl.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/python/python-lsp-impl/src/com/intellij/python/lsp/impl/PyLspToolAdvertiserService.kt b/python/python-lsp-impl/src/com/intellij/python/lsp/impl/PyLspToolAdvertiserService.kt new file mode 100644 index 000000000000..a549a11cdd68 --- /dev/null +++ b/python/python-lsp-impl/src/com/intellij/python/lsp/impl/PyLspToolAdvertiserService.kt @@ -0,0 +1,199 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.lsp.impl + +import com.intellij.ide.util.PropertiesComponent +import com.intellij.notification.NotificationAction +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.modules +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.startup.ProjectActivity +import com.intellij.python.pyright.BasedpyrightPyTool +import com.intellij.python.pyright.PyrightPyTool +import com.intellij.python.pytools.PyTool +import com.intellij.python.pytools.PyToolsState +import com.intellij.python.pytools.lsp.PyLspTool +import com.intellij.python.pytools.ui.configuration.PyExternalToolsConfigurable +import com.intellij.python.ruff.RuffPyTool +import com.intellij.python.ty.TyPyTool +import com.jetbrains.python.PyBundle +import com.jetbrains.python.packaging.common.PythonPackageManagementListener +import com.jetbrains.python.packaging.management.PythonPackageManager +import com.jetbrains.python.sdk.findPythonSdk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jetbrains.annotations.ApiStatus +import kotlin.time.Duration.Companion.seconds + +private const val NOTIFICATION_GROUP_ID = "Python LSP Tools" +private const val DONT_ASK_PROPERTY_PREFIX = "python.lsp.tool.dont.ask." + +private fun lspTools(): List> = listOf( + RuffPyTool.getInstance(), + //PyreflyPyTool.getInstance(), + BasedpyrightPyTool.getInstance(), + PyrightPyTool.getInstance(), + TyPyTool.getInstance(), +) + +/** + * Service that detects LSP tools installed in the user's virtual environment + * and shows a balloon notification offering to enable them. + * + * Also listens for package installation events to suggest enabling tools when installed. + */ +@ApiStatus.Internal +@Service(Service.Level.PROJECT) +class PyLspToolAdvertiserService(private val project: Project, private val cs: CoroutineScope) { + + private val shownForTools = mutableSetOf() + + init { + // Subscribe to package management events to detect when LSP tools are installed + project.messageBus.connect(cs).subscribe( + PythonPackageManager.PACKAGE_MANAGEMENT_TOPIC, + object : PythonPackageManagementListener { + override fun packagesChanged(sdk: Sdk) { + thisLogger().debug("Packages changed for SDK '${sdk.name}', triggering LSP tool check") + // Defer until no modal dialog is open. If the user installs a tool from within + // the settings dialog (modal), the settings haven't been applied yet at this point, + // so isEnabled() would return false and trigger a spurious notification. Scheduling + // with ModalityState.nonModal() means the check runs only after the dialog is + // closed and settings are applied, so isEnabled() reflects the user's choice. + ApplicationManager.getApplication().invokeLater({ + cs.launch { checkAndAdvertise() } + }, ModalityState.nonModal()) + } + } + ) + } + + /** + * Checks for installed LSP tools and shows notifications for any that are found + * but not yet enabled. + */ + suspend fun checkAndAdvertise() { + val installedPackages = getInstalledPackages() + val pyToolsState = PyToolsState.getInstance(project) + + val toolsToAdvertise = lspTools().mapNotNull { tool -> + val isInstalled = tool.packageName.name in installedPackages + val name = tool.presentableName + val isEnabled = pyToolsState.isEnabled(tool) + if (isInstalled && (isEnabled || isDontAskSet(tool))) { + thisLogger().debug("LSP tool '$name': installed=true, enabled=$isEnabled, dontAsk=${isDontAskSet(tool)}") + } + // shownForTools (keyed on the stable presentable name) suppresses repeat notifications when + // checkAndAdvertise re-runs on package-change events. + if (!isInstalled || isEnabled || isDontAskSet(tool) || name in shownForTools) { + return@mapNotNull null + } + tool + } + + if (toolsToAdvertise.size > 1) { + for (tool in toolsToAdvertise) { + thisLogger().info("LSP tool '${tool.presentableName}' installed but not enabled") + shownForTools.add(tool.presentableName) + } + showMultipleToolsNotification(toolsToAdvertise) + } + else if (toolsToAdvertise.size == 1) { + val tool = toolsToAdvertise.single() + thisLogger().info("LSP tool '${tool.presentableName}' installed but not enabled, showing notification") + shownForTools.add(tool.presentableName) + showNotification(tool) + } + } + + private suspend fun getInstalledPackages(): Set = project.modules + .mapNotNull { it.findPythonSdk() } + .distinct() + .also { sdks -> thisLogger().debug("Scanning ${sdks.size} SDK(s) for installed packages") } + .flatMapTo(mutableSetOf()) { sdk -> + PythonPackageManager + .forSdk(project, sdk) + .listInstalledPackages() + .map { it.name.lowercase() } + } + + private fun showMultipleToolsNotification(tools: List>) { + val toolNames = tools.dropLast(1).joinToString { it.presentableName } + + " and " + tools.last().presentableName + thisLogger().info("Notifying user about multiple LSP tools: $toolNames") + val notification = NotificationGroupManager.getInstance() + .getNotificationGroup(NOTIFICATION_GROUP_ID) + .createNotification( + PyBundle.message("lsp.tool.advertiser.multiple.title"), + PyBundle.message("lsp.tool.advertiser.multiple.message", toolNames), + NotificationType.INFORMATION + ) + .addAction(NotificationAction.createSimpleExpiring(PyBundle.message("lsp.tool.advertiser.settings")) { + ShowSettingsUtil.getInstance().showSettingsDialog(project, PyExternalToolsConfigurable::class.java) + }) + .addAction(NotificationAction.createSimpleExpiring(PyBundle.message("lsp.tool.advertiser.ignore")) { + for (tool in tools) { + setDontAsk(tool) + } + }) + + notification.notify(project) + } + + private fun showNotification(tool: PyLspTool<*>) { + val name = tool.presentableName + thisLogger().info("Notifying user about LSP tool: $name") + val notification = NotificationGroupManager.getInstance() + .getNotificationGroup(NOTIFICATION_GROUP_ID) + .createNotification( + PyBundle.message("lsp.tool.advertiser.title", name), + PyBundle.message("lsp.tool.advertiser.message", name), + NotificationType.INFORMATION + ) + .setIcon(tool.icon) + .addAction(NotificationAction.createSimpleExpiring(PyBundle.message("lsp.tool.advertiser.yes")) { + // Mirror the External Tools page's "Enable" toggle: flip the framework-level state and + // run the tool's lifecycle hook (which starts the LSP server). + PyToolsState.getInstance(project).setEnabled(tool, true) + tool.onEnabledChanged(project, true) + }) + .addAction(NotificationAction.createSimpleExpiring(PyBundle.message("lsp.tool.advertiser.no")) { + setDontAsk(tool) + }) + .addAction(NotificationAction.createSimpleExpiring(PyBundle.message("lsp.tool.advertiser.settings")) { + ShowSettingsUtil.getInstance().showSettingsDialog(project, PyExternalToolsConfigurable::class.java) + }) + + notification.notify(project) + } + + private fun isDontAskSet(tool: PyTool): Boolean { + return PropertiesComponent.getInstance(project).getBoolean(DONT_ASK_PROPERTY_PREFIX + tool.presentableName, false) + } + + private fun setDontAsk(tool: PyTool) { + thisLogger().debug("Setting don't-ask for LSP tool: ${tool.presentableName}") + PropertiesComponent.getInstance(project).setValue(DONT_ASK_PROPERTY_PREFIX + tool.presentableName, true) + } + + companion object { + fun getInstance(project: Project): PyLspToolAdvertiserService = + project.service() + } +} + +class PyLspToolAdvertiserStartupActivity : ProjectActivity { + override suspend fun execute(project: Project) { + thisLogger().debug("PyLspToolAdvertiserStartupActivity: waiting 15s before first check") + delay(15.seconds) + PyLspToolAdvertiserService.getInstance(project).checkAndAdvertise() + } +} diff --git a/python/python-pyrefly/BUILD.bazel b/python/python-pyrefly/BUILD.bazel new file mode 100644 index 000000000000..6484d31c5eba --- /dev/null +++ b/python/python-pyrefly/BUILD.bazel @@ -0,0 +1,111 @@ +load("@rules_jvm//:jvm.bzl", "jvm_library") + +### auto-generated section `build intellij.python.pyrefly` start + +jvm_library( + name = "pyrefly", + srcs = glob( + [ + "src/**/*.kt", + "src/**/*.java", + "src/**/*.form", + "gen/**/*.kt", + "gen/**/*.java", + ], + allow_empty = True, + ), + module_name = "intellij.python.pyrefly", + resource_strip_prefix = "resources", + resources = glob( + ["resources/**/*"], + ), + visibility = ["//visibility:public"], + deps = [ + # do not sort, + "//platform/editor-ui-api:editor-ui", + "//platform/lang-impl", + "//python:python-community-impl", + "//python/python-sdk:sdk", + "//python/python-core-impl:community-core-impl", + "//platform/code-style-impl:codeStyle-impl", + "//platform/editor-ui-ex:editor-ex", + "//platform/platform-impl:ide-impl", + "//platform/ide-core", + "//xml/xml-parser:parser", + "//platform/core-ui", + "//xml/impl", + "//platform/util/jdom", + "//platform/smRunner", + "//libraries/guava", + "//libraries/fastutil", + "//platform/lsp", + "//platform/core-api:core", + "@lib//:eclipse-lsp4j", + "@lib//:eclipse-lsp4j-jsonrpc", + "//platform/projectModel-api:projectModel", + "//python/python-lsp-core:lsp-core", + "//platform/core-impl", + "//platform/lsp-impl", + "//platform/statistics", + "//platform/observable", + "//platform/util:util-ui", + "//python/python-pytools:pytools", + "//python/python-pytools-ui:pytools-ui", + ], +) + +jvm_library( + name = "pyrefly_test_lib", + testonly = True, + srcs = glob( + [], + allow_empty = True, + ), + module_name = "intellij.python.pyrefly", + visibility = ["//visibility:public"], + runtime_deps = [ + # do not sort, + ":pyrefly", + "//platform/editor-ui-api:editor-ui_test_lib", + "//platform/lang-impl:lang-impl_test_lib", + "//python:python-community-impl_test_lib", + "//python/python-sdk:sdk_test_lib", + "//python/python-core-impl:community-core-impl_test_lib", + "//platform/code-style-impl:codeStyle-impl_test_lib", + "//platform/editor-ui-ex:editor-ex_test_lib", + "//platform/platform-impl:ide-impl_test_lib", + "//platform/ide-core:ide-core_test_lib", + "//xml/xml-parser:parser_test_lib", + "//platform/core-ui:core-ui_test_lib", + "//xml/impl:impl_test_lib", + "//platform/util/jdom:jdom_test_lib", + "//platform/smRunner:smRunner_test_lib", + "//libraries/guava:guava_test_lib", + "//libraries/fastutil:fastutil_test_lib", + "//platform/lsp:lsp_test_lib", + "//platform/testFramework", + "//platform/testFramework:testFramework_test_lib", + "//platform/lsp/testFramework", + "//platform/lsp/testFramework:testFramework_test_lib", + "//platform/core-api:core_test_lib", + "//platform/projectModel-api:projectModel_test_lib", + "//python/python-lsp-core:lsp-core_test_lib", + "//platform/core-impl:core-impl_test_lib", + "//platform/lsp-impl:lsp-impl_test_lib", + "//platform/statistics:statistics_test_lib", + "//platform/observable:observable_test_lib", + "//platform/util:util-ui_test_lib", + "//python/python-pytools:pytools_test_lib", + "//python/python-pytools-ui:pytools-ui_test_lib", + ], +) +### auto-generated section `build intellij.python.pyrefly` end + +### auto-generated section `iml intellij.python.pyrefly` start +exports_files( + [ + "intellij.python.pyrefly.iml", + ], + visibility = ["//visibility:public"], +) +### auto-generated section `iml intellij.python.pyrefly` end diff --git a/python/python-pyrefly/OWNERSHIP b/python/python-pyrefly/OWNERSHIP new file mode 100644 index 000000000000..e5e207deb924 --- /dev/null +++ b/python/python-pyrefly/OWNERSHIP @@ -0,0 +1,2 @@ +owner 'PyCharm Codeinsight' + diff --git a/python/python-pyrefly/gen/com/intellij/python/pyrefly/icons/PythonPyreflyIcons.java b/python/python-pyrefly/gen/com/intellij/python/pyrefly/icons/PythonPyreflyIcons.java new file mode 100644 index 000000000000..b73db570b965 --- /dev/null +++ b/python/python-pyrefly/gen/com/intellij/python/pyrefly/icons/PythonPyreflyIcons.java @@ -0,0 +1,17 @@ +package com.intellij.python.pyrefly.icons; + +import com.intellij.ui.IconManager; +import org.jetbrains.annotations.NotNull; + +import javax.swing.*; + +/** + * NOTE THIS FILE IS AUTO-GENERATED + * DO NOT EDIT IT BY HAND, run "Generate icon classes" configuration instead + */ +public final class PythonPyreflyIcons { + private static @NotNull Icon load(@NotNull String path, int cacheKey, int flags) { + return IconManager.getInstance().loadRasterizedIcon(path, PythonPyreflyIcons.class.getClassLoader(), cacheKey, flags); + } + /** 16x16 */ public static final @NotNull Icon Pyrefly = load("icons/pyrefly.svg", -1308406599, 2); +} diff --git a/python/python-pyrefly/intellij.python.pyrefly.iml b/python/python-pyrefly/intellij.python.pyrefly.iml new file mode 100644 index 000000000000..7df684ddd7a3 --- /dev/null +++ b/python/python-pyrefly/intellij.python.pyrefly.iml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/python/python-pyrefly/resources/icons/pyrefly.svg b/python/python-pyrefly/resources/icons/pyrefly.svg new file mode 100644 index 000000000000..0a3e8a8c8a49 --- /dev/null +++ b/python/python-pyrefly/resources/icons/pyrefly.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/python/python-pyrefly/resources/icons/pyrefly_dark.svg b/python/python-pyrefly/resources/icons/pyrefly_dark.svg new file mode 100644 index 000000000000..0a3e8a8c8a49 --- /dev/null +++ b/python/python-pyrefly/resources/icons/pyrefly_dark.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/python/python-pyrefly/resources/intellij.python.pyrefly.xml b/python/python-pyrefly/resources/intellij.python.pyrefly.xml new file mode 100644 index 000000000000..58539bde5f4e --- /dev/null +++ b/python/python-pyrefly/resources/intellij.python.pyrefly.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/python/python-pyrefly/resources/messages/PyreflyBundle.properties b/python/python-pyrefly/resources/messages/PyreflyBundle.properties new file mode 100644 index 000000000000..80d3340b1243 --- /dev/null +++ b/python/python-pyrefly/resources/messages/PyreflyBundle.properties @@ -0,0 +1,2 @@ +pyrefly.additional.settings.title=Other Features: +pyrefly.tool.description=A fast static type checker and language server for Python, from Meta. \ No newline at end of file diff --git a/python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyBundle.kt b/python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyBundle.kt new file mode 100644 index 000000000000..3cfb1735d8b7 --- /dev/null +++ b/python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyBundle.kt @@ -0,0 +1,18 @@ +package com.intellij.python.pyrefly + +import com.intellij.DynamicBundle +import org.jetbrains.annotations.Nls +import org.jetbrains.annotations.NonNls +import org.jetbrains.annotations.PropertyKey + +object PyreflyBundle { + private val myDynamicBundle = DynamicBundle(this::class.java, BUNDLE) + + @NonNls + const val BUNDLE: String = "messages.PyreflyBundle" + + @Nls + fun message(@PropertyKey(resourceBundle = BUNDLE) key: String, vararg params: Any): String { + return myDynamicBundle.getMessage(key, *params) + } +} \ No newline at end of file diff --git a/python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyConfiguration.kt b/python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyConfiguration.kt new file mode 100644 index 000000000000..39ad0bfa67a0 --- /dev/null +++ b/python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyConfiguration.kt @@ -0,0 +1,25 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.pyrefly + +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.python.pytools.lsp.LSP_TOOLS_STORAGE_FILE +import com.intellij.python.pytools.lsp.PyLspToolConfiguration +import com.intellij.util.xmlb.XmlSerializerUtil + + +@Service(Service.Level.PROJECT) +@State( + name = "PyreflyConfiguration2", + storages = [Storage(LSP_TOOLS_STORAGE_FILE)] +) +data class PyreflyConfiguration( + override var inlayHints: Boolean? = false, + override var completions: Boolean? = false, + override var documentation: Boolean? = false, +) : PyLspToolConfiguration() { + override fun loadState(state: PyreflyConfiguration) { + XmlSerializerUtil.copyBean(state, this) + } +} diff --git a/python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyDetailConfigurable.kt b/python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyDetailConfigurable.kt new file mode 100644 index 000000000000..0d85a4e2e9bb --- /dev/null +++ b/python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyDetailConfigurable.kt @@ -0,0 +1,12 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.pyrefly + +import com.intellij.openapi.project.Project +import com.intellij.python.pytools.ui.PyLspToolDetailConfigurable + +internal class PyreflyDetailConfigurable( + project: Project +) : PyLspToolDetailConfigurable( + project = project, + tool = PyreflyPyTool.getInstance() +) diff --git a/python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyPyTool.kt b/python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyPyTool.kt new file mode 100644 index 000000000000..a526aeb510a7 --- /dev/null +++ b/python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyPyTool.kt @@ -0,0 +1,47 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.pyrefly + +import com.intellij.openapi.components.service +import com.intellij.openapi.options.UnnamedConfigurable +import com.intellij.openapi.project.Project +import com.intellij.platform.lsp.api.LspClientManager +import com.intellij.platform.lsp.api.stopAndRestartClientsIfNeeded +import com.intellij.platform.lsp.api.stopClients +import com.intellij.python.pyrefly.lsp.PyreflyLspIntegrationProvider +import com.intellij.python.pytools.PyTool +import com.intellij.python.pytools.lsp.PyLspTool +import com.intellij.python.pytools.configuration.ConfigurablePyTool +import com.intellij.python.pytools.ui.pyLspToolFeaturesSummary +import com.jetbrains.python.packaging.PyPackageName +import org.jetbrains.annotations.ApiStatus +import javax.swing.Icon + +/** + * [Pyrefly](https://pyrefly.org/) — a fast Python type checker written in Rust by Meta, providing type + * checking and IDE features through a language server. + */ +@ApiStatus.Internal +class PyreflyPyTool : PyLspTool(), ConfigurablePyTool { + override val presentableName: String = "Pyrefly" + override val description: String get() = PyreflyBundle.message("pyrefly.tool.description") + override val packageName: PyPackageName = PyPackageName.from("pyrefly") + + override fun configuration(project: Project): PyreflyConfiguration = project.service() + + override val icon: Icon = PyreflyUtil.getDefaultPyreflyIcon() + + override fun createConfigurable(project: Project): UnnamedConfigurable = PyreflyDetailConfigurable(project) + + override fun summaryFor(project: Project): String = pyLspToolFeaturesSummary(configuration(project)) + + override fun onEnabledChanged(project: Project, enabled: Boolean) { + val manager = LspClientManager.getInstance(project) + if (enabled) manager.stopAndRestartClientsIfNeeded() + else manager.stopClients() + } + + @Suppress("CompanionObjectInExtension") + companion object { + fun getInstance(): PyreflyPyTool = PyTool.EP_NAME.findExtensionOrFail(PyreflyPyTool::class.java) + } +} diff --git a/python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyPyTypeEngineProvider.kt b/python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyPyTypeEngineProvider.kt new file mode 100644 index 000000000000..7694952b7f74 --- /dev/null +++ b/python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyPyTypeEngineProvider.kt @@ -0,0 +1,71 @@ +package com.intellij.python.pyrefly + +import com.intellij.icons.AllIcons +import com.intellij.openapi.components.service +import com.intellij.openapi.observable.properties.PropertyGraph +import com.intellij.openapi.project.Project +import com.intellij.platform.lsp.api.LspClientManager +import com.intellij.platform.lsp.api.stopAndRestartClientsIfNeeded +import com.intellij.platform.lsp.api.stopClients +import com.intellij.python.lsp.core.PyLspCoreBundle +import com.intellij.python.lsp.core.typeEngine.PyTypeEngineProvider +import com.intellij.python.lsp.core.typeEngine.PyTypeEngineType +import com.intellij.python.pyrefly.lsp.PyreflyLspIntegrationProvider +import com.intellij.python.pyrefly.typeProvider.PyreflyExternalPyTypeResolverProvider +import com.intellij.python.pytools.isEnabledOn +import com.intellij.python.pytools.ui.PyToolsUiBundle +import com.intellij.python.pytools.ui.toSafeProperty +import com.intellij.ui.dsl.builder.Panel +import com.intellij.ui.dsl.builder.RightGap +import com.intellij.ui.dsl.builder.RowLayout +import com.intellij.ui.dsl.builder.RowsRange +import com.intellij.ui.dsl.builder.bindSelected + +internal class PyreflyPyTypeEngineProvider : PyTypeEngineProvider { + override val pyTypeEngineType: PyTypeEngineType + get() = PyTypeEngineType.PYREFLY + + override fun isSupported(project: Project): Boolean { + return PyreflyExternalPyTypeResolverProvider.Util.isAvailable(project) + } + + override fun updateLspServers(project: Project) { + val lspServerManager = LspClientManager.getInstance(project) + if (shouldBeEnabled(project)) { + lspServerManager.stopAndRestartClientsIfNeeded() + } + else { + lspServerManager.stopClients() + } + } + + private fun shouldBeEnabled(project: Project): Boolean { + return isSupported(project) && PyreflyPyTool.getInstance().isEnabledOn(project) + } + + override fun Panel.createConfigurableContent(project: Project, propertyGraph: PropertyGraph): RowsRange { + val pyreflySettings = project.service() + + return rowsRange { + row { + comment(PyLspCoreBundle.message("pyrefly.description")).gap(RightGap.SMALL) + icon(AllIcons.General.Beta) + }.layout(RowLayout.INDEPENDENT) + + collapsibleGroup(PyreflyBundle.message("pyrefly.additional.settings.title"), indent = true) { + if (pyreflySettings.inlayHints != null) { + row("") { + checkBox(PyToolsUiBundle.message("checkbox.inlay.hints")) + .bindSelected(pyreflySettings::inlayHints.toSafeProperty()) + } + } + if (pyreflySettings.documentation != null) { + row("") { + checkBox(PyToolsUiBundle.message("checkbox.documentation")) + .bindSelected(pyreflySettings::documentation.toSafeProperty()) + } + } + }.expanded = false + } + } +} diff --git a/python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyUsageCollector.kt b/python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyUsageCollector.kt new file mode 100644 index 000000000000..3af90bc213a8 --- /dev/null +++ b/python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyUsageCollector.kt @@ -0,0 +1,50 @@ +package com.intellij.python.pyrefly + +import com.intellij.internal.statistic.eventLog.EventLogGroup +import com.intellij.internal.statistic.eventLog.events.EventFields +import com.intellij.internal.statistic.service.fus.collectors.CounterUsagesCollector +import com.intellij.openapi.projectRoots.Sdk +import com.jetbrains.python.psi.LanguageLevel +import com.jetbrains.python.sdk.flavors.PythonSdkFlavor +import com.jetbrains.python.sdk.legacy.PythonSdkUtil +import kotlin.time.Duration + +object PyreflyUsageCollector : CounterUsagesCollector() { + private val GROUP = EventLogGroup("python.pyrefly.type.inference", 7) + private val DURATION = EventFields.Int("duration_basket", "Duration of string type resolution, grouped into 100ms time buckets") + private val SUCCESS = EventFields.Boolean("success") + private val PYTHON_VERSION = EventFields.Version + + private val STRING_TYPE_RESOLUTION_TIME = GROUP.registerEvent("string.type.resolution.time", DURATION) + private val PYREFLY_AUTO_INSTALLED = GROUP.registerEvent("pyrefly.auto.installed", SUCCESS) + private val SERVER_STARTUP = GROUP.registerVarargEvent("server.startup", SUCCESS, PYTHON_VERSION) + + fun logStringTypeResolutionTime(duration: Duration) { + val basket = duration.inWholeMilliseconds.toInt() / 100 + STRING_TYPE_RESOLUTION_TIME.log(basket) + } + + fun logPyreflyAutoInstalled(success: Boolean) { + PYREFLY_AUTO_INSTALLED.log(success) + } + + fun logServerStartup(success: Boolean, sdk: Sdk?) { + val fields = buildList { + add(SUCCESS with success) + sdk.pythonVersion()?.let { + add(PYTHON_VERSION with it.toPythonVersion()) + } + } + SERVER_STARTUP.log(*fields.toTypedArray()) + } + + private fun Sdk?.pythonVersion(): LanguageLevel? { + if (this == null || !PythonSdkUtil.isPythonSdk(this)) return null + + return PythonSdkFlavor.getFlavor(this) + ?.getLanguageLevel(this) + ?: versionString?.let(LanguageLevel::fromPythonVersion) + } + + override fun getGroup(): EventLogGroup = GROUP +} diff --git a/python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyUtil.kt b/python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyUtil.kt new file mode 100644 index 000000000000..1e756752138b --- /dev/null +++ b/python/python-pyrefly/src/com/intellij/python/pyrefly/PyreflyUtil.kt @@ -0,0 +1,11 @@ +package com.intellij.python.pyrefly + +import com.intellij.python.pyrefly.icons.PythonPyreflyIcons +import com.intellij.util.IconUtil +import javax.swing.Icon + +object PyreflyUtil { + fun getDefaultPyreflyIcon(): Icon { + return IconUtil.resizeSquared(PythonPyreflyIcons.Pyrefly, 16) + } +} \ No newline at end of file diff --git a/python/python-pyrefly/src/com/intellij/python/pyrefly/lsp/PyreflyLsp4jServer.kt b/python/python-pyrefly/src/com/intellij/python/pyrefly/lsp/PyreflyLsp4jServer.kt new file mode 100644 index 000000000000..797375e200a8 --- /dev/null +++ b/python/python-pyrefly/src/com/intellij/python/pyrefly/lsp/PyreflyLsp4jServer.kt @@ -0,0 +1,79 @@ +package com.intellij.python.pyrefly.lsp + +import com.intellij.platform.lsp.api.Lsp4jServer +import org.eclipse.lsp4j.MarkupContent +import org.eclipse.lsp4j.Position +import org.eclipse.lsp4j.Range +import org.eclipse.lsp4j.TextDocumentIdentifier +import org.eclipse.lsp4j.jsonrpc.services.JsonRequest +import java.util.concurrent.CompletableFuture + +/** + * Pyrefly Type Server Protocol (TSP) endpoints. + * + * @see Type Server Protocol + */ +interface PyreflyLsp4jServer : Lsp4jServer { + /** + * Requests a type for a given element range. + */ + @JsonRequest("types/provide-type") + fun provideType(text_document: TextDocumentIdentifier, positions: List): CompletableFuture + + @JsonRequest("typeServer/getSnapshot") + fun getSnapshot(): CompletableFuture + + @JsonRequest("typeServer/getComputedType") + fun getComputedType(params: GetComputedTypeParams): CompletableFuture + + data class ProvideTypeResponse(val contents: List) + + data class TspNode(val uri: String, val range: Range) + + data class GetComputedTypeParams(val arg: TspNode, val snapshot: Int) + + data class TspDeclaration( + val kind: String? = null, + val name: String? = null, + val node: TspNode? = null, + val uri: String? = null, + ) + + data class TspSpecializedFunctionTypes( + val parameterTypes: List? = null, + val returnType: TspType? = null, + ) + + data class TspType( + val kind: String? = null, + val name: String? = null, + val moduleName: String? = null, + val uri: String? = null, + val stubContent: String? = null, + val declaration: TspDeclaration? = null, + val typeArgs: List? = null, + val subTypes: List? = null, + val overloads: List? = null, + val implementation: TspType? = null, + val returnType: TspType? = null, + val specializedTypes: TspSpecializedFunctionTypes? = null, + val boundToType: TspType? = null, + /** + * `TypeFlags` bitfield from the TSP protocol. Bit 3 (`0x8`) is `LITERAL` and indicates + * the type carries a literal value in [literalValue]. See `tsp_types::TypeFlags` upstream + * for the full set of bits. + */ + val flags: Int? = null, + /** + * For literal types (flag `LITERAL` set), the literal payload — an `Int`, `Boolean`, or + * `String` after Gson-deserialization of pyrefly's untagged `LiteralValue` enum. Enum + * / sentinel literals come through as JSON objects and we treat them as `null` for now. + */ + val literalValue: Any? = null, + ) + + companion object { + /** Bit 3 of `TypeFlags` — set when the type wraps a literal value. */ + const val LITERAL_FLAG: Int = 0x8 + } +} \ No newline at end of file diff --git a/python/python-pyrefly/src/com/intellij/python/pyrefly/lsp/PyreflyLspClientDescriptor.kt b/python/python-pyrefly/src/com/intellij/python/pyrefly/lsp/PyreflyLspClientDescriptor.kt new file mode 100644 index 000000000000..72ee889cb74a --- /dev/null +++ b/python/python-pyrefly/src/com/intellij/python/pyrefly/lsp/PyreflyLspClientDescriptor.kt @@ -0,0 +1,115 @@ +package com.intellij.python.pyrefly.lsp + +import com.intellij.execution.process.BaseProcessHandler +import com.intellij.openapi.components.service +import com.intellij.openapi.module.Module +import com.intellij.openapi.util.registry.Registry +import com.intellij.platform.lsp.api.Lsp4jServer +import com.intellij.platform.lsp.api.customization.LspFoldingRangeCustomizer +import com.intellij.platform.lsp.api.customization.LspFoldingRangeDisabled +import com.intellij.python.lsp.core.PyLspToolCustomization +import com.intellij.python.lsp.core.PyLspToolDescriptor +import com.intellij.python.lsp.core.typeEngine.PyTypeEngineUtils +import com.intellij.python.lsp.core.utils.PyLspServerModificationTracker +import com.intellij.python.pyrefly.PyreflyConfiguration +import com.intellij.python.pyrefly.PyreflyPyTool +import com.intellij.python.pyrefly.PyreflyUsageCollector +import com.intellij.python.pytools.configuration.ExecutableDiscoveryMode +import com.intellij.python.pytools.getState +import com.intellij.python.pytools.lsp.PyLspToolSettings +import com.jetbrains.python.codeInsight.typing.PyTypeShed +import com.jetbrains.python.sdk.pythonSdk +import org.eclipse.lsp4j.ConfigurationItem +import org.eclipse.lsp4j.InitializeResult + +@Suppress("UsagesOfObsoleteApi") +class PyreflyLspClientDescriptor(module: Module) : PyLspToolDescriptor(module, PyreflyPyTool.getInstance()) { + override val toolConfig: PyLspToolSettings + get() = project.service() + + override val lsp4jServerClass: Class + get() = PyreflyLsp4jServer::class.java + + override val lspCustomization: PyLspToolCustomization = object : PyLspToolCustomization(toolConfig, pyTool, project) { + override val foldingRangeCustomizer: LspFoldingRangeCustomizer = LspFoldingRangeDisabled + } + + override val lspServerListener: PyLspToolDescriptorLspServerListener = object : PyLspToolDescriptorLspServerListener() { + private var initialized = false + + override fun serverInitialized(params: InitializeResult) { + super.serverInitialized(params) + initialized = true + PyreflyUsageCollector.logServerStartup(success = true, sdk = module.pythonSdk) + } + + override fun serverStopped(shutdownNormally: Boolean) { + super.serverStopped(shutdownNormally) + if (!shutdownNormally && !initialized) { + PyreflyUsageCollector.logServerStartup(success = false, sdk = module.pythonSdk) + } + initialized = false + PyLspServerModificationTracker.getInstance(project).incModificationCount() + } + } + + override fun lspArguments(): List = + listOf(if (Registry.`is`("pyrefly.type.engine.tsp")) "tsp" else "lsp") + + override fun createInitializationOptions(): Map? { + val homePath = module.pythonSdk?.homePath ?: return null + return buildMap { + put("pythonPath", homePath) + put("pyrefly", buildPyreflyClientSettings()) + } + } + + /** + * Pyrefly's `apply_client_configuration` overwrites every known field on every call, + * resetting any value missing from the payload to `None`. The IDE replies to both the + * `initialize.initializationOptions` channel and the `workspace/configuration` request + * for `"python"`, so any setting we want Pyrefly to keep has to appear in **both** + * responses — otherwise the second response silently undoes the first. + * + * See [`apply_client_configuration`](https://github.com/facebook/pyrefly/blob/main/pyrefly/lib/lsp/non_wasm/workspace.rs) + * and the upstream `remove_*_clears_and_flags_modified` tests for the exact contract. + */ + private fun buildPyreflyClientSettings(): Map = buildMap { + put("displayTypeErrors", "force-on") + // Point Pyrefly at PyCharm's bundled typeshed so stdlib (and any third-party + // packages typeshed knows about) is resolved from a directory PyCharm already + // indexes. Without this, Pyrefly responds with URIs inside its own + // materialized `/pyrefly_bundled_typeshed_*` dir, and `PyClass.qualifiedName` + // for those symbols ends up empty because PyCharm has no source/library root + // for that path. + PyTypeShed.directory + ?.let { vf -> vf.fileSystem.getNioPath(vf)?.toString() } + ?.let { put("typeshedPath", it) } + // Stop Pyrefly from falling back to its bundled third-party stubs (pandas-stubs, + // boto3-stubs, ...) when a package isn't available in the user's site-packages + // and isn't in PyCharm's typeshed either. Those bundled stubs land under + // `/pyrefly_bundled_third_party_*` which the IDE doesn't index, so PSI + // can't compute a qualifiedName for symbols resolved through them. With this + // flag set, Pyrefly instead reports `MissingStubs` / `NotFound`, and the IDE + // surfaces a regular diagnostic the user can act on (install -stubs). + put("disableBundledThirdPartyStubs", true) + } + + override fun getWorkspaceConfiguration(item: ConfigurationItem): Map> = + mapOf("pyrefly" to buildPyreflyClientSettings()) + + override fun startServerProcess(): BaseProcessHandler<*> { + when (PyreflyPyTool.getInstance().getState(project).discoveryMode) { + ExecutableDiscoveryMode.INTERPRETER -> { + val pythonSdk = module.pythonSdk ?: error("Cannot find PythonSdk for module " + module.name) + if (!PyTypeEngineUtils.isExternalTypeEngineSupported(project)) { + error("Pyrefly type engine available only for local not read only interpreters. Current:$pythonSdk") + } + } + ExecutableDiscoveryMode.PATH -> error("Path for pyrefly executable is not setup") + ExecutableDiscoveryMode.UVX -> error("UVX mode is not yet supported for Pyrefly") + } + + return super.startServerProcess() + } +} \ No newline at end of file diff --git a/python/python-pyrefly/src/com/intellij/python/pyrefly/lsp/PyreflyLspIntegrationProvider.kt b/python/python-pyrefly/src/com/intellij/python/pyrefly/lsp/PyreflyLspIntegrationProvider.kt new file mode 100644 index 000000000000..11adf710dd8d --- /dev/null +++ b/python/python-pyrefly/src/com/intellij/python/pyrefly/lsp/PyreflyLspIntegrationProvider.kt @@ -0,0 +1,9 @@ +package com.intellij.python.pyrefly.lsp + +import com.intellij.openapi.module.Module +import com.intellij.python.lsp.core.PyLspToolIntegrationProvider +import com.intellij.python.lsp.core.PyLspToolDescriptor + +class PyreflyLspIntegrationProvider : PyLspToolIntegrationProvider() { + override fun getDescriptor(module: Module): PyLspToolDescriptor = PyreflyLspClientDescriptor(module) +} \ No newline at end of file diff --git a/python/python-pyrefly/src/com/intellij/python/pyrefly/typeProvider/PyreflyExternalPyTypeResolverProvider.kt b/python/python-pyrefly/src/com/intellij/python/pyrefly/typeProvider/PyreflyExternalPyTypeResolverProvider.kt new file mode 100644 index 000000000000..5b184e3502dc --- /dev/null +++ b/python/python-pyrefly/src/com/intellij/python/pyrefly/typeProvider/PyreflyExternalPyTypeResolverProvider.kt @@ -0,0 +1,50 @@ +package com.intellij.python.pyrefly.typeProvider + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.module.Module +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.registry.Registry +import com.intellij.platform.lsp.api.LspClientManager +import com.intellij.platform.lsp.api.ensureClientStarted +import com.intellij.platform.lsp.api.getClients +import com.intellij.python.lsp.core.typeEngine.PyTypeEngineUtils +import com.intellij.python.pyrefly.PyreflyPyTool +import com.intellij.python.pyrefly.lsp.PyreflyLspClientDescriptor +import com.intellij.python.pyrefly.lsp.PyreflyLspIntegrationProvider +import com.intellij.python.pytools.isEnabledOn +import com.jetbrains.python.psi.types.engine.PyTypeEngine +import com.jetbrains.python.psi.types.engine.PyTypeEngineProvider + + +/** + * External type provider that delegates to Pyrefly's LSP endpoint. + * This provider is enabled when the Type Engine feature is enabled via registry. + * The actual per-module check for Pyrefly configuration is done in PyreflyTypeResolver.isSupportedForResolve. + */ +class PyreflyExternalPyTypeResolverProvider : PyTypeEngineProvider { + override fun createResolver(module: Module): PyTypeEngine? { + // Check if type engine feature is enabled (via registry or unit tests) + val isFeatureEnabled = Util.isAvailable(module.project) + + if (!isFeatureEnabled) { + return null + } + + if (!PyreflyPyTool.getInstance().isEnabledOn(module.project)) { + return null + } + val lspServerManager = LspClientManager.getInstance(module.project) + lspServerManager.ensureClientStarted(PyreflyLspClientDescriptor(module)) + val server = lspServerManager.getClients().firstOrNull() ?: return null + + return PyreflyTypeResolver(module, server) + } + + object Util { + fun isAvailable(project: Project): Boolean { + return (PyTypeEngineUtils.isExternalTypeEngineSupported(project) || + Registry.`is`("pyrefly.type.engine") || + ApplicationManager.getApplication().isUnitTestMode) + } + } +} \ No newline at end of file diff --git a/python/python-pyrefly/src/com/intellij/python/pyrefly/typeProvider/PyreflyOffsetDetectorVisitor.kt b/python/python-pyrefly/src/com/intellij/python/pyrefly/typeProvider/PyreflyOffsetDetectorVisitor.kt new file mode 100644 index 000000000000..8b3e97d94271 --- /dev/null +++ b/python/python-pyrefly/src/com/intellij/python/pyrefly/typeProvider/PyreflyOffsetDetectorVisitor.kt @@ -0,0 +1,66 @@ +package com.intellij.python.pyrefly.typeProvider + +import com.intellij.psi.util.endOffset +import com.intellij.psi.util.startOffset +import com.jetbrains.python.psi.PyBinaryExpression +import com.jetbrains.python.psi.PyCallExpression +import com.jetbrains.python.psi.PyDecorator +import com.jetbrains.python.psi.PyDictLiteralExpression +import com.jetbrains.python.psi.PyElement +import com.jetbrains.python.psi.PyElementVisitor +import com.jetbrains.python.psi.PyFunction +import com.jetbrains.python.psi.PyLambdaExpression +import com.jetbrains.python.psi.PyPrefixExpression +import com.jetbrains.python.psi.PySequenceExpression +import com.jetbrains.python.psi.PySubscriptionExpression +import com.jetbrains.python.psi.PyTupleExpression + +class PyreflyOffsetDetectorVisitor : PyElementVisitor() { + var offset: Int = -1 + private set + + override fun visitPyElement(node: PyElement) { + offset = node.endOffset - 1 + } + + override fun visitPyFunction(node: PyFunction) { + offset = node.nameNode?.startOffset ?: node.startOffset + } + + override fun visitPyLambdaExpression(node: PyLambdaExpression) { + offset = node.startOffset + } + + override fun visitPyCallExpression(node: PyCallExpression) { + offset = node.endOffset + } + + override fun visitPyDecorator(node: PyDecorator) { + offset = node.endOffset + } + + override fun visitPySubscriptionExpression(node: PySubscriptionExpression) { + offset = node.endOffset + } + + override fun visitPyPrefixExpression(node: PyPrefixExpression) { + offset = node.startOffset + } + + override fun visitPySequenceExpression(node: PySequenceExpression) { + offset = node.endOffset + } + + override fun visitPyTupleExpression(node: PyTupleExpression) { + // we need to hit a comma or space to reveal the whole tuple type + offset = node.elements.firstOrNull()?.endOffset?.plus(1) ?: node.endOffset + } + + override fun visitPyDictLiteralExpression(node: PyDictLiteralExpression) { + offset = node.endOffset + } + + override fun visitPyBinaryExpression(node: PyBinaryExpression) { + offset = node.psiOperator?.startOffset ?: node.startOffset + } +} \ No newline at end of file diff --git a/python/python-pyrefly/src/com/intellij/python/pyrefly/typeProvider/PyreflyTypeEvalContext.kt b/python/python-pyrefly/src/com/intellij/python/pyrefly/typeProvider/PyreflyTypeEvalContext.kt new file mode 100644 index 000000000000..a5daa77198fd --- /dev/null +++ b/python/python-pyrefly/src/com/intellij/python/pyrefly/typeProvider/PyreflyTypeEvalContext.kt @@ -0,0 +1,430 @@ +package com.intellij.python.pyrefly.typeProvider + +import com.intellij.openapi.application.runReadActionBlocking +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.editor.Document +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.util.Ref +import com.intellij.openapi.util.registry.Registry +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.platform.lsp.api.LspClient +import com.intellij.platform.lsp.util.getOffsetInDocument +import com.intellij.platform.lsp.impl.LspClientImpl +import com.intellij.platform.lsp.util.getLsp4jPosition +import com.intellij.platform.lsp.util.getOffsetInDocument +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.python.lsp.core.type.LspTypeEvalContext +import com.intellij.python.lsp.core.type.PyStringTypeResolver +import com.intellij.python.pyrefly.PyreflyUsageCollector +import com.intellij.python.pyrefly.lsp.PyreflyLsp4jServer +import com.jetbrains.python.PyNames +import com.jetbrains.python.codeInsight.typing.PyTypingTypeProvider +import com.jetbrains.python.psi.PyCallable +import com.jetbrains.python.psi.PyClass +import com.jetbrains.python.psi.PyFile +import com.jetbrains.python.psi.PyTargetExpression +import com.jetbrains.python.psi.PyTypeParameter +import com.jetbrains.python.psi.PyTypedElement +import com.jetbrains.python.psi.impl.ParamHelper +import com.jetbrains.python.psi.impl.PyBuiltinCache +import com.jetbrains.python.psi.types.PyAnyType +import com.jetbrains.python.psi.types.PyCallableParameter +import com.jetbrains.python.psi.types.PyCallableParameterImpl +import com.jetbrains.python.psi.types.PyCallableType +import com.jetbrains.python.psi.types.PyClassTypeImpl +import com.jetbrains.python.psi.types.PyCollectionTypeImpl +import com.jetbrains.python.psi.types.PyFunctionType +import com.jetbrains.python.psi.types.PyFunctionTypeImpl +import com.jetbrains.python.psi.types.PyLiteralType +import com.jetbrains.python.psi.types.PyModuleType +import com.jetbrains.python.psi.types.PyNeverType +import com.jetbrains.python.psi.types.PyOverloadType +import com.jetbrains.python.psi.types.PyTupleType +import com.jetbrains.python.psi.types.PyType +import com.jetbrains.python.psi.types.PyTypeVarType +import com.jetbrains.python.psi.types.PyTypeVarTypeImpl +import com.jetbrains.python.psi.types.PyUnionType +import com.jetbrains.python.psi.types.TypeEvalContext +import org.eclipse.lsp4j.Range +import org.eclipse.lsp4j.jsonrpc.ResponseErrorException +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.annotations.Unmodifiable +import org.jetbrains.annotations.VisibleForTesting +import kotlin.time.measureTimedValue + +@Suppress("SSBasedInspection") +open class PyreflyTypeEvalContext internal constructor(val lspClient: LspClient, psiFile: PsiFile) : LspTypeEvalContext(psiFile) { + + private val snapshot: Int? by lazy { + try { + lspClient.sendRequestSync(LspClient.DEFAULT_REQUEST_TIMEOUT_MS) { + (it as PyreflyLsp4jServer).getSnapshot() + } + } + catch (e: ResponseErrorException) { + e.responseError?.code + null + } + } + + override fun provideType(element: PyTypedElement, isUserInitiated: Boolean): Ref? { + if (!Registry.`is`("pyrefly.type.engine.tsp")) { + // Old way: fetch types over LSP via the base-class flow (requestTypes / resolveStringType). + return super.provideType(element, isUserInitiated) + } + if (element is PsiFile) return null + val file = psiFile.virtualFile ?: return null + val document = FileDocumentManager.getInstance().getDocument(file) ?: return null + val sourceUri = lspClient.getDocumentIdentifier(file).uri + + val textRange = element.textRange + if (textRange.isEmpty) return null + + val offsetDetector = PyreflyOffsetDetectorVisitor() + element.accept(offsetDetector) + val position = getLsp4jPosition(document, offsetDetector.offset) + val node = PyreflyLsp4jServer.TspNode(sourceUri, Range(position, position)) + + val snapshot = this.snapshot ?: return null + val tspType = try { + lspClient.sendRequestSync(LspClient.DEFAULT_REQUEST_TIMEOUT_MS) { + (it as PyreflyLsp4jServer).getComputedType( + PyreflyLsp4jServer.GetComputedTypeParams(arg = node, snapshot = snapshot) + ) + } + } + catch (e: ResponseErrorException) { + e.responseError?.code + null + } ?: return null + + //logTypeDefinition(node, tspType) + + return buildPyType(element, tspType) + } + + private fun buildPyType(pyElement: PyTypedElement, tspType: PyreflyLsp4jServer.TspType): Ref? { + // Pyrefly marks `Literal[...]` types via the LITERAL bit (0x8) of TypeFlags and stores + // the value in `literalValue`. Detect this before falling through to the generic + // class-type path so we surface `Literal[5]` / `Literal["foo"]` instead of bare `int` / + // `str` — otherwise `tuple[Literal[1], Literal[2], Literal[3]]` collapses to `tuple[int, int, int]`. + if (tspType.kind == "3" && tspType.flags?.and(PyreflyLsp4jServer.LITERAL_FLAG) != 0) { + buildPyLiteralType(pyElement, tspType)?.let { return it } + } + return when (tspType.kind) { + "0" -> buildPyBuiltInType(pyElement, tspType) + "2" -> buildPyFunctionType(pyElement, tspType) + "3" -> buildPyClassType(pyElement, tspType) + "4" -> buildPyUnionType(pyElement, tspType) + "5" -> buildPyModuleType(pyElement, tspType) + "6" -> buildPyTypeVarType(pyElement, tspType) + "7" -> buildPyOverloadedType(pyElement, tspType) + else -> null + } + } + + private data class ResolvedDefTarget(val file: PyFile, val element: PsiElement, val offset: Int) + + /** + * Resolve the [PyFile] and the [PsiElement] (plus its offset) that a TSP declaration node points to. + * Must be called inside a read action. + */ + private fun resolveDefTarget(pyElement: PyTypedElement, defNode: PyreflyLsp4jServer.TspNode): ResolvedDefTarget? { + val virtualFile = VirtualFileManager.getInstance().findFileByUrl(defNode.uri) ?: return null + val document = FileDocumentManager.getInstance().getDocument(virtualFile) ?: return null + val offset = getOffsetInDocument(document, defNode.range.start) ?: return null + val targetFile = pyElement.manager.findFile(virtualFile) as? PyFile ?: return null + val targetElement = targetFile.findElementAt(offset) ?: return null + return ResolvedDefTarget(targetFile, targetElement, offset) + } + + /** + * Build a `PyLiteralType` for a Class-kind TSP type that carries `LITERAL` flag. + * + * Pyrefly emits the literal payload as `literalValue` — Kotlin sees it as `Int`, + * `Boolean`, or `String` after Gson-deserialization of the untagged TSP enum. + * Bytes / enum / sentinel literals come through as JSON objects or `null` from our + * `Any?` field and fall back to the regular class-type path. + */ + private fun buildPyLiteralType(pyElement: PyTypedElement, tspType: PyreflyLsp4jServer.TspType): Ref? { + val value = tspType.literalValue ?: return null + return runReadActionBlocking { + val literal = PyLiteralType.fromLiteralValue(pyElement, value) + literal?.let { + thisLogger().info("Pyrefly TSP: built PyLiteralType ${it.name}") + Ref.create(it) + } + } + } + + private fun buildPyBuiltInType(pyElement: PyTypedElement, tspType: PyreflyLsp4jServer.TspType): Ref? { + val name = tspType.name ?: return null + val type: PyType? = when (name.lowercase()) { + "any" -> PyAnyType.any + "never" -> PyNeverType.NEVER + "noreturn" -> PyNeverType.NO_RETURN + "ellipsis" -> runReadActionBlocking { PyBuiltinCache.getInstance(pyElement).ellipsisType } + "unknown" -> PyAnyType.unknown + "unbound" -> null + "callable" -> runReadActionBlocking { PyTypingTypeProvider.createTypingCallableType(pyElement) } + else -> runReadActionBlocking { + PyClassTypeImpl.createTypeByQName(pyElement, "typing.$name", false) + ?: PyClassTypeImpl.createTypeByQName(pyElement, PyTypingTypeProvider.SPECIAL_FORM, false) + } + } + if (type == null) return null + thisLogger().info("Pyrefly TSP: built builtin type for $name") + return Ref.create(type) + } + + private fun buildPyClassType(pyElement: PyTypedElement, tspType: PyreflyLsp4jServer.TspType): Ref? { + val declaration = tspType.declaration ?: return null + val defNode = declaration.node + if (defNode == null || defNode.uri.isEmpty()) { + // Pyrefly emits a synthesized stub class with `typeArgs` for built-in generic instances + // such as `tuple[...]`. Recover the element types from `typeArgs`. + if (!tspType.typeArgs.isNullOrEmpty()) { + buildPyTupleType(pyElement, tspType.typeArgs)?.let { return it } + } + return buildBuiltinClassType(pyElement, declaration.name) + } + val resolved = runReadActionBlocking { + val (_, targetElement, offset) = resolveDefTarget(pyElement, defNode) ?: return@runReadActionBlocking null + val pyClass = PsiTreeUtil.getParentOfType(targetElement, PyClass::class.java) ?: return@runReadActionBlocking null + val typeArgs = tspType.typeArgs + val classType: PyType = if (!typeArgs.isNullOrEmpty()) { + val elementTypes = typeArgs.map { buildPyType(pyElement, it)?.get() } + if (pyClass.qualifiedName == PyNames.FQN.TUPLE) { + PyTupleType.create(pyElement, elementTypes) ?: PyClassTypeImpl(pyClass, false) + } + else { + PyCollectionTypeImpl(pyClass, false, elementTypes) + } + } + else { + PyClassTypeImpl(pyClass, false) + } + thisLogger().info("Pyrefly TSP: built PyClassType for ${pyClass.qualifiedName ?: pyClass.name} at ${defNode.uri}:$offset (typeArgs=${typeArgs?.size ?: 0})") + Ref.create(classType) + } + if (resolved != null) return resolved + // Pyrefly emits a sentinel `range=(0,0)` for builtin instances (e.g. `int` for the literal + // default value `5`). The offset points to the start of `builtins.pyi`, so PSI gives us no + // class — fall back to looking up the class by `declaration.name` in the builtin cache. + return buildBuiltinClassType(pyElement, declaration.name) + } + + private fun buildBuiltinClassType(pyElement: PyTypedElement, name: String?): Ref? { + if (name.isNullOrEmpty()) return null + return runReadActionBlocking { + val type = PyBuiltinCache.getInstance(pyElement).getObjectType(name) ?: return@runReadActionBlocking null + thisLogger().info("Pyrefly TSP: built PyClassType for builtin $name") + Ref.create(type) + } + } + + private fun buildPyTupleType(pyElement: PyTypedElement, typeArgs: List): Ref? { + if (typeArgs.isEmpty()) return null + return runReadActionBlocking { + val elementTypes = typeArgs.map { buildPyType(pyElement, it)?.get() } + val tupleType = PyTupleType.create(pyElement, elementTypes) ?: return@runReadActionBlocking null + thisLogger().info("Pyrefly TSP: built PyTupleType with ${elementTypes.size} elements") + Ref.create(tupleType) + } + } + + private fun buildPyFunctionType(pyElement: PyTypedElement, tspType: PyreflyLsp4jServer.TspType): Ref? { + val declaration = tspType.declaration ?: return null + val defNode = declaration.node + if (defNode == null || defNode.uri.isEmpty()) { + // Pyrefly wraps class/type references in a Function envelope whose top-level + // declaration is a Synthesized stub. The real symbol lives on `returnType`. + val returnType = tspType.returnType + if (returnType != null && returnType.hasSourceLocation()) { + return buildPyType(pyElement, returnType) + } + return buildBuiltinFunctionType(pyElement, declaration.name, tspType) + } + val resolved = runReadActionBlocking { + val (_, targetElement, offset) = resolveDefTarget(pyElement, defNode) ?: return@runReadActionBlocking null + val callable = PsiTreeUtil.getParentOfType(targetElement, PyCallable::class.java) ?: return@runReadActionBlocking null + thisLogger().info("Pyrefly TSP: built PyFunctionType for ${callable.name} at ${defNode.uri}:$offset") + Ref.create(buildFunctionType(pyElement, callable, tspType)) + } + if (resolved != null) return resolved + // Sentinel `range=(0,0)` for builtin callables: PSI lookup at offset 0 misses the actual + // `def`, so resolve by `declaration.name` via the builtin cache. + return buildBuiltinFunctionType(pyElement, declaration.name, tspType) + } + + private fun buildBuiltinFunctionType(pyElement: PyTypedElement, name: String?, tspType: PyreflyLsp4jServer.TspType): Ref? { + if (name.isNullOrEmpty()) return null + return runReadActionBlocking { + val callable = PyBuiltinCache.getInstance(pyElement).getByName(name) as? PyCallable ?: return@runReadActionBlocking null + thisLogger().info("Pyrefly TSP: built PyFunctionType for builtin $name") + Ref.create(buildFunctionType(pyElement, callable, tspType)) + } + } + + private fun buildFunctionType( + pyElement: PyTypedElement, + callable: PyCallable, + tspType: PyreflyLsp4jServer.TspType, + ): PyFunctionType { + val psiParams = callable.parameterList.parameters + val substituted = tspType.specializedTypes?.parameterTypes + val parameters = psiParams.mapIndexed { i, p -> + val resolved = substituted?.getOrNull(i)?.let { buildPyType(pyElement, it)?.get() } + if (resolved != null) PyCallableParameterImpl.psi(p, resolved) else PyCallableParameterImpl.psi(p) + } + val returnTsp = tspType.specializedTypes?.returnType ?: tspType.returnType + val returnType = returnTsp?.let { buildPyType(pyElement, it)?.get() } + return PyreflyFunctionType(callable, parameters, returnType) + } + + private class PyreflyFunctionType( + callable: PyCallable, + parameters: List, + private val tspReturnType: PyType?, + ) : PyFunctionTypeImpl(callable, parameters) { + override fun getReturnType(context: TypeEvalContext): PyType? = tspReturnType + + override fun dropSelf(context: TypeEvalContext): PyFunctionType { + val params = getParameters(context) ?: return this + val newParams = ParamHelper.dropSelf(params) + return if (newParams.size < params.size) PyreflyFunctionType(callable, newParams, tspReturnType) else this + } + } + + private fun buildPyUnionType(pyElement: PyTypedElement, tspType: PyreflyLsp4jServer.TspType): Ref? { + val subTypes = tspType.subTypes ?: return null + if (subTypes.isEmpty()) return null + val members = subTypes.map { buildPyType(pyElement, it)?.get() } + if (members.all { it == null }) return null + thisLogger().info("Pyrefly TSP: built PyUnionType with ${members.size} members") + return Ref.create(PyUnionType.union(members)) + } + + private fun buildPyModuleType(pyElement: PyTypedElement, tspType: PyreflyLsp4jServer.TspType): Ref? { + val uri = tspType.uri + if (uri.isNullOrEmpty()) return null + val virtualFile = VirtualFileManager.getInstance().findFileByUrl(uri) ?: return null + return runReadActionBlocking { + val moduleFile = pyElement.manager.findFile(virtualFile) as? PyFile ?: return@runReadActionBlocking null + thisLogger().info("Pyrefly TSP: built PyModuleType for ${tspType.moduleName ?: moduleFile.name}") + Ref.create(PyModuleType(moduleFile)) + } + } + + private fun buildPyTypeVarType(pyElement: PyTypedElement, tspType: PyreflyLsp4jServer.TspType): Ref? { + val declaration = tspType.declaration ?: return null + val name = declaration.name + if (name.isNullOrEmpty()) return null + val defNode = declaration.node + if (defNode == null || defNode.uri.isEmpty()) { + thisLogger().info("Pyrefly TSP: built minimal PyTypeVarType for $name (no declaration node)") + return Ref.create(PyTypeVarTypeImpl(name, null)) + } + val resolved = runReadActionBlocking { + val (targetFile, targetElement, offset) = resolveDefTarget(pyElement, defNode) ?: return@runReadActionBlocking null + val context = TypeEvalContext.codeAnalysis(pyElement.project, targetFile) + + val typeParameter = PsiTreeUtil.getParentOfType(targetElement, PyTypeParameter::class.java) + if (typeParameter != null) { + val pep695Type = PyTypingTypeProvider.getTypeParameterTypeFromTypeParameter(typeParameter, context) + if (pep695Type is PyTypeVarType) { + thisLogger().info("Pyrefly TSP: built PyTypeVarType for $name from PyTypeParameter at ${defNode.uri}:$offset") + return@runReadActionBlocking Ref.create(pep695Type) + } + } + + val targetExpression = PsiTreeUtil.getParentOfType(targetElement, PyTargetExpression::class.java) + if (targetExpression != null) { + val assignedType = context.getType(targetExpression) + if (assignedType is PyTypeVarType) { + thisLogger().info("Pyrefly TSP: built PyTypeVarType for $name from PyTargetExpression at ${defNode.uri}:$offset") + return@runReadActionBlocking Ref.create(assignedType) + } + } + + null + } + if (resolved != null) return resolved + thisLogger().info("Pyrefly TSP: fell back to minimal PyTypeVarType for $name") + return Ref.create(PyTypeVarTypeImpl(name, null)) + } + + private fun buildPyOverloadedType(pyElement: PyTypedElement, tspType: PyreflyLsp4jServer.TspType): Ref? { + val overloads = tspType.overloads + val implementationTsp = tspType.implementation + if (overloads.isNullOrEmpty() && implementationTsp == null) return null + val items = overloads?.map { buildPyType(pyElement, it)?.get() as? PyCallableType } ?: emptyList() + val impl = implementationTsp?.let { buildPyType(pyElement, it) } + if (items.isEmpty() && impl == null) return null + thisLogger().info("Pyrefly TSP: built PyOverloadType with ${items.size} overloads (impl=${impl?.get() != null})") + return Ref.create(PyOverloadType(items, impl)) + } + + override fun requestTypes(pyTypedElements: @Unmodifiable Collection): List? { + val file = psiFile.virtualFile ?: return null + val document = FileDocumentManager.getInstance().getDocument(file) ?: return null + val textDocument = lspClient.getDocumentIdentifier(file) + + val positions = pyTypedElements.mapNotNull { psiElement -> + if (psiElement is PsiFile) return@mapNotNull null + + val textRange = psiElement.textRange + if (textRange.isEmpty) return@mapNotNull null + + val offsetDetector = PyreflyOffsetDetectorVisitor() + psiElement.accept(offsetDetector) + val offset = offsetDetector.offset + getLsp4jPosition(document, offset) + } + + val response = try { + lspClient.sendRequestSync { + (it as PyreflyLsp4jServer).provideType(textDocument, positions) + } + } + catch (e: ResponseErrorException) { + e.responseError?.code + return null + } + return response?.contents?.map { it.value } + } + + override fun resolveStringType(element: PyTypedElement, stringType: String): Ref? { + return resolveStringTypeImpl(element, stringType) + } + + private fun PyreflyLsp4jServer.TspType?.hasSourceLocation(): Boolean { + val type = this ?: return false + val decl = type.declaration + if (decl != null) { + val nodeUri = decl.node?.uri + if (!nodeUri.isNullOrEmpty() || !decl.uri.isNullOrEmpty()) return true + } + // Class/type references wrapped in a Function envelope keep their real declaration on returnType. + return type.returnType?.hasSourceLocation() == true + } + + companion object { + @ApiStatus.Internal + @VisibleForTesting + fun resolveStringTypeImpl( + element: PyTypedElement, + stringType: String, + ): Ref? { + val (result, duration) = measureTimedValue { + PyStringTypeResolver.resolvePyType(element, stringType) + } + PyreflyUsageCollector.logStringTypeResolutionTime(duration) + return result + } + } +} diff --git a/python/python-pyrefly/src/com/intellij/python/pyrefly/typeProvider/PyreflyTypeResolver.kt b/python/python-pyrefly/src/com/intellij/python/pyrefly/typeProvider/PyreflyTypeResolver.kt new file mode 100644 index 000000000000..8f46049f099e --- /dev/null +++ b/python/python-pyrefly/src/com/intellij/python/pyrefly/typeProvider/PyreflyTypeResolver.kt @@ -0,0 +1,54 @@ +package com.intellij.python.pyrefly.typeProvider + +import com.intellij.openapi.module.Module +import com.intellij.openapi.util.Ref +import com.intellij.openapi.util.registry.Registry +import com.intellij.platform.lsp.api.LspClient +import com.intellij.psi.util.parents +import com.intellij.python.lsp.core.type.LspPyTypeResolver +import com.intellij.python.lsp.core.typeEngine.PyTypeEngineType +import com.intellij.python.pyrefly.PyreflyPyTool +import com.intellij.python.pytools.isEnabledOn +import com.jetbrains.python.psi.PyFromImportStatement +import com.jetbrains.python.psi.PyQualifiedExpression +import com.jetbrains.python.psi.PyTypedElement +import com.jetbrains.python.psi.types.PyType + +internal class PyreflyTypeResolver(private val module: Module, val lspClient: LspClient) : LspPyTypeResolver { + override val name: String = PyTypeEngineType.PYREFLY.name + + override fun isSupportedForResolve(pyTypedElement: PyTypedElement): Boolean { + if (pyTypedElement.isInNotebookFile()) { + return false + } + + // First check the base class conditions + if (!super.isSupportedForResolve(pyTypedElement)) { + return false + } + // when there is `from x.y`, for `y` pyrefly will return `Module[x]`, so we do it ourselves + if ( + pyTypedElement.parents(false).any { it is PyFromImportStatement } + && pyTypedElement is PyQualifiedExpression + && pyTypedElement.isQualified + ) { + return false + } + return Registry.`is`("pyrefly.type.engine") || PyreflyPyTool.getInstance().isEnabledOn(module.project) + } + + override fun resolveType(pyTypedElement: PyTypedElement, isLibrary: Boolean, isUserInitiated: Boolean): Ref? { + if (pyTypedElement.isInNotebookFile()) { + return null + } + + val realFile = pyTypedElement.containingFile?.originalFile ?: return null + + val pyreflyTypeEvalContext = PyreflyTypeResolverFileCache.getInstance(module.project).getContext(realFile, lspClient, isLibrary) + return pyreflyTypeEvalContext.provideType(pyTypedElement, isUserInitiated) + } + + private fun PyTypedElement.isInNotebookFile(): Boolean { + return containingFile?.viewProvider?.virtualFile?.extension == "ipynb" + } +} diff --git a/python/python-pyrefly/src/com/intellij/python/pyrefly/typeProvider/PyreflyTypeResolverFileCache.kt b/python/python-pyrefly/src/com/intellij/python/pyrefly/typeProvider/PyreflyTypeResolverFileCache.kt new file mode 100644 index 000000000000..fc57594d6a50 --- /dev/null +++ b/python/python-pyrefly/src/com/intellij/python/pyrefly/typeProvider/PyreflyTypeResolverFileCache.kt @@ -0,0 +1,69 @@ +package com.intellij.python.pyrefly.typeProvider + +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.LowMemoryWatcher +import com.intellij.openapi.util.LowMemoryWatcher.LowMemoryWatcherType +import com.intellij.openapi.util.SimpleModificationTracker +import com.intellij.platform.lsp.api.LspClient +import com.intellij.psi.PsiFile +import com.intellij.psi.util.CachedValue +import com.intellij.psi.util.CachedValueProvider +import com.intellij.psi.util.CachedValuesManager +import com.intellij.psi.util.PsiModificationTracker +import com.intellij.python.lsp.core.type.LspTypeEvalContext +import com.intellij.python.lsp.core.utils.PyLspServerModificationTracker +import com.intellij.util.containers.ContainerUtil +import com.jetbrains.python.psi.types.PyLibraryModificationTracker +import java.util.concurrent.ConcurrentMap + +/** + * Pyrefly contains own cache for resolved types, + * and we need to rely on it not on our psi tracker + * as we do for the built-in resolver + */ +@Service(Service.Level.PROJECT) +internal class PyreflyTypeResolverFileCache(val project: Project) : Disposable.Default { + private val lowMemoryModificationTracker = SimpleModificationTracker() + + + private val cachedMapStorage = CachedValuesManager.getManager(project).createCachedValue { + val map = ContainerUtil.createConcurrentSoftValueMap() + CachedValueProvider.Result.create(map, + PsiModificationTracker.getInstance(project ), + lowMemoryModificationTracker, + PyLspServerModificationTracker.getInstance(project)) + } + + private val librariesCachedMapStorage: CachedValue> = + CachedValuesManager.getManager(project).createCachedValue { + val map = ContainerUtil.createConcurrentSoftValueMap() + CachedValueProvider.Result.create(map, + PyLibraryModificationTracker.getInstance(project), + lowMemoryModificationTracker, + PyLspServerModificationTracker.getInstance(project)) + } + + + init { + LowMemoryWatcher.register( + { + lowMemoryModificationTracker.incModificationCount() + cachedMapStorage.value + librariesCachedMapStorage.value + }, LowMemoryWatcherType.ALWAYS, this) + } + + + fun getContext(file: PsiFile, lspClient: LspClient, isLibrary: Boolean): LspTypeEvalContext { + val storage = if (isLibrary) librariesCachedMapStorage else cachedMapStorage + val cache = storage.value + return cache.getOrPut(file) { PyreflyTypeEvalContext(lspClient, file) } + } + + companion object { + fun getInstance(project: Project): PyreflyTypeResolverFileCache = project.service() + } +} \ No newline at end of file diff --git a/python/python-pyright/BUILD.bazel b/python/python-pyright/BUILD.bazel new file mode 100644 index 000000000000..d29de9a520c9 --- /dev/null +++ b/python/python-pyright/BUILD.bazel @@ -0,0 +1,107 @@ +load("@rules_jvm//:jvm.bzl", "jvm_library") + +### auto-generated section `build intellij.python.pyright` start + +jvm_library( + name = "pyright", + srcs = glob( + [ + "src/**/*.kt", + "src/**/*.java", + "src/**/*.form", + "gen/**/*.kt", + "gen/**/*.java", + ], + allow_empty = True, + ), + module_name = "intellij.python.pyright", + resource_strip_prefix = "resources", + resources = glob( + ["resources/**/*"], + ), + visibility = ["//visibility:public"], + deps = [ + # do not sort, + "//libraries/gson", + "//platform/editor-ui-api:editor-ui", + "//platform/lang-impl", + "//python:python-community-impl", + "//python/python-sdk:sdk", + "//python/python-core-impl:community-core-impl", + "@lib//:kotlin-stdlib", + "//platform/code-style-impl:codeStyle-impl", + "//platform/editor-ui-ex:editor-ex", + "//platform/platform-impl:ide-impl", + "//platform/ide-core", + "//xml/xml-parser:parser", + "//platform/core-ui", + "//xml/impl", + "//platform/util/jdom", + "//platform/smRunner", + "//libraries/guava", + "//libraries/fastutil", + "//platform/lsp", + "//platform/core-api:core", + "//json", + "@lib//:eclipse-lsp4j", + "//platform/projectModel-api:projectModel", + "//python/python-lsp-core:lsp-core", + "//python/python-pytools-ui:pytools-ui", + "//platform/analysis-api:analysis", + "//python/python-pytools:pytools", + ], +) + +jvm_library( + name = "pyright_test_lib", + testonly = True, + srcs = glob( + [], + allow_empty = True, + ), + module_name = "intellij.python.pyright", + visibility = ["//visibility:public"], + runtime_deps = [ + # do not sort, + ":pyright", + "//libraries/gson:gson_test_lib", + "//platform/editor-ui-api:editor-ui_test_lib", + "//platform/lang-impl:lang-impl_test_lib", + "//python:python-community-impl_test_lib", + "//python/python-sdk:sdk_test_lib", + "//python/python-core-impl:community-core-impl_test_lib", + "//platform/code-style-impl:codeStyle-impl_test_lib", + "//platform/editor-ui-ex:editor-ex_test_lib", + "//platform/platform-impl:ide-impl_test_lib", + "//platform/ide-core:ide-core_test_lib", + "//xml/xml-parser:parser_test_lib", + "//platform/core-ui:core-ui_test_lib", + "//xml/impl:impl_test_lib", + "//platform/util/jdom:jdom_test_lib", + "//platform/smRunner:smRunner_test_lib", + "//libraries/guava:guava_test_lib", + "//libraries/fastutil:fastutil_test_lib", + "//platform/lsp:lsp_test_lib", + "//platform/testFramework", + "//platform/testFramework:testFramework_test_lib", + "//platform/lsp/testFramework", + "//platform/lsp/testFramework:testFramework_test_lib", + "//platform/core-api:core_test_lib", + "//json:json_test_lib", + "//platform/projectModel-api:projectModel_test_lib", + "//python/python-lsp-core:lsp-core_test_lib", + "//python/python-pytools-ui:pytools-ui_test_lib", + "//platform/analysis-api:analysis_test_lib", + "//python/python-pytools:pytools_test_lib", + ], +) +### auto-generated section `build intellij.python.pyright` end + +### auto-generated section `iml intellij.python.pyright` start +exports_files( + [ + "intellij.python.pyright.iml", + ], + visibility = ["//visibility:public"], +) +### auto-generated section `iml intellij.python.pyright` end diff --git a/python/python-pyright/OWNERSHIP b/python/python-pyright/OWNERSHIP new file mode 100644 index 000000000000..e5e207deb924 --- /dev/null +++ b/python/python-pyright/OWNERSHIP @@ -0,0 +1,2 @@ +owner 'PyCharm Codeinsight' + diff --git a/python/python-pyright/gen/com/intellij/python/pyright/icons/PythonPyrightIcons.java b/python/python-pyright/gen/com/intellij/python/pyright/icons/PythonPyrightIcons.java new file mode 100644 index 000000000000..73d8f892a6dc --- /dev/null +++ b/python/python-pyright/gen/com/intellij/python/pyright/icons/PythonPyrightIcons.java @@ -0,0 +1,18 @@ +package com.intellij.python.pyright.icons; + +import com.intellij.ui.IconManager; +import org.jetbrains.annotations.NotNull; + +import javax.swing.*; + +/** + * NOTE THIS FILE IS AUTO-GENERATED + * DO NOT EDIT IT BY HAND, run "Generate icon classes" configuration instead + */ +public final class PythonPyrightIcons { + private static @NotNull Icon load(@NotNull String path, int cacheKey, int flags) { + return IconManager.getInstance().loadRasterizedIcon(path, PythonPyrightIcons.class.getClassLoader(), cacheKey, flags); + } + /** 85x85 */ public static final @NotNull Icon Basedpyright = load("icons/basedpyright.png", 0, 2); + /** 128x128 */ public static final @NotNull Icon Pyright = load("icons/pyright.png", 0, 2); +} diff --git a/python/python-pyright/intellij.python.pyright.iml b/python/python-pyright/intellij.python.pyright.iml new file mode 100644 index 000000000000..e37d195a56f5 --- /dev/null +++ b/python/python-pyright/intellij.python.pyright.iml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/python/python-pyright/resources/icons/basedpyright.png b/python/python-pyright/resources/icons/basedpyright.png new file mode 100644 index 0000000000000000000000000000000000000000..b216dd0ef54c890e3f7c82047df04e3001035c5c GIT binary patch literal 151 zcmeAS@N?(olHy`uVBq!ia0vp^p&-n`3?%urBQk&#TYyi9E06|)rRh7*11YAGAirRS z|IrhbwgP!}o-U3d6}R4A-N?nDz~SKd`~UnrVJ<19*?VR)J?7POV_>*&&!TeHs%O({ qZ@zw9vF!Z?7tHF(`<*9DM&*Ki5cOg@hT0v1df(!6Z=z2Kh|s;~#ge u>eGH5eZKvAC literal 0 HcmV?d00001 diff --git a/python/python-pyright/resources/icons/pyright.png b/python/python-pyright/resources/icons/pyright.png new file mode 100644 index 0000000000000000000000000000000000000000..b1bf166f98c705e1dca28a3456a5473dc4e58f39 GIT binary patch literal 8459 zcmV+mA@tsfP)+1r!KX>{V>VCZItCL1C$VGjrztr+fx3g#scJ_|Es8uMms(s_r@Wod0b1 z+1RM0l1eJ6q>@T1sicxhDygKBN-C+Ol1eJ6q*B+RMT=%FoIZWr4Egse-3Rp9XP^0n zhI*3Vf7R5i7%%_7N?n*PUApA#;nC;Mc6K%t5#d3;zP%|v-tf_a1v3}Q?^UUb(O>@Z zmwsMePG6;`2U1N9TCgC8($WIR-Mza|gz(R+s%Dk#*|WKy{I@D~aw4LK0t0FCiV3RZI(Ca<_vT0Mztjm`#_mr=#l9f|l-q4c|K4?R4ys?h{_{T%^=9`=7 zt+%$&XP@n-(W3`ac(@m>UR}frpfD5xE@AE35@8t%C?_XOV1#ZI6r}ln;lepjT)TFm zhkPHEtdOQmneeQ5p_g8&rZ2vDfqwQgv+%*N1k0C?COO!zC{2=ShsF8tz211Lx+Y4MREgwP!UzBAS5MPVf4YH;MlI#%$I_ZL4c&kxhDCs-fFi&mJy~`|rPDD}NT1+L^X(+kk`)J@wRN-uV#r#fuGv5B}vZpQ3l)-A+kK z!8CAS&@CX?99A&G6HmY*jH2x9F!J_(lnjRc|1odgw91nw4{PPmrBXZ4qD8Z&i5Ia$ z$I^G-9cQ#}Y4?NQd1o7a^wG1Fl@(1fFaT zPCh=4G-uA#)o0J1?Jj>tmF^MM)jdB_ywKvsBk0?2Ut|F{C47bd@P`95b!wJC_ih3M zj1&rx*GS0CVJO1l#d+)+s;fr|*AOMZWiR1g0>7RyWBj@^XU@3DpI4Fi5=6 z%$XVVr#~H~OP8LyqwviNu%EVWox+g-Qo;!NWZ|}i&8}etyM~&Y63WeuAdRMnP%y9m zs;a7}ym;}fqx{)bx=Y%(Z)c)-q4DEW=*ErX!nbd4g!r4{D_3^YsZ%SE=n;QEkTZg^ zP+P-_MF6gVYZyJ+B%%#FL>&JRU~&D&AAj6U{vIl|f-YZvFW`|!Y<|ef3ZbvQI?QR_ z<_aGSAP9v?ObnvT%!n2N!R9cc1}FkB0+cH+&nT=-Pl}BV`DV$I1@qs1_k!vgT1(%2 z^Nm;c?hpKP;J`ro^2<8<^{=04kmzY1Z>8V={&~&_8Vuf?5o{?CYz~_(f*CBXT{~KU zMWX3? z*|6{*(CM^SeSQ1T<;%P2x4+$YSHcJX?sxm>*=J{yzrPF3nw3S%myc{DuzST&1Xu(G zEEX5XFj$O_kN9%;?x*wQ?|y4GR9u|UtdwwD zbA8u<%wk?%7zGF0)8N5zKRtKuTz`2T?n{b_3P07OhYcrqkidEK&5ezO*&4?65DpF* z5=qI)dLe|ijRac4ifb_E5emt{!G_Ap3hU&xxbG-0FY9Fy?$Nn(Pc*3Mw-o~jPM(}e zK|$^UoQ-OAMzF0I76D3-n(9v`Q`pz?dfZpEXwm%5;)RYJS;(<|Q^H5)4;?=rf4qlF zz4&)?vVmY^1UGJ+$Qi-PN@N7{8wsVo8F_;V6H*wQo;h>k{&WLl#uTg*FLdC*0{Z5g z#x;FK@Ob^?lYNW}jvQG+wY78U7r%Iti$U`HEin}4g%=hw5S%#iarVQqK-!yOg%B$C z@86rIPanTkUYjmYOP5xz5ihiJ=QN9DT_^*D3_Jk0aO~Jp%FK+S9z7nU=x9Io?dbcH zg>8v}0bYK2B@#1KRMas*&=La_=H!G^RFv*N@2kpUUET9#;)PbPE~e|(k2NUk;uXc7 z&p$uFf;)JyQV4AT0V9Nmdkf@jUJVToqU z7(yN%kI~+}PY;&YsY}wneY;1C7hwYPxtm8R>mtpIknM{v4zZtq=9vnLjMNg|>*du? z`0ZkW5$bp+dwV-@yBCr~val^NtO>3=Zd{sB9$(tJwT#QdvOwCK(Q5!a;^OiEZP>78 zxwKSWkBF)t)My<3QBo4eJ0B(O$YzflOkmI3655edT7_7zseSAaoR;ZA&nBE(WfMJ8tlk*&jy zM650g>0UA{(VRJ145+?px`u_Wq^y;fM!oMd|pFZ8F zd-n%uNu-Ij+DC;_Pu?l7S(l>4ix?;87fKTK#>rzxvG5#n>zmTVB`Scry)E`g0IiW>Qz=22%&lKzvWO6!dJ$_ zVq*iySqR@0r)6O)!f(Jg6#+kognE*{p9^DFQQ89!(J$upF zvoFWW>(=S%=+T4voSclKJ$pVtiHSPevSl*+bqERo5dPg#Yqur-^wa0)$tR~Wn0x7^ z6>J6Yd;C4z1)4_?B8G-~ldGF8#l`zIE&O`S7C~4DQ@oZU3>tE9>?JTsU-5bM!r~;d zB`~*%o4bqeMj7C0U|k@1{EP|aO`FD%!QeoprNu|&_3N~B`t-3u+1crPoSh#N;g_p0 ztK(SUK=jzT5Zaw~yv(o^2M$y+U__Z0E<;HRBXLR9z8%@KaBKxH*pUKiiq$QKRe;x|2%Z)IitLmor+eiT9TTYnsBaHFB@ToJ%ylV zirs#Q=l9-ycSn=?{^syUAMN4MZ%@yDV%JP$APC_jj68XAxmZkIM2fd0{F?$-EJ9GQ zn@}WMzE*IMrwGNusA6Ug?cF<rX3sZd`0Nu=df^3()u|H>(GZSNNj9>nlK?A}E#tf=AH_Y3JBjKi=UG zy1}3oCO(axetITHJFtSVTDTvu&E?Cx*nQR2RdJRuH`geDsa9A|t%zh2K9bklDyX`; z`oWDGYjP73qAy{fGB?-2**f@QgmXajmWn*BWi*T6dN9jK8vXO1uhGGSRlGaBz57vo z;*UN*I>sz?FE2ZCb?rk&gD=+;%FBn)Q%_B!3l}!BazY50B0>joeJldy0#J2!?k(_v zwt+%LNBfA~>`DFmyOPo9D?-?KjvgTN@Nf--T)3jt)G!)9elYFYRlyMp?uC286)N)$ ziYt8QohRvo51!`y2;Ot!#xc%)L`Hg1V4&L%^4fPeO`KRdL92EDqnB4tnlfdO5cXCU zJW{zWDc81yu}iU2;^PBp_Uz%TY%VT+ghDt|LXtljBQ?To+q1wmUiMr+7&U4DtzSQZ z@c|YXI}XCd04c6%34r2vz#Fg(NM40Uc$pPTu_&+zg2N*``To6ocW2NT8mi%Z#^%kF zcsLc71UU`d6BebZ`&r_zzdp?05sfm#h9z51h*(&dJ2xOe^HEAl0B5qyxps5wwxZGU zmuV}8D?ubsUY;&Yu^S5*J|CtX01m>+$uZHgrNzAKPM==Qf<#3TyUtujZhT!!jC(?kDEh0@ba@5%CLchYKI|DK(#XQ2Zu!H2bcSmW*)mIjePMa3}M zvu6$;4}%4R5K#1K&fsWs8P|R7wY3~6ApH!Gc3znFt5<7p1H1tYLg2^)m`$y@vM9f|Ef|6>DM_KE zq#!O%0Wh}Ks@#qtd_+wrPE@li!&EaAz=IVa_q;5RcBd03jwbl{*b2Y8iZx&U0$q_I zWZ;0hx<&NZV-L~Joio_Kx3#;&^?>+suk^u25qb3DTm}GVXqY>trI}91^5_sU70CG({i6*s+7Sl8IC{td9KN)-rZKl38olmT}mL5SDWpg8lq#sjO_| zURf3$LfP5rC(_e(40t-y4YVi23c&OuBtwcBjj6ytz#>=&SOkQ6zy18@PB6IFkc%#1J8^eol1_5 zJ-L8YKZUK)^ro59%c^BrbO`O=zk4ud_YnRdAtNtTS7rziqvpu+jTw`~#T;}PAs?aD zI&n4B;ji1+{g_cPq$=HSqiqN05o)G$z} zii*-wRz^aFEQ=1I3l}at92VyPSLCXZO_LX?t1|Eoa8FH5DQEsr2->oxl#4y)V$g7! zK7EK#kR%~^BMlg!qlgF%kEJWM02G$cnjRJ9&)v;XrmCuuoUnqzXtfSpj;_D^qoTdV z-#Cf;E*>k(qQhw9$ehE22M63&3IN6lCGhX)CrnKZqIK&^xblY~4>XQI5l|3Px`~h_ zRmRch&(9H-AVXM=0hFC>q{PHPt|dSz(5o06>?u&ce*@uzBcr?ssrHK(-xwy#p~Gn1 zx|O5!dPnZN0~(eWs;jaY48A~1mK2ezt1WjH!xG5#e}$nK=mmgspgatPkQD^4!-hq3 z+%F4Xu>e6quHv4vl4Ut`7-5#p!^4)Rdf><8h3d)-PdWI0ehk&+Tu}aG=+=&WKqvqnLz<0L`X2cj#^op2GT} z137(g1lJiXHL!$)I8$7lo(>Fwe%#t0wdtxWfYc`-~YgtZR7m^y$-mbvn=g*|B3vvqrSK3L|TW?A~Ah zdRpw@LhcJdb0~x<3;0fqUO}8-8XMDJ2wKxf0r2nO5En{Ii}_q$f2*KDgA(5=DvIR< ziM&uL-jNbY4>uJiAEOK$NrAd?0c)F*hU{GlZkBai+oPi|;@bT#-0AR+i z<+Zm8DlZ?iJ3QQhKK#(!X(KOMXJ-frpL9YSQy~ytQX`MOAqt|p|4)u z%W*%NKroFobZ9is381Su;%1LvR1}&+y*LZlQZR@J0F9&ugD)A4-i;Ih{|?eS(U8HW zv+~+o1sy+r+!c>z+p}jTSF_}W>}-tP4seHt7v-SoIdk$TJj|OM93B(Gx91rFq-%dP z*IJ%7E-Xyo$)T3G0i@HRIOr=hMtV1L0jvN)&NOUj@=1B^t%e2+i2dc5F>$<8I%7Tn zpBbPI2_Dqrp#V=jF@f91k(_aL?ac!urZ~$ZW(?Fs2sa=ph<$o&^q8J<^HFs_vrKW^(cd^F1 zA9nzA3%CWr)K>sBZ=lFiR+ieL^M`;NCQW*rN7Usipu%o$-DuXV@%!X;w;DQjtS-{i zv$t5l%4Q$g(oqbdgVECmGY^H`w{IR57o~7H7iJ#w1sEmgh71b=vBa1U=pW!JtVYYu zA3`Ouv^16UK^|Q8m9Gm93-h4I2SqC=b$YBY{iQ!~bD!$J6VRPmG($Iy9$ zCJvkjfHJMShb<43K>goja@7YVA7=2PnQ>IdlTg8NlhIrKOLvK-^qyg(-DlGkYgc*IHI`0zpBZ9NNjx zE0!5aBZPq&83xj!5@_L?zy+u{=H{l(lGoho$Y>1uJx;s2)5%?J$%?zdFsgE292$X4 z9U3=rHjrNLY8FtvF57#_m|D)ui)A+;3td(KWC9009>!-BSyD#%nT7EC5dQl0V_3j<$^)3|0O_~t0G1R$X$QN0 z{WB+dt*w%F?%Y=D>e`JnZSd8yfSY0rMw$;yI>_z=2Pn;%ohwYcH;+nrY5MXab+nn6 z8J`FJ{Cb@y@8%5~#@yEI*Jp%@<>iAoY{Yp(^0lD=;SnBWGU>m!x^}Rp=)i%!1}`sL zI(Trwt&=~SVkKb%fy2xL)ZxU9tjuUWg$o6l;1GmIZJV%Z6TZ3gal9;)bl$vSEh++m z@&0k+Qn;bSQW_ZFIaIiSq@=Lx@;X~J84S9wu?x)O~_hjIvj7BS<4ow#D?Wo4#T))D?0Tj^Rl?CtL--&lSVri?|?e$m@exMLO z#=~$Hpm{K)d1k+%0u&43##tT+F`1Jx2Vl3O ziIS^uudSu3$^y#Fh$0_v2d=^?A&@oh_WEr8jOOki40oE3dB1(AcRR-4B4cPt0pb$; z#in?WGBVb{rYiyh$BYGafE9{MGjZt936X+kFNu3xV4)3MtoEF zsP>`c3*lo2w^sOIb0H{(t%-aujDxwmKT5B@axz(7-%dd$Q}{=tM#b<6o@lMY!XaT3 z64v%rxO>ftb;8cafzim|VfW&ASXrpeWhfBB!0hZO#uD}KhY8`HJ#9pYIPRjnuAPQv z&z^VyPx(Rzt&gugw^ntU-CmEC&x`CHrgxCN!_K`M;hPn}TnWs~G&ZON;(p=bA_8*m zPUp_OYLeHr)6j<>z8mD@)AwIKzI`Ym(ZAE|_WG;{U#oSYgm@hb47>JjgBRlicJ{Mz$bs0K% zuy%5{ZZ_QI(q8>}&0(|8$60?K{HQq0_hHl z-EC;!zGr60Yu4px+O)AJ5E+=_tSdCcL#ol35f>LgNlAJdG$@o~bXWsC69`AUO_?&7 zAN;g%;c(u;%Cmu>B(N4Z>IJ?Xr-I!{LChmzXoH07Z2si>(piGtFQjxk36?li|6`T87RD&_Wkhz zlI}w2z4*b-k&%A?S6*INJ9X;V@$=`;oR^zBQ^gFS!`WrYa2#;AE=7WNr2~i7T^n!x)*D5QgS3UpyE-(3efU2vjAFi#f4OqUsDy_V{q+G6@1C5Ai!^HgQCT@-_pxJj5%TA5DIGmp-RHZrpH*i17J0jRt4r-&;Iz%f!c`f8T}M zt@L^q0pt=tm^pL&ro)H#>*UX;QajMqt5<_jFX$i8@1B4{Ww(cgxss=657G!E9~I^Q zf0HH^?R)8^x)}NMsMG;;=+LuM5E`Nlx-F7KW+ZP8>vTGt>LpBkn3q7_g{M}otQ=KW zx7Snt3@UXnRW6ubhp>oUL2K}U62b{k2|$P49uVM2iHRY9Dl044edf%`aQU7pSs4u( zl5o{dxPyemfW`uDf)x~RjPxQ8kM88`{4fK$2@?vA?Ao;{1y4AU@1v5H(zR>XytTgl z{@2I1_wDh(&D|aq7R}wWXNRABZIwC+oj-pz4WS{Pmn&Be zmE9g;@E|X*M>%FU8nr)6oLIDf_wH?R^0idzq_lbS>b0nlDF-+yCLRYTdwBGq&=3z1 zv*(}Mv18+i0|$0>jrJ*(8q(y+C1-l~wxLiFzIg~hhv(+x<_@|vabn^6+S(_>+1r!KX>{V>VCZItCL1C$VGjrztr+fx3g#scJ_|Es8uMms(s_r@Wod0b1 z+1RM0l1eJ6q>@T1sicxhDygKBN-C+Ol1eJ6q*B+RMT=%FoIZWr4Egse-3Rp9XP^0n zhI*3Vf7R5i7%%_7N?n*PUApA#;nC;Mc6K%t5#d3;zP%|v-tf_a1v3}Q?^UUb(O>@Z zmwsMePG6;`2U1N9TCgC8($WIR-Mza|gz(R+s%Dk#*|WKy{I@D~aw4LK0t0FCiV3RZI(Ca<_vT0Mztjm`#_mr=#l9f|l-q4c|K4?R4ys?h{_{T%^=9`=7 zt+%$&XP@n-(W3`ac(@m>UR}frpfD5xE@AE35@8t%C?_XOV1#ZI6r}ln;lepjT)TFm zhkPHEtdOQmneeQ5p_g8&rZ2vDfqwQgv+%*N1k0C?COO!zC{2=ShsF8tz211Lx+Y4MREgwP!UzBAS5MPVf4YH;MlI#%$I_ZL4c&kxhDCs-fFi&mJy~`|rPDD}NT1+L^X(+kk`)J@wRN-uV#r#fuGv5B}vZpQ3l)-A+kK z!8CAS&@CX?99A&G6HmY*jH2x9F!J_(lnjRc|1odgw91nw4{PPmrBXZ4qD8Z&i5Ia$ z$I^G-9cQ#}Y4?NQd1o7a^wG1Fl@(1fFaT zPCh=4G-uA#)o0J1?Jj>tmF^MM)jdB_ywKvsBk0?2Ut|F{C47bd@P`95b!wJC_ih3M zj1&rx*GS0CVJO1l#d+)+s;fr|*AOMZWiR1g0>7RyWBj@^XU@3DpI4Fi5=6 z%$XVVr#~H~OP8LyqwviNu%EVWox+g-Qo;!NWZ|}i&8}etyM~&Y63WeuAdRMnP%y9m zs;a7}ym;}fqx{)bx=Y%(Z)c)-q4DEW=*ErX!nbd4g!r4{D_3^YsZ%SE=n;QEkTZg^ zP+P-_MF6gVYZyJ+B%%#FL>&JRU~&D&AAj6U{vIl|f-YZvFW`|!Y<|ef3ZbvQI?QR_ z<_aGSAP9v?ObnvT%!n2N!R9cc1}FkB0+cH+&nT=-Pl}BV`DV$I1@qs1_k!vgT1(%2 z^Nm;c?hpKP;J`ro^2<8<^{=04kmzY1Z>8V={&~&_8Vuf?5o{?CYz~_(f*CBXT{~KU zMWX3? z*|6{*(CM^SeSQ1T<;%P2x4+$YSHcJX?sxm>*=J{yzrPF3nw3S%myc{DuzST&1Xu(G zEEX5XFj$O_kN9%;?x*wQ?|y4GR9u|UtdwwD zbA8u<%wk?%7zGF0)8N5zKRtKuTz`2T?n{b_3P07OhYcrqkidEK&5ezO*&4?65DpF* z5=qI)dLe|ijRac4ifb_E5emt{!G_Ap3hU&xxbG-0FY9Fy?$Nn(Pc*3Mw-o~jPM(}e zK|$^UoQ-OAMzF0I76D3-n(9v`Q`pz?dfZpEXwm%5;)RYJS;(<|Q^H5)4;?=rf4qlF zz4&)?vVmY^1UGJ+$Qi-PN@N7{8wsVo8F_;V6H*wQo;h>k{&WLl#uTg*FLdC*0{Z5g z#x;FK@Ob^?lYNW}jvQG+wY78U7r%Iti$U`HEin}4g%=hw5S%#iarVQqK-!yOg%B$C z@86rIPanTkUYjmYOP5xz5ihiJ=QN9DT_^*D3_Jk0aO~Jp%FK+S9z7nU=x9Io?dbcH zg>8v}0bYK2B@#1KRMas*&=La_=H!G^RFv*N@2kpUUET9#;)PbPE~e|(k2NUk;uXc7 z&p$uFf;)JyQV4AT0V9Nmdkf@jUJVToqU z7(yN%kI~+}PY;&YsY}wneY;1C7hwYPxtm8R>mtpIknM{v4zZtq=9vnLjMNg|>*du? z`0ZkW5$bp+dwV-@yBCr~val^NtO>3=Zd{sB9$(tJwT#QdvOwCK(Q5!a;^OiEZP>78 zxwKSWkBF)t)My<3QBo4eJ0B(O$YzflOkmI3655edT7_7zseSAaoR;ZA&nBE(WfMJ8tlk*&jy zM650g>0UA{(VRJ145+?px`u_Wq^y;fM!oMd|pFZ8F zd-n%uNu-Ij+DC;_Pu?l7S(l>4ix?;87fKTK#>rzxvG5#n>zmTVB`Scry)E`g0IiW>Qz=22%&lKzvWO6!dJ$_ zVq*iySqR@0r)6O)!f(Jg6#+kognE*{p9^DFQQ89!(J$upF zvoFWW>(=S%=+T4voSclKJ$pVtiHSPevSl*+bqERo5dPg#Yqur-^wa0)$tR~Wn0x7^ z6>J6Yd;C4z1)4_?B8G-~ldGF8#l`zIE&O`S7C~4DQ@oZU3>tE9>?JTsU-5bM!r~;d zB`~*%o4bqeMj7C0U|k@1{EP|aO`FD%!QeoprNu|&_3N~B`t-3u+1crPoSh#N;g_p0 ztK(SUK=jzT5Zaw~yv(o^2M$y+U__Z0E<;HRBXLR9z8%@KaBKxH*pUKiiq$QKRe;x|2%Z)IitLmor+eiT9TTYnsBaHFB@ToJ%ylV zirs#Q=l9-ycSn=?{^syUAMN4MZ%@yDV%JP$APC_jj68XAxmZkIM2fd0{F?$-EJ9GQ zn@}WMzE*IMrwGNusA6Ug?cF<rX3sZd`0Nu=df^3()u|H>(GZSNNj9>nlK?A}E#tf=AH_Y3JBjKi=UG zy1}3oCO(axetITHJFtSVTDTvu&E?Cx*nQR2RdJRuH`geDsa9A|t%zh2K9bklDyX`; z`oWDGYjP73qAy{fGB?-2**f@QgmXajmWn*BWi*T6dN9jK8vXO1uhGGSRlGaBz57vo z;*UN*I>sz?FE2ZCb?rk&gD=+;%FBn)Q%_B!3l}!BazY50B0>joeJldy0#J2!?k(_v zwt+%LNBfA~>`DFmyOPo9D?-?KjvgTN@Nf--T)3jt)G!)9elYFYRlyMp?uC286)N)$ ziYt8QohRvo51!`y2;Ot!#xc%)L`Hg1V4&L%^4fPeO`KRdL92EDqnB4tnlfdO5cXCU zJW{zWDc81yu}iU2;^PBp_Uz%TY%VT+ghDt|LXtljBQ?To+q1wmUiMr+7&U4DtzSQZ z@c|YXI}XCd04c6%34r2vz#Fg(NM40Uc$pPTu_&+zg2N*``To6ocW2NT8mi%Z#^%kF zcsLc71UU`d6BebZ`&r_zzdp?05sfm#h9z51h*(&dJ2xOe^HEAl0B5qyxps5wwxZGU zmuV}8D?ubsUY;&Yu^S5*J|CtX01m>+$uZHgrNzAKPM==Qf<#3TyUtujZhT!!jC(?kDEh0@ba@5%CLchYKI|DK(#XQ2Zu!H2bcSmW*)mIjePMa3}M zvu6$;4}%4R5K#1K&fsWs8P|R7wY3~6ApH!Gc3znFt5<7p1H1tYLg2^)m`$y@vM9f|Ef|6>DM_KE zq#!O%0Wh}Ks@#qtd_+wrPE@li!&EaAz=IVa_q;5RcBd03jwbl{*b2Y8iZx&U0$q_I zWZ;0hx<&NZV-L~Joio_Kx3#;&^?>+suk^u25qb3DTm}GVXqY>trI}91^5_sU70CG({i6*s+7Sl8IC{td9KN)-rZKl38olmT}mL5SDWpg8lq#sjO_| zURf3$LfP5rC(_e(40t-y4YVi23c&OuBtwcBjj6ytz#>=&SOkQ6zy18@PB6IFkc%#1J8^eol1_5 zJ-L8YKZUK)^ro59%c^BrbO`O=zk4ud_YnRdAtNtTS7rziqvpu+jTw`~#T;}PAs?aD zI&n4B;ji1+{g_cPq$=HSqiqN05o)G$z} zii*-wRz^aFEQ=1I3l}at92VyPSLCXZO_LX?t1|Eoa8FH5DQEsr2->oxl#4y)V$g7! zK7EK#kR%~^BMlg!qlgF%kEJWM02G$cnjRJ9&)v;XrmCuuoUnqzXtfSpj;_D^qoTdV z-#Cf;E*>k(qQhw9$ehE22M63&3IN6lCGhX)CrnKZqIK&^xblY~4>XQI5l|3Px`~h_ zRmRch&(9H-AVXM=0hFC>q{PHPt|dSz(5o06>?u&ce*@uzBcr?ssrHK(-xwy#p~Gn1 zx|O5!dPnZN0~(eWs;jaY48A~1mK2ezt1WjH!xG5#e}$nK=mmgspgatPkQD^4!-hq3 z+%F4Xu>e6quHv4vl4Ut`7-5#p!^4)Rdf><8h3d)-PdWI0ehk&+Tu}aG=+=&WKqvqnLz<0L`X2cj#^op2GT} z137(g1lJiXHL!$)I8$7lo(>Fwe%#t0wdtxWfYc`-~YgtZR7m^y$-mbvn=g*|B3vvqrSK3L|TW?A~Ah zdRpw@LhcJdb0~x<3;0fqUO}8-8XMDJ2wKxf0r2nO5En{Ii}_q$f2*KDgA(5=DvIR< ziM&uL-jNbY4>uJiAEOK$NrAd?0c)F*hU{GlZkBai+oPi|;@bT#-0AR+i z<+Zm8DlZ?iJ3QQhKK#(!X(KOMXJ-frpL9YSQy~ytQX`MOAqt|p|4)u z%W*%NKroFobZ9is381Su;%1LvR1}&+y*LZlQZR@J0F9&ugD)A4-i;Ih{|?eS(U8HW zv+~+o1sy+r+!c>z+p}jTSF_}W>}-tP4seHt7v-SoIdk$TJj|OM93B(Gx91rFq-%dP z*IJ%7E-Xyo$)T3G0i@HRIOr=hMtV1L0jvN)&NOUj@=1B^t%e2+i2dc5F>$<8I%7Tn zpBbPI2_Dqrp#V=jF@f91k(_aL?ac!urZ~$ZW(?Fs2sa=ph<$o&^q8J<^HFs_vrKW^(cd^F1 zA9nzA3%CWr)K>sBZ=lFiR+ieL^M`;NCQW*rN7Usipu%o$-DuXV@%!X;w;DQjtS-{i zv$t5l%4Q$g(oqbdgVECmGY^H`w{IR57o~7H7iJ#w1sEmgh71b=vBa1U=pW!JtVYYu zA3`Ouv^16UK^|Q8m9Gm93-h4I2SqC=b$YBY{iQ!~bD!$J6VRPmG($Iy9$ zCJvkjfHJMShb<43K>goja@7YVA7=2PnQ>IdlTg8NlhIrKOLvK-^qyg(-DlGkYgc*IHI`0zpBZ9NNjx zE0!5aBZPq&83xj!5@_L?zy+u{=H{l(lGoho$Y>1uJx;s2)5%?J$%?zdFsgE292$X4 z9U3=rHjrNLY8FtvF57#_m|D)ui)A+;3td(KWC9009>!-BSyD#%nT7EC5dQl0V_3j<$^)3|0O_~t0G1R$X$QN0 z{WB+dt*w%F?%Y=D>e`JnZSd8yfSY0rMw$;yI>_z=2Pn;%ohwYcH;+nrY5MXab+nn6 z8J`FJ{Cb@y@8%5~#@yEI*Jp%@<>iAoY{Yp(^0lD=;SnBWGU>m!x^}Rp=)i%!1}`sL zI(Trwt&=~SVkKb%fy2xL)ZxU9tjuUWg$o6l;1GmIZJV%Z6TZ3gal9;)bl$vSEh++m z@&0k+Qn;bSQW_ZFIaIiSq@=Lx@;X~J84S9wu?x)O~_hjIvj7BS<4ow#D?Wo4#T))D?0Tj^Rl?CtL--&lSVri?|?e$m@exMLO z#=~$Hpm{K)d1k+%0u&43##tT+F`1Jx2Vl3O ziIS^uudSu3$^y#Fh$0_v2d=^?A&@oh_WEr8jOOki40oE3dB1(AcRR-4B4cPt0pb$; z#in?WGBVb{rYiyh$BYGafE9{MGjZt936X+kFNu3xV4)3MtoEF zsP>`c3*lo2w^sOIb0H{(t%-aujDxwmKT5B@axz(7-%dd$Q}{=tM#b<6o@lMY!XaT3 z64v%rxO>ftb;8cafzim|VfW&ASXrpeWhfBB!0hZO#uD}KhY8`HJ#9pYIPRjnuAPQv z&z^VyPx(Rzt&gugw^ntU-CmEC&x`CHrgxCN!_K`M;hPn}TnWs~G&ZON;(p=bA_8*m zPUp_OYLeHr)6j<>z8mD@)AwIKzI`Ym(ZAE|_WG;{U#oSYgm@hb47>JjgBRlicJ{Mz$bs0K% zuy%5{ZZ_QI(q8>}&0(|8$60?K{HQq0_hHl z-EC;!zGr60Yu4px+O)AJ5E+=_tSdCcL#ol35f>LgNlAJdG$@o~bXWsC69`AUO_?&7 zAN;g%;c(u;%Cmu>B(N4Z>IJ?Xr-I!{LChmzXoH07Z2si>(piGtFQjxk36?li|6`T87RD&_Wkhz zlI}w2z4*b-k&%A?S6*INJ9X;V@$=`;oR^zBQ^gFS!`WrYa2#;AE=7WNr2~i7T^n!x)*D5QgS3UpyE-(3efU2vjAFi#f4OqUsDy_V{q+G6@1C5Ai!^HgQCT@-_pxJj5%TA5DIGmp-RHZrpH*i17J0jRt4r-&;Iz%f!c`f8T}M zt@L^q0pt=tm^pL&ro)H#>*UX;QajMqt5<_jFX$i8@1B4{Ww(cgxss=657G!E9~I^Q zf0HH^?R)8^x)}NMsMG;;=+LuM5E`Nlx-F7KW+ZP8>vTGt>LpBkn3q7_g{M}otQ=KW zx7Snt3@UXnRW6ubhp>oUL2K}U62b{k2|$P49uVM2iHRY9Dl044edf%`aQU7pSs4u( zl5o{dxPyemfW`uDf)x~RjPxQ8kM88`{4fK$2@?vA?Ao;{1y4AU@1v5H(zR>XytTgl z{@2I1_wDh(&D|aq7R}wWXNRABZIwC+oj-pz4WS{Pmn&Be zmE9g;@E|X*M>%FU8nr)6oLIDf_wH?R^0idzq_lbS>b0nlDF-+yCLRYTdwBGq&=3z1 zv*(}Mv18+i0|$0>jrJ*(8q(y+C1-l~wxLiFzIg~hhv(+x<_@|vabn^6+S(_> + + + + + + + + + + + + + + + + + + + diff --git a/python/python-pyright/resources/messages/PyrightBundle.properties b/python/python-pyright/resources/messages/PyrightBundle.properties new file mode 100644 index 000000000000..b220c45689bd --- /dev/null +++ b/python/python-pyright/resources/messages/PyrightBundle.properties @@ -0,0 +1,2 @@ +pyright.tool.description=A static type checker for Python, with type inference and language server support. +basedpyright.tool.description=A community fork of Pyright with additional features and bug fixes. \ No newline at end of file diff --git a/python/python-pyright/src/com/intellij/python/pyright/BasedpyrightConfigurable.kt b/python/python-pyright/src/com/intellij/python/pyright/BasedpyrightConfigurable.kt new file mode 100644 index 000000000000..449d4429e5c3 --- /dev/null +++ b/python/python-pyright/src/com/intellij/python/pyright/BasedpyrightConfigurable.kt @@ -0,0 +1,32 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.pyright + +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.project.Project +import com.intellij.python.pytools.lsp.LSP_TOOLS_STORAGE_FILE +import com.intellij.python.pytools.lsp.PyLspToolConfiguration +import com.intellij.python.pytools.ui.PyLspToolDetailConfigurable +import com.intellij.util.xmlb.XmlSerializerUtil + +class BasedpyrightConfigurable( + project: Project, +) : PyLspToolDetailConfigurable( + project = project, + tool = BasedpyrightPyTool.getInstance() +) + +@Service(Service.Level.PROJECT) +@State( + name = "BasedpyrightConfiguration", + storages = [Storage(LSP_TOOLS_STORAGE_FILE)] +) +data class BasedpyrightConfiguration( + override var inlayHints: Boolean? = true, + override var completions: Boolean? = true, +) : PyLspToolConfiguration() { + override fun loadState(state: BasedpyrightConfiguration) { + XmlSerializerUtil.copyBean(state, this) + } +} diff --git a/python/python-pyright/src/com/intellij/python/pyright/BasedpyrightPyTool.kt b/python/python-pyright/src/com/intellij/python/pyright/BasedpyrightPyTool.kt new file mode 100644 index 000000000000..7e973ecad0e8 --- /dev/null +++ b/python/python-pyright/src/com/intellij/python/pyright/BasedpyrightPyTool.kt @@ -0,0 +1,38 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.pyright + +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.python.pytools.PyTool +import com.intellij.python.pytools.lsp.PyLspTool +import com.intellij.python.pytools.configuration.ConfigurablePyTool +import com.intellij.python.pytools.ui.pyLspToolFeaturesSummary +import com.jetbrains.python.packaging.PyPackageName +import org.jetbrains.annotations.ApiStatus +import javax.swing.Icon + +/** + * [Basedpyright](https://docs.basedpyright.com/) — an open-source fork of Pyright that adds features + * such as stricter type inference and language-server capabilities otherwise exclusive to Pylance. + */ +@ApiStatus.Internal +class BasedpyrightPyTool : PyLspTool(), ConfigurablePyTool { + override val presentableName: String = "Basedpyright" + override val description: String get() = PyrightBundle.message("basedpyright.tool.description") + override val packageName: PyPackageName = PyPackageName.from("basedpyright") + + override fun configuration(project: Project): BasedpyrightConfiguration = project.service() + + override val icon: Icon = PyrightUtil.getDefaultBasedPyrightIcon() + + override fun createConfigurable(project: Project): BasedpyrightConfigurable = BasedpyrightConfigurable(project) + + override fun summaryFor(project: Project): String = pyLspToolFeaturesSummary(configuration(project)) + + override fun onEnabledChanged(project: Project, enabled: Boolean): Unit = restartOrStopPyrightProvider(project) + + @Suppress("CompanionObjectInExtension") + companion object { + fun getInstance(): BasedpyrightPyTool = PyTool.EP_NAME.findExtensionOrFail(BasedpyrightPyTool::class.java) + } +} diff --git a/python/python-pyright/src/com/intellij/python/pyright/PyrightBundle.kt b/python/python-pyright/src/com/intellij/python/pyright/PyrightBundle.kt new file mode 100644 index 000000000000..ce65d3c0360a --- /dev/null +++ b/python/python-pyright/src/com/intellij/python/pyright/PyrightBundle.kt @@ -0,0 +1,18 @@ +package com.intellij.python.pyright + +import com.intellij.DynamicBundle +import org.jetbrains.annotations.Nls +import org.jetbrains.annotations.NonNls +import org.jetbrains.annotations.PropertyKey + +object PyrightBundle { + private val myDynamicBundle = DynamicBundle(this::class.java, BUNDLE) + + @NonNls + const val BUNDLE: String = "messages.PyrightBundle" + + @Nls + fun message(@PropertyKey(resourceBundle = BUNDLE) key: String, vararg params: Any): String { + return myDynamicBundle.getMessage(key, *params) + } +} diff --git a/python/python-pyright/src/com/intellij/python/pyright/PyrightConfigurable.kt b/python/python-pyright/src/com/intellij/python/pyright/PyrightConfigurable.kt new file mode 100644 index 000000000000..1f0b2f9624a7 --- /dev/null +++ b/python/python-pyright/src/com/intellij/python/pyright/PyrightConfigurable.kt @@ -0,0 +1,31 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.pyright + +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.project.Project +import com.intellij.python.pytools.lsp.LSP_TOOLS_STORAGE_FILE +import com.intellij.python.pytools.lsp.PyLspToolConfiguration +import com.intellij.python.pytools.ui.PyLspToolDetailConfigurable +import com.intellij.util.xmlb.XmlSerializerUtil + +class PyrightConfigurable( + project: Project +) : PyLspToolDetailConfigurable( + project = project, + tool = PyrightPyTool.getInstance() +) + +@Service(Service.Level.PROJECT) +@State( + name = "PyrightConfiguration", + storages = [Storage(LSP_TOOLS_STORAGE_FILE)] +) +data class PyrightConfiguration( + override var completions: Boolean? = true, +) : PyLspToolConfiguration() { + override fun loadState(state: PyrightConfiguration) { + XmlSerializerUtil.copyBean(state, this) + } +} diff --git a/python/python-pyright/src/com/intellij/python/pyright/PyrightLspClient.kt b/python/python-pyright/src/com/intellij/python/pyright/PyrightLspClient.kt new file mode 100644 index 000000000000..86465158922d --- /dev/null +++ b/python/python-pyright/src/com/intellij/python/pyright/PyrightLspClient.kt @@ -0,0 +1,89 @@ +package com.intellij.python.pyright + +import com.google.gson.JsonObject +import com.intellij.openapi.module.Module +import com.intellij.openapi.project.Project +import com.intellij.platform.lsp.api.LspClient +import com.intellij.platform.lsp.api.LspClientManager +import com.intellij.platform.lsp.api.LspClientManagerListener +import com.intellij.platform.lsp.api.LspServerState +import com.intellij.platform.lsp.api.stopAndRestartClientsIfNeeded +import com.intellij.platform.lsp.api.stopClients +import com.intellij.python.lsp.core.PyLspToolDescriptor +import com.intellij.python.lsp.core.PyLspToolIntegrationProvider +import com.intellij.python.pytools.isEnabledOn +import com.intellij.python.pytools.lsp.PyLspTool +import com.intellij.python.pytools.lsp.PyLspToolSettings +import com.jetbrains.python.PythonPluginDisposable +import org.eclipse.lsp4j.DidChangeConfigurationParams + +/** The pyright-family tools sharing one LSP server, in priority order (basedpyright wins over pyright). */ +private fun pyrightFamilyTools(): List> = listOf( + BasedpyrightPyTool.getInstance(), + PyrightPyTool.getInstance(), +) + +/** The pyright-family tool that should serve a project: the first enabled one, or basedpyright as a (disabled) fallback. */ +private fun activePyrightTool(project: Project): PyLspTool<*> { + val tools = pyrightFamilyTools() + return tools.firstOrNull { it.isEnabledOn(project) } ?: tools.first() +} + +/** + * Re-evaluate which pyright-family server should serve [project]: restart the shared + * [PyrightLspIntegrationProvider] clients while either tool is enabled (so the rebuilt descriptor binds to + * the now-active one), otherwise stop them. Called from each pyright-family tool's `onEnabledChanged`. + */ +internal fun restartOrStopPyrightProvider(project: Project) { + val manager = LspClientManager.getInstance(project) + if (pyrightFamilyTools().any { it.isEnabledOn(project) }) manager.stopAndRestartClientsIfNeeded() + else manager.stopClients() +} + +class PyrightLspIntegrationProvider : PyLspToolIntegrationProvider() { + override fun getDescriptor(module: Module): PyLspToolDescriptor = + PyrightLspClientDescriptor(module) +} + +class PyrightLspClientDescriptor(module: Module) : PyLspToolDescriptor( + module = module, + pyTool = activePyrightTool(module.project), +) { + + init { + // Send empty settings to make pyright LSP work properly + LspClientManager.getInstance(project).addListener(object : LspClientManagerListener { + override fun serverStateChanged(lspClient: LspClient) { + if (lspClient.descriptor !is PyrightLspClientDescriptor) return + + if (lspClient.state == LspServerState.Running) { + lspClient.sendNotification { server -> + server.workspaceService.didChangeConfiguration(DidChangeConfigurationParams(JsonObject())) + } + } + } + }, PythonPluginDisposable.getInstance(project)) + } + + override fun createInitializationOptions(): Any { + return JsonObject() + } + + override val executableName: String + get() = "${pyTool.packageName.name}-langserver" + + override fun lspArguments(): List { + return listOf("server", "--stdio") + } + + override val toolConfig: PyLspToolSettings + get() = pyTool.configuration(project) + + override val commandDescriptions: Map = mapOf( + "pyright.createtypestub" to null, + "pyright.restartserver" to "Restart server", + "basedpyright.createtypestub" to null, + "basedpyright.restartserver" to "Restart server", + "basedpyright.writeBaseline" to "Write new errors to baseline", + ) +} diff --git a/python/python-pyright/src/com/intellij/python/pyright/PyrightPyTool.kt b/python/python-pyright/src/com/intellij/python/pyright/PyrightPyTool.kt new file mode 100644 index 000000000000..496a7e5776cc --- /dev/null +++ b/python/python-pyright/src/com/intellij/python/pyright/PyrightPyTool.kt @@ -0,0 +1,38 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.pyright + +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.python.pytools.PyTool +import com.intellij.python.pytools.lsp.PyLspTool +import com.intellij.python.pytools.configuration.ConfigurablePyTool +import com.intellij.python.pytools.ui.pyLspToolFeaturesSummary +import com.jetbrains.python.packaging.PyPackageName +import org.jetbrains.annotations.ApiStatus +import javax.swing.Icon + +/** + * [Pyright](https://microsoft.github.io/pyright/) — a fast static type checker for Python from + * Microsoft, providing type checking and language-server features such as completions and hovers. + */ +@ApiStatus.Internal +class PyrightPyTool : PyLspTool(), ConfigurablePyTool { + override val presentableName: String = "Pyright" + override val description: String get() = PyrightBundle.message("pyright.tool.description") + override val packageName: PyPackageName = PyPackageName.from("pyright") + + override fun configuration(project: Project): PyrightConfiguration = project.service() + + override val icon: Icon = PyrightUtil.getDefaultPyrightIcon() + + override fun createConfigurable(project: Project): PyrightConfigurable = PyrightConfigurable(project) + + override fun summaryFor(project: Project): String = pyLspToolFeaturesSummary(configuration(project)) + + override fun onEnabledChanged(project: Project, enabled: Boolean): Unit = restartOrStopPyrightProvider(project) + + @Suppress("CompanionObjectInExtension") + companion object { + fun getInstance(): PyrightPyTool = PyTool.EP_NAME.findExtensionOrFail(PyrightPyTool::class.java) + } +} diff --git a/python/python-pyright/src/com/intellij/python/pyright/PyrightUtil.kt b/python/python-pyright/src/com/intellij/python/pyright/PyrightUtil.kt new file mode 100644 index 000000000000..d0e49977b2f6 --- /dev/null +++ b/python/python-pyright/src/com/intellij/python/pyright/PyrightUtil.kt @@ -0,0 +1,14 @@ +package com.intellij.python.pyright + +import com.intellij.python.pyright.icons.PythonPyrightIcons +import com.intellij.util.IconUtil +import javax.swing.Icon + +object PyrightUtil { + fun getDefaultPyrightIcon(): Icon { + return IconUtil.resizeSquared(PythonPyrightIcons.Pyright, 16) + } + fun getDefaultBasedPyrightIcon(): Icon { + return IconUtil.resizeSquared(PythonPyrightIcons.Basedpyright, 16) + } +} \ No newline at end of file diff --git a/python/python-ruff/BUILD.bazel b/python/python-ruff/BUILD.bazel new file mode 100644 index 000000000000..0ed1b6bec22e --- /dev/null +++ b/python/python-ruff/BUILD.bazel @@ -0,0 +1,125 @@ +load("@rules_jvm//:jvm.bzl", "jvm_library") + +### auto-generated section `build intellij.python.ruff` start + +jvm_library( + name = "ruff", + srcs = glob( + [ + "src/**/*.kt", + "src/**/*.java", + "src/**/*.form", + "gen/**/*.kt", + "gen/**/*.java", + ], + allow_empty = True, + ), + module_name = "intellij.python.ruff", + resource_strip_prefix = "resources", + resources = glob( + ["resources/**/*"], + ), + visibility = ["//visibility:public"], + deps = [ + # do not sort, + "//platform/editor-ui-api:editor-ui", + "//platform/lang-impl", + "//python:python-community-impl", + "//python/python-sdk:sdk", + "//python/python-core-impl:community-core-impl", + "//platform/core-impl", + "//platform/code-style-impl:codeStyle-impl", + "//platform/editor-ui-ex:editor-ex", + "//platform/platform-impl:ide-impl", + "//platform/ide-core", + "//xml/xml-parser:parser", + "//platform/core-ui", + "//xml/impl", + "//platform/util/jdom", + "//platform/util:util-ui", + "//platform/smRunner", + "//libraries/guava", + "//libraries/fastutil", + "//platform/projectModel-api:projectModel", + "//platform/projectModel-impl", + "//platform/lsp", + "//platform/core-api:core", + "@lib//:eclipse-lsp4j", + "@lib//:eclipse-lsp4j-jsonrpc", + "//platform/analysis-api:analysis", + "//platform/code-style-api:codeStyle", + "//python/python-lsp-core:lsp-core", + "//python/python-pytools-ui:pytools-ui", + "//plugins/toml/core", + "//libraries/gson", + "//platform/indexing-api:indexing", + "//platform/markdown-utils", + "//python/python-pyproject:pyproject", + "//platform/lang-api:lang", + "//python/python-pytools:pytools", + "//python/python-exec-service:community-execService", + ], +) + +jvm_library( + name = "ruff_test_lib", + testonly = True, + srcs = glob( + [], + allow_empty = True, + ), + module_name = "intellij.python.ruff", + visibility = ["//visibility:public"], + runtime_deps = [ + # do not sort, + ":ruff", + "//platform/editor-ui-api:editor-ui_test_lib", + "//platform/lang-impl:lang-impl_test_lib", + "//python:python-community-impl_test_lib", + "//python/python-sdk:sdk_test_lib", + "//python/python-core-impl:community-core-impl_test_lib", + "//platform/core-impl:core-impl_test_lib", + "//platform/code-style-impl:codeStyle-impl_test_lib", + "//platform/editor-ui-ex:editor-ex_test_lib", + "//platform/platform-impl:ide-impl_test_lib", + "//platform/ide-core:ide-core_test_lib", + "//xml/xml-parser:parser_test_lib", + "//platform/core-ui:core-ui_test_lib", + "//xml/impl:impl_test_lib", + "//platform/util/jdom:jdom_test_lib", + "//platform/util:util-ui_test_lib", + "//platform/smRunner:smRunner_test_lib", + "//libraries/guava:guava_test_lib", + "//libraries/fastutil:fastutil_test_lib", + "//platform/projectModel-api:projectModel_test_lib", + "//platform/projectModel-impl:projectModel-impl_test_lib", + "//platform/lsp:lsp_test_lib", + "//platform/testFramework", + "//platform/testFramework:testFramework_test_lib", + "//platform/lsp/testFramework", + "//platform/lsp/testFramework:testFramework_test_lib", + "//platform/core-api:core_test_lib", + "//platform/analysis-api:analysis_test_lib", + "//platform/code-style-api:codeStyle_test_lib", + "//python/python-lsp-core:lsp-core_test_lib", + "//python/python-pytools-ui:pytools-ui_test_lib", + "//plugins/toml/core:core_test_lib", + "//libraries/gson:gson_test_lib", + "//platform/indexing-api:indexing_test_lib", + "//platform/markdown-utils:markdown-utils_test_lib", + "//python/python-pyproject:pyproject_test_lib", + "//platform/lang-api:lang_test_lib", + "//python/python-pytools:pytools_test_lib", + "//python/python-exec-service:community-execService_test_lib", + ], +) +### auto-generated section `build intellij.python.ruff` end + +### auto-generated section `iml intellij.python.ruff` start +exports_files( + [ + "intellij.python.ruff.iml", + ], + visibility = ["//visibility:public"], +) +### auto-generated section `iml intellij.python.ruff` end diff --git a/python/python-ruff/OWNERSHIP b/python/python-ruff/OWNERSHIP new file mode 100644 index 000000000000..e5e207deb924 --- /dev/null +++ b/python/python-ruff/OWNERSHIP @@ -0,0 +1,2 @@ +owner 'PyCharm Codeinsight' + diff --git a/python/python-ruff/gen/com/intellij/python/ruff/icons/PythonRuffIcons.java b/python/python-ruff/gen/com/intellij/python/ruff/icons/PythonRuffIcons.java new file mode 100644 index 000000000000..4b244969a731 --- /dev/null +++ b/python/python-ruff/gen/com/intellij/python/ruff/icons/PythonRuffIcons.java @@ -0,0 +1,17 @@ +package com.intellij.python.ruff.icons; + +import com.intellij.ui.IconManager; +import org.jetbrains.annotations.NotNull; + +import javax.swing.*; + +/** + * NOTE THIS FILE IS AUTO-GENERATED + * DO NOT EDIT IT BY HAND, run "Generate icon classes" configuration instead + */ +public final class PythonRuffIcons { + private static @NotNull Icon load(@NotNull String path, int cacheKey, int flags) { + return IconManager.getInstance().loadRasterizedIcon(path, PythonRuffIcons.class.getClassLoader(), cacheKey, flags); + } + /** 100x100 */ public static final @NotNull Icon Ruff = load("icons/ruff.svg", 19919750, 2); +} diff --git a/python/python-ruff/intellij.python.ruff.iml b/python/python-ruff/intellij.python.ruff.iml new file mode 100644 index 000000000000..33bd1241d9ab --- /dev/null +++ b/python/python-ruff/intellij.python.ruff.iml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/python/python-ruff/resources/icons/ruff.svg b/python/python-ruff/resources/icons/ruff.svg new file mode 100644 index 000000000000..b12b3dea2691 --- /dev/null +++ b/python/python-ruff/resources/icons/ruff.svg @@ -0,0 +1,4 @@ + + + + diff --git a/python/python-ruff/resources/icons/ruff_dark.svg b/python/python-ruff/resources/icons/ruff_dark.svg new file mode 100644 index 000000000000..b12b3dea2691 --- /dev/null +++ b/python/python-ruff/resources/icons/ruff_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/python/python-ruff/resources/inlayProviders/RuffSuppressionCodes/preview.py b/python/python-ruff/resources/inlayProviders/RuffSuppressionCodes/preview.py new file mode 100644 index 000000000000..a55d2a7ed5b7 --- /dev/null +++ b/python/python-ruff/resources/inlayProviders/RuffSuppressionCodes/preview.py @@ -0,0 +1,3 @@ +# ruff: ignore: E101/*<# mixed-spaces-and-tabs #>*/, E111/*<# indentation-with-invalid-multiple #>*/ + +print() # noqa: T201/*<# print #>*/ \ No newline at end of file diff --git a/python/python-ruff/resources/inlayProviders/RuffTomlCodes/preview.toml b/python/python-ruff/resources/inlayProviders/RuffTomlCodes/preview.toml new file mode 100644 index 000000000000..9b1a09d08288 --- /dev/null +++ b/python/python-ruff/resources/inlayProviders/RuffTomlCodes/preview.toml @@ -0,0 +1,5 @@ +[lint] +select = [ + "F"/*<# Pyflakes #>*/, + "E101"/*<# mixed-spaces-and-tabs #>*/, +] \ No newline at end of file diff --git a/python/python-ruff/resources/intellij.python.ruff.xml b/python/python-ruff/resources/intellij.python.ruff.xml new file mode 100644 index 000000000000..1ddacd689947 --- /dev/null +++ b/python/python-ruff/resources/intellij.python.ruff.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/python/python-ruff/resources/messages/RuffBundle.properties b/python/python-ruff/resources/messages/RuffBundle.properties new file mode 100644 index 000000000000..3fd2929f505c --- /dev/null +++ b/python/python-ruff/resources/messages/RuffBundle.properties @@ -0,0 +1,15 @@ +ruff.suppression.inlay.hints.name=Ruff error code names +ruff.tool.description=An extremely fast Python linter and code formatter, written in Rust. +ruff.toml.inlay.hints.name=Ruff error code names +intention.name.disable.for.this.project=Disable for this project +intention.family.name.disable.ruff.rule=Disable Ruff rule +command.name.ruff.disable.rule=Ruff Disable Rule +intention.family.name.disable.ruff.rule.for.entire.file=Disable Ruff rule for the entire file +intention.name.disable.for.this.file=Disable for this file +command.name.append.ruff.rule.to.noqa.comment=Append Ruff Rule to Noqa Comment +command.name.add.ruff.noqa.comment=Add Ruff Noqa Comment +inspection.message.ruff.config.error=Ruff config error: {0} +checkbox.formatting=Formatting +checkbox.import.optimizer=Import optimizer +inspection.message.ruff.rule.documentation=Ruff rule documentation +intention.name.disable.for.this.line=Disable for this line \ No newline at end of file diff --git a/python/python-ruff/src/com/intellij/python/ruff/RuffBundle.kt b/python/python-ruff/src/com/intellij/python/ruff/RuffBundle.kt new file mode 100644 index 000000000000..e2c24ec34180 --- /dev/null +++ b/python/python-ruff/src/com/intellij/python/ruff/RuffBundle.kt @@ -0,0 +1,18 @@ +package com.intellij.python.ruff + +import com.intellij.DynamicBundle +import org.jetbrains.annotations.Nls +import org.jetbrains.annotations.NonNls +import org.jetbrains.annotations.PropertyKey + +object RuffBundle { + private val myDynamicBundle = DynamicBundle(this::class.java, BUNDLE) + + @NonNls + const val BUNDLE: String = "messages.RuffBundle" + + @Nls + fun message(@PropertyKey(resourceBundle = BUNDLE) key: String, vararg params: Any): String { + return myDynamicBundle.getMessage(key, *params) + } +} diff --git a/python/python-ruff/src/com/intellij/python/ruff/RuffConfigurable.kt b/python/python-ruff/src/com/intellij/python/ruff/RuffConfigurable.kt new file mode 100644 index 000000000000..9629e80c93d5 --- /dev/null +++ b/python/python-ruff/src/com/intellij/python/ruff/RuffConfigurable.kt @@ -0,0 +1,52 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.ruff + +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.project.Project +import com.intellij.python.pytools.lsp.LSP_TOOLS_STORAGE_FILE +import com.intellij.python.pytools.lsp.PyLspToolConfiguration +import com.intellij.python.pytools.lsp.PyLspToolSettings +import com.intellij.python.pytools.ui.PyLspToolDetailConfigurable +import com.intellij.ui.dsl.builder.Panel +import com.intellij.ui.dsl.builder.bindSelected +import com.intellij.util.xmlb.XmlSerializerUtil + +/** + * Project-wide Ruff detail configurable. + * + * Path / discovery mode / enabled state live in [com.intellij.python.pytools.PyToolsState] and are edited from the + * `External Tools` table; this dialog only exposes the tool-specific feature toggles. + */ +class RuffConfigurable(project: Project) : + PyLspToolDetailConfigurable(project, RuffPyTool.getInstance()) { + + override fun Panel.extraRows() { + row("") { + checkBox(RuffBundle.message("checkbox.formatting")).bindSelected(settings::formatting) + } + row("") { + checkBox(RuffBundle.message("checkbox.import.optimizer")).bindSelected(settings::sortImports) + } + } +} + +interface RuffSettings : PyLspToolSettings { + var sortImports: Boolean + var formatting: Boolean +} + +@Service(Service.Level.PROJECT) +@State( + name = "RuffConfiguration", + storages = [Storage(LSP_TOOLS_STORAGE_FILE)] +) +data class RuffConfiguration( + override var sortImports: Boolean = true, + override var formatting: Boolean = true, +) : PyLspToolConfiguration(), RuffSettings { + override fun loadState(state: RuffConfiguration) { + XmlSerializerUtil.copyBean(state, this) + } +} diff --git a/python/python-ruff/src/com/intellij/python/ruff/RuffImportOptimizer.kt b/python/python-ruff/src/com/intellij/python/ruff/RuffImportOptimizer.kt new file mode 100644 index 000000000000..18ac0db965b4 --- /dev/null +++ b/python/python-ruff/src/com/intellij/python/ruff/RuffImportOptimizer.kt @@ -0,0 +1,96 @@ +package com.intellij.python.ruff + +import com.intellij.lang.ImportOptimizer +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.editor.impl.DocumentImpl +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.platform.lsp.api.LspClientManager +import com.intellij.platform.lsp.api.getClients +import com.intellij.platform.lsp.util.applyTextEdits +import com.intellij.platform.lsp.util.getLsp4jRange +import com.intellij.psi.PsiFile +import com.intellij.python.ruff.server.RuffLspIntegrationProvider +import org.eclipse.lsp4j.CodeActionContext +import org.eclipse.lsp4j.CodeActionKind.SourceOrganizeImports +import org.eclipse.lsp4j.CodeActionParams +import org.eclipse.lsp4j.CodeActionTriggerKind + +private val LOG = logger() + +/** + * Import optimizer that uses the Ruff LSP server to optimize imports in Python files. + * + */ +class RuffImportOptimizer : ImportOptimizer { + override fun supports(psiFile: PsiFile): Boolean { + val virtualFile = psiFile.virtualFile ?: return false + val project = psiFile.project + + val toolConfig = project.service() + if (!toolConfig.sortImports) return false + + val lspServerManager = LspClientManager.getInstance(project) + val servers = lspServerManager.getClients() + return servers.any { it.descriptor.isSupportedFile(virtualFile) } + } + + override fun processFile(psiFile: PsiFile): Runnable { + val noResult = Runnable { } + val virtualFile = psiFile.virtualFile ?: return noResult + val project = psiFile.project + + val lspServerManager = LspClientManager.getInstance(project) + val servers = lspServerManager.getClients() + + if (servers.isEmpty()) { + LOG.warn("No Ruff LSP server found for file: ${virtualFile.path}") + return noResult + } + + val document = FileDocumentManager.getInstance().getDocument(virtualFile) ?: return noResult + + val server = servers.first { it.descriptor.isSupportedFile(virtualFile) } + + val range = getLsp4jRange(document, 0, document.textLength) + + val params = CodeActionParams( + server.getDocumentIdentifier(virtualFile), + range, + CodeActionContext().apply { + diagnostics = emptyList() + triggerKind = CodeActionTriggerKind.Invoked + only = listOf(SourceOrganizeImports) + } + ) + + val tempDocument = DocumentImpl(document.text, false, true) + + val codeActions = server.sendRequestSync { it.textDocumentService.codeAction(params) } ?: return noResult + + var updated = false + + for (either in codeActions) { + val codeAction = either.right ?: continue + if (codeAction.data == null) continue + + val resolvedAction = server.sendRequestSync { it.textDocumentService.resolveCodeAction(codeAction) } ?: continue + + if (resolvedAction.edit == null) continue + val changes = resolvedAction.edit?.documentChanges ?: continue + for (change in changes) { + if (!change.isLeft) continue + val textDocumentEdit = change.left ?: continue + val edits = textDocumentEdit.edits + + updated = true + applyTextEdits(tempDocument, edits) + } + } + return Runnable { + if (updated) { + document.setText(tempDocument.text) + } + } + } +} diff --git a/python/python-ruff/src/com/intellij/python/ruff/RuffPyTool.kt b/python/python-ruff/src/com/intellij/python/ruff/RuffPyTool.kt new file mode 100644 index 000000000000..3a7216a8d8b8 --- /dev/null +++ b/python/python-ruff/src/com/intellij/python/ruff/RuffPyTool.kt @@ -0,0 +1,64 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.ruff + +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.platform.lsp.api.LspClientManager +import com.intellij.python.pytools.PyTool +import com.intellij.python.pytools.statistics.PyToolFusSnapshot +import com.intellij.python.pytools.lsp.PyLspTool +import com.intellij.python.pytools.configuration.ConfigurablePyTool +import com.intellij.python.pytools.ui.PyToolsUiBundle +import com.intellij.python.ruff.server.RuffLspIntegrationProvider +import com.jetbrains.python.packaging.PyPackageName +import org.jetbrains.annotations.ApiStatus +import javax.swing.Icon + +/** + * [Ruff](https://docs.astral.sh/ruff/) — an extremely fast Python linter and code formatter written in + * Rust by Astral. It combines linting, import sorting, and formatting in a single tool, aiming to + * replace Flake8, isort, pyupgrade, and Black. + */ +@ApiStatus.Internal +class RuffPyTool : PyLspTool(), ConfigurablePyTool { + override val presentableName: String = "Ruff" + override val description: String get() = RuffBundle.message("ruff.tool.description") + override val packageName: PyPackageName = PyPackageName.from("ruff") + + override fun configuration(project: Project): RuffConfiguration = project.service() + + override val icon: Icon = RuffUtil.getDefaultRuffIcon() + + override fun createConfigurable(project: Project): RuffConfigurable = RuffConfigurable(project) + + override fun summaryFor(project: Project): String { + val cfg = configuration(project) + return buildList { + if (cfg.inspections) add(PyToolsUiBundle.message("checkbox.inspections")) + if (cfg.formatting) add(RuffBundle.message("checkbox.formatting")) + if (cfg.sortImports) add(RuffBundle.message("checkbox.import.optimizer")) + if (cfg.completions == true) add(PyToolsUiBundle.message("checkbox.completions")) + if (cfg.inlayHints == true) add(PyToolsUiBundle.message("checkbox.inlay.hints")) + if (cfg.documentation == true) add(PyToolsUiBundle.message("checkbox.documentation")) + }.joinToString(", ") + } + + override fun onEnabledChanged(project: Project, enabled: Boolean) { + val manager = LspClientManager.getInstance(project) + if (enabled) manager.startClientsIfNeeded(RuffLspIntegrationProvider::class.java) + else manager.stopClients(RuffLspIntegrationProvider::class.java) + } + + override fun configurationFusSnapshot(project: Project): PyToolFusSnapshot { + val cfg = configuration(project) + return super.configurationFusSnapshot(project).copy( + formatting = cfg.formatting, + sortImports = cfg.sortImports, + ) + } + + @Suppress("CompanionObjectInExtension") + companion object { + fun getInstance(): RuffPyTool = PyTool.EP_NAME.findExtensionOrFail(RuffPyTool::class.java) + } +} diff --git a/python/python-ruff/src/com/intellij/python/ruff/RuffService.kt b/python/python-ruff/src/com/intellij/python/ruff/RuffService.kt new file mode 100644 index 000000000000..af27d6e085b7 --- /dev/null +++ b/python/python-ruff/src/com/intellij/python/ruff/RuffService.kt @@ -0,0 +1,242 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.ruff + +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.Project +import com.intellij.python.community.execService.Args +import com.intellij.python.pytools.executeOn +import com.jetbrains.python.NON_INTERACTIVE_ROOT_TRACE_CONTEXT +import com.jetbrains.python.orLogException +import com.jetbrains.python.sdk.ModuleOrProject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.intellij.lang.annotations.Language +import org.jetbrains.annotations.Nls + +/** + * Information about a Ruff configuration option. + */ +data class RuffConfigOptionInfo( + /** + * The documentation for the config option. + */ + @param:Nls val doc: String, + + /** + * The default value for the config option. + */ + @param:Nls val default: String, + + /** + * The type of value expected for the config option. + */ + @param:Nls val valueType: String, + + /** + * The scope of the config option. + */ + @param:Nls val scope: String?, + + /** + * An example of how to use the config option. + */ + @param:Nls val example: String, + + /** + * Whether the config option is deprecated. + */ + @param:Nls val deprecated: String?, +) + +/** + * Information about a Ruff rule. + */ +data class RuffRuleInfo( + @param:Nls val name: String, + @param:Nls val code: String, + @param:Nls val linter: String, + val summary: String, + val fix: String, + /** + * formatted in Markdown + */ + val explanation: String, + val preview: Boolean, +) + +/** + * Service for fetching and storing Ruff configuration options and rule information. + */ +@Service(Service.Level.PROJECT) +class RuffService(val project: Project, val cs: CoroutineScope) { + private var _configOptions: Map? = null + + var configOptions: Map + get() { + _configOptions?.let { return it } + cs.launch(NON_INTERACTIVE_ROOT_TRACE_CONTEXT) { gatherConfigOptionInformation() } + return _configOptions ?: emptyMap() + } + private set(value) { + _configOptions = value + } + + private var _configOptionGroups: Set? = null + var configOptionGroups: Set + get() { + _configOptionGroups?.let { return it } + cs.launch(NON_INTERACTIVE_ROOT_TRACE_CONTEXT) { gatherConfigOptionInformation() } + return _configOptionGroups ?: emptySet() + } + private set(value) { + _configOptionGroups = value + } + + private var _ruleInformation: Map? = null + var ruleInformation: Map + get() { + _ruleInformation?.let { return it } + cs.launch(NON_INTERACTIVE_ROOT_TRACE_CONTEXT) { gatherRuleInformation() } + return _ruleInformation ?: emptyMap() + } + private set(value) { + _ruleInformation = value + } + + private var _linterInformation: Map? = null + var linterInformation: Map + get() { + _linterInformation?.let { return it } + cs.launch(NON_INTERACTIVE_ROOT_TRACE_CONTEXT) { gatherRuleInformation() } + return _linterInformation ?: emptyMap() + } + private set(value) { + _linterInformation = value + } + + companion object { + private val LOG = logger() + } + + /** + * Fetches all Ruff configuration options from the Ruff executable. + * + * @return A map of option paths to their information, or an empty map if fetching fails. + */ + suspend fun gatherConfigOptionInformation() { + val output = RuffPyTool.getInstance().executeOn( + ModuleOrProject.ProjectOnly(project), + Args("config", "--output-format=json") + ).orLogException(LOG) + + output?.let { loadConfigOptionInformation(output) } + } + + /** + * Parses the JSON output from the Ruff config command containing all configuration options. + * + * @param jsonString The JSON output from the Ruff config command. + * @return A map of option paths to their information, or an empty map if parsing fails. + */ + fun loadConfigOptionInformation(@Language("JSON") jsonString: String) { + try { + val jsonElement = JsonParser.parseString(jsonString) + val jsonObject = jsonElement as? JsonObject ?: return + + configOptions = buildMap { + jsonObject.entrySet().forEach { (key, value) -> + processConfigOption(key, value, this) + } + } + configOptionGroups = configOptions.keys.asSequence() + .filter { "." in it } + .map { option -> option.dropLastWhile { it != '.' }.dropLast(1) } + .toSet() + } + catch (e: Exception) { + LOG.warn("Error parsing Ruff config options JSON", e) + } + } + + /** + * Processes a configuration option from the JSON output. + * + * @param path The path to the configuration option. + * @param value The JSON value for the configuration option. + * @param result The map to add the processed option to. + */ + private fun processConfigOption(path: String, value: JsonElement, result: MutableMap) { + if (value !is JsonObject) { + return + } + + // Check if this is a config option, or a nested object + if (!value.has("doc")) return + + try { + result[path] = RuffConfigOptionInfo( + doc = value.get("doc").asString, + default = value.get("default")?.let { if (it.isJsonNull) "" else it.asString } ?: "", + valueType = value.get("value_type")?.let { if (it.isJsonNull) "" else it.asString } ?: "", + scope = value.get("scope")?.let { if (it.isJsonNull) null else it.asString }, + example = value.get("example")?.let { if (it.isJsonNull) "" else it.asString } ?: "", + deprecated = value.get("deprecated")?.let { + if (it.isJsonNull) null + else + it.asJsonObject.get("message").asString + } + ) + } + catch (e: Exception) { + LOG.warn("Error parsing config option at path $path", e) + } + value.entrySet().forEach { (nestedKey, nestedValue) -> + if (nestedValue is JsonObject) { + processConfigOption("$path.$nestedKey", nestedValue, result) + } + } + } + + /** + * Gathers rule information from the Ruff executable. + */ + suspend fun gatherRuleInformation() { + val output = RuffPyTool.getInstance().executeOn( + ModuleOrProject.ProjectOnly(project), + Args("rule", "--output-format", "json", "--all") + ).orLogException(LOG) + + output?.let { loadRuleInformation(it) } + } + + /** + * Loads rule information from a JSON string. + * This method is primarily used for testing. + * + * @param jsonString The JSON string containing rule information. + */ + fun loadRuleInformation(@Language("JSON") jsonString: String) { + val jsonArray = JsonParser.parseString(jsonString) as? JsonArray ?: return + ruleInformation = jsonArray.associate { item -> + item as JsonObject + val code = item.get("code").asString + code to RuffRuleInfo( + name = item.get("name").asString, + code = code, + linter = item.get("linter").asString, + summary = item.get("summary").asString, + fix = item.get("fix").asString, + explanation = item.get("explanation").asString, + preview = item.get("preview").asBoolean, + ) + } + linterInformation = ruleInformation.entries.associate { (key, value) -> + key.takeWhile { it.isLetter() } to value.linter + } + } +} \ No newline at end of file diff --git a/python/python-ruff/src/com/intellij/python/ruff/RuffUtil.kt b/python/python-ruff/src/com/intellij/python/ruff/RuffUtil.kt new file mode 100644 index 000000000000..f8b6aff9d352 --- /dev/null +++ b/python/python-ruff/src/com/intellij/python/ruff/RuffUtil.kt @@ -0,0 +1,13 @@ +package com.intellij.python.ruff + +import com.intellij.python.ruff.icons.PythonRuffIcons +import com.intellij.util.IconUtil +import javax.swing.Icon + +object RuffUtil { + const val RUFF_WEBSITE: String = "https://docs.astral.sh/ruff" + + fun getDefaultRuffIcon(): Icon { + return IconUtil.resizeSquared(PythonRuffIcons.Ruff, 16) + } +} diff --git a/python/python-ruff/src/com/intellij/python/ruff/codeinsight/PatternUtil.kt b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/PatternUtil.kt new file mode 100644 index 000000000000..674e14d83b6e --- /dev/null +++ b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/PatternUtil.kt @@ -0,0 +1,22 @@ +package com.intellij.python.ruff.codeinsight + +import com.intellij.patterns.PatternCondition +import com.intellij.patterns.PlatformPatterns +import com.intellij.patterns.PsiElementPattern +import com.intellij.psi.PsiElement +import com.intellij.util.ProcessingContext + +inline fun PsiElementPattern.Capture.withParent(): PsiElementPattern.Capture = + withParent(T::class.java) as PsiElementPattern.Capture + + +inline fun psiElement(): PsiElementPattern.Capture = + PlatformPatterns.psiElement(T::class.java) + +inline fun PsiElementPattern.Capture.with( + debugMethodName: String? = null, + crossinline accept: PatternCondition.(T, ProcessingContext?) -> Boolean +): PsiElementPattern.Capture = + with(object : PatternCondition(debugMethodName) { + override fun accepts(t: T, context: ProcessingContext?) = accept(t, context) + }) diff --git a/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffConfigExternalAnnotator.kt b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffConfigExternalAnnotator.kt new file mode 100644 index 000000000000..d5efce1e7c3f --- /dev/null +++ b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffConfigExternalAnnotator.kt @@ -0,0 +1,158 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.ruff.codeinsight + +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.module.ModuleUtilCore +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.NlsSafe +import com.intellij.openapi.util.TextRange +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiFile +import com.intellij.python.ruff.RuffBundle +import com.jetbrains.python.sdk.getExecutablePath +import com.jetbrains.python.sdk.pythonSdk +import org.toml.lang.psi.TomlFile +import java.io.BufferedReader +import java.io.File +import java.io.InputStreamReader +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.absolutePathString +import kotlin.io.path.div +import kotlin.io.path.writeText + +/** + * External annotator for Ruff config files (.ruff.toml, ruff.toml, and pyproject.toml). + */ +class RuffConfigExternalAnnotator : ExternalAnnotator() { + + companion object { + private val LOG = logger() + + } + + data class State( + val project: Project, + val file: PsiFile, + val virtualFile: VirtualFile, + val tempFile: Path, + ) + + class Result( + val errorLine: Int = -1, + val errorColumn: Int = -1, + val errorWidth: Int = -1, + val errorMessage: String = "", + ) + + /** + * Collects information from the file to be used for annotation. + */ + override fun collectInformation(file: PsiFile): State? { + // Only process TOML files + if (file !is TomlFile) return null + + if (!file.isRuffConfigFile) return null + + val virtualFile = file.virtualFile ?: return null + + val tempDir = Files.createTempDirectory(null) + val tempFile = tempDir / file.name + tempFile.writeText(file.text) + return State(file.project, file, virtualFile, tempFile) + } + + /** + * Executes the external tool and processes its output. + */ + override fun doAnnotate(state: State): Result? { + val module = ModuleUtilCore.findModuleForFile(state.virtualFile, state.project) + val sdk = module?.pythonSdk ?: state.project.pythonSdk ?: return null + + val ruffExecutable = sdk.getExecutablePath("ruff") ?: return null.also { + LOG.info("Could not find ruff executable in SDK: ${sdk.name}") + } + + val workingDir = state.virtualFile.parent.path + + val processBuilder = ProcessBuilder(ruffExecutable.absolutePathString(), "check", "?", "--config", state.tempFile.absolutePathString()) + .directory(File(workingDir)) + .redirectErrorStream(true) + + try { + val process = processBuilder.start() + val output = BufferedReader(InputStreamReader(process.inputStream)).use { it.readText() } + + return parseOutput(output) + } + catch (e: Exception) { + LOG.warn("Error executing ruff check ? command", e) + return null + } + } + + private fun parseOutput(output: String): Result? { + if (SUCCESS_PATTERN matches output) { + return null + } + + val match = ERROR_PATTERN.find(output) ?: return null.also { + LOG.info("Could not parse `ruff check ?` output: $output") + } + + return Result( + errorLine = match.groups[1]!!.value.toInt(), + errorColumn = match.groups[2]!!.value.toInt(), + errorWidth = match.groups[3]!!.value.length, + errorMessage = match.groups[4]!!.value + ) + } + + override fun apply(file: PsiFile, result: Result?, holder: AnnotationHolder) { + if (result == null) return + + val document = file.viewProvider.document ?: return + + val lineStartOffset = document.getLineStartOffset(result.errorLine - 1) + + val startOffset = lineStartOffset + result.errorColumn - 1 + val endOffset = startOffset + result.errorWidth + + val message = RuffBundle.message("inspection.message.ruff.config.error", result.errorMessage) + holder.newAnnotation(HighlightSeverity.ERROR, message) + .range(TextRange(startOffset, endOffset)) + .tooltip(buildTooltip(result)) + .create() + + } + + @NlsSafe + private fun buildTooltip(result: Result): String { + return """ + + +

Ruff config error: ${result.errorMessage}

+ + + """.trimIndent() + } +} + +private val ERROR_PATTERN = Regex( + """ + ruff failed + (?:Cause: Failed to load configuration `.+` + )?Cause: Failed to parse .+ + Cause: TOML parse error at line (\d+), column (\d+) + \s*\| + \d+ \| .* + \s*\|\s+(\^+) + (.+) + """.trimIndent() +) + +// Pattern to match the success message from ruff check ? output +private val SUCCESS_PATTERN = Regex("\\?:1:1: E902 No such file or directory \\(os error 2\\)\\s+Found 1 error\\.") \ No newline at end of file diff --git a/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffConfigOptionCompletionContributor.kt b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffConfigOptionCompletionContributor.kt new file mode 100644 index 000000000000..b521a0bdcfa2 --- /dev/null +++ b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffConfigOptionCompletionContributor.kt @@ -0,0 +1,133 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.ruff.codeinsight + +import com.intellij.codeInsight.completion.BaseCompletionService +import com.intellij.codeInsight.completion.CompletionContributor +import com.intellij.codeInsight.completion.CompletionParameters +import com.intellij.codeInsight.completion.CompletionProcessEx +import com.intellij.codeInsight.completion.CompletionProvider +import com.intellij.codeInsight.completion.CompletionResultSet +import com.intellij.codeInsight.completion.CompletionType +import com.intellij.codeInsight.lookup.LookupElementBuilder +import com.intellij.openapi.components.service +import com.intellij.patterns.PlatformPatterns.psiElement +import com.intellij.psi.PsiElement +import com.intellij.python.pyproject.PY_PROJECT_TOML +import com.intellij.python.ruff.RuffService +import com.intellij.util.ProcessingContext +import org.toml.lang.psi.TomlFile +import org.toml.lang.psi.TomlKeySegment +import org.toml.lang.psi.TomlTable +import org.toml.lang.psi.TomlTableHeader +import kotlin.math.max + +/** + * Provides code completion for Ruff configuration options in TOML files. + */ +class RuffConfigOptionCompletionContributor : CompletionContributor() { + + init { + extend( + CompletionType.BASIC, + psiElement().inFile( + psiElement().withName(".ruff.toml", "ruff.toml", PY_PROJECT_TOML) + ), + RuffConfigOptionCompletionProvider() + ) + } +} + +/** + * Completion provider for Ruff configuration options. + * Provides completion items with both the option name and its documentation. + */ +private class RuffConfigOptionCompletionProvider : CompletionProvider() { + override fun addCompletions(parameters: CompletionParameters, context: ProcessingContext, result: CompletionResultSet) { + val project = parameters.position.project + val ruffService = project.service() + + val configOptions = ruffService.configOptions + val configGroups = ruffService.configOptionGroups + + val position = parameters.position + + if (configGroups.isEmpty()) { + return + } + + /** + * Case 1: Completing inside a table header, e.g., [tool.ruff.lint.ru] + */ + fun completeInsideTableHeader(): Boolean { + var e: PsiElement? = position + while (e != null && e !is TomlTableHeader) e = e.parent + val header = e ?: return false + val key = header.key ?: return true + val full = key.text + if (header.containingFile.name == PY_PROJECT_TOML && !full.startsWith("tool.ruff")) return true + val ruffPathRaw = full.removePrefix("tool.ruff").trimStart('.') + val parts = if (ruffPathRaw.isEmpty()) emptyList() else ruffPathRaw.split('.') + val prefixParts = if (parts.isEmpty()) emptyList() else parts.dropLast(1) + + addGroupSegments(prefixParts, parameters, result, configGroups) + return true + } + + /** + * Case 2: Completing in the body of a \[tool.ruff...] table (new key at blank line, etc.) + */ + fun completeInTableBody(): Boolean { + var e: PsiElement? = position + if (position.parent !is TomlKeySegment) return false + while (e != null && e !is TomlTable) e = e.parent + val full = (position.parent as TomlKeySegment).fullName ?: return false + if (position.containingFile.name == PY_PROJECT_TOML && !full.startsWith("tool.ruff")) return false + + val ruffPathRaw = full.removePrefix("tool.ruff").trimStart('.') + val prefixParts = if (ruffPathRaw.isEmpty()) emptyList() else ruffPathRaw.split('.').dropLast(1) + + // First, groups (nested tables) + addGroupSegments(prefixParts, parameters, result, configGroups) + + // then keys (leaf options) + for ((path, configInfo) in configOptions) { + val parts = path.split('.') + if (parts.dropLast(1) == prefixParts) { + val segment = parts.last() + result.addElement( + LookupElementBuilder.create(segment) + .withCaseSensitivity(false) + .withTypeText(configInfo.valueType, true) + .withTailText(" default: ${configInfo.default}", true) + ) + } + } + return true + } + + + if (completeInsideTableHeader()) return + if (completeInTableBody()) return + } + + /** + * Helper to add next-level group segments under a given prefix + */ + private fun addGroupSegments(prefixParts: List, parameters: CompletionParameters, result: CompletionResultSet, configGroups: Set = emptySet()) { + // remove junk words + (parameters.process as CompletionProcessEx).putUserData(BaseCompletionService.FORBID_WORD_COMPLETION, true) + // prevent the default stuff from appearing + result.stopHere() + + for (option in configGroups) { + val optionParts = option.split('.') + if (optionParts.size == prefixParts.size + 1 && optionParts.take(max(0, prefixParts.size - 1)) == prefixParts.dropLast(1)) { + val segment = optionParts.last() + result.addElement( + LookupElementBuilder.create(segment) + .withCaseSensitivity(false) + ) + } + } + } +} diff --git a/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffConfigOptionCompletionDocumentationProvider.kt b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffConfigOptionCompletionDocumentationProvider.kt new file mode 100644 index 000000000000..dc7e5675b3b2 --- /dev/null +++ b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffConfigOptionCompletionDocumentationProvider.kt @@ -0,0 +1,38 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.ruff.codeinsight + +import com.intellij.codeInsight.lookup.LookupElement +import com.intellij.openapi.components.service +import com.intellij.platform.backend.documentation.DocumentationTarget +import com.intellij.platform.backend.documentation.LookupElementDocumentationTargetProvider +import com.intellij.psi.PsiFile +import com.intellij.python.ruff.RuffService +import org.toml.lang.psi.TomlKeySegment +import org.toml.lang.psi.TomlKeyValue +import org.toml.lang.psi.TomlTable + +/** + * Provides documentation for Ruff configuration option completion items. + * This provider serves the same documentation as [RuffConfigOptionDocumentationProvider] + * but for lookup elements in code completion. + */ +private class RuffConfigOptionCompletionDocumentationProvider : LookupElementDocumentationTargetProvider { + override fun documentationTarget(psiFile: PsiFile, element: LookupElement, offset: Int): DocumentationTarget? { + val anchorElement = (if (offset - 1 >= 0) psiFile.findElementAt(offset - 1) else psiFile.findElementAt(offset)) + val anchorParent = anchorElement?.parent + val configPath = when (anchorParent) { + is TomlKeySegment -> + anchorParent.fullName?.dropLastWhile { it != '.' } ?: return null + is TomlKeyValue -> + anchorParent.fullName + "." + is PsiFile if anchorElement.prevSibling is TomlTable -> + (anchorElement.prevSibling as TomlTable).header.key?.fullName?.plus(".") ?: return null + else -> + return null + } + element.lookupString + + val configInfo = psiFile.project.service().configOptions[configPath.removePrefix("tool.ruff.")] ?: return null + + return RuffConfigOptionDocumentationTarget(configPath, configInfo, psiFile.project) + } +} diff --git a/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffConfigOptionDocumentationProvider.kt b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffConfigOptionDocumentationProvider.kt new file mode 100644 index 000000000000..7e3f2e5e9844 --- /dev/null +++ b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffConfigOptionDocumentationProvider.kt @@ -0,0 +1,173 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.ruff.codeinsight + +import com.intellij.markdown.utils.doc.DocMarkdownToHtmlConverter +import com.intellij.model.Pointer +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.platform.backend.documentation.DocumentationResult +import com.intellij.platform.backend.documentation.DocumentationTarget +import com.intellij.platform.backend.documentation.DocumentationTargetProvider +import com.intellij.platform.backend.presentation.TargetPresentation +import com.intellij.psi.PsiFile +import com.intellij.python.ruff.RuffConfigOptionInfo +import com.intellij.python.ruff.RuffService +import com.intellij.python.ruff.RuffUtil +import org.jetbrains.annotations.Nls +import org.toml.lang.psi.TomlFile +import org.toml.lang.psi.TomlHeaderOwner +import org.toml.lang.psi.TomlKey +import org.toml.lang.psi.TomlKeySegment +import org.toml.lang.psi.TomlKeyValue +import org.toml.lang.psi.TomlTable +import org.toml.lang.psi.TomlTableHeader + +/** + * Provides documentation tooltips for Ruff configuration options in ruff config files. + * When hovering over a configuration option key in a ruff config file, this provider + * shows the option documentation fetched from the Ruff executable. + */ +class RuffConfigOptionDocumentationProvider : DocumentationTargetProvider { + override fun documentationTargets(file: PsiFile, offset: Int): List { + if (file !is TomlFile) { + return emptyList() + } + + if (!file.isRuffConfigFile) return emptyList() + + val element = file.findElementAt(offset) ?: return emptyList() + + val keySegment = element.parent as? TomlKeySegment ?: return emptyList() + + val configPath = keySegment.ruffConfigPath() ?: return emptyList() + + val configInfo = file.project.service().configOptions[configPath] ?: return emptyList() + + return listOf(RuffConfigOptionDocumentationTarget(configPath, configInfo, file.project)) + } + + /** + * Gets the full path to a key in a TOML file. + * + * @param keyValue The key-value pair. + * @return The full path to the key (e.g., "tool.ruff.lint.select"). + */ + private fun getFullPath(keyValue: TomlKeyValue): String { + val parent = keyValue.parent + + if (parent is TomlTable) { + val tableHeader = parent.header + val tableKey = tableHeader.key + + if (tableKey != null) { + return tableKey.text + "." + keyValue.key.text + } + } + + return keyValue.key.text + } +} + +/** + * Documentation target for a Ruff configuration option. + */ +class RuffConfigOptionDocumentationTarget( + @param:Nls private val configPath: String, + private val configInfo: RuffConfigOptionInfo, + private val project: Project, +) : DocumentationTarget { + override fun createPointer(): Pointer { + return Pointer.hardPointer(this) + } + + override fun computePresentation(): TargetPresentation { + return TargetPresentation.builder(configPath) + .icon(RuffUtil.getDefaultRuffIcon()) + .presentableText(configPath) + .presentation() + } + + override fun computeDocumentationHint(): @Nls String { + return formatConfigOptionDocumentationHint(configInfo) + } + + override fun computeDocumentation(): DocumentationResult { + val html = formatConfigOptionDocumentation(configInfo, project) + return DocumentationResult.documentation(html) + } + + /** + * Formats a hint for configuration option documentation. + * + * @param configInfo The configuration option information. + * @return A formatted hint for the documentation. + */ + @Nls + private fun formatConfigOptionDocumentationHint(configInfo: RuffConfigOptionInfo): String { + val firstLine = configInfo.doc.split("\n").firstOrNull() ?: "" + return "$configPath
$firstLine" + } + + /** + * Formats full documentation for a configuration option. + * + * @param configInfo The configuration option information. + * @param project The project. + * @return Formatted HTML documentation. + */ + @Nls + private fun formatConfigOptionDocumentation(configInfo: RuffConfigOptionInfo, project: Project): String { + @Suppress("HardCodedStringLiteral") + @Nls val markdown = buildString { + append("# ${configPath.split(".").last()}\n\n") + + if (configInfo.deprecated != null) { + append("**This option is deprecated**: ${configInfo.deprecated}\n\n") + } + + append(configInfo.doc) + append("\n\n") + + append("**Type:** `${configInfo.valueType}`\n\n") + + if (configInfo.default.isNotEmpty()) { + append("**Default:** `${configInfo.default}`\n\n") + } + + if (configInfo.scope != null) { + append("**Scope:** ${configInfo.scope}\n\n") + } + + if (configInfo.example.isNotEmpty()) { + append("**Example:**\n\n```toml\n${configInfo.example}\n```\n\n") + } + } + + return DocMarkdownToHtmlConverter.convert(project, markdown) + } +} + +val TomlKey.fullName: String + get() = parent.let { + when (it) { + is TomlKeyValue -> (it.parent as TomlTable).header.key!!.text + "." + text + is TomlTableHeader -> text + else -> error("invalid toml structure") + } + } + +/** + * gets the full name up until this KeySegment + */ +val TomlKeySegment.fullName: String? + get() = parent.parent.let { + when (it) { + is TomlKeyValue -> when (val ancestor = it.parent) { + is TomlHeaderOwner -> ancestor.header.key!!.text + "." + parent.text.take(textRangeInParent.endOffset) + is TomlFile -> parent.text.take(textRangeInParent.endOffset) + else -> null + } + is TomlTableHeader -> parent.text.take(textRangeInParent.endOffset) + else -> null + } + } diff --git a/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffConfigOptionGotoDeclarationHandler.kt b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffConfigOptionGotoDeclarationHandler.kt new file mode 100644 index 000000000000..3c00a640a3ee --- /dev/null +++ b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffConfigOptionGotoDeclarationHandler.kt @@ -0,0 +1,42 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.ruff.codeinsight + +import com.intellij.codeInsight.navigation.actions.GotoDeclarationHandlerBase +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.paths.WebReference +import com.intellij.psi.PsiElement +import com.intellij.python.ruff.RuffUtil.RUFF_WEBSITE +import org.toml.lang.psi.TomlFile +import org.toml.lang.psi.TomlKeySegment + +/** + * Handles documentation links for Ruff configuration options in TOML files. + * For example: + * ```toml + * [tool.ruff.lint] + * allowed-confusables = ["a"] + * ``` + * Then it will link to `https://docs.astral.sh/ruff/settings/#lint_allowed-confusables` + */ +class RuffConfigOptionGotoDeclarationHandler : GotoDeclarationHandlerBase() { + + override fun getGotoDeclarationTarget(sourceElement: PsiElement?, editor: Editor): PsiElement? { + sourceElement ?: return null + if (sourceElement.containingFile !is TomlFile) return null + return handleTomlFile(sourceElement) + } + + private fun handleTomlFile(sourceElement: PsiElement): PsiElement? { + val file = sourceElement.containingFile as? TomlFile ?: return null + if (!file.isRuffConfigFile) return null + + val keySegment = sourceElement.parent as? TomlKeySegment ?: return null + + val configPath = keySegment.ruffConfigPath() ?: return null + + val formattedPath = if (configPath.isEmpty()) "top-level" else configPath.replace(".", "_") + val url = "${RUFF_WEBSITE}/settings/#$formattedPath" + + return WebReference(keySegment, url).resolve() + } +} diff --git a/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffDocumentationUtil.kt b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffDocumentationUtil.kt new file mode 100644 index 000000000000..edc69b00ba2d --- /dev/null +++ b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffDocumentationUtil.kt @@ -0,0 +1,107 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.ruff.codeinsight + +import com.intellij.markdown.utils.doc.DocMarkdownToHtmlConverter +import com.intellij.model.Pointer +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.NlsSafe +import com.intellij.platform.backend.documentation.DocumentationResult +import com.intellij.platform.backend.documentation.DocumentationTarget +import com.intellij.platform.backend.presentation.TargetPresentation +import com.intellij.python.pyproject.PY_PROJECT_TOML +import com.intellij.python.ruff.RuffRuleInfo +import com.intellij.python.ruff.RuffUtil +import org.jetbrains.annotations.Nls +import org.toml.lang.psi.TomlArray +import org.toml.lang.psi.TomlFile +import org.toml.lang.psi.TomlKeySegment +import org.toml.lang.psi.TomlKeyValue +import org.toml.lang.psi.TomlLiteral +import org.toml.lang.psi.TomlTable + +/** + * Utility class for Ruff documentation-related functionality. + * Contains common code shared between Ruff-related classes for handling TOML configuration and documentation. + */ +object RuffDocumentationUtil { + val CODE_PATTERN = Regex("[A-Z]+[0-9]{3}") + + val RUFF_LINTER_PATTERN = Regex("[A-Z]+") + + val SUPPRESSION_PATTERN = Regex("# (?:ruff: )?noqa: ?([A-Z]+[0-9]{3}(?:, ?([A-Z]+[0-9]{3}))*)?") + + val ERROR_CODE_ARRAY_KEYS = setOf( + "select", "ignore", "fixable", "unfixable", + "extend-fixable", "extend-safe-fixes", "extend-select", + "extend-unsafe-fixes", "extend-ignore" + ).map { "lint.$it" } + + fun isRuffErrorCode(code: String): Boolean { + return CODE_PATTERN.matches(code) + } + + fun TomlLiteral.isRuffCodeElement(): Boolean { + val array = this.parent as? TomlArray ?: return false + + val keyValue = array.parent as? TomlKeyValue ?: return false + + val configPath = (keyValue.key.children.last() as TomlKeySegment).ruffConfigPath().orEmpty() + + return configPath in ERROR_CODE_ARRAY_KEYS || Regex("""lint\.(extend-)?per-file-ignores\..*""") matches configPath + } + + @Nls + fun formatRuleDocumentationHint(ruleInfo: RuffRuleInfo): @NlsSafe String { + return "${ruleInfo.name}: ${ruleInfo.code}
${ruleInfo.summary}" + } + + @Nls + fun formatRuleDocumentation(ruleInfo: RuffRuleInfo, project: Project): String { + @Suppress("HardCodedStringLiteral") + return DocMarkdownToHtmlConverter.convert(project, """ + # ${ruleInfo.name} (${ruleInfo.code}) + Derived from the **${ruleInfo.linter}** linter. + + """.trimIndent() + ruleInfo.explanation) + } +} + +class RuffRuleDocumentationTarget(private val ruleInfo: RuffRuleInfo, private val project: Project) : DocumentationTarget { + override fun createPointer(): Pointer { + return Pointer.hardPointer(this) + } + + override fun computePresentation(): TargetPresentation { + return TargetPresentation.builder(ruleInfo.code) + .icon(RuffUtil.getDefaultRuffIcon()) + .presentableText("${ruleInfo.code}: ${ruleInfo.name}") + .presentation() + } + + override fun computeDocumentationHint(): @Nls String { + return RuffDocumentationUtil.formatRuleDocumentationHint(ruleInfo) + } + + override fun computeDocumentation(): DocumentationResult { + val html = RuffDocumentationUtil.formatRuleDocumentation(ruleInfo, project) + return DocumentationResult.documentation(html) + } +} + +/** + * warning: is not robust against keys that contain dots (but no ruff keys do 🙂) + */ +val TomlKeyValue.fullName: String + get() = (parent as TomlTable).header.key?.text + "." + key.text + +val TomlFile.isRuffConfigFile: Boolean + get() = name == PY_PROJECT_TOML || name == "ruff.toml" || name == ".ruff.toml" + +fun TomlKeySegment.ruffConfigPath(): @NlsSafe String? { + val fullName = fullName ?: return null + return when { + this.containingFile.name != PY_PROJECT_TOML -> fullName + fullName.startsWith("tool.ruff.") -> fullName.removePrefix("tool.ruff.") + else -> null + } +} diff --git a/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffErrorCodeCompletionContributor.kt b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffErrorCodeCompletionContributor.kt new file mode 100644 index 000000000000..99ec30b544fe --- /dev/null +++ b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffErrorCodeCompletionContributor.kt @@ -0,0 +1,126 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.ruff.codeinsight + +import com.intellij.codeInsight.completion.CompletionContributor +import com.intellij.codeInsight.completion.CompletionParameters +import com.intellij.codeInsight.completion.CompletionProvider +import com.intellij.codeInsight.completion.CompletionResultSet +import com.intellij.codeInsight.completion.CompletionType +import com.intellij.codeInsight.completion.PrioritizedLookupElement +import com.intellij.codeInsight.lookup.LookupElementBuilder +import com.intellij.openapi.components.service +import com.intellij.patterns.PlatformPatterns.psiComment +import com.intellij.patterns.PlatformPatterns.psiElement +import com.intellij.patterns.PsiElementPattern +import com.intellij.patterns.StandardPatterns +import com.intellij.psi.PsiElement +import com.intellij.python.ruff.RuffService +import com.intellij.python.ruff.codeinsight.RuffDocumentationUtil.isRuffCodeElement +import com.intellij.util.ProcessingContext +import com.jetbrains.python.psi.PyFile +import org.toml.lang.TomlLanguage +import org.toml.lang.psi.TomlArray +import org.toml.lang.psi.TomlKeyValue +import org.toml.lang.psi.TomlLiteral + +/** + * Provides code completion for Ruff error codes in Python comments and TOML files. + * In the completion item, both the error code and its name are shown. + */ +class RuffErrorCodeCompletionContributor : CompletionContributor() { + + init { + val provider = RuffErrorCodeCompletionProvider() + // Python `ruff: noqa:` completion + extend( + CompletionType.BASIC, + psiComment().withParent().withText(StandardPatterns.string().startsWith("# ruff: noqa:")), + provider + ) + // Python `noqa: ` completion + extend( + CompletionType.BASIC, + psiComment().withText(StandardPatterns.string().startsWith("# noqa:")), + provider + ) + + // TOML file completion + extend( + CompletionType.BASIC, + tomlRuffLintPattern(), + provider + ) + } + + /** + * Pattern to match TOML elements where Ruff error codes can be completed. + */ + private fun tomlRuffLintPattern(): PsiElementPattern.Capture { + return psiElement() + .withLanguage(TomlLanguage) + .withParent( + psiElement() + .with("ruffLintKey") { literal, _ -> + literal.isRuffCodeElement() + } + .withParent( + psiElement() + .withParent() + ) + ) + } + + /** + * Completion provider for Ruff error codes. + * Provides completion items with both the error code and its name. + */ + private class RuffErrorCodeCompletionProvider : CompletionProvider() { + override fun addCompletions(parameters: CompletionParameters, context: ProcessingContext, result: CompletionResultSet) { + // prevent the default stuff from appearing + result.stopHere() + val project = parameters.position.project + val ruffService = project.service() + + for ((code, ruleInfo) in ruffService.ruleInformation) { + result.addElement( + LookupElementBuilder.create(code) + .withPresentableText(code) + .withTypeText(ruleInfo.name, true) + .withTailText(" (${ruleInfo.linter})", true) + .withLookupString(ruleInfo.name) + .withCaseSensitivity(true), + ) + } + + // Add TOML config exclusive options + if (parameters.position.containingFile.language == TomlLanguage) { + // Add special "ALL" code for selecting all rules + result.addElement( + PrioritizedLookupElement.withPriority( + LookupElementBuilder.create("ALL") + .withPresentableText("ALL") + .withTypeText("All Ruff rules", true) + .withLookupString("everything") + .withLookupString("all rules") + .withLookupString("all linters") + .withTailText(" (all rules)", true) + .withCaseSensitivity(true), + 1.0, // want it to appear at the top of the results + ) + ) + + // Add linter prefixes + for ((prefix, linterName) in ruffService.linterInformation) { + result.addElement( + LookupElementBuilder.create(prefix) + .withPresentableText(prefix) + .withTypeText("All $linterName rules", true) + .withTailText(" ($linterName)", true) + .withLookupString(linterName) + .withCaseSensitivity(true) + ) + } + } + } + } +} \ No newline at end of file diff --git a/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffErrorCodeCompletionDocumentationProvider.kt b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffErrorCodeCompletionDocumentationProvider.kt new file mode 100644 index 000000000000..f48084c7122f --- /dev/null +++ b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffErrorCodeCompletionDocumentationProvider.kt @@ -0,0 +1,26 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.ruff.codeinsight + +import com.intellij.codeInsight.lookup.LookupElement +import com.intellij.openapi.components.service +import com.intellij.platform.backend.documentation.DocumentationTarget +import com.intellij.platform.backend.documentation.LookupElementDocumentationTargetProvider +import com.intellij.psi.PsiFile +import com.intellij.python.ruff.RuffService + +/** + * Provides documentation for Ruff error code completion items. + */ +class RuffErrorCodeCompletionDocumentationProvider : LookupElementDocumentationTargetProvider { + override fun documentationTarget(psiFile: PsiFile, element: LookupElement, offset: Int): DocumentationTarget? { + val code = element.lookupString + + if (!RuffDocumentationUtil.isRuffErrorCode(code)) { + return null + } + + val ruleInfo = psiFile.project.service().ruleInformation[code] ?: return null + + return RuffRuleDocumentationTarget(ruleInfo, psiFile.project) + } +} diff --git a/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffErrorCodeDocumentationProvider.kt b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffErrorCodeDocumentationProvider.kt new file mode 100644 index 000000000000..e143be967d47 --- /dev/null +++ b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffErrorCodeDocumentationProvider.kt @@ -0,0 +1,49 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.ruff.codeinsight + +import com.intellij.openapi.components.service +import com.intellij.platform.backend.documentation.DocumentationTarget +import com.intellij.platform.backend.documentation.DocumentationTargetProvider +import com.intellij.psi.PsiFile +import com.intellij.python.ruff.RuffService +import com.intellij.python.ruff.codeinsight.RuffDocumentationUtil.isRuffCodeElement +import org.toml.lang.psi.TomlFile +import org.toml.lang.psi.TomlLiteral + +/** + * Provides documentation tooltips for Ruff error codes in ruff config files. + * When hovering over a Ruff error code in a ruff config file, this provider + * shows the rule documentation from RuffCodeService. + */ +class RuffErrorCodeDocumentationProvider : DocumentationTargetProvider { + override fun documentationTargets(file: PsiFile, offset: Int): List { + if (file !is TomlFile) { + return emptyList() + } + + val element = file.findElementAt(offset) ?: return emptyList() + + val literal = when { + element is TomlLiteral -> element + element.parent is TomlLiteral -> element.parent as TomlLiteral + else -> return emptyList() + } + + val text = literal.text.trim('"', '\'') + + if (!RuffDocumentationUtil.isRuffErrorCode(text)) { + return emptyList() + } + + if (!literal.isRuffCodeElement()) { + return emptyList() + } + + // Get the rule information from RuffCodeService + val ruleInfo = literal.project.service().ruleInformation[text] ?: return emptyList() + + // Create and return a documentation target for this rule + return listOf(RuffRuleDocumentationTarget(ruleInfo, literal.project)) + } + +} diff --git a/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffErrorCodeGotoDeclarationHandler.kt b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffErrorCodeGotoDeclarationHandler.kt new file mode 100644 index 000000000000..adac8111ac04 --- /dev/null +++ b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffErrorCodeGotoDeclarationHandler.kt @@ -0,0 +1,122 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.ruff.codeinsight + +import com.intellij.openapi.components.service +import com.intellij.openapi.paths.WebReference +import com.intellij.openapi.util.TextRange +import com.intellij.patterns.PlatformPatterns +import com.intellij.psi.PsiComment +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiReference +import com.intellij.psi.PsiReferenceContributor +import com.intellij.psi.PsiReferenceProvider +import com.intellij.psi.PsiReferenceRegistrar +import com.intellij.psi.util.elementType +import com.intellij.python.ruff.RuffService +import com.intellij.python.ruff.RuffUtil.RUFF_WEBSITE +import com.intellij.python.ruff.codeinsight.RuffDocumentationUtil.isRuffCodeElement +import com.intellij.util.ProcessingContext +import com.jetbrains.python.PyTokenTypes +import com.jetbrains.python.psi.PyFile +import org.toml.lang.psi.TomlFile +import org.toml.lang.psi.TomlLiteral + + +/** + * Provides web references functionality for Ruff error codes in pyproject.toml files + * and Python file `noqa` comments. + * + * When clicking on a Ruff error code, this handler navigates to the + * corresponding documentation page on the Ruff website. + * + * For Python files, it supports error codes in comments like: + * - `# noqa: F401` + * - `# noqa: E501, F401` + * - `# ruff: noqa: F401` + * + * For TOML files, it supports error codes in Ruff configuration sections. + * + * we aren't using a `GotoDeclarationHandler` because they appear withing comments + */ +internal object RuffErrorCodeReferenceProvider : PsiReferenceProvider() { + override fun acceptsTarget(target: PsiElement): Boolean = false + + override fun getReferencesByElement(element: PsiElement, context: ProcessingContext): Array { + return when (element.containingFile) { + is PyFile -> handlePythonFile(element) + is TomlFile -> handleTomlFile(element) + else -> null + } ?: PsiReference.EMPTY_ARRAY + } + + /** + * Handles navigation for Ruff error codes in TOML files. + */ + private fun handleTomlFile(element: PsiElement): Array? { + val literal = when { + element is TomlLiteral -> element + element.parent is TomlLiteral -> element.parent as TomlLiteral + else -> return null + } + + + val text = literal.text.trim('"', '\'') + + if (!RuffDocumentationUtil.CODE_PATTERN.matches(text) && !RuffDocumentationUtil.RUFF_LINTER_PATTERN.matches(text)) { + return null + } + + if (!literal.isRuffCodeElement()) { + return null + } + + return arrayOf(createWebReference(literal, text)) + } + + /** + * Handles navigation for Ruff error codes in Python file `noqa` comments. + */ + private fun handlePythonFile(sourceElement: PsiElement): Array? { + val comment = when { + sourceElement is PsiComment -> sourceElement + sourceElement.parent is PsiComment -> sourceElement.parent as PsiComment + else -> return null + } + + if (comment.elementType != PyTokenTypes.END_OF_LINE_COMMENT) { + return null + } + + val match = RuffDocumentationUtil.SUPPRESSION_PATTERN.find(comment.text) ?: return null + + val allCodesGroup = match.groups[1] ?: return null + + return RuffDocumentationUtil.CODE_PATTERN.findAll(comment.text, startIndex = allCodesGroup.range.first).map { code -> + createWebReference(comment, code.value, TextRange(code.range.first, code.range.last + 1)) + }.toList().toTypedArray() + } + + private fun createWebReference(element: PsiElement, errorCode: String, textRange: TextRange? = null): PsiReference { + val ruffService = element.project.service() + + val url = ruffService.ruleInformation[errorCode]?.let { + "$RUFF_WEBSITE/rules/${it.name}/" + } ?: run { + val linterCode = errorCode.takeWhile { it.isLetter() } + ruffService.linterInformation[linterCode]?.let { linterName -> + "$RUFF_WEBSITE/rules/#${linterName.lowercase().replace(" ", "-")}-${linterCode.lowercase()}" + } + } ?: "$RUFF_WEBSITE/rules/" + + return WebReference(element, textRange, url).apply { + // while these do link to web, they aren't "urls" and shouldn't be highlighted + highlight = false + } + } +} + +private class RuffErrorCodeReferenceContributor : PsiReferenceContributor() { + override fun registerReferenceProviders(registrar: PsiReferenceRegistrar) { + registrar.registerReferenceProvider(PlatformPatterns.psiElement(), RuffErrorCodeReferenceProvider) + } +} diff --git a/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffSuppressionInlayHintsProvider.kt b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffSuppressionInlayHintsProvider.kt new file mode 100644 index 000000000000..e17bbccdfddb --- /dev/null +++ b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffSuppressionInlayHintsProvider.kt @@ -0,0 +1,58 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.ruff.codeinsight + +import com.intellij.codeInsight.hints.declarative.HintFormat +import com.intellij.codeInsight.hints.declarative.HintMarginPadding +import com.intellij.codeInsight.hints.declarative.InlayHintsCollector +import com.intellij.codeInsight.hints.declarative.InlayHintsProvider +import com.intellij.codeInsight.hints.declarative.InlayTreeSink +import com.intellij.codeInsight.hints.declarative.InlineInlayPosition +import com.intellij.codeInsight.hints.declarative.SharedBypassCollector +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.Editor +import com.intellij.psi.PsiComment +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.util.elementType +import com.intellij.psi.util.startOffset +import com.intellij.python.ruff.RuffService +import com.intellij.python.ruff.codeinsight.RuffDocumentationUtil.CODE_PATTERN +import com.intellij.python.ruff.codeinsight.RuffDocumentationUtil.SUPPRESSION_PATTERN +import com.jetbrains.python.PyTokenTypes + +/** + * Provides inlay hints for Ruff suppression codes in comments. + * Displays the rule name for each suppression code. + * For example: `# noqa: F108` will show `# noqa: F108 (unused-value)` + */ +class RuffSuppressionInlayHintsProvider : InlayHintsProvider { + override fun createCollector(file: PsiFile, editor: Editor): InlayHintsCollector = Collector() + + private class Collector : SharedBypassCollector { + val hintFormat = HintFormat.default + .withHorizontalMargin(HintMarginPadding.MarginAndSmallerPadding) + + override fun collectFromElement(element: PsiElement, sink: InlayTreeSink) { + if (element is PsiComment && element.elementType == PyTokenTypes.END_OF_LINE_COMMENT) { + processComment(element, sink) + } + } + + private fun processComment(comment: PsiComment, sink: InlayTreeSink) { + val commentText = comment.text + val matcher = SUPPRESSION_PATTERN.find(commentText)?.groups[1] ?: return + + for (match in CODE_PATTERN.findAll(matcher.value)) { + val code = match.value + val ruleName = comment.project.service().ruleInformation[code]?.name ?: return + + sink.addPresentation( + position = InlineInlayPosition(comment.startOffset + matcher.range.first + match.range.last + 1, false), + hintFormat = hintFormat + ) { + text(ruleName) + } + } + } + } +} diff --git a/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffTomlInlayHintsProvider.kt b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffTomlInlayHintsProvider.kt new file mode 100644 index 000000000000..25884e4155d6 --- /dev/null +++ b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffTomlInlayHintsProvider.kt @@ -0,0 +1,58 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.ruff.codeinsight + +import com.intellij.codeInsight.hints.declarative.HintFormat +import com.intellij.codeInsight.hints.declarative.InlayHintsCollector +import com.intellij.codeInsight.hints.declarative.InlayHintsProvider +import com.intellij.codeInsight.hints.declarative.InlayTreeSink +import com.intellij.codeInsight.hints.declarative.InlineInlayPosition +import com.intellij.codeInsight.hints.declarative.SharedBypassCollector +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.Editor +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.python.ruff.RuffService +import com.intellij.python.ruff.codeinsight.RuffDocumentationUtil.isRuffCodeElement +import org.toml.lang.psi.TomlFile +import org.toml.lang.psi.TomlLiteral + +/** + * Provides inlay hints for Ruff codes in pyproject.toml, ruff.toml, and .ruff.toml files. + * Displays the rule name for each code in the ruff lint configuration sections. + * For example: `"F108"` will show `"F108"unused-value` + */ +class RuffTomlInlayHintsProvider : InlayHintsProvider { + override fun createCollector(file: PsiFile, editor: Editor): InlayHintsCollector? { + if (file !is TomlFile) return null + if (!file.isRuffConfigFile) return null + + return Collector() + } + + private class Collector() : SharedBypassCollector { + val hintFormat = HintFormat.default + + override fun collectFromElement(element: PsiElement, sink: InlayTreeSink) { + if (element is TomlLiteral && (element.isRuffCodeElement())) { + processRuffCode(element, sink) + } + } + + private fun processRuffCode(element: TomlLiteral, sink: InlayTreeSink) { + val code = element.text.trim('"', '\'') + val ruffService = element.project.service() + val ruleName = ruffService.ruleInformation[code]?.name + ?: ruffService.linterInformation[code.takeWhile { it.isLetter() }] + ?: return + + // Add inlay hint after the code + // add a blank space to separate the code from the rule name + sink.addPresentation( + position = InlineInlayPosition(element.textRange.endOffset, false), + hintFormat = hintFormat + ) { + text(ruleName) + } + } + } +} diff --git a/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffTooltipLinkHandler.kt b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffTooltipLinkHandler.kt new file mode 100644 index 000000000000..d4d6a1ed2683 --- /dev/null +++ b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/RuffTooltipLinkHandler.kt @@ -0,0 +1,56 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.ruff.codeinsight + +import com.intellij.codeInsight.highlighting.TooltipLinkHandler +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.python.ruff.RuffBundle +import com.intellij.python.ruff.RuffRuleInfo +import com.intellij.python.ruff.RuffService +import org.jetbrains.annotations.NotNull + +/** + * Handles tooltip links to Ruff rule documentation. + * Links are in the format: https://docs.astral.sh/ruff/rules/rule-name + * The documentation is retrieved from RuffCodeService. + */ +class RuffTooltipLinkHandler : TooltipLinkHandler() { + + override fun getDescription(@NotNull refSuffix: String, @NotNull editor: Editor): String? { + val project = editor.project ?: return null + if (project.isDisposed) return null + + val ruleName = extractRuleName(refSuffix) ?: return null + + val ruleInfo = findRuleByName(project, ruleName) ?: return null + + return RuffDocumentationUtil.formatRuleDocumentation(ruleInfo, project) + } + + override fun getDescriptionTitle(@NotNull refSuffix: String, @NotNull editor: Editor): String { + return RuffBundle.message("inspection.message.ruff.rule.documentation") + } + + /** + * Extracts the rule name from the URL suffix. + * @param refSuffix The suffix of the URL after the prefix (e.g., "rules/useless-expression") + * @return The rule name (e.g., "useless-expression") or null if the format is invalid + */ + private fun extractRuleName(refSuffix: String): String? { + // The refSuffix should be in the format "rules/rule-name" + val parts = refSuffix.split("/") + if (parts.size != 2 || parts[0] != "rules") { + return null + } + return parts[1] + } + + private fun findRuleByName(project: Project, ruleName: String): RuffRuleInfo? { + val ruffService = project.service() + + return ruffService.ruleInformation.values.find { ruleInfo -> + ruleInfo.name.lowercase() == ruleName + } + } +} diff --git a/python/python-ruff/src/com/intellij/python/ruff/codeinsight/actions/RuffDisableRuleForFileIntentionAction.kt b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/actions/RuffDisableRuleForFileIntentionAction.kt new file mode 100644 index 000000000000..42108916bace --- /dev/null +++ b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/actions/RuffDisableRuleForFileIntentionAction.kt @@ -0,0 +1,61 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.ruff.codeinsight.actions + +import com.intellij.codeInsight.intention.impl.BaseIntentionAction +import com.intellij.codeInspection.util.IntentionFamilyName +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.TextRange +import com.intellij.psi.PsiFile +import com.intellij.python.pytools.statistics.PyToolUsagesCollector.Helper.logDisableRule +import com.intellij.python.ruff.RuffBundle +import com.intellij.util.IncorrectOperationException + +/** + * An intention action that disables a Ruff rule for the entire file by adding a comment + * at the top of the file: # ruff: noqa: RULE_CODE + */ +class RuffDisableRuleForFileIntentionAction(private val ruleCode: String) : BaseIntentionAction() { + init { + text = RuffBundle.message("intention.name.disable.for.this.file") + } + + @IntentionFamilyName + override fun getFamilyName(): String = RuffBundle.message("intention.family.name.disable.ruff.rule.for.entire.file") + + override fun isAvailable(project: Project, editor: Editor, file: PsiFile): Boolean { + return true + } + + @Throws(IncorrectOperationException::class) + override fun invoke(project: Project, editor: Editor, file: PsiFile) { + logDisableRule(project, true) + + val document = editor.document + + val firstLineText = if (document.lineCount > 0) document.getText(TextRange(0, document.getLineEndOffset(0))) else "" + + val noqaCommentRegex = """#\s*ruff:\s*noqa:\s*(.*?)(#|$)""".toRegex() + val matchResult = noqaCommentRegex.find(firstLineText) + + if (matchResult != null) { + val existingRuleCodes = matchResult.groupValues[1].split(",").map { it.trim() } + + if (ruleCode !in existingRuleCodes) { + val updatedRuleCodes = (existingRuleCodes + ruleCode).joinToString(", ") + val updatedComment = "# ruff: noqa: $updatedRuleCodes" + + WriteCommandAction.runWriteCommandAction(project, RuffBundle.message("command.name.append.ruff.rule.to.noqa.comment"), null, { + document.replaceString(matchResult.range.first, matchResult.range.last + 1, updatedComment) + }, file) + } + } else { + val comment = "# ruff: noqa: $ruleCode\n" + + WriteCommandAction.runWriteCommandAction(project, RuffBundle.message("command.name.add.ruff.noqa.comment"), null, { + document.insertString(0, comment) + }, file) + } + } +} \ No newline at end of file diff --git a/python/python-ruff/src/com/intellij/python/ruff/codeinsight/actions/RuffDisableRuleIntentionAction.kt b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/actions/RuffDisableRuleIntentionAction.kt new file mode 100644 index 000000000000..409449c64cdd --- /dev/null +++ b/python/python-ruff/src/com/intellij/python/ruff/codeinsight/actions/RuffDisableRuleIntentionAction.kt @@ -0,0 +1,270 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.ruff.codeinsight.actions + +import com.intellij.codeInsight.intention.impl.BaseIntentionAction +import com.intellij.codeInspection.util.IntentionFamilyName +import com.intellij.icons.AllIcons +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Iconable +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiFileFactory +import com.intellij.psi.PsiManager +import com.intellij.psi.PsiWhiteSpace +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.python.pyproject.PY_PROJECT_TOML +import com.intellij.python.pytools.statistics.PyToolUsagesCollector +import com.intellij.python.ruff.RuffBundle +import com.intellij.util.IncorrectOperationException +import org.toml.lang.psi.TomlArray +import org.toml.lang.psi.TomlFile +import org.toml.lang.psi.TomlFileType +import org.toml.lang.psi.TomlKeyValue +import org.toml.lang.psi.TomlLiteral +import org.toml.lang.psi.TomlPsiFactory +import org.toml.lang.psi.TomlTable +import org.toml.lang.psi.ext.TomlLiteralKind +import org.toml.lang.psi.ext.kind +import javax.swing.Icon + +/** + * An intention action that disables a Ruff rule by adding it to the tool.ruff.lint.ignore array + * in the pyproject.toml file. + */ +class RuffDisableRuleIntentionAction(private val ruleCode: String) : BaseIntentionAction(), Iconable { + init { + text = RuffBundle.message("intention.name.disable.for.this.project") + } + + override fun getIcon(flags: Int): Icon = AllIcons.Actions.Cancel + + @IntentionFamilyName + override fun getFamilyName(): String = RuffBundle.message("intention.family.name.disable.ruff.rule") + + override fun isAvailable(project: Project, editor: Editor, file: PsiFile): Boolean { + return true + } + + @Throws(IncorrectOperationException::class) + override fun invoke(project: Project, editor: Editor, file: PsiFile) { + PyToolUsagesCollector.Helper.logDisableRule(project, false) + + val configFile = findRuffConfigFile(project, file) + + val isPyProjectTarget = configFile?.name == PY_PROJECT_TOML + val sectionName = if (isPyProjectTarget) "tool.ruff.lint" else "lint" + + WriteCommandAction.runWriteCommandAction(project, RuffBundle.message("command.name.ruff.disable.rule"), "ruff", { + if (configFile != null) { + val psiConfig = PsiManager.getInstance(project).findFile(configFile) as? TomlFile + if (psiConfig != null) { + addRuleToIgnoreInSection(psiConfig, ruleCode, sectionName) + commitAndSave(project, psiConfig) + } + } else { + val parentVFile = file.virtualFile?.parent + require(parentVFile != null) { "Cannot determine target directory for ruff.toml" } + val psiDir = PsiManager.getInstance(project).findDirectory(parentVFile) + require(psiDir != null) { "Cannot find PSI directory for target location" } + val newPsiFile = PsiFileFactory.getInstance(project).createFileFromText("ruff.toml", TomlFileType, "") as TomlFile + addRuleToIgnoreInSection(newPsiFile, ruleCode, "lint") + val added = psiDir.add(newPsiFile) + val addedPsiFile = (added as? PsiFile) ?: PsiManager.getInstance(project).findFile((added.containingFile?.virtualFile) ?: return@runWriteCommandAction) + if (addedPsiFile != null) { + commitAndSave(project, addedPsiFile) + } + } + PyToolUsagesCollector.Helper.logDisableRule(project, false) + }, file) + } + + private fun commitAndSave(project: Project, psiFile: PsiFile) { + val doc = PsiDocumentManager.getInstance(project).getDocument(psiFile) ?: return + val pdm = PsiDocumentManager.getInstance(project) + pdm.commitDocument(doc) + pdm.doPostponedOperationsAndUnblockDocument(doc) + pdm.commitDocument(doc) + FileDocumentManager.getInstance().saveDocument(doc) + psiFile.virtualFile?.refresh(false, false) + } + + /** + * Finds the pyproject.toml file in the project. + * This method first looks for the file in the project root directory, + * and if not found, searches for it in the entire project using FilenameIndex. + */ + private fun findRuffConfigFile(project: Project, contextFile: PsiFile?): VirtualFile? { + val candidateDirs = mutableListOf() + contextFile?.virtualFile?.parent?.let { candidateDirs.add(it) } + project.basePath?.let { base -> + LocalFileSystem.getInstance().findFileByPath(base)?.let { baseDir -> + if (candidateDirs.none { it.path == baseDir.path }) candidateDirs.add(baseDir) + } + } + + val names = listOf(".ruff.toml", "ruff.toml", PY_PROJECT_TOML) + for (dir in candidateDirs) { + for (name in names) { + dir.findChild(name)?.let { return it } + } + } + return null + } + + private fun addRuleToIgnoreInSection(tomlFile: TomlFile, ruleCode: String, sectionName: String) { + val factory = TomlPsiFactory(tomlFile.project) + + fun findTable(section: String): TomlTable? { + val tables = PsiTreeUtil.findChildrenOfType(tomlFile, TomlTable::class.java) + return tables.firstOrNull { table -> + val key = table.header.key ?: return@firstOrNull false + val path = key.segments.joinToString(".") { it.name ?: "" } + path == section + } + } + + var table = findTable(sectionName) + if (table == null) { + val lastDot = sectionName.lastIndexOf('.') + val parentSection = if (lastDot != -1) sectionName.take(lastDot) else null + val parentTable = parentSection?.let { findTable(it) } + + fun addAtEnd(): TomlTable { + if (tomlFile.text.isNotEmpty()) { + val twoNl = factory.createWhitespace("\n\n") + val last = tomlFile.lastChild + if (last is PsiWhiteSpace) { + last.replace(twoNl) + } else { + tomlFile.add(twoNl) + } + } + return tomlFile.add(factory.createTable(sectionName)) as TomlTable + } + + table = if (parentTable != null) { + if (parentTable.entries.isEmpty()) { + // Ensure exactly one newline between headers: normalize whitespace after parent header to a single \n + val afterParent = parentTable.nextSibling + val oneNl = factory.createWhitespace("\n") + val ws = if (afterParent is PsiWhiteSpace) { + afterParent.replace(oneNl) + } else { + tomlFile.addAfter(oneNl, parentTable) + } + val t = tomlFile.addAfter(factory.createTable(sectionName), ws) as TomlTable + t + } else { + // Ensure exactly one empty line between sections: normalize whitespace after parent block to two \n + var sibling = parentTable.nextSibling + var lastInParentBlock: PsiElement = parentTable + while (sibling != null && sibling !is TomlTable) { + lastInParentBlock = sibling + sibling = sibling.nextSibling + } + val twoNl = factory.createWhitespace("\n\n") + val ws = if (lastInParentBlock is PsiWhiteSpace) { + lastInParentBlock.replace(twoNl) + } else { + tomlFile.addAfter(twoNl, lastInParentBlock) + } + if (sibling is TomlTable) { + val t = tomlFile.addBefore(factory.createTable(sectionName), sibling) as TomlTable + t + } else { + val t = tomlFile.addAfter(factory.createTable(sectionName), ws) as TomlTable + t + } + } + } else { + addAtEnd() + } + } + + fun findIgnoreEntry(tbl: TomlTable): TomlKeyValue? = + tbl.entries.firstOrNull { kv -> + val segments = kv.key.segments + segments.size == 1 && segments[0].name == "ignore" + } + + val existingIgnore = findIgnoreEntry(table) + + fun replaceIgnore(target: TomlKeyValue, newValueText: String) { + val newKv = factory.createKeyValue("ignore", newValueText) + val replaced = target.replace(newKv) as TomlKeyValue + val ws = replaced.nextSibling + if (ws is PsiWhiteSpace) { + ws.replace(factory.createNewline()) + } + } + + fun getStringValue(lit: TomlLiteral): String? { + val k = lit.kind + return if (k is TomlLiteralKind.String) k.value else null + } + + fun buildArrayText(items: List, multiline: Boolean): String { + return if (!multiline && items.size <= 1) { + items.firstOrNull()?.let { "[\"$it\"]" } ?: "[]" + } else { + val body = items.joinToString(",\n ") { "\"$it\"" } + "[\n $body,\n]" + } + } + + if (existingIgnore != null) { + val value = existingIgnore.value + when (value) { + is TomlArray -> { + val existingNames = value.elements.mapNotNull { (it as? TomlLiteral)?.let { lit -> getStringValue(lit) } }.toMutableList() + if (existingNames.contains(ruleCode)) return + val multiline = existingNames.isNotEmpty() + val newValueText = buildArrayText(existingNames + ruleCode, multiline) + replaceIgnore(existingIgnore, newValueText) + } + is TomlLiteral -> { + val existing = getStringValue(value) + if (existing == ruleCode) return + val items = listOfNotNull(existing, ruleCode) + val newValueText = buildArrayText(items, multiline = items.size > 1) + replaceIgnore(existingIgnore, newValueText) + } + else -> { + replaceIgnore(existingIgnore, "[\"$ruleCode\"]") + } + } + } else { + val newKv = factory.createKeyValue("ignore", "[\"$ruleCode\"]") + val anchor = table.entries.lastOrNull() + if (anchor != null) { + val nl = factory.createNewline() + val insertedNl = table.addAfter(nl, anchor) + val added = table.addAfter(newKv, insertedNl) + table.addAfter(factory.createNewline(), added) + } else { + val afterHeader = table.firstChild.nextSibling + val oneNl = factory.createNewline() + val wsAnchor = if (afterHeader is PsiWhiteSpace) { + afterHeader.replace(oneNl) + } else { + table.addAfter(oneNl, table.firstChild) + } + table.addAfter(newKv, wsAnchor) + } + } + + val last = table.lastChild + val nl = when (table.nextSibling) { + is PsiWhiteSpace -> return + null -> factory.createNewline() + else -> factory.createWhitespace("\n\n") + } + if (last is PsiWhiteSpace) last.replace(nl) else table.add(nl) + } +} diff --git a/python/python-ruff/src/com/intellij/python/ruff/server/RuffLspClient.kt b/python/python-ruff/src/com/intellij/python/ruff/server/RuffLspClient.kt new file mode 100644 index 000000000000..54953c188854 --- /dev/null +++ b/python/python-ruff/src/com/intellij/python/ruff/server/RuffLspClient.kt @@ -0,0 +1,116 @@ +package com.intellij.python.ruff.server + +import com.intellij.codeInsight.intention.IntentionAction +import com.intellij.codeInspection.util.IntentionName +import com.intellij.openapi.components.service +import com.intellij.openapi.module.Module +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.platform.lsp.api.customization.LspFormattingSupport +import com.intellij.python.lsp.core.PyLspService +import com.intellij.python.lsp.core.PyLspToolCustomization +import com.intellij.python.lsp.core.PyLspToolDescriptor +import com.intellij.python.lsp.core.PyLspToolIntegrationProvider +import com.intellij.python.ruff.RuffBundle +import com.intellij.python.ruff.RuffConfiguration +import com.intellij.python.ruff.RuffPyTool +import com.intellij.python.ruff.RuffService +import com.intellij.python.ruff.RuffSettings +import com.intellij.python.ruff.codeinsight.actions.RuffDisableRuleForFileIntentionAction +import com.intellij.python.ruff.codeinsight.actions.RuffDisableRuleIntentionAction +import com.jetbrains.python.NON_INTERACTIVE_ROOT_TRACE_CONTEXT +import kotlinx.coroutines.launch +import org.eclipse.lsp4j.Diagnostic +import org.eclipse.lsp4j.InitializeResult +import org.jetbrains.annotations.Nls + +class RuffLspIntegrationProvider : PyLspToolIntegrationProvider() { + override fun getDescriptor(module: Module): RuffLspClientDescriptor = + RuffLspClientDescriptor(module) +} + +class RuffLspClientDescriptor(module: Module) : PyLspToolDescriptor(module, RuffPyTool.getInstance()) { + override val toolConfig: RuffSettings + get() = project.service() + + override fun lspArguments(): List { + return listOf("server") + } + + override val lspCustomization: PyLspToolCustomization = object : PyLspToolCustomization(toolConfig, pyTool, project) { + override val formattingCustomizer = object : LspFormattingSupport() { + override fun shouldFormatThisFileExclusivelyByServer( + file: VirtualFile, + ideCanFormatThisFileItself: Boolean, + serverExplicitlyWantsToFormatThisFile: Boolean, + ): Boolean { + return this@RuffLspClientDescriptor.toolConfig.formatting + } + } + + override val diagnosticsSupport: PyLspToolDiagnosticsSupport = object : PyLspToolDiagnosticsSupport() { + override fun customizeQuickFixes(diagnostic: Diagnostic, quickFixes: List): List { + val disableQuickFix = if (quickFixes.count { + // there are always 8, we need to count the filled ones + it.text.isNotEmpty() + } > 1) quickFixes.firstOrNull { ": Disable for this line" in it.text }?.let { + object : PyLspAction(diagnostic, it) { + override fun getText(): @IntentionName String { + return RuffBundle.message("intention.name.disable.for.this.line") + } + } + } + else null + return quickFixes.mapNotNull { quickfix -> + if (quickfix === disableQuickFix?.action) null + else object : PyLspAction(diagnostic, quickfix) { + override fun getText(): @IntentionName String { + return quickFixMessage(quickfix.text) + } + + override fun getOptions(): List = + quickFixOptions(diagnostic).let { + if (disableQuickFix != null) { + listOf(disableQuickFix) + it + } + else it + } + } + } + } + } + + override fun codeCustomizer(@Nls code: String): String = + project.service().ruleInformation[code]?.name ?: code + + override fun quickFixMessage(text: String): @IntentionName String { + @Suppress("HardCodedStringLiteral") + return Regex("""(?<=Ruff \()(\w+)""").replace(text) { codeCustomizer(it.groupValues[1]) } + } + + override fun quickFixOptions(diagnostic: Diagnostic): List { + val code = diagnostic.code?.get()?.toString() ?: return emptyList() + return listOf( + RuffDisableRuleIntentionAction(code), + RuffDisableRuleForFileIntentionAction(code), + ) + } + } + + override val commandDescriptions: Map = mapOf( + "ruff.applyFormat" to "Format document", + "ruff.applyAutofix" to null, + "ruff.applyOrganizeImports" to null, + "ruff.printDebugInformation" to "Print debug information", + ) + + override val lspServerListener: PyLspToolDescriptorLspServerListener = object : PyLspToolDescriptorLspServerListener() { + override fun serverInitialized(params: InitializeResult) { + super.serverInitialized(params) + val service = project.service() + project.service().cs.launch(NON_INTERACTIVE_ROOT_TRACE_CONTEXT) { + service.gatherRuleInformation() + service.gatherConfigOptionInformation() + } + } + } +} diff --git a/python/python-ty/BUILD.bazel b/python/python-ty/BUILD.bazel new file mode 100644 index 000000000000..27ea2d038cad --- /dev/null +++ b/python/python-ty/BUILD.bazel @@ -0,0 +1,119 @@ +load("@rules_jvm//:jvm.bzl", "jvm_library") + +### auto-generated section `build intellij.python.ty` start + +jvm_library( + name = "ty", + srcs = glob( + [ + "src/**/*.kt", + "src/**/*.java", + "src/**/*.form", + "gen/**/*.kt", + "gen/**/*.java", + ], + allow_empty = True, + ), + module_name = "intellij.python.ty", + resource_strip_prefix = "resources", + resources = glob( + ["resources/**/*"], + ), + visibility = ["//visibility:public"], + deps = [ + # do not sort, + "//platform/projectModel-api:projectModel", + "//platform/projectModel-impl", + "//platform/editor-ui-api:editor-ui", + "//platform/lang-impl", + "//python:python-community-impl", + "//python/python-sdk:sdk", + "//python/python-core-impl:community-core-impl", + "//platform/code-style-impl:codeStyle-impl", + "//platform/editor-ui-ex:editor-ex", + "//platform/platform-impl:ide-impl", + "//xml/xml-parser:parser", + "//platform/core-ui", + "//xml/impl", + "//platform/util/jdom", + "//platform/smRunner", + "//libraries/guava", + "//libraries/fastutil", + "//platform/lsp", + "//platform/core-api:core", + "@lib//:eclipse-lsp4j", + "@lib//:eclipse-lsp4j-jsonrpc", + "//platform/analysis-api:analysis", + "//python/python-lsp-core:lsp-core", + "//python/python-pytools-ui:pytools-ui", + "//platform/core-impl", + "//platform/statistics", + "//libraries/jackson/jackson", + "//libraries/jackson/databind", + "//platform/progress/shared:ide-progress", + "//platform/ide-core", + "//platform/util/progress", + "//platform/observable", + "//python/python-pytools:pytools", + ], +) + +jvm_library( + name = "ty_test_lib", + testonly = True, + srcs = glob( + [], + allow_empty = True, + ), + module_name = "intellij.python.ty", + visibility = ["//visibility:public"], + runtime_deps = [ + # do not sort, + ":ty", + "//platform/projectModel-api:projectModel_test_lib", + "//platform/projectModel-impl:projectModel-impl_test_lib", + "//platform/editor-ui-api:editor-ui_test_lib", + "//platform/lang-impl:lang-impl_test_lib", + "//python:python-community-impl_test_lib", + "//python/python-sdk:sdk_test_lib", + "//python/python-core-impl:community-core-impl_test_lib", + "//platform/code-style-impl:codeStyle-impl_test_lib", + "//platform/editor-ui-ex:editor-ex_test_lib", + "//platform/platform-impl:ide-impl_test_lib", + "//xml/xml-parser:parser_test_lib", + "//platform/core-ui:core-ui_test_lib", + "//xml/impl:impl_test_lib", + "//platform/util/jdom:jdom_test_lib", + "//platform/smRunner:smRunner_test_lib", + "//libraries/guava:guava_test_lib", + "//libraries/fastutil:fastutil_test_lib", + "//platform/lsp:lsp_test_lib", + "//platform/testFramework", + "//platform/testFramework:testFramework_test_lib", + "//platform/lsp/testFramework", + "//platform/lsp/testFramework:testFramework_test_lib", + "//platform/core-api:core_test_lib", + "//platform/analysis-api:analysis_test_lib", + "//python/python-lsp-core:lsp-core_test_lib", + "//python/python-pytools-ui:pytools-ui_test_lib", + "//platform/core-impl:core-impl_test_lib", + "//platform/statistics:statistics_test_lib", + "//libraries/jackson/jackson:jackson_test_lib", + "//libraries/jackson/databind:databind_test_lib", + "//platform/progress/shared:ide-progress_test_lib", + "//platform/ide-core:ide-core_test_lib", + "//platform/util/progress:progress_test_lib", + "//platform/observable:observable_test_lib", + "//python/python-pytools:pytools_test_lib", + ], +) +### auto-generated section `build intellij.python.ty` end + +### auto-generated section `iml intellij.python.ty` start +exports_files( + [ + "intellij.python.ty.iml", + ], + visibility = ["//visibility:public"], +) +### auto-generated section `iml intellij.python.ty` end diff --git a/python/python-ty/OWNERSHIP b/python/python-ty/OWNERSHIP new file mode 100644 index 000000000000..e5e207deb924 --- /dev/null +++ b/python/python-ty/OWNERSHIP @@ -0,0 +1,2 @@ +owner 'PyCharm Codeinsight' + diff --git a/python/python-ty/gen/com/intellij/python/ty/icons/PythonTYIcons.java b/python/python-ty/gen/com/intellij/python/ty/icons/PythonTYIcons.java new file mode 100644 index 000000000000..731fdc258661 --- /dev/null +++ b/python/python-ty/gen/com/intellij/python/ty/icons/PythonTYIcons.java @@ -0,0 +1,17 @@ +package com.intellij.python.ty.icons; + +import com.intellij.ui.IconManager; +import org.jetbrains.annotations.NotNull; + +import javax.swing.*; + +/** + * NOTE THIS FILE IS AUTO-GENERATED + * DO NOT EDIT IT BY HAND, run "Generate icon classes" configuration instead + */ +public final class PythonTYIcons { + private static @NotNull Icon load(@NotNull String path, int cacheKey, int flags) { + return IconManager.getInstance().loadRasterizedIcon(path, PythonTYIcons.class.getClassLoader(), cacheKey, flags); + } + /** 400x400 */ public static final @NotNull Icon TY = load("icons/ty.png", 0, 2); +} diff --git a/python/python-ty/intellij.python.ty.iml b/python/python-ty/intellij.python.ty.iml new file mode 100644 index 000000000000..8371225179ce --- /dev/null +++ b/python/python-ty/intellij.python.ty.iml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/python/python-ty/resources/icons/ty.png b/python/python-ty/resources/icons/ty.png new file mode 100644 index 0000000000000000000000000000000000000000..a36792d2d04e73fbeda7c4fd66c8e94367bf35fb GIT binary patch literal 2015 zcmb7Fc{JPE9#3MYrXlDM)gL0%(uR?tC_fQIEz@X4Q)w+pTdmrYmY_mCV$x|d+M#Nl zP8St~5azY@)G``cT3R)hP<^VFMpWgEwA72fcivyqbKcx@zsvpH@BQ5Gz4v?0&EFs7 ztF39I34uVg{RsGA2n5RA6m=y9(h}MINnx7y2ORWPSOCCS>toOejHNEdLJx!1#aJRf zYu!!#zoC^L#!7)BFjil0f>!WNZGpg8DB=}evGB8Rp}z?NidhAr4#paeak*CRvoPgA zOWNf}aTf}|&1B=nFK|yEIbXQ!n3%DPKykZ$$AOw`1-QCgtFX57`Su(e?0DdIyV^1F zoC7rpLplL=KCpEqZYJ}Y(_?=6xgu{jdT`JI2pqFN;ZjB0lgSM!p9Mx#0GYsK#HIL4 z;am(C&acO8e4$lV@)=q@QwT)0+z;=0FlF?um_tH@LUM_?Yz<3P0bAJouj|-cj`q)$ zE9(_CO-Cfvwfm}yi!&WQrsXv58TK3>`?$>fxZd9W%hc>rJzel_DmsOZnUzYh;e3xh z^H!Y?jkX8gYMsHD?5JP8+MAe3YU`%EJFZm3Iae{(nGR0aMagvKt<*u}Rb)y3<=aEa zk7uY#>#UPW6p3T;HRG*yI9+2R>KkJ}R4ac=jBd|2Ve+Cl>6lxhB&@dCQy}znaJo8n z@*ib==`_@3biSBc@`Kt}p^+KlMvA0gfE7|gOqbijXDX2QPZ>E&7CYoczJ=_6h7^h# z?0L=9g^vU5FMGx?tJk8$+)$MAq2BQv-J9a~-J(8)3jk^L# zNYU%OGSd2ibMmGBxCtzWXd`M{4(G$ZbUn37Bg`)y=dWI@g6E`#2nmpZH=qEVQ0jq^ z6|&P`0mNs~vPw4rr~=UXZv@F7AOd!2d*>j8d$$N`;HZ#8hu#>6^}yu%1Oq~eL2VqX zX|^-c7bLZS*TLR-J%S#AJPFH(?c~KMmfoanvPr+o!e?EP8FH<-t_4MkxO0|dm>=x# zuJNt+j>2}5kBpbv#T9{9bQPrp!-Fh!b@*m|{h2?co)o>Mz(}cQo)%Ao7Z^#jwQ<;W zRS0BbLsB*n)B)<@AJVW*uzrZr@rrZlf; zhPerzcaL#5#qC|>84N8S?tb>Jm5$}BcKAk#se05P>s{kX4tBg&3HhbUx_%8!0)+_G}59LvF z0%M{K-d#66efR)^uF~J>M-G4Qm2Xrm00%?7s&vjH%JNnNRzB#bV_pT%j_0{=Grj2i zv3+@ACa=l6A#r5>5=Q8-BgZ+)ji1mpu+_xKmwFR9CtpoaRy;KNmFVR`;#saUA@?Z1 zV9|VO)phz;&AZ6%!biVGZD2>vX!JN=Y0ahPTc}DB4Rk+oa1*aJ{Z-3IL@LX|X>{O} zZGBho#k2lZf3zJ;MPOb0-Oc5(&bUET*Tt)po8@N4mb>3)l~Q_ro}EgHYM7F;+BiNf z0lnU*O19Ufr#8fVpMJdbG2e`SPf)!v=bv#T^mUDvw@1!4zPUZ3FsyINSn=aQ{Jewk Jb-0M^e*toOejHNEdLJx!1#aJRf zYu!!#zoC^L#!7)BFjil0f>!WNZGpg8DB=}evGB8Rp}z?NidhAr4#paeak*CRvoPgA zOWNf}aTf}|&1B=nFK|yEIbXQ!n3%DPKykZ$$AOw`1-QCgtFX57`Su(e?0DdIyV^1F zoC7rpLplL=KCpEqZYJ}Y(_?=6xgu{jdT`JI2pqFN;ZjB0lgSM!p9Mx#0GYsK#HIL4 z;am(C&acO8e4$lV@)=q@QwT)0+z;=0FlF?um_tH@LUM_?Yz<3P0bAJouj|-cj`q)$ zE9(_CO-Cfvwfm}yi!&WQrsXv58TK3>`?$>fxZd9W%hc>rJzel_DmsOZnUzYh;e3xh z^H!Y?jkX8gYMsHD?5JP8+MAe3YU`%EJFZm3Iae{(nGR0aMagvKt<*u}Rb)y3<=aEa zk7uY#>#UPW6p3T;HRG*yI9+2R>KkJ}R4ac=jBd|2Ve+Cl>6lxhB&@dCQy}znaJo8n z@*ib==`_@3biSBc@`Kt}p^+KlMvA0gfE7|gOqbijXDX2QPZ>E&7CYoczJ=_6h7^h# z?0L=9g^vU5FMGx?tJk8$+)$MAq2BQv-J9a~-J(8)3jk^L# zNYU%OGSd2ibMmGBxCtzWXd`M{4(G$ZbUn37Bg`)y=dWI@g6E`#2nmpZH=qEVQ0jq^ z6|&P`0mNs~vPw4rr~=UXZv@F7AOd!2d*>j8d$$N`;HZ#8hu#>6^}yu%1Oq~eL2VqX zX|^-c7bLZS*TLR-J%S#AJPFH(?c~KMmfoanvPr+o!e?EP8FH<-t_4MkxO0|dm>=x# zuJNt+j>2}5kBpbv#T9{9bQPrp!-Fh!b@*m|{h2?co)o>Mz(}cQo)%Ao7Z^#jwQ<;W zRS0BbLsB*n)B)<@AJVW*uzrZr@rrZlf; zhPerzcaL#5#qC|>84N8S?tb>Jm5$}BcKAk#se05P>s{kX4tBg&3HhbUx_%8!0)+_G}59LvF z0%M{K-d#66efR)^uF~J>M-G4Qm2Xrm00%?7s&vjH%JNnNRzB#bV_pT%j_0{=Grj2i zv3+@ACa=l6A#r5>5=Q8-BgZ+)ji1mpu+_xKmwFR9CtpoaRy;KNmFVR`;#saUA@?Z1 zV9|VO)phz;&AZ6%!biVGZD2>vX!JN=Y0ahPTc}DB4Rk+oa1*aJ{Z-3IL@LX|X>{O} zZGBho#k2lZf3zJ;MPOb0-Oc5(&bUET*Tt)po8@N4mb>3)l~Q_ro}EgHYM7F;+BiNf z0lnU*O19Ufr#8fVpMJdbG2e`SPf)!v=bv#T^mUDvw@1!4zPUZ3FsyINSn=aQ{Jewk Jb-0M^e* + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/python/python-ty/resources/messages/TyBundle.properties b/python/python-ty/resources/messages/TyBundle.properties new file mode 100644 index 000000000000..6a1e2779d357 --- /dev/null +++ b/python/python-ty/resources/messages/TyBundle.properties @@ -0,0 +1,8 @@ +install.ty.button=Download 'ty' +ty.tool.description=An extremely fast Python type checker and language server, written in Rust. +install.ty.progress=Downloading 'ty'\u2026 +install.ty.progress.fetching=Fetching the latest 'ty' release information \u2026 +install.ty.progress.downloading=Downloading 'ty' {0}\u2026 +install.ty.title='ty' Installation +install.ty.success='ty' successfully installed to: {0} +install.ty.failed=Failed to download 'ty'. Please check the logs for more details. \ No newline at end of file diff --git a/python/python-ty/src/com/intellij/python/ty/TyBundle.kt b/python/python-ty/src/com/intellij/python/ty/TyBundle.kt new file mode 100644 index 000000000000..f45a11aac71d --- /dev/null +++ b/python/python-ty/src/com/intellij/python/ty/TyBundle.kt @@ -0,0 +1,18 @@ +package com.intellij.python.ty + +import com.intellij.DynamicBundle +import org.jetbrains.annotations.Nls +import org.jetbrains.annotations.NonNls +import org.jetbrains.annotations.PropertyKey + +object TyBundle { + private val myDynamicBundle = DynamicBundle(this::class.java, BUNDLE) + + @NonNls + const val BUNDLE: String = "messages.TyBundle" + + @Nls + fun message(@PropertyKey(resourceBundle = BUNDLE) key: String, vararg params: Any): String { + return myDynamicBundle.getMessage(key, *params) + } +} diff --git a/python/python-ty/src/com/intellij/python/ty/TyConfigurable.kt b/python/python-ty/src/com/intellij/python/ty/TyConfigurable.kt new file mode 100644 index 000000000000..351b8e8ab8e5 --- /dev/null +++ b/python/python-ty/src/com/intellij/python/ty/TyConfigurable.kt @@ -0,0 +1,29 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.ty + +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.project.Project +import com.intellij.python.pytools.lsp.LSP_TOOLS_STORAGE_FILE +import com.intellij.python.pytools.lsp.PyLspToolConfiguration +import com.intellij.python.pytools.ui.PyLspToolDetailConfigurable +import com.intellij.util.xmlb.XmlSerializerUtil + +class TyConfigurable(project: Project) : + PyLspToolDetailConfigurable(project, TyPyTool.getInstance()) + +@Service(Service.Level.PROJECT) +@State( + name = "TyConfiguration", + storages = [Storage(LSP_TOOLS_STORAGE_FILE)] +) +data class TyConfiguration( + override var inlayHints: Boolean? = true, + override var completions: Boolean? = true, + override var documentation: Boolean? = true, +) : PyLspToolConfiguration() { + override fun loadState(state: TyConfiguration) { + XmlSerializerUtil.copyBean(state, this) + } +} diff --git a/python/python-ty/src/com/intellij/python/ty/TyLsp4jServer.kt b/python/python-ty/src/com/intellij/python/ty/TyLsp4jServer.kt new file mode 100644 index 000000000000..b41598e89867 --- /dev/null +++ b/python/python-ty/src/com/intellij/python/ty/TyLsp4jServer.kt @@ -0,0 +1,19 @@ +package com.intellij.python.ty + +import com.intellij.platform.lsp.api.Lsp4jServer +import org.eclipse.lsp4j.jsonrpc.services.JsonRequest +import java.util.concurrent.CompletableFuture + +/** + * LSP4J interface for Ty custom endpoints. + */ +internal interface TyLsp4jServer : Lsp4jServer { + /** + * Requests a type for a given element range. + * Endpoint: "types/provide-type" + * + * The server is expected to return a JSON object, e.g. { "ty": "fully.qualified.Type[Arg]" } + */ + @JsonRequest("types/provide-type") + fun provideType(params: Map): CompletableFuture> +} diff --git a/python/python-ty/src/com/intellij/python/ty/TyLspClient.kt b/python/python-ty/src/com/intellij/python/ty/TyLspClient.kt new file mode 100644 index 000000000000..9014d17fc157 --- /dev/null +++ b/python/python-ty/src/com/intellij/python/ty/TyLspClient.kt @@ -0,0 +1,44 @@ +package com.intellij.python.ty + +import com.intellij.openapi.components.service +import com.intellij.openapi.module.Module +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.platform.lsp.api.Lsp4jServer +import com.intellij.platform.lsp.api.customization.LspGoToDefinitionDisabled +import com.intellij.python.lsp.core.PyLspToolCustomization +import com.intellij.python.lsp.core.PyLspToolDescriptor +import com.intellij.python.lsp.core.PyLspToolIntegrationProvider +import com.intellij.python.pytools.lsp.PyLspToolSettings + +class TyLspIntegrationProvider : PyLspToolIntegrationProvider() { + override fun getDescriptor(module: Module): PyLspToolDescriptor = + TyLspClientDescriptor(module) +} + +class TyLspClientDescriptor(module: Module) : PyLspToolDescriptor(module, TyPyTool.getInstance()) { + override fun lspArguments(): List = + listOf("server") + + override val toolConfig: PyLspToolSettings + get() = project.service() + + override val lsp4jServerClass: Class + get() = TyLsp4jServer::class.java + + override val commandDescriptions: Map = mapOf( + "ty.printDebugInformation" to "Print debug information", + ) + + override val lspCustomization: PyLspToolCustomization = object : PyLspToolCustomization(toolConfig, pyTool, project) { + // workaround for IJPL-228417 + override val goToDefinitionCustomizer = LspGoToDefinitionDisabled + + override val diagnosticsSupport: PyLspToolDiagnosticsSupport = object : PyLspToolDiagnosticsSupport() { + override fun shouldAskServerForDiagnostics(file: VirtualFile): Boolean = + super.shouldAskServerForDiagnostics(file) && !file.isJupyterNotebook() + } + } +} + +private fun VirtualFile.isJupyterNotebook(): Boolean = + extension.equals("ipynb", ignoreCase = true) diff --git a/python/python-ty/src/com/intellij/python/ty/TyPyTool.kt b/python/python-ty/src/com/intellij/python/ty/TyPyTool.kt new file mode 100644 index 000000000000..243307ff2960 --- /dev/null +++ b/python/python-ty/src/com/intellij/python/ty/TyPyTool.kt @@ -0,0 +1,43 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.ty + +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.platform.lsp.api.LspClientManager +import com.intellij.python.pytools.PyTool +import com.intellij.python.pytools.lsp.PyLspTool +import com.intellij.python.pytools.configuration.ConfigurablePyTool +import com.intellij.python.pytools.ui.pyLspToolFeaturesSummary +import com.jetbrains.python.packaging.PyPackageName +import org.jetbrains.annotations.ApiStatus +import javax.swing.Icon + +/** + * [ty](https://github.com/astral-sh/ty) — an extremely fast Python type checker and language server + * written in Rust by Astral (currently in preview). + */ +@ApiStatus.Internal +class TyPyTool : PyLspTool(), ConfigurablePyTool { + override val presentableName: String = "ty" + override val description: String get() = TyBundle.message("ty.tool.description") + override val packageName: PyPackageName = PyPackageName.from("ty") + + override fun configuration(project: Project): TyConfiguration = project.service() + + override val icon: Icon = TyUtil.getDefaultTyIcon() + + override fun createConfigurable(project: Project): TyConfigurable = TyConfigurable(project) + + override fun summaryFor(project: Project): String = pyLspToolFeaturesSummary(configuration(project)) + + override fun onEnabledChanged(project: Project, enabled: Boolean) { + val manager = LspClientManager.getInstance(project) + if (enabled) manager.startClientsIfNeeded(TyLspIntegrationProvider::class.java) + else manager.stopClients(TyLspIntegrationProvider::class.java) + } + + @Suppress("CompanionObjectInExtension") + companion object { + fun getInstance(): TyPyTool = PyTool.EP_NAME.findExtensionOrFail(TyPyTool::class.java) + } +} diff --git a/python/python-ty/src/com/intellij/python/ty/TyService.kt b/python/python-ty/src/com/intellij/python/ty/TyService.kt new file mode 100644 index 000000000000..29027f36535b --- /dev/null +++ b/python/python-ty/src/com/intellij/python/ty/TyService.kt @@ -0,0 +1,7 @@ +package com.intellij.python.ty + +import com.intellij.openapi.components.Service +import kotlinx.coroutines.CoroutineScope + +@Service(Service.Level.PROJECT) +class TyService(val cs: CoroutineScope) \ No newline at end of file diff --git a/python/python-ty/src/com/intellij/python/ty/TyUsageCollector.kt b/python/python-ty/src/com/intellij/python/ty/TyUsageCollector.kt new file mode 100644 index 000000000000..e9038d1d058e --- /dev/null +++ b/python/python-ty/src/com/intellij/python/ty/TyUsageCollector.kt @@ -0,0 +1,25 @@ +package com.intellij.python.ty + +import com.intellij.internal.statistic.eventLog.EventLogGroup +import com.intellij.internal.statistic.eventLog.events.EventFields +import com.intellij.internal.statistic.service.fus.collectors.CounterUsagesCollector +import com.intellij.openapi.util.Ref +import com.jetbrains.python.psi.types.PyType +import kotlin.time.measureTimedValue + +object TyUsageCollector : CounterUsagesCollector() { + private val GROUP = EventLogGroup("python.ty.type.inference", 4) + private val DURATION = EventFields.Int("duration_basket", "Duration of string type resolution, grouped into 100ms time buckets") + private val STRING_TYPE_RESOLUTION_TIME = GROUP.registerEvent("string.type.resolution.time", DURATION) + + fun logStringTypeResolutionTime(block: () -> Ref?): Ref? { + val (result, duration) = measureTimedValue { + block() + } + val basket = (duration.inWholeMilliseconds / 100).toInt() + STRING_TYPE_RESOLUTION_TIME.log(basket) + return result + } + + override fun getGroup(): EventLogGroup = GROUP +} diff --git a/python/python-ty/src/com/intellij/python/ty/TyUtil.kt b/python/python-ty/src/com/intellij/python/ty/TyUtil.kt new file mode 100644 index 000000000000..b6dc41127da6 --- /dev/null +++ b/python/python-ty/src/com/intellij/python/ty/TyUtil.kt @@ -0,0 +1,164 @@ +package com.intellij.python.ty + +import com.fasterxml.jackson.databind.ObjectMapper +import com.intellij.openapi.application.PathManager +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.util.SystemInfo +import com.intellij.python.ty.icons.PythonTYIcons +import com.intellij.util.IconUtil +import com.intellij.util.io.HttpRequests +import com.intellij.util.system.CpuArch +import com.intellij.util.system.OS +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.PosixFilePermission +import javax.swing.Icon +import kotlin.io.path.div +import kotlin.io.path.setPosixFilePermissions + +private const val TY_GITHUB_API_URL = "https://api.github.com/repos/JetBrains/ty/releases/latest" + +object TyUtil { + private val LOG = logger() + + fun getDefaultTyIcon(): Icon { + return IconUtil.downscaleIconToSize(PythonTYIcons.TY, 16, 16) + } + + /** + * Gets the installation directory for ty binary in the IDE's bin directory. + */ + fun getTyInstallationDirectory(): Path { + return PathManager.getBinDir() + } + + /** + * Gets the expected binary name for the current platform. + */ + fun getTyBinaryName(): String { + return if (OS.CURRENT == OS.Windows) "ty.exe" else "ty" + } + + /** + * Checks if ty is already installed in the IDE's bin directory. + */ + fun isTyInstalled(): Boolean { + val installDir = getTyInstallationDirectory() + val binaryName = getTyBinaryName() + val binaryPath = installDir / binaryName + return Files.exists(binaryPath) + } + + /** + * Fetches the latest release information from GitHub API. + */ + fun fetchLatestRelease(): TyReleaseInfo? { + return try { + fetchLatestReleaseImpl() + } + catch (e: Exception) { + LOG.error("Failed to fetch latest Ty release from GitHub", e) + null + } + } + + private fun fetchLatestReleaseImpl(): TyReleaseInfo? { + val response = HttpRequests.request(TY_GITHUB_API_URL) + .userAgent("intellij-ty-plugin") + .readString() + + val mapper = ObjectMapper() + val jsonNode = mapper.readTree(response) + + val tagName = jsonNode.get("tag_name")?.asText() ?: return null + val assets = jsonNode.get("assets") ?: return null + + val platformSuffix = getPlatformSuffix() + val asset = assets.find { node -> + val name = node.get("name")?.asText() ?: "" + name.contains(platformSuffix, ignoreCase = true) + } ?: return null + + val downloadUrl = asset.get("browser_download_url")?.asText() ?: return null + + return TyReleaseInfo(tagName, downloadUrl) + } + + /** + * Downloads the ty binary for the current platform to the IDE's bin directory. + */ + fun downloadTyBinary(indicator: ProgressIndicator? = null): Path? { + return try { + downloadTyBinaryImpl(indicator) + } + catch (e: Exception) { + LOG.error("Failed to download ty binary", e) + null + } + } + + fun downloadTyBinaryImpl(indicator: ProgressIndicator? = null): Path? { + indicator?.text = TyBundle.message("install.ty.progress.fetching") + val releaseInfo = fetchLatestRelease() ?: return null + + LOG.info("Downloading ty ${releaseInfo.version} from ${releaseInfo.downloadUrl}") + + indicator?.text = TyBundle.message("install.ty.progress.downloading", releaseInfo.version) + val installDir = getTyInstallationDirectory() + Files.createDirectories(installDir) + + val binaryPath = installDir / getTyBinaryName() + + HttpRequests.request(releaseInfo.downloadUrl) + .userAgent("intellij-ty-Plugin") + .saveToFile(binaryPath, indicator) + + // Make executable on Unix-like systems + if (OS.CURRENT != OS.Windows) { + try { + binaryPath.setPosixFilePermissions( + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, + PosixFilePermission.GROUP_READ, + PosixFilePermission.GROUP_EXECUTE, + PosixFilePermission.OTHERS_READ, + PosixFilePermission.OTHERS_EXECUTE + ) + ) + } + catch (e: IOException) { + LOG.error("Failed to set executable permissions on ty binary", e) + return null + } + } + + LOG.info("Successfully downloaded ty to $binaryPath") + return binaryPath + } + + private fun getPlatformSuffix(): String { + val os = when { + SystemInfo.isWindows -> "pc-windows-msvc" + SystemInfo.isLinux -> "unknown-linux-TODO" // TODO: linux type: musl, gnu, + SystemInfo.isMac -> "apple-darwin" + else -> return "" + } + + val arch = when (CpuArch.CURRENT) { + CpuArch.X86_64 -> "x86_64" + CpuArch.ARM64 -> "aarch64" + else -> return "" + } + + return "$arch-$os" + } +} + +data class TyReleaseInfo( + val version: String, + val downloadUrl: String, +) diff --git a/python/python-ty/src/com/intellij/python/ty/typeProvider/TyExternalPyTypeResolverProvider.kt b/python/python-ty/src/com/intellij/python/ty/typeProvider/TyExternalPyTypeResolverProvider.kt new file mode 100644 index 000000000000..63472e087cf1 --- /dev/null +++ b/python/python-ty/src/com/intellij/python/ty/typeProvider/TyExternalPyTypeResolverProvider.kt @@ -0,0 +1,39 @@ +package com.intellij.python.ty.typeProvider + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.module.Module +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.registry.Registry +import com.intellij.platform.lsp.api.LspClientManager +import com.intellij.python.lsp.core.typeEngine.PyTypeEngineUtils +import com.intellij.python.ty.TyLspIntegrationProvider +import com.jetbrains.python.psi.types.engine.PyTypeEngine +import com.jetbrains.python.psi.types.engine.PyTypeEngineProvider + +/** + * External type provider that delegates to ty's LSP endpoint. + * This provider is enabled when the Type Engine feature is enabled via registry. + * The actual per-module check for ty configuration is done in TyTypeResolver.isSupportedForResolve. + */ +class TyExternalPyTypeResolverProvider : PyTypeEngineProvider { + override fun createResolver(module: Module): PyTypeEngine? { + // Check if type engine feature is enabled (via registry or unit tests) + val isFeatureEnabled = isTyTypeProviderFeatureEnabled(module.project) + if (!isFeatureEnabled) { + return null + } + + // Check if LSP server exists - if not, we can't provide types + val lspServerExists = LspClientManager.getInstance(module.project).getClients(TyLspIntegrationProvider::class.java).isNotEmpty() + if (!lspServerExists) { + return null + } + return TyTypeResolver() + } + +} + +internal fun isTyTypeProviderFeatureEnabled(project: Project): Boolean = + ApplicationManager.getApplication().isUnitTestMode || + PyTypeEngineUtils.isExternalTypeEngineSupported(project) && + Registry.`is`("ty.type.engine.support") \ No newline at end of file diff --git a/python/python-ty/src/com/intellij/python/ty/typeProvider/TyPyTypeEngine.kt b/python/python-ty/src/com/intellij/python/ty/typeProvider/TyPyTypeEngine.kt new file mode 100644 index 000000000000..daeb4035e6ad --- /dev/null +++ b/python/python-ty/src/com/intellij/python/ty/typeProvider/TyPyTypeEngine.kt @@ -0,0 +1,91 @@ +package com.intellij.python.ty.typeProvider + +import com.intellij.openapi.components.service +import com.intellij.openapi.observable.properties.PropertyGraph +import com.intellij.openapi.progress.coroutineToIndicator +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages +import com.intellij.platform.ide.progress.withBackgroundProgress +import com.intellij.platform.lsp.api.LspClientManager +import com.intellij.platform.lsp.api.stopAndRestartClientsIfNeeded +import com.intellij.platform.lsp.api.stopClients +import com.intellij.platform.util.progress.createProgressPipe +import com.intellij.python.lsp.core.PyLspCoreBundle +import com.intellij.python.lsp.core.typeEngine.PyTypeEngineProjectSettings +import com.intellij.python.lsp.core.typeEngine.PyTypeEngineProvider +import com.intellij.python.lsp.core.typeEngine.PyTypeEngineType +import com.intellij.python.ty.TyBundle +import com.intellij.python.ty.TyLspIntegrationProvider +import com.intellij.python.ty.TyService +import com.intellij.python.ty.TyUtil +import com.intellij.ui.dsl.builder.Panel +import com.intellij.ui.dsl.builder.RowsRange +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +internal class TyPyTypeEngine : PyTypeEngineProvider { + override val pyTypeEngineType: PyTypeEngineType + get() = PyTypeEngineType.TY + + override fun isSupported(project: Project): Boolean { + return isTyTypeProviderFeatureEnabled(project) + } + + override fun updateLspServers(project: Project) { + val lspServerManager = LspClientManager.getInstance(project) + if (shouldBeEnabled(project)) { + lspServerManager.stopAndRestartClientsIfNeeded() + } + else { + lspServerManager.stopClients() + } + } + + private fun shouldBeEnabled(project: Project): Boolean { + return isSupported(project) && project.service().typeEngine == PyTypeEngineType.TY + } + + + @Suppress("DialogTitleCapitalization") + override fun Panel.createConfigurableContent(project: Project, propertyGraph: PropertyGraph): RowsRange { + return rowsRange { + val tyInstalledProperty = propertyGraph.property(TyUtil.isTyInstalled()) + + row { + comment(PyLspCoreBundle.message("ty.description")) + } + + row { + button(TyBundle.message("install.ty.button")) { + project.service().cs.launch { + withBackgroundProgress(project, TyBundle.message("install.ty.progress"), true) { + val result = coroutineScope { + createProgressPipe().collectProgressUpdates { + coroutineToIndicator { indicator -> + TyUtil.downloadTyBinary(indicator) + } + } + } + if (result != null) { + // Update the property to reflect ty is now installed + tyInstalledProperty.set(true) + Messages.showInfoMessage( + project, + TyBundle.message("install.ty.success", result.toString()), + TyBundle.message("install.ty.title") + ) + } + else { + Messages.showErrorDialog( + project, + TyBundle.message("install.ty.failed"), + TyBundle.message("install.ty.title") + ) + } + } + } + } + } + } + } +} diff --git a/python/python-ty/src/com/intellij/python/ty/typeProvider/TyTypeContext.kt b/python/python-ty/src/com/intellij/python/ty/typeProvider/TyTypeContext.kt new file mode 100644 index 000000000000..67be2f5a6806 --- /dev/null +++ b/python/python-ty/src/com/intellij/python/ty/typeProvider/TyTypeContext.kt @@ -0,0 +1,53 @@ +package com.intellij.python.ty.typeProvider + +import com.intellij.openapi.util.Ref +import com.intellij.openapi.vfs.findDocument +import com.intellij.platform.lsp.api.LspClientManager +import com.intellij.platform.lsp.api.LspIntegrationProvider +import com.intellij.platform.lsp.util.getLsp4jRange +import com.intellij.psi.PsiFile +import com.intellij.python.lsp.core.type.LspTypeEvalContext +import com.intellij.python.lsp.core.type.PyStringTypeResolver +import com.intellij.python.ty.TyLsp4jServer +import com.intellij.python.ty.TyLspIntegrationProvider +import com.intellij.python.ty.TyUsageCollector +import com.jetbrains.python.psi.PyTypedElement +import com.jetbrains.python.psi.types.PyType +import org.jetbrains.annotations.Unmodifiable + + +class TyTypeContext(psiFile: PsiFile) : LspTypeEvalContext(psiFile) { + override fun requestTypes( + pyTypedElements: @Unmodifiable Collection, + ): List? { + val project = psiFile.project + val lspServers = LspClientManager.getInstance(project) + .getClients(TyLspIntegrationProvider::class.java as Class) + + val lspServer = lspServers.firstOrNull() ?: return null + + val virtualFile = psiFile.virtualFile ?: return null + val document = virtualFile.findDocument() ?: return null + val args = mapOf( + "textDocument" to lspServer.getDocumentIdentifier(virtualFile), + "ranges" to pyTypedElements.map { + val startOffset = it.textRange?.startOffset ?: return@map null + getLsp4jRange(document, startOffset, it.textLength) + }, + ) + + val response = lspServer.sendRequestSync { + (it as TyLsp4jServer).provideType(args) + } + + @Suppress("UNCHECKED_CAST") + val tys = (response?.get("tys") as? List) ?: (response?.get("types") as? List) ?: return null + return tys + } + + override fun resolveStringType(element: PyTypedElement, stringType: String): Ref? { + return TyUsageCollector.logStringTypeResolutionTime { + PyStringTypeResolver.resolvePyType(element, stringType) + } + } +} \ No newline at end of file diff --git a/python/python-ty/src/com/intellij/python/ty/typeProvider/TyTypeResolver.kt b/python/python-ty/src/com/intellij/python/ty/typeProvider/TyTypeResolver.kt new file mode 100644 index 000000000000..fdd308caf1b4 --- /dev/null +++ b/python/python-ty/src/com/intellij/python/ty/typeProvider/TyTypeResolver.kt @@ -0,0 +1,27 @@ +package com.intellij.python.ty.typeProvider + +import com.intellij.openapi.util.Ref +import com.intellij.python.lsp.core.type.LspPyTypeResolver +import com.intellij.python.lsp.core.typeEngine.PyTypeEngineProjectSettings +import com.intellij.python.lsp.core.typeEngine.PyTypeEngineType +import com.jetbrains.python.psi.PyTypedElement +import com.jetbrains.python.psi.types.PyType + +internal class TyTypeResolver : LspPyTypeResolver { + override val name: String = PyTypeEngineType.TY.name + + override fun isSupportedForResolve(pyTypedElement: PyTypedElement): Boolean { + // First check the base class conditions + if (!super.isSupportedForResolve(pyTypedElement)) { + return false + } + + // Check if the module's type engine is set to TY + return PyTypeEngineProjectSettings.getInstance(pyTypedElement.project).typeEngine == PyTypeEngineType.TY + } + + override fun resolveType(pyTypedElement: PyTypedElement, isLibrary: Boolean, isUserInitiated: Boolean): Ref? { + val context = TyTypeContext(pyTypedElement.containingFile) + return context.provideType(pyTypedElement, isUserInitiated) + } +} diff --git a/python/python-type-engine/BUILD.bazel b/python/python-type-engine/BUILD.bazel new file mode 100644 index 000000000000..52fcf970d78d --- /dev/null +++ b/python/python-type-engine/BUILD.bazel @@ -0,0 +1,125 @@ +load("@rules_jvm//:jvm.bzl", "jvm_library") + +### auto-generated section `build intellij.python.typeEngine` start + +jvm_library( + name = "typeEngine", + srcs = glob( + [ + "src/**/*.kt", + "src/**/*.java", + "src/**/*.form", + ], + allow_empty = True, + ), + module_name = "intellij.python.typeEngine", + resource_strip_prefix = "resources", + resources = glob( + ["resources/**/*"], + ), + visibility = ["//visibility:public"], + deps = [ + # do not sort, + "//platform/projectModel-api:projectModel", + "//platform/projectModel-impl", + "//platform/editor-ui-api:editor-ui", + "//platform/lang-impl", + "//python:python-community-impl", + "//python/python-sdk:sdk", + "//python/python-core-impl:community-core-impl", + "//platform/code-style-impl:codeStyle-impl", + "//platform/editor-ui-ex:editor-ex", + "//platform/platform-impl:ide-impl", + "//xml/xml-parser:parser", + "//platform/core-ui", + "//xml/impl", + "//platform/util/jdom", + "//platform/smRunner", + "//libraries/guava", + "//libraries/fastutil", + "//platform/lsp", + "//platform/core-api:core", + "@lib//:eclipse-lsp4j", + "@lib//:eclipse-lsp4j-jsonrpc", + "//platform/analysis-api:analysis", + "//python/python-lsp-core:lsp-core", + "//platform/core-impl", + "//libraries/jackson/jackson", + "//libraries/jackson/databind", + "//platform/observable", + "//python/python-pyrefly:pyrefly", + "//python/python-ty:ty", + "//platform/progress/shared:ide-progress", + "//platform/ide-core", + "//platform/util/progress", + "//platform/util:util-ui", + "//platform/lang-api:lang", + "//platform/statistics", + "//python/python-pytools:pytools", + "//python/python-pytools-ui:pytools-ui", + ], +) + +jvm_library( + name = "typeEngine_test_lib", + testonly = True, + srcs = glob( + [], + allow_empty = True, + ), + module_name = "intellij.python.typeEngine", + visibility = ["//visibility:public"], + runtime_deps = [ + # do not sort, + ":typeEngine", + "//platform/projectModel-api:projectModel_test_lib", + "//platform/projectModel-impl:projectModel-impl_test_lib", + "//platform/editor-ui-api:editor-ui_test_lib", + "//platform/lang-impl:lang-impl_test_lib", + "//python:python-community-impl_test_lib", + "//python/python-sdk:sdk_test_lib", + "//python/python-core-impl:community-core-impl_test_lib", + "//platform/code-style-impl:codeStyle-impl_test_lib", + "//platform/editor-ui-ex:editor-ex_test_lib", + "//platform/platform-impl:ide-impl_test_lib", + "//xml/xml-parser:parser_test_lib", + "//platform/core-ui:core-ui_test_lib", + "//xml/impl:impl_test_lib", + "//platform/util/jdom:jdom_test_lib", + "//platform/smRunner:smRunner_test_lib", + "//libraries/guava:guava_test_lib", + "//libraries/fastutil:fastutil_test_lib", + "//platform/lsp:lsp_test_lib", + "//platform/testFramework", + "//platform/testFramework:testFramework_test_lib", + "//platform/lsp/testFramework", + "//platform/lsp/testFramework:testFramework_test_lib", + "//platform/core-api:core_test_lib", + "//platform/analysis-api:analysis_test_lib", + "//python/python-lsp-core:lsp-core_test_lib", + "//platform/core-impl:core-impl_test_lib", + "//libraries/jackson/jackson:jackson_test_lib", + "//libraries/jackson/databind:databind_test_lib", + "//platform/observable:observable_test_lib", + "//python/python-pyrefly:pyrefly_test_lib", + "//python/python-ty:ty_test_lib", + "//platform/progress/shared:ide-progress_test_lib", + "//platform/ide-core:ide-core_test_lib", + "//platform/util/progress:progress_test_lib", + "//platform/util:util-ui_test_lib", + "//platform/lang-api:lang_test_lib", + "//platform/statistics:statistics_test_lib", + "//python/python-pytools:pytools_test_lib", + "//python/python-pytools-ui:pytools-ui_test_lib", + ], +) +### auto-generated section `build intellij.python.typeEngine` end + +### auto-generated section `iml intellij.python.typeEngine` start +exports_files( + [ + "intellij.python.typeEngine.iml", + ], + visibility = ["//visibility:public"], +) +### auto-generated section `iml intellij.python.typeEngine` end diff --git a/python/python-type-engine/intellij.python.typeEngine.iml b/python/python-type-engine/intellij.python.typeEngine.iml new file mode 100644 index 000000000000..8390294b21b5 --- /dev/null +++ b/python/python-type-engine/intellij.python.typeEngine.iml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/python/python-type-engine/resources/intellij.python.typeEngine.xml b/python/python-type-engine/resources/intellij.python.typeEngine.xml new file mode 100644 index 000000000000..efb61ddfff67 --- /dev/null +++ b/python/python-type-engine/resources/intellij.python.typeEngine.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/python/python-type-engine/resources/messages/TypeEngineBundle.properties b/python/python-type-engine/resources/messages/TypeEngineBundle.properties new file mode 100644 index 000000000000..abde9596e537 --- /dev/null +++ b/python/python-type-engine/resources/messages/TypeEngineBundle.properties @@ -0,0 +1,8 @@ +notification.group.python.type.engine=Python type engine notifications +display.name=Type Engine +project.default=Project default +open.settings=Type Engine Settings\u2026 +widget.type.text=Types: {0} +widget.type.tooltip=Selected type provider for python: {0} +action.engine.pyrefly.install.and.use=Install and Use Pyrefly +python.type.engine.notification.package.removed=Package 'pyrefly' has been removed. Switched to built-in type inference. diff --git a/python/python-type-engine/src/com/intellij/python/typeEngine/PyChooserTypeEngineConfigurableProvider.kt b/python/python-type-engine/src/com/intellij/python/typeEngine/PyChooserTypeEngineConfigurableProvider.kt new file mode 100644 index 000000000000..ed02012a3046 --- /dev/null +++ b/python/python-type-engine/src/com/intellij/python/typeEngine/PyChooserTypeEngineConfigurableProvider.kt @@ -0,0 +1,18 @@ +package com.intellij.python.typeEngine + +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.options.ConfigurableProvider +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.registry.Registry +import com.intellij.python.lsp.core.typeEngine.PyTypeEngineConfigurable + +/** + * Provider for Type Engine configurable. + */ +class PyChooserTypeEngineConfigurableProvider(private val project: Project) : ConfigurableProvider() { + override fun canCreateConfigurable(): Boolean { + return Registry.`is`("pycharm.type.engine", true) + } + + override fun createConfigurable(): Configurable = PyTypeEngineConfigurable(project) +} \ No newline at end of file diff --git a/python/python-type-engine/src/com/intellij/python/typeEngine/PyTypeEngineStatusBarWidget.kt b/python/python-type-engine/src/com/intellij/python/typeEngine/PyTypeEngineStatusBarWidget.kt new file mode 100644 index 000000000000..55c99dfcd7b2 --- /dev/null +++ b/python/python-type-engine/src/com/intellij/python/typeEngine/PyTypeEngineStatusBarWidget.kt @@ -0,0 +1,224 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.typeEngine + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.actionSystem.PlatformCoreDataKeys +import com.intellij.openapi.actionSystem.ex.ActionUtil +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.module.Module +import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.project.ModuleListener +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.ui.popup.ListPopup +import com.intellij.openapi.util.registry.Registry +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.wm.StatusBarWidget +import com.intellij.openapi.wm.StatusBarWidgetFactory +import com.intellij.openapi.wm.impl.status.EditorBasedStatusBarPopup +import com.intellij.python.lsp.core.listener.PyLspListener +import com.intellij.python.lsp.core.typeEngine.PyTypeEngineConfigurable +import com.intellij.python.lsp.core.typeEngine.PyTypeEngineProjectSettings +import com.intellij.python.lsp.core.typeEngine.PyTypeEngineProvider +import com.intellij.python.lsp.core.typeEngine.PyTypeEngineType +import com.intellij.python.lsp.core.typeEngine.PyTypeEngineUsageCollector +import com.intellij.python.lsp.core.typeEngine.PyTypeEngineUtils +import com.intellij.util.messages.MessageBusConnection +import com.jetbrains.python.PythonFileType +import com.jetbrains.python.packaging.management.PythonPackageManager +import com.jetbrains.python.packaging.management.hasInstalledPackageSnapshot +import com.jetbrains.python.pyi.PyiFileType +import com.jetbrains.python.sdk.PySdkListener +import com.jetbrains.python.sdk.pythonSdk +import kotlinx.coroutines.CoroutineScope +import kotlin.coroutines.cancellation.CancellationException + +private const val ID: String = "pythonTypeEngineWidget" + +/** + * Factory for creating the Type Engine status bar widget. + */ +internal class PyTypeEngineStatusBarWidgetFactory : StatusBarWidgetFactory { + override fun getId(): String = ID + + override fun getDisplayName(): String = TypeEngineBundle.message("display.name") + + override fun isAvailable(project: Project): Boolean { + return Registry.`is`("pycharm.type.engine", true) + } + + override fun createWidget(project: Project, scope: CoroutineScope): StatusBarWidget { + return PyTypeEngineStatusBarWidget(project, scope) + } +} + +/** + * Status bar widget that displays the current Type Engine selection. + */ +private class PyTypeEngineStatusBarWidget( + project: Project, + scope: CoroutineScope, +) : EditorBasedStatusBarPopup(project = project, isWriteableFileRequired = false, scope = scope) { + override fun ID(): String = ID + + override fun createInstance(project: Project): StatusBarWidget = PyTypeEngineStatusBarWidget(project, scope) + + override fun getWidgetState(file: VirtualFile?): WidgetState { + if (file?.fileType !is PythonFileType) + return WidgetState.HIDDEN + if (!PyTypeEngineUtils.isExternalTypeEngineSupported(project)) + return WidgetState.HIDDEN + + val typeEngine = PyTypeEngineProjectSettings.getInstance(project).typeEngine + return WidgetState( + TypeEngineBundle.message("widget.type.tooltip", typeEngine.displayName), + TypeEngineBundle.message("widget.type.text", typeEngine.displayName), + true + ) + } + + override fun registerCustomListeners(connection: MessageBusConnection) { + connection.subscribe(ModuleListener.TOPIC, object : ModuleListener { + override fun moduleRemoved(project: Project, module: Module) { + tryUpdateWidgetState() + } + + override fun modulesAdded(project: Project, modules: List) { + if (!project.isInitialized) + return + tryUpdateWidgetState() + } + }) + + connection.subscribe(PyLspListener.TOPIC, object : PyLspListener { + override fun onTypeSettingsChange() { + tryUpdateWidgetState() + } + }) + + connection.subscribe(PySdkListener.TOPIC, object : PySdkListener { + override fun moduleSdkUpdated(module: Module, prevSdk: Sdk?, newSdk: Sdk?) { + tryUpdateWidgetState() + } + }) + } + + override fun isEnabledForFile(file: VirtualFile?): Boolean = file?.fileType in setOf(PythonFileType.INSTANCE, PyiFileType.INSTANCE) + + override fun createPopup(context: DataContext): ListPopup { + PyTypeEngineUsageCollector.logStatusBarWidgetClicked(project) + val group = DefaultActionGroup() + + val module = context.getData(PlatformCoreDataKeys.MODULE) + // Add engine selection actions + PyTypeEngineProvider.getSupportedTypes(project).forEach { + group.add(SelectEngineAction(it, module)) + } + + // Add separator + group.addSeparator() + + // Add settings action + group.add(OpenTypeEngineSettingsAction()) + + return JBPopupFactory.getInstance().createActionGroupPopup( + TypeEngineBundle.message("display.name"), + group, + context, + JBPopupFactory.ActionSelectionAid.SPEEDSEARCH, + false, + ActionPlaces.STATUS_BAR_PLACE + ) + } + + /** + * Action to select a specific type engine. + */ + private inner class SelectEngineAction( + private val engine: PyTypeEngineType, + val module: Module?, + ) : DumbAwareAction(engine.displayName, null, null) { + + override fun actionPerformed(e: AnActionEvent) { + val settings = PyTypeEngineProjectSettings.getInstance(project) + settings.typeEngine = engine + PyTypeEngineUsageCollector.logEngineChanged(project, engine) + tryUpdateWidgetState() + } + + @Suppress("UsagesOfObsoleteApi") + override fun update(e: AnActionEvent) { + val currentEngine = PyTypeEngineProjectSettings.getInstance(project).typeEngine + if (engine == PyTypeEngineType.PYREFLY) { + e.presentation.putClientProperty(ActionUtil.SECONDARY_ICON, AllIcons.General.Beta) + } + e.presentation.icon = if (currentEngine == engine) + AllIcons.Actions.Checked + else + null + + if (engine == PyTypeEngineType.PYCHARM) { + return + } + + val sdk = module?.pythonSdk + if (sdk == null) { + e.presentation.isEnabledAndVisible = false + return + } + + val packageManager = PythonPackageManager.forSdk(project, sdk) + val isInstalled = packageManager.hasInstalledPackageSnapshot(engine.packageName) + if (!isInstalled && engine == PyTypeEngineType.PYREFLY) { + e.presentation.text = TypeEngineBundle.message("action.engine.pyrefly.install.and.use") + } + } + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + } + + private fun tryUpdateWidgetState() { + if (ApplicationManager.getApplication().isUnitTestMode) { + return + } + if (project.isDisposed) { + return + } + @Suppress("PyExceptionTooBroad") + try { + update() + } + catch (t: CancellationException) { + throw t + } + catch (t: Throwable) { + thisLogger().warn("Cannot update type widget", t) + //ignore + } + } + + /** + * Action to open Type Engine settings. + */ + private inner class OpenTypeEngineSettingsAction : DumbAwareAction( + TypeEngineBundle.message("open.settings"), + null, + AllIcons.General.Settings + ) { + override fun actionPerformed(e: AnActionEvent) { + PyTypeEngineUsageCollector.logSettingsOpened(project) + ShowSettingsUtil.getInstance().showSettingsDialog( + project, + PyTypeEngineConfigurable::class.java + ) + } + } +} \ No newline at end of file diff --git a/python/python-type-engine/src/com/intellij/python/typeEngine/RestartLspServersListener.kt b/python/python-type-engine/src/com/intellij/python/typeEngine/RestartLspServersListener.kt new file mode 100644 index 000000000000..722c150ab343 --- /dev/null +++ b/python/python-type-engine/src/com/intellij/python/typeEngine/RestartLspServersListener.kt @@ -0,0 +1,139 @@ +package com.intellij.python.typeEngine + +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.EDT +import com.intellij.openapi.components.service +import com.intellij.openapi.module.Module +import com.intellij.openapi.project.ModuleListener +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.modules +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.psi.PsiDocumentManager +import com.intellij.python.lsp.core.listener.PyLspListener +import com.intellij.python.lsp.core.typeEngine.PyTypeEngineProjectSettings +import com.intellij.python.lsp.core.typeEngine.PyTypeEngineProvider +import com.intellij.python.lsp.core.typeEngine.PyTypeEngineType +import com.intellij.python.lsp.core.typeEngine.PyTypeEngineUtils +import com.intellij.python.pyrefly.PyreflyConfiguration +import com.intellij.python.pyrefly.PyreflyPyTool +import com.intellij.python.pyrefly.PyreflyUsageCollector +import com.intellij.python.pytools.PyToolsState +import com.intellij.python.pytools.configuration.ExecutableDiscoveryMode +import com.intellij.python.pytools.isEnabledOn +import com.intellij.python.pytools.ui.getInstalledToolPackage +import com.intellij.python.ty.TyPyTool +import com.jetbrains.python.extensions.getSdk +import com.jetbrains.python.packaging.PythonVersionValue +import com.jetbrains.python.packaging.common.PythonPackageManagementListener +import com.jetbrains.python.packaging.management.PythonPackageManager +import com.jetbrains.python.packaging.management.ui.PythonPackageManagerUI +import com.jetbrains.python.packaging.management.ui.installPyRequirementsBackground +import com.jetbrains.python.packaging.pyRequirement +import com.jetbrains.python.packaging.requirement.PyRequirementRelation +import com.jetbrains.python.sdk.PySdkListener +import com.jetbrains.python.sdk.isReadOnly +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +internal class RestartLspServersListener(val project: Project) : PyLspListener, ModuleListener, PySdkListener, + PythonPackageManagementListener { + override fun moduleSdkUpdated(module: Module, prevSdk: Sdk?, newSdk: Sdk?) { + updateModules() + updateLspServers() + } + + override fun modulesAdded(project: Project, modules: List) { + updateModules() + } + + override fun moduleRemoved(project: Project, module: Module) { + updateModules() + } + + override fun packagesChanged(sdk: Sdk) { + if (ApplicationManager.getApplication().isUnitTestMode) { + return + } + val typeEngineProjectSettings = PyTypeEngineProjectSettings.getInstance(project) + + if (typeEngineProjectSettings.typeEngine != PyTypeEngineType.PYREFLY) + return + + val pyreflyConfiguration = project.service() + if (pyreflyConfiguration.executableDiscoveryMode != ExecutableDiscoveryMode.INTERPRETER) + return + + val manager = PythonPackageManager.forSdk(project, sdk) + project.service().coroutineScope.launch { + val isInstalled = manager.getInstalledToolPackage(PyreflyPyTool.getInstance()) != null + if (isInstalled) { + return@launch + } + + //If package has been removed change type settings to default + typeEngineProjectSettings.typeEngine = PyTypeEngineType.PYCHARM + + val notificationGroup = NotificationGroupManager.getInstance().getNotificationGroup("Python Type Engine") + val notification = notificationGroup.createNotification(TypeEngineBundle.message("python.type.engine.notification.package.removed"), + NotificationType.INFORMATION) + withContext(Dispatchers.EDT) { + notification.notify(project) + } + } + } + + override fun onTypeSettingsChange() { + project.service().coroutineScope.launch { + val typeEngineProjectSettings = PyTypeEngineProjectSettings.getInstance(project) + + val wasEnabled = PyreflyPyTool.getInstance().isEnabledOn(project) + if (!wasEnabled && typeEngineProjectSettings.typeEngine == PyTypeEngineType.PYREFLY) { + project.modules.forEach { module -> + val pythonSdk = module.getSdk() ?: return@forEach + val managerUI = PythonPackageManagerUI.forSdk(project, pythonSdk) + if (pythonSdk.isReadOnly) { + return@launch + } + + val pythonPackage = managerUI.manager.getInstalledToolPackage(PyreflyPyTool.getInstance()) + val version = pythonPackage?.version?.let { PythonVersionValue.parse(it) }?.successOrNull + val minimumVersion = "0.60.0" + val typeResolveSupported = PythonVersionValue.parse(minimumVersion).successOrNull!! + if (version == null || version < typeResolveSupported) { + val primaryPackageName = PyreflyPyTool.getInstance().packageName.name + val pyRequirement = pyRequirement(primaryPackageName, PyRequirementRelation.GTE, minimumVersion) + val result = managerUI.installPyRequirementsBackground(listOf(pyRequirement)) + PyreflyUsageCollector.logPyreflyAutoInstalled(result != null) + } + } + } + with(PyToolsState.getInstance(project)) { + setEnabled(PyreflyPyTool.getInstance(), typeEngineProjectSettings.typeEngine == PyTypeEngineType.PYREFLY) + setEnabled(TyPyTool.getInstance(), typeEngineProjectSettings.typeEngine == PyTypeEngineType.TY) + } + updateLspServers() + } + } + + private fun updateModules() { + val isSupported = PyTypeEngineUtils.isExternalTypeEngineSupported(project) + val typeEngineProjectSettings = PyTypeEngineProjectSettings.getInstance(project) + + if (!isSupported && typeEngineProjectSettings.typeEngine != PyTypeEngineType.PYCHARM) { + typeEngineProjectSettings.typeEngine = PyTypeEngineType.PYCHARM + onTypeSettingsChange() + } + } + + private fun updateLspServers() { + project.service().coroutineScope.launch { + PyTypeEngineProvider.updateLspServers(project) + withContext(Dispatchers.EDT) { + PsiDocumentManager.getInstance(project).reparseFiles(listOf(), true) + } + } + } +} \ No newline at end of file diff --git a/python/python-type-engine/src/com/intellij/python/typeEngine/TypeEngineBundle.kt b/python/python-type-engine/src/com/intellij/python/typeEngine/TypeEngineBundle.kt new file mode 100644 index 000000000000..e81059b39379 --- /dev/null +++ b/python/python-type-engine/src/com/intellij/python/typeEngine/TypeEngineBundle.kt @@ -0,0 +1,18 @@ +package com.intellij.python.typeEngine + +import com.intellij.DynamicBundle +import org.jetbrains.annotations.Nls +import org.jetbrains.annotations.NonNls +import org.jetbrains.annotations.PropertyKey + +object TypeEngineBundle { + private val myDynamicBundle = DynamicBundle(this::class.java, BUNDLE) + + @NonNls + const val BUNDLE: String = "messages.TypeEngineBundle" + + @Nls + fun message(@PropertyKey(resourceBundle = BUNDLE) key: String, vararg params: Any): String { + return myDynamicBundle.getMessage(key, *params) + } +} diff --git a/python/python-type-engine/src/com/intellij/python/typeEngine/TypeInferenceCoroutine.kt b/python/python-type-engine/src/com/intellij/python/typeEngine/TypeInferenceCoroutine.kt new file mode 100644 index 000000000000..0a5db8a3a785 --- /dev/null +++ b/python/python-type-engine/src/com/intellij/python/typeEngine/TypeInferenceCoroutine.kt @@ -0,0 +1,10 @@ +package com.intellij.python.typeEngine + +import com.intellij.openapi.components.Service +import kotlinx.coroutines.CoroutineScope + +/** + * Service to provide a coroutine scope for the project. + */ +@Service(Service.Level.PROJECT) +internal class TypeInferenceCoroutine(val coroutineScope: CoroutineScope) \ No newline at end of file diff --git a/python/testSrc/com/intellij/python/lsp/core/PyLspSupportedFileTest.kt b/python/testSrc/com/intellij/python/lsp/core/PyLspSupportedFileTest.kt new file mode 100644 index 000000000000..6993abbbcd2f --- /dev/null +++ b/python/testSrc/com/intellij/python/lsp/core/PyLspSupportedFileTest.kt @@ -0,0 +1,40 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.lsp.core + +import com.intellij.lang.Language +import com.intellij.testFramework.LightVirtualFile +import com.jetbrains.python.PythonFileType +import com.jetbrains.python.PythonLanguage +import com.jetbrains.python.fixtures.PyTestCase + +class PyLspSupportedFileTest : PyTestCase() { + fun testPythonSourceIsSupported() { + val pythonFile = LightVirtualFile("test.py", PythonFileType.INSTANCE, "x = 1") + + assertTrue(isPythonFile(pythonFile)) + } + + fun testNotebookIsNotSupportedWhenGlobalNotebookLspIsDisabled() { + val notebook = LightVirtualFile("test.ipynb") + + assertFalse( + isPythonFile(notebook, notebookSupported = true, resolvers = listOf(FakeNotebookLanguageResolver(PythonLanguageHolder.language))), + ) + } + + fun testNotebookIsNotSupportedWhenDescriptorDoesNotSupportNotebooks() { + val notebook = LightVirtualFile("test.ipynb") + + assertFalse( + isPythonFile(notebook, notebookSupported = false, resolvers = listOf(FakeNotebookLanguageResolver(PythonLanguageHolder.language))), + ) + } + + private class FakeNotebookLanguageResolver(private val language: Language?) : NotebookLanguageResolver { + override fun resolveLanguage(file: com.intellij.openapi.vfs.VirtualFile): Language? = language + } + + private object PythonLanguageHolder { + val language: Language = PythonLanguage.INSTANCE + } +} diff --git a/python/testSrc/com/intellij/python/lsp/core/type/LspCollectSupportedTypesVisitorTest.kt b/python/testSrc/com/intellij/python/lsp/core/type/LspCollectSupportedTypesVisitorTest.kt new file mode 100644 index 000000000000..1a5e50c71122 --- /dev/null +++ b/python/testSrc/com/intellij/python/lsp/core/type/LspCollectSupportedTypesVisitorTest.kt @@ -0,0 +1,116 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.lsp.core.type + +import com.jetbrains.python.fixtures.PyTestCase +import com.jetbrains.python.psi.PyBinaryExpression +import com.jetbrains.python.psi.PyCallExpression +import com.jetbrains.python.psi.PyCapturePattern +import com.jetbrains.python.psi.PyClassPattern +import com.jetbrains.python.psi.PyDecorator +import com.jetbrains.python.psi.PyDictLiteralExpression +import com.jetbrains.python.psi.PyFunction +import com.jetbrains.python.psi.PyLambdaExpression +import com.jetbrains.python.psi.PyListLiteralExpression +import com.jetbrains.python.psi.PyLiteralPattern +import com.jetbrains.python.psi.PyMappingPattern +import com.jetbrains.python.psi.PyNamedParameter +import com.jetbrains.python.psi.PyPrefixExpression +import com.jetbrains.python.psi.PyReferenceExpression +import com.jetbrains.python.psi.PySequencePattern +import com.jetbrains.python.psi.PySetCompExpression +import com.jetbrains.python.psi.PySetLiteralExpression +import com.jetbrains.python.psi.PySubscriptionExpression +import com.jetbrains.python.psi.PyTargetExpression +import com.jetbrains.python.psi.PyYieldExpression + +class LspCollectSupportedTypesVisitorTest : PyTestCase() { + fun `test visitor collects all supported types`() { + myFixture.configureByText("test.py", """ + import typing + from typing import Dict, List, Set + + # PyFunction + @property # PyDecorator + def test_function(param: int) -> str: # PyNamedParameter + # PyReferenceExpression, PyTargetExpression, PyBinaryExpression + x = y + z + + # PyCallExpression + result = some_function(1, 2, 3) + + # PyListLiteralExpression + list_val = [1, 2, 3] + + # PyDictLiteralExpression + dict_val = {"key": "value", "num": 42} + + # PySetLiteralExpression + set_val = {1, 2, 3} + + # PySetCompExpression + set_comp = {x for x in range(10) if x % 2 == 0} + + # PySubscriptionExpression + indexed = list_val[0] + + # PyPrefixExpression + negative = -x + not_val = not True + + # PyYieldExpression + yield result + + # PyLambdaExpression + lambda_func = lambda a, b: a + b + + # Pattern matching examples (Python 3.10+) + def match_example(value): + match value: + # PyLiteralPattern + case 42: + return "literal" + # PyCapturePattern + case x: + return f"captured {x}" + # PyWildcardPattern + case _: + return "wildcard" + # PySequencePattern + case [first, *rest]: + return f"sequence: first={first}, rest={rest}" + # PyMappingPattern + case {"key": val, **other}: + return f"mapping: key={val}, other={other}" + # PyClassPattern + case dict() as d: + return f"class pattern: {d}" + """.trimIndent()) + + val visitor = LspCollectSupportedTypesVisitor() + myFixture.file.accept(visitor) + + val results = visitor.result + + // Verify specific type instances + assertTrue("Should have PyMappingPattern instances", results.any { it is PyMappingPattern }) + assertTrue("Should have PySequencePattern instances", results.any { it is PySequencePattern }) + assertTrue("Should have PyBinaryExpression instances", results.any { it is PyBinaryExpression }) + assertTrue("Should have PyCallExpression instances", results.any { it is PyCallExpression }) + assertTrue("Should have PyCapturePattern instances", results.any { it is PyCapturePattern }) + assertTrue("Should have PyClassPattern instances", results.any { it is PyClassPattern }) + assertTrue("Should have PyDecorator instances", results.any { it is PyDecorator }) + assertTrue("Should have PyDictLiteralExpression instances", results.any { it is PyDictLiteralExpression }) + assertTrue("Should have PyFunction instances", results.any { it is PyFunction }) + assertTrue("Should have PyLambdaExpression instances", results.any { it is PyLambdaExpression }) + assertTrue("Should have PyListLiteralExpression instances", results.any { it is PyListLiteralExpression }) + assertTrue("Should have PyLiteralPattern instances", results.any { it is PyLiteralPattern }) + assertTrue("Should have PyNamedParameter instances", results.any { it is PyNamedParameter }) + assertTrue("Should have PyPrefixExpression instances", results.any { it is PyPrefixExpression }) + assertTrue("Should have PyReferenceExpression instances", results.any { it is PyReferenceExpression }) + assertTrue("Should have PySetCompExpression instances", results.any { it is PySetCompExpression }) + assertTrue("Should have PySetLiteralExpression instances", results.any { it is PySetLiteralExpression }) + assertTrue("Should have PySubscriptionExpression instances", results.any { it is PySubscriptionExpression }) + assertTrue("Should have PyTargetExpression instances", results.any { it is PyTargetExpression }) + assertTrue("Should have PyYieldExpression instances", results.any { it is PyYieldExpression }) + } +} \ No newline at end of file diff --git a/python/testSrc/com/intellij/python/lsp/core/type/LspIsSupportedTypesVisitorTest.kt b/python/testSrc/com/intellij/python/lsp/core/type/LspIsSupportedTypesVisitorTest.kt new file mode 100644 index 000000000000..753ff34f49eb --- /dev/null +++ b/python/testSrc/com/intellij/python/lsp/core/type/LspIsSupportedTypesVisitorTest.kt @@ -0,0 +1,135 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.lsp.core.type + +import com.intellij.psi.PsiElement +import com.intellij.psi.util.PsiTreeUtil +import com.jetbrains.python.fixtures.PyTestCase +import com.jetbrains.python.psi.PyBinaryExpression +import com.jetbrains.python.psi.PyCallExpression +import com.jetbrains.python.psi.PyCapturePattern +import com.jetbrains.python.psi.PyClassPattern +import com.jetbrains.python.psi.PyDecorator +import com.jetbrains.python.psi.PyDictLiteralExpression +import com.jetbrains.python.psi.PyFunction +import com.jetbrains.python.psi.PyLambdaExpression +import com.jetbrains.python.psi.PyListLiteralExpression +import com.jetbrains.python.psi.PyLiteralPattern +import com.jetbrains.python.psi.PyMappingPattern +import com.jetbrains.python.psi.PyNamedParameter +import com.jetbrains.python.psi.PyPrefixExpression +import com.jetbrains.python.psi.PyReferenceExpression +import com.jetbrains.python.psi.PySequencePattern +import com.jetbrains.python.psi.PySetCompExpression +import com.jetbrains.python.psi.PySetLiteralExpression +import com.jetbrains.python.psi.PySubscriptionExpression +import com.jetbrains.python.psi.PyTargetExpression +import com.jetbrains.python.psi.PyWildcardPattern +import com.jetbrains.python.psi.PyYieldExpression +import kotlin.reflect.KClass + +class LspIsSupportedTypesVisitorTest : PyTestCase() { + + private fun testElementSupported(elementClass: KClass, code: String) { + myFixture.configureByText("test.py", code.trimIndent()) + + val element = PsiTreeUtil.findChildOfType(myFixture.file, elementClass.java) + assertNotNull("Should find ${elementClass.simpleName} in file", element) + + val visitor = LspIsSupportedTypesVisitor() + element!!.accept(visitor) + assertTrue("Should detect ${elementClass.simpleName} as supported", visitor.isSupported) + } + + fun `test PyMappingPattern is supported`() = testElementSupported(PyMappingPattern::class, """ + def match_example(value): + match value: + case {"key": val, **other}: + return f"mapping: key={val}, other={other}" + """) + + fun `test PySequencePattern is supported`() = testElementSupported(PySequencePattern::class, """ + def match_example(value): + match value: + case [first, *rest]: + return f"sequence: first={first}, rest={rest}" + """) + + fun `test PyBinaryExpression is supported`() = testElementSupported(PyBinaryExpression::class, "x = y + z") + + fun `test PyCallExpression is supported`() = testElementSupported(PyCallExpression::class, "result = some_function(1, 2, 3)") + + fun `test PyCapturePattern is supported`() = testElementSupported(PyCapturePattern::class, """ + def match_example(value): + match value: + case x: + return f"captured {x}" + """) + + fun `test PyClassPattern is supported`() = testElementSupported(PyClassPattern::class, """ + def match_example(value): + match value: + case dict() as d: + return f"class pattern: {d}" + """) + + fun `test PyDecorator is supported`() = testElementSupported(PyDecorator::class, """ + @property + def test_function(): + pass + """) + + fun `test PyDictLiteralExpression is supported`() = testElementSupported(PyDictLiteralExpression::class, """dict_val = {"key": "value", "num": 42}""") + + fun `test PyFunction is supported`() = testElementSupported(PyFunction::class, """ + def test_function(): + pass + """) + + fun `test PyLambdaExpression is supported`() = testElementSupported(PyLambdaExpression::class, "lambda_func = lambda a, b: a + b") + + fun `test PyListLiteralExpression is supported`() = testElementSupported(PyListLiteralExpression::class, "list_val = [1, 2, 3]") + + fun `test PyLiteralPattern is supported`() = testElementSupported(PyLiteralPattern::class, """ + def match_example(value): + match value: + case 42: + return "literal" + """) + + fun `test PyNamedParameter is supported`() = testElementSupported(PyNamedParameter::class, """ + def test_function(param: int): + pass + """) + + fun `test PyPrefixExpression is supported`() = testElementSupported(PyPrefixExpression::class, "negative = -x") + + fun `test PyReferenceExpression is supported`() = testElementSupported(PyReferenceExpression::class, "x = y") + + fun `test PySetCompExpression is supported`() = testElementSupported(PySetCompExpression::class, "set_comp = {x for x in range(10) if x % 2 == 0}") + + fun `test PySetLiteralExpression is supported`() = testElementSupported(PySetLiteralExpression::class, "set_val = {1, 2, 3}") + + fun `test PySubscriptionExpression is supported`() = testElementSupported(PySubscriptionExpression::class, "indexed = list_val[0]") + + fun `test PyTargetExpression is supported`() = testElementSupported(PyTargetExpression::class, "x = 42") + + fun `test PyWildcardPattern is supported`() = testElementSupported(PyWildcardPattern::class, """ + def match_example(value): + match value: + case _: + return "wildcard" + """) + + fun `test PyYieldExpression is supported`() = testElementSupported(PyYieldExpression::class, """ + def generator(): + yield 42 + """) + + fun `test unsupported file returns false`() { + myFixture.configureByText("empty.py", "# Just a comment") + + val visitor = LspIsSupportedTypesVisitor() + myFixture.file.accept(visitor) + assertFalse("Should not detect supported types in comment-only file", visitor.isSupported) + } +} \ No newline at end of file diff --git a/python/testSrc/com/intellij/python/utils/PyStringTypeResolverTest.kt b/python/testSrc/com/intellij/python/utils/PyStringTypeResolverTest.kt new file mode 100644 index 000000000000..4238c69b976b --- /dev/null +++ b/python/testSrc/com/intellij/python/utils/PyStringTypeResolverTest.kt @@ -0,0 +1,526 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.utils + +import com.intellij.openapi.application.PathManager +import com.intellij.psi.PsiElement +import com.intellij.python.lsp.core.type.PyStringTypeResolver +import com.jetbrains.python.PyCustomType +import com.jetbrains.python.PyNames +import com.jetbrains.python.codeInsight.typing.PyTypingTypeProvider +import com.jetbrains.python.fixtures.PyTestCase +import com.jetbrains.python.psi.AccessDirection +import com.jetbrains.python.psi.PyCallable +import com.jetbrains.python.psi.PyFile +import com.jetbrains.python.psi.PyTypedElement +import com.jetbrains.python.psi.impl.PyBuiltinCache +import com.jetbrains.python.psi.impl.PyFileImpl +import com.jetbrains.python.psi.resolve.PyResolveContext +import com.jetbrains.python.psi.types.PyAnyType +import com.jetbrains.python.psi.types.PyCallableType +import com.jetbrains.python.psi.types.PyClassType +import com.jetbrains.python.psi.types.PyClassTypeImpl +import com.jetbrains.python.psi.types.PyFunctionType +import com.jetbrains.python.psi.types.PyLiteralType +import com.jetbrains.python.psi.types.PyModuleType +import com.jetbrains.python.psi.types.PyNumericTowerUtil +import com.jetbrains.python.psi.types.PyOverloadType +import com.jetbrains.python.psi.types.PySelfType +import com.jetbrains.python.psi.types.PyTupleType +import com.jetbrains.python.psi.types.PyType +import com.jetbrains.python.psi.types.PyTypeVarType +import com.jetbrains.python.psi.types.PyTypedDictType +import com.jetbrains.python.psi.types.PyTypingNewType +import com.jetbrains.python.psi.types.PyUnionType +import com.jetbrains.python.psi.types.PyUnpackedTupleTypeImpl +import com.jetbrains.python.psi.types.TypeEvalContext +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract + +class PyStringTypeResolverTest : PyTestCase() { + override fun setUp() { + super.setUp() + myFixture.configureByText("test.py", """ + from typing import TypedDict, Protocol, Generic, Literal + + def f() -> None: ... + + class A: + class B: + def f(self) -> None: ... + def f(self) -> None: ... + @staticmethod + def s() -> None: ... + def g[T: int](t: T) -> T: ... + + class B: + def f(self) -> None: ... + + class B(TypedDict): + a: int + """.trimIndent()) + } + + val builtins get() = PyBuiltinCache.getInstance(myFixture.file) + + private val typeEvalContext: TypeEvalContext + get() = TypeEvalContext.externalContext(myFixture.project) + + @OptIn(ExperimentalContracts::class) + inline fun Any?.assertIs(): T & Any { + contract { returns() implies (this@assertIs is T) } + + return assertInstanceOf(this, T::class.java) + } + + private inline fun parse(s: String, anchor: PsiElement = myFixture.file): T { + val result = PyStringTypeResolver.resolvePyType(anchor as PyTypedElement, s.trimIndent()) + assertNotNull("the type completely failed to parse", result) + return result!!.get().assertIs() + } + + fun `test parse unresolved`() { + val ty = PyStringTypeResolver.resolvePyType(myFixture.file as PyFileImpl, "unresolved") + assertNull("The type was resolved, which is unexpected", ty) + } + + fun `test parse Any old`() { + val result = PyStringTypeResolver.resolvePyType(myFixture.file as PyTypedElement, PyTypingTypeProvider.ANY) + assertNotNull("the type completely failed to parse", result) + assertNull("The type was not Any", result!!.get()) + } + + fun `test parse Any`() { + withNewAnyTypeEnabled { + parse(PyTypingTypeProvider.ANY) + } + } + + fun `test parse Unknown`() { + withNewAnyTypeEnabled { + parse(PyNames.UNKNOWN_TYPE) + } + } + + fun `test parse union with Any`() { + parse("typing.Any | None") + } + + fun `test parse simple`() { + val mapping = + parse("collections.abc.Mapping[collections.abc.Sequence[builtins.int], collections.abc.Iterable[builtins.str]]") + + val params = mapping.typeArguments + assertSize(2, params) + + val (keyParam, valParam) = params + + // Key is Sequence[int] + keyParam.assertIs() + val keyIter = keyParam.iteratedItemType + assertEquals(builtins.intType, keyIter) + + // Value is Iterable[str] + valParam.assertIs() + val valIter = valParam.iteratedItemType!! + assertEquals(builtins.strType, valIter) + } + + fun `test parse tuple fixed`() { + val tupleType = parse("builtins.tuple[builtins.int, builtins.str]") + assertFalse(tupleType.isHomogeneous) + + val elems = tupleType.elementTypes + + assertSize(2, elems) + val (first, second) = elems + assertEquals(builtins.intType, first) + assertEquals(builtins.strType, second) + } + + fun `test parse int`() { + val type = parse("builtins.int") + assertFalse(type.isDefinition) + assertEquals(builtins.intType, type) + } + + fun `test parse type int`() { + val type = parse("builtins.type[builtins.int]") + assertTrue(type.isDefinition) + assertEquals(PyNames.FQN.INT, type.classQName) + } + + fun `test parse tuple variadic`() { + val tupleType = parse("builtins.tuple[builtins.int, ...]") + + assertTrue(tupleType.isHomogeneous) + assertEquals(builtins.intType, tupleType.elementTypes.single()) + } + + fun `test parse literal`() { + val union = parse("typing.Literal[1, 2]") + assertSize(2, union.members) + + val (left, right) = union.members.toList() + left.assertIs() + assertEquals("1", left.expressionText) + right.assertIs() + assertEquals("2", right.expressionText) + } + + fun `test parse function type`() { + val fnType = parse("def test.f(x: builtins.int, y: builtins.str = 'abb') -> builtins.str") + + assertEquals("f", fnType.callable.name) + + val parameters = fnType.getParameters(typeEvalContext)!! + assertSize(2, parameters) + + val parameter0 = parameters[0] + assertEquals("x", parameter0.name) + val parameter0Type = parameter0.getType(typeEvalContext).assertIs() + assertEquals("int", parameter0Type.name) + assertFalse(parameter0.hasDefaultValue()) + + val parameter1 = parameters[1] + assertEquals("y", parameter1.name) + val parameter1Type = parameter1.getType(typeEvalContext).assertIs() + assertEquals("str", parameter1Type.name) + assertTrue(parameter1.hasDefaultValue()) + + val returnType = fnType.getReturnType(typeEvalContext).assertIs() + assertEquals("str", returnType.name) + } + + fun `test parse member callable`() { + val fnType = parse("def test.A.f() -> None") + + assertEquals("f", fnType.callable.name) + } + + fun `test parse member callable staticmethod`() { + val fnType = parse("def test.A.s() -> None") + + assertEquals("s", fnType.callable.name) + } + + fun `test parse function from nested class`() { + val fnType = parse("def test.A.B.f() -> None") + + assertEquals("f", fnType.callable.name) + } + + fun `test parse function unresolved`() { + parse("def unresolved() -> Unknown") + } + + fun `test parse function with type parameter`() { + val fnType = parse("def test.A.g[T: builtins.int](x: T) -> T") + + assertEquals("g", fnType.callable.name) + + fnType.getReturnType(typeEvalContext).assertIs() + } + + fun `test parse dict literal expression type`() { + //{'new_col': ['sum', 'mean']} + parse("builtins.dict[builtins.str, builtins.list[builtins.str]]") + } + + fun `test parse module expression type`() { + myFixture.configureByFile("pandas/__init__.py") + val moduleType = parse("Module[pandas]") + assertEquals("pandas", moduleType.name) + } + + fun `test parse callable type`() { + val ct = parse("(builtins.int) -> builtins.str") + + val params = ct.getParameters(typeEvalContext)!! + assertEquals(1, params.size) + val p0Ty = params.single().getType(typeEvalContext)!! + assertEquals("int", p0Ty.name) + val ret = ct.getReturnType(typeEvalContext)!! + assertEquals("str", ret.name) + } + + fun `test callable with parameter with default value`() { + parse(""" + ( + *, + key: None = None, + ) -> builtins.bool + """) + } + + fun `test parse callable type complex`() { + val ct = parse("(builtins.int | builtins.list[builtins.int]) -> None") + + val params = ct.getParameters(typeEvalContext)!! + assertEquals(1, params.size) + val p0Ty = params.single().getType(typeEvalContext)!! + assertEquals("int | list", p0Ty.name) + assertEquals("int", ((p0Ty as PyUnionType).members.last()!! as PyClassType).iteratedItemType!!.name) + } + + fun `test parse callable type with type parameter`() { + val ct = parse("[T: builtins.int](x: T) -> T") + + val params = ct.getParameters(typeEvalContext)!! + assertSize(1, params) + params.single().getType(typeEvalContext).assertIs() + ct.getReturnType(typeEvalContext).assertIs() + } + + fun `test parse callable named parameter`() { + val ct = parse("(a: builtins.int) -> None") + + val params = ct.getParameters(typeEvalContext)!! + assertSize(1, params) + val param = params.single() + assertEquals("a", param.name) + assertEquals(builtins.intType, param.getType(typeEvalContext)) + + val ret = ct.getReturnType(typeEvalContext)!! + assertEquals(builtins.noneType, ret) + } + + fun `test parse callable default parameter`() { + val ct = parse("(a: builtins.int = ...) -> None") + + val params = ct.getParameters(typeEvalContext)!! + assertSize(1, params) + val param = params.single() + assertEquals("a", param.name) + assertEquals(builtins.intType, param.getType(typeEvalContext)) + assertTrue(param.hasDefaultValue()) + + val ret = ct.getReturnType(typeEvalContext)!! + assertEquals(builtins.noneType, ret) + } + + fun `test parse nested callable`() { + // Callable[[Callable[[], None], Callable[[], None]] + val outer = parse("(() -> None, /) -> () -> None") + + // Outer should have exactly one positional-only parameter which is a callable + val params = outer.getParameters(typeEvalContext)!! + assertSize(2, params) + val (first, slash) = params + val innerParamCallable = first.getType(typeEvalContext).assertIs() + + assertTrue(slash.isPositionOnlySeparator) + + // Validate inner callable signature: no params, returns None + val innerParamCallableParams = innerParamCallable.getParameters(typeEvalContext)!! + assertEmpty(innerParamCallableParams) + val innerParamCallableRet = innerParamCallable.getReturnType(typeEvalContext)!! + assertEquals(builtins.noneType, innerParamCallableRet) + + // Return type should also be a callable + val innerRetCallable = outer.getReturnType(typeEvalContext).assertIs() + + val innerRetCallableParams = innerRetCallable.getParameters(typeEvalContext)!! + assertEmpty(innerRetCallableParams) + val innerRetCallableRet = innerRetCallable.getReturnType(typeEvalContext)!! + assertEquals(builtins.noneType, innerRetCallableRet) + } + + fun `test callable variadic`() { + val ct = parse("(*builtins.str, **test.B) -> None") + + val params = ct.getParameters(typeEvalContext)!! + assertSize(2, params) + val (args, kwargs) = params + + // Check *args parameter + assertTrue(args.isPositionalContainer) + assertFalse(args.isKeywordContainer) + assertNull(args.name) + // The type should be str (a star expression unwraps it) + val argsType = args.getType(typeEvalContext).assertIs() + assertEquals(builtins.strType, argsType) + + // Check **kwargs parameter + assertFalse(kwargs.isPositionalContainer) + assertTrue(kwargs.isKeywordContainer) + assertNull(kwargs.name) + val kwargsType = kwargs.getType(typeEvalContext).assertIs() + // The type should be B (as double star expression unwraps it) + assertEquals("B", kwargsType.name) + + val ret = ct.getReturnType(typeEvalContext).assertIs() + assertEquals(builtins.noneType, ret) + } + + fun `test type callable`() { + parse("builtins.type[Callable]") + } + + fun `test type typed dict`() { + parse("builtins.type[TypedDict]") + } + + fun `test Protocol`() { + parse("builtins.type[Protocol]") + } + + fun `test Generic`() { + parse("builtins.type[Generic]") + } + + fun `test special form`() { + val type = parse("builtins.type[Literal]") + assertFalse(type.isDefinition) + assertEquals("typing._SpecialForm", type.classQName) + val resolveContext = PyResolveContext.defaultContext(TypeEvalContext.codeInsightFallback(myFixture.project)) + val getItemMember = type.resolveMember("__getitem__", null, AccessDirection.READ, resolveContext)!!.single().element + getItemMember.assertIs() + } + + fun `test callable complex variadic`() { + val ct = parse("(*a: *builtins.tuple[builtins.int], **b: **test.B) -> None") + + val params = ct.getParameters(typeEvalContext)!! + assertSize(2, params) + val (args, kwargs) = params + + // Check *args parameter + assertTrue(args.isPositionalContainer) + assertEquals("a", args.name) + // The type should be *tuple[int] (a star expression wrapping tuple) + args.getType(typeEvalContext).assertIs() + + // Check **kwargs parameter + assertTrue(kwargs.isKeywordContainer) + assertEquals("b", kwargs.name) + // The type should be **B (a double star expression wrapping B) + kwargs.getType(typeEvalContext).assertIs() + + val ret = ct.getReturnType(typeEvalContext)!! + assertEquals(builtins.noneType, ret) + } + + fun `test complex callable`() { + val fnType = + parse("(builtins.str, /, x: builtins.int = builtins.int, *args: builtins.float, z: builtins.bool, **kwargs: builtins.complex) -> builtins.int") + + val params = fnType.getParameters(typeEvalContext)!! + assertSize(6, params) + val (first, slash, x, args, z) = params + val kwargs = params.last() + + assertNull(first.name) + assertEquals(builtins.strType, first.getType(typeEvalContext)) + + assertTrue(slash.isPositionOnlySeparator) + + assertEquals("x", x.name) + assertEquals(builtins.intType, x.getType(typeEvalContext)) + assertTrue(x.hasDefaultValue()) + + assertTrue(args.isPositionalContainer) + assertFalse(args.isKeywordContainer) + assertEquals("args", args.name) + assertEquals(PyNumericTowerUtil.enrich(builtins.floatType), args.getType(typeEvalContext)) + + assertEquals("z", z.name) + assertEquals(builtins.boolType, z.getType(typeEvalContext)) + + assertFalse(kwargs.isPositionalContainer) + assertTrue(kwargs.isKeywordContainer) + assertEquals("kwargs", kwargs.name) + val kwargsType = kwargs.getType(typeEvalContext).assertIs() + assertEquals(builtins.dictType!!.pyClass, kwargsType.pyClass) + assertEquals(PyNumericTowerUtil.enrich(builtins.complexType), kwargsType.typeArguments[1]) + } + + fun `test parse generic scope function`() { + val ty = parse("T@test.f") + assertEquals("T", ty.name) + assertEquals("test.f", ty.scopeOwner!!.qualifiedName) + } + + fun `test parse generic scope class`() { + val ty = parse("T@test.A") + assertEquals("T", ty.name) + assertEquals("test.A", ty.scopeOwner!!.qualifiedName) + } + + fun `test parse generic scope nested class with function`() { + val ty = parse("T@test.A.B.f") + assertEquals("T", ty.name) + assertEquals("test.A.B.f", ty.scopeOwner!!.qualifiedName) + } + + fun `test parse generic scope method`() { + val ty = parse("T@test.A.f") + assertEquals("T", ty.name) + assertEquals("test.A.f", ty.scopeOwner!!.qualifiedName) + } + + fun `test self type`() { + val ty = parse("typing.Self@test.A") + assertEquals("test.A", ty.pyClass.qualifiedName) + } + + fun `test new type`() { + val file = myFixture.configureByText("user_id.py", """ + from typing import NewType + + UserId = NewType("UserId", int) + """.trimIndent()) + val type = parse("user_id.UserId", file) + assertEquals("UserId", type.name) + assertEquals(builtins.intType, type.classType) + } + + fun `test enum literal`() { + val file = myFixture.configureByText("color.py", """ + from enum import Enum + + class Color(Enum): + RED = 1 + GREEN = 2 + BLUE = 3 + """.trimIndent()) as PyFile + val enumClass = file.findTopLevelClass("Color") + val type = parse("typing.Literal[color.Color.BLUE, color.Color.RED]", file) + val expectedNames = listOf("BLUE", "RED") + assertSize(expectedNames.size, type.members) + for ((index, subType) in type.members.withIndex()) { + subType.assertIs() + assertEquals(enumClass, subType.pyClass) + assertEquals(expectedNames[index], subType.enumMemberName) + } + } + + fun `test overload`() { + val type = parse("Overload[(int) -> int, (str) -> str]") + assertSize(2, type.items) + for (item in type.items) { + item.assertIs() + } + } + + fun `test isSelf`() { + myFixture.configureByText("test.py", """ + class A: + def f(self, a: A): ... + @classmethod + def m(cls, a: type[A]): ... + """.trimIndent()) + val method = parse("def test.A.f(self: test.A, a: test.A) -> None") + val methodParameters = method.getParameters(typeEvalContext)!! + assertTrue(methodParameters[0].isSelf) + assertFalse(methodParameters[1].isSelf) + + val classmethod = parse("def test.A.m(cls: builtins.type[test.A], a: builtins.type[test.A]) -> None") + val classParameters = classmethod.getParameters(typeEvalContext)!! + assertTrue(classParameters[0].isSelf) + assertFalse(classParameters[1].isSelf) + } + + override fun getTestDataPath(): String { + return PathManager.getHomePath() + "/python/testData/lsp/typeInference" + } +} \ No newline at end of file