diff --git a/python/python-black/src/com/intellij/python/black/BlackPyTool.kt b/python/python-black/src/com/intellij/python/black/BlackPyTool.kt index 0cf2ce1c1f4a..2224ec17194c 100644 --- a/python/python-black/src/com/intellij/python/black/BlackPyTool.kt +++ b/python/python-black/src/com/intellij/python/black/BlackPyTool.kt @@ -9,6 +9,7 @@ import com.intellij.openapi.util.registry.Registry import com.intellij.python.pytools.PyTool import com.intellij.python.pytools.PyToolsState import com.intellij.python.pytools.configuration.ExecutableDiscoveryMode +import com.intellij.python.pytools.ui.PyToolDetailConfigurableProvider import com.intellij.python.black.PyBlackBundle.message import com.intellij.python.black.configuration.BlackFormatterConfigurable import com.intellij.python.black.configuration.BlackFormatterConfiguration @@ -17,7 +18,7 @@ import org.jetbrains.annotations.ApiStatus import kotlin.io.path.Path @ApiStatus.Internal -class BlackPyTool : PyTool { +class BlackPyTool : PyTool, PyToolDetailConfigurableProvider { override val presentableName: String = "Black" override val description: String get() = message("black.tool.description") override val packageName: PyPackageName = PyPackageName.from("black") @@ -45,7 +46,7 @@ class BlackPyTool : PyTool { return entry } - override val detailConfigurable: (Project) -> UnnamedConfigurable = ::BlackFormatterConfigurable + override fun createConfigurable(project: Project): UnnamedConfigurable = BlackFormatterConfigurable(project) override fun summaryFor(project: Project): String { val cfg = BlackFormatterConfiguration.getBlackConfiguration(project) diff --git a/python/python-black/tests/BUILD.bazel b/python/python-black/tests/BUILD.bazel index 255ce10c74c6..7b561d3b1251 100644 --- a/python/python-black/tests/BUILD.bazel +++ b/python/python-black/tests/BUILD.bazel @@ -91,6 +91,8 @@ jvm_library( "//python/python-pyproject:pyproject_test_lib", "//python/python-pytools:pytools", "//python/python-pytools:pytools_test_lib", + "//python/python-pytools-ui:pytools-ui", + "//python/python-pytools-ui:pytools-ui_test_lib", "//python/python-sdk:sdk", "//python/python-sdk:sdk_test_lib", "//python/python-test-env/junit5", diff --git a/python/python-black/tests/intellij.python.black.tests.iml b/python/python-black/tests/intellij.python.black.tests.iml index d26e44b0cda6..68d7f6d04db7 100644 --- a/python/python-black/tests/intellij.python.black.tests.iml +++ b/python/python-black/tests/intellij.python.black.tests.iml @@ -36,6 +36,7 @@ + diff --git a/python/python-black/tests/testResources/intellij.python.black.tests.xml b/python/python-black/tests/testResources/intellij.python.black.tests.xml index aabb99cc8931..f967cb065d11 100644 --- a/python/python-black/tests/testResources/intellij.python.black.tests.xml +++ b/python/python-black/tests/testResources/intellij.python.black.tests.xml @@ -18,12 +18,13 @@ - + + diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PyLspToolDetailConfigurable.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PyLspToolDetailConfigurable.kt index 2e07897cbba0..740620a7eae3 100644 --- a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PyLspToolDetailConfigurable.kt +++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PyLspToolDetailConfigurable.kt @@ -2,11 +2,10 @@ package com.intellij.python.pytools.ui import com.intellij.openapi.options.BoundConfigurable -import com.intellij.openapi.options.Configurable import com.intellij.openapi.project.Project import com.intellij.openapi.ui.DialogPanel -import com.intellij.python.pytools.PyTool -import com.intellij.python.pytools.lsp.PyLspToolSettings +import com.intellij.python.pytools.lsp.PyLspTool +import com.intellij.python.pytools.lsp.PyLspToolConfiguration import com.intellij.python.pytools.statistics.PyToolActionSource import com.intellij.python.pytools.statistics.PyToolUsagesCollector import com.intellij.ui.dsl.builder.Panel @@ -14,21 +13,22 @@ import com.intellij.ui.dsl.builder.panel /** * Shared base for every LSP-backed tool's detail configurable. Subclasses supply only the - * per-project [settings] service, an optional [inlayHintLabel] override (Pyright uses a - * tool-specific string), and optional tool-specific rows via [extraRows] (Ruff uses this for + * concrete [PyLspTool] (whose [PyLspTool.configuration] supplies [settings]), an optional + * [inlayHintLabel] override, and optional tool-specific rows via [extraRows] (Ruff uses this for * its `formatting` and `sortImports` checkboxes). * * Logging happens automatically in [apply] via [PyToolUsagesCollector.Helper.logConfigurationChanged], - * which pulls field values from [PyTool.configurationFusSnapshot]. A new tool that extends this + * which pulls field values from the tool's `configurationFusSnapshot`. A new tool that extends this * base therefore cannot accidentally skip FUS reporting — there is no per-subclass `apply` to * forget. */ -abstract class PyLspToolDetailConfigurable( +abstract class PyLspToolDetailConfigurable>( protected val project: Project, - private val tool: PyTool, + private val tool: PyLspTool, ) : BoundConfigurable(tool.presentableName) { - protected abstract val settings: PyLspToolSettings + /** This tool's settings, taken straight from [PyLspTool.configuration] (the single source). */ + protected val settings: C get() = tool.configuration(project) /** Label used for the inlay-hint row. Override when the tool needs a custom string. */ protected open val inlayHintLabel: String = PyToolsUiBundle.message("checkbox.inlay.hints") diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PyToolDetailConfigurableProvider.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PyToolDetailConfigurableProvider.kt new file mode 100644 index 000000000000..e8fa63aab7a2 --- /dev/null +++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PyToolDetailConfigurableProvider.kt @@ -0,0 +1,27 @@ +// 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.pytools.ui + +import com.intellij.openapi.options.UnnamedConfigurable +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.NlsSafe +import com.intellij.python.pytools.lsp.PyLspToolSettings + +/** + * Implemented by a `com.intellij.python.pytools.PyTool` that contributes a detail panel to the + * External Tools table's Edit dialog. Kept out of `PyTool` itself so the core tool abstraction stays + * free of UI types; the table discovers it with `tool as? PyToolDetailConfigurableProvider`. + */ +interface PyToolDetailConfigurableProvider { + fun createConfigurable(project: Project): UnnamedConfigurable +} + +/** + * Shared comma-separated summary of the standard LSP feature toggles, for `PyTool.summaryFor`. + * Tools with extra toggles (e.g. Ruff) build their own. + */ +fun pyLspToolFeaturesSummary(settings: PyLspToolSettings): @NlsSafe String = buildList { + if (settings.inspections) add(PyToolsUiBundle.message("checkbox.inspections")) + if (settings.completions == true) add(PyToolsUiBundle.message("checkbox.completions")) + if (settings.inlayHints == true) add(PyToolsUiBundle.message("checkbox.inlay.hints")) + if (settings.documentation == true) add(PyToolsUiBundle.message("checkbox.documentation")) +}.joinToString(", ") diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PythonPackageManagerExt.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PythonPackageManagerExt.kt index 58ca5a762462..59c1865947d3 100644 --- a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PythonPackageManagerExt.kt +++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PythonPackageManagerExt.kt @@ -6,6 +6,6 @@ import com.jetbrains.python.packaging.management.PythonPackageManager import com.jetbrains.python.packaging.management.getInstalledPackageSnapshot fun PythonPackageManager.getInstalledToolPackage(pyTool: PyTool): PythonPackage? { - return pyTool.aliases.firstNotNullOfOrNull { getInstalledPackageSnapshot(it.name) } + return getInstalledPackageSnapshot(pyTool.packageName.name) } diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsCellRenderers.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsCellRenderers.kt index 772b12c18e55..f42b3f90f75b 100644 --- a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsCellRenderers.kt +++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsCellRenderers.kt @@ -176,7 +176,7 @@ internal class ToolCellRenderer(private val host: ToolCellHost) : JPanel(null), // handler exits early, and the cursor stays default. Same rule mirrored in // hover hit-testing and the table mouseClicked listener. paintGear = (row == host.hoveredRow) && (toolRow?.staged?.enabled == true) - gearEnabled = toolRow?.tool?.detailConfigurable != null + gearEnabled = toolRow?.detailConfigurableProvider != null return this } diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsRowModel.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsRowModel.kt index 6cea233689d7..1e5b249a162a 100644 --- a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsRowModel.kt +++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsRowModel.kt @@ -22,6 +22,7 @@ import com.intellij.python.pytools.findExecutableInPath import com.intellij.python.pytools.findExecutableInSdk import com.jetbrains.python.sdk.pyInterpreterPresentation import com.intellij.python.pytools.ui.PyToolsUiBundle +import com.intellij.python.pytools.ui.PyToolDetailConfigurableProvider import com.intellij.python.pytools.ui.icons.PythonPytoolsUIIcons import com.jetbrains.python.Result import com.intellij.python.pytools.validateCustomPath @@ -91,7 +92,10 @@ internal class ToolRow( * glyph next to `Sdk`, plus a tooltip listing the resolved binary path per SDK. */ var sdkAvailability: SdkAvailability? = null, -) +) { + /** This tool's detail-panel provider, or `null` when the tool has no detail configurable. */ + val detailConfigurableProvider: PyToolDetailConfigurableProvider? = tool as? PyToolDetailConfigurableProvider +} /** * Project-SDK detection snapshot for one [ToolRow]: an ordered list of SDKs with the tool's diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTable.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTable.kt index b841a8e98d21..31b8d5441cf2 100644 --- a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTable.kt +++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTable.kt @@ -241,7 +241,7 @@ internal class PyExternalToolsTable( // opens the detail dialog. Disabled tools surface no actions — including double // click — to match the missing gear icon and keep the row visually inert. val row = rows[viewRow] - if (row.tool.detailConfigurable == null || !row.staged.enabled) return + if (row.detailConfigurableProvider == null || !row.staged.enabled) return val onGear = isOverGearIcon(e, viewRow) if (onGear || e.clickCount == 2) { openDetailDialog(row) @@ -304,7 +304,7 @@ internal class PyExternalToolsTable( // visibly behaves like a clickable affordance. Tools without settings get a disabled // gear and keep the default cursor. val overGear = effective >= 0 && - rows[effective].tool.detailConfigurable != null && + rows[effective].detailConfigurableProvider != null && isOverGearIcon(e, effective) val isIconWithAction = pathIconAtHover(pathEffective).let { it == PathIconKind.INSTALL || it == PathIconKind.UPGRADE || it == PathIconKind.RESET @@ -455,7 +455,7 @@ internal class PyExternalToolsTable( /** Open the per-tool detail dialog for [toolRow]; refresh the row on commit. */ private fun openDetailDialog(toolRow: ToolRow) { - val configurable = toolRow.detail ?: toolRow.tool.detailConfigurable?.invoke(project) ?: return + val configurable = toolRow.detail ?: toolRow.detailConfigurableProvider?.createConfigurable(project) ?: return toolRow.detail = configurable val component = configurable.createComponent() ?: return configurable.reset() diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTooltips.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTooltips.kt index 47ba522ea4f9..d350143fe0d8 100644 --- a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTooltips.kt +++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTooltips.kt @@ -103,7 +103,7 @@ private fun lookupStrategyText(mode: com.intellij.python.pytools.configuration.E */ private fun toolColumnTooltip(toolRow: ToolRow, host: TooltipHost, eventX: Int, cellRect: Rectangle): String { val onGear = isOverIcon(eventX, cellRect, PythonPytoolsUIIcons.Settings.iconWidth) - if (onGear && toolRow.staged.enabled && toolRow.tool.detailConfigurable != null) { + if (onGear && toolRow.staged.enabled && toolRow.detailConfigurableProvider != null) { return PyToolsUiBundle.message("settings.external.tools.edit.tooltip", toolRow.tool.presentableName) } // Match the cell-rendering rule: a disabled tool's options aren't surfaced anywhere — its diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/UvController.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/UvController.kt index 45a1ac76182d..6a6ccb4295a3 100644 --- a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/UvController.kt +++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/UvController.kt @@ -109,25 +109,10 @@ internal class UvController( private var attemptedUpgrades: Set = emptySet() /** - * Package name uv knows this row's tool by — a single value, never a set. Derived from the - * basename of the detected binary, matched against the tool's `aliases`: for Pyright - * (aliases `[pyright, basedpyright]`) this resolves to whichever of the two was actually - * found on disk, and every uv operation (lookup, install/upgrade, attempted-upgrade tracking) - * targets that same package. When nothing is detected the row is showing INSTALL and we fall - * back to `installInfo.packageName.name` — the package the INSTALL click would put on disk. + * Package name uv knows this row's tool by. Each tool maps 1:1 to a single PyPI package, so every uv + * operation (lookup, install/upgrade, attempted-upgrade tracking) targets the tool's package name. */ - private fun ToolRow.uvPackageName(): String { - val detectedPath = when (val pfv = pathFieldValue) { - is PathFieldValue.Custom -> pfv.path - is PathFieldValue.AutoDetected -> pfv.path - else -> null - } - if (detectedPath != null) { - val baseName = detectedPath.fileName.toString().removeSuffix(".exe") - tool.aliases.firstOrNull { it.name == baseName }?.let { return it.name } - } - return tool.installInfo.packageName.name - } + private fun ToolRow.uvPackageName(): String = tool.packageName.name /** True iff the detected package is currently installed by uv (per the cached list). */ fun isUvManaged(toolRow: ToolRow): Boolean = diff --git a/python/python-pytools/src/com/intellij/python/pytools/PyTool.kt b/python/python-pytools/src/com/intellij/python/pytools/PyTool.kt index d827dd9f690d..4de9cfb24115 100644 --- a/python/python-pytools/src/com/intellij/python/pytools/PyTool.kt +++ b/python/python-pytools/src/com/intellij/python/pytools/PyTool.kt @@ -2,7 +2,6 @@ package com.intellij.python.pytools import com.intellij.openapi.extensions.ExtensionPointName -import com.intellij.openapi.options.UnnamedConfigurable import com.intellij.openapi.project.Project import com.intellij.openapi.util.NlsSafe import com.intellij.python.pytools.statistics.PyToolFusSnapshot @@ -10,15 +9,9 @@ import com.jetbrains.python.packaging.PyPackageName import org.jetbrains.annotations.Nls import com.intellij.openapi.util.Version as PlatformVersion -data class InstallInfo( - val packageName: PyPackageName, - val installHelp: @Nls String? = null, -) - interface PyTool { val presentableName: @NlsSafe String val packageName: PyPackageName - val aliases: List get() = listOf(packageName) /** * One-line user-facing description of the tool (e.g. "Linter and code formatter for Python"). @@ -27,8 +20,6 @@ interface PyTool { */ val description: @Nls String - val installInfo: InstallInfo get() = InstallInfo(packageName) - /** * Provides a unique identifier (python package name) for the feature usage statistics (FUS) system. * The identifier is dynamically derived from the first package name in the list of known package names. @@ -54,13 +45,6 @@ interface PyTool { */ fun migrateLegacyState(project: Project): PyToolsState.ToolEntry? = null - /** - * Factory that builds the per-tool detail UI shown in the Edit dialog of the External Tools - * table. `null` (the default) means the tool has no extra options — the table uses this to dim - * and disable the gear icon for the row. - */ - val detailConfigurable: ((Project) -> UnnamedConfigurable)? get() = null - /** * Compact, comma-separated summary of currently-activated features for the External Tools table * (e.g. "Inspections, Formatting"). Returning an empty string hides the cell content for the diff --git a/python/python-pytools/src/com/intellij/python/pytools/PyToolExt.kt b/python/python-pytools/src/com/intellij/python/pytools/PyToolExt.kt index 4d92c0b1451a..01d19c7f2e13 100644 --- a/python/python-pytools/src/com/intellij/python/pytools/PyToolExt.kt +++ b/python/python-pytools/src/com/intellij/python/pytools/PyToolExt.kt @@ -39,18 +39,17 @@ fun PyTool.isEnabledOn(project: Project): Boolean = getState(project).enabled suspend fun PyTool.getExecutableWithBaseArgs( moduleOrProject: ModuleOrProject, - executableNames: List = aliases.map { it.name }, + executableName: String = packageName.name, workingDir: Path? = null, - isUvxSupported: Boolean = true ): PyResult>> { val state = getState(moduleOrProject.project) val toolBinaryPath = when (state.discoveryMode) { ExecutableDiscoveryMode.INTERPRETER -> { val pyRichSdk = moduleOrProject.moduleIfExists?.pythonSdk?.pyRichSdk() - pyRichSdk?.let { findExecutableInSdk(it, executableNames) } ?: findExecutableInPath(state, executableNames) + pyRichSdk?.let { findExecutableInSdk(it, executableName) } ?: findExecutableInPath(state, executableName) } - ExecutableDiscoveryMode.PATH -> findExecutableInPath(state, executableNames) + ExecutableDiscoveryMode.PATH -> findExecutableInPath(state, executableName) ExecutableDiscoveryMode.UVX -> null } @@ -62,14 +61,13 @@ suspend fun PyTool.getExecutableWithBaseArgs( BinOnEel(toolBinaryPath, workDir = workDir).let { PyResult.success(it to emptyList()) } } else { - if (!isUvxSupported) { - return PyResult.localizedError(message("uvx.is.not.installed")) - } - val uvxPath = localEel.exec.where("uvx") ?: return PyResult.localizedError(message("uvx.is.not.installed")) - val uvxArgs = listOf(packageName.name) + // `uvx ` only works when the package's entry point matches its name. When the executable + // differs (e.g. pyright → pyright-langserver) uvx needs `--from `. + val uvxArgs = if (executableName == packageName.name) listOf(executableName) + else listOf("--from", packageName.name, executableName) BinOnEel(uvxPath.asNioPath(), workDir = workDir).let { PyResult.success(it to uvxArgs) } } } @@ -116,11 +114,6 @@ suspend fun PyTool.resolveVersion(moduleOrProject: ModuleOrProject): PyResult): Sequence { - return executableNames.asSequence().map { getOsSpecificBinaryName(it) } -} - - private fun EelOsFamily.getOsSpecificBinaryName(binaryName: String): String = when (this) { EelOsFamily.Posix -> binaryName EelOsFamily.Windows -> "$binaryName.exe" @@ -129,22 +122,18 @@ private fun EelOsFamily.getOsSpecificBinaryName(binaryName: String): String = wh /** * only local sdks are supported currently */ -fun PyTool.findExecutableInSdk(pyRichSdk: PyRichSdk, executableNames: List = aliases.map { it.name }): Path? { +fun PyTool.findExecutableInSdk(pyRichSdk: PyRichSdk, executableName: String = packageName.name): Path? { return pyRichSdk.pythonBinaryPath?.let { basePythonBinaryPath -> val osFamily = basePythonBinaryPath.getEelDescriptor().osFamily - osFamily.getOsSpecificBinaryFileNames(executableNames).firstNotNullOfOrNull { binaryFileName -> - basePythonBinaryPath.resolveSibling(binaryFileName).takeIf { it.isExecutable() } - } + basePythonBinaryPath.resolveSibling(osFamily.getOsSpecificBinaryName(executableName)).takeIf { it.isExecutable() } } } -private fun PyTool.findExecutableInPath(state: PyToolsState.ToolEntry, executableNames: List = aliases.map { it.name }): Path? { - return state.customToolBinaryPath ?: findExecutableInPath(executableNames) +private fun PyTool.findExecutableInPath(state: PyToolsState.ToolEntry, executableName: String = packageName.name): Path? { + return state.customToolBinaryPath ?: findExecutableInPath(executableName) } fun PyTool.findExecutableInPath( - executableNames: List = aliases.map { it.name }, + executableName: String = packageName.name, osFamily: EelOsFamily = LocalEelDescriptor.osFamily, -): Path? = osFamily.getOsSpecificBinaryFileNames(executableNames).firstNotNullOfOrNull { - PathEnvironmentVariableUtil.findInPath(it)?.toPath() -} +): Path? = PathEnvironmentVariableUtil.findInPath(osFamily.getOsSpecificBinaryName(executableName))?.toPath() diff --git a/python/python-pytools/src/com/intellij/python/pytools/PyToolVersionCheck.kt b/python/python-pytools/src/com/intellij/python/pytools/PyToolVersionCheck.kt index fe2cdaef4c70..6bfb58c74841 100644 --- a/python/python-pytools/src/com/intellij/python/pytools/PyToolVersionCheck.kt +++ b/python/python-pytools/src/com/intellij/python/pytools/PyToolVersionCheck.kt @@ -68,16 +68,8 @@ suspend fun BinaryToExec.getToolVersion(toolVersionPrefix: String): PyResult --version` does not match. - * - * The expected version-prefix is derived from [path]'s basename matched against [PyTool.aliases] — - * required for tools where the primary [PyTool.packageName] differs from the actually-installed - * alias (e.g. Pyright is integrated as `pyright` but `basedpyright --version` prints - * `basedpyright X.Y.Z`, which would never match the `pyright` prefix). When the basename - * matches no known alias we fall back to the primary [PyTool.packageName] for backward compatibility. */ @ApiStatus.Internal suspend fun PyTool.validateCustomPath(path: Path): PyResult { - val baseName = path.fileName.toString().removeSuffix(".exe") - val versionPrefix = aliases.firstOrNull { it.name == baseName }?.name ?: packageName.name - return BinOnEel(path).getToolVersion(versionPrefix) + return BinOnEel(path).getToolVersion(packageName.name) } diff --git a/python/python-pytools/src/com/intellij/python/pytools/lsp/PyLspTool.kt b/python/python-pytools/src/com/intellij/python/pytools/lsp/PyLspTool.kt new file mode 100644 index 000000000000..29185c9a1691 --- /dev/null +++ b/python/python-pytools/src/com/intellij/python/pytools/lsp/PyLspTool.kt @@ -0,0 +1,36 @@ +// 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.pytools.lsp + +import com.intellij.openapi.project.Project +import com.intellij.python.pytools.PyTool +import com.intellij.python.pytools.PyToolsState +import com.intellij.python.pytools.statistics.PyToolFusSnapshot +import javax.swing.Icon + +/** + * Base for every LSP-backed [PyTool]. Captures the shared, non-UI wiring around a per-project + * [PyLspToolConfiguration] — its [configuration] service is the single source of the tool's settings, + * used here for legacy-state migration and the FUS snapshot, and by the detail UI in the UI module. + * + * UI concerns (the detail configurable, the features summary) deliberately live in the UI layer and + * are not part of this base. + */ +abstract class PyLspTool> : PyTool { + /** Per-project settings service backing this tool — the single source of its configuration. */ + abstract fun configuration(project: Project): C + + /** Icon shown for this tool's LSP server (status-bar widget, advertiser notification, ...). */ + abstract val icon: Icon + + override fun migrateLegacyState(project: Project): PyToolsState.ToolEntry = configuration(project).migrateToPyToolState() + + override fun configurationFusSnapshot(project: Project): PyToolFusSnapshot { + val cfg = configuration(project) + return super.configurationFusSnapshot(project).copy( + inspections = cfg.inspections, + completions = cfg.completions, + inlayHints = cfg.inlayHints, + documentation = cfg.documentation, + ) + } +}