()
+
+ }
+
+ 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 000000000000..a36792d2d04e
Binary files /dev/null and b/python/python-ty/resources/icons/ty.png differ
diff --git a/python/python-ty/resources/icons/ty_dark.png b/python/python-ty/resources/icons/ty_dark.png
new file mode 100644
index 000000000000..a36792d2d04e
Binary files /dev/null and b/python/python-ty/resources/icons/ty_dark.png differ
diff --git a/python/python-ty/resources/intellij.python.ty.xml b/python/python-ty/resources/intellij.python.ty.xml
new file mode 100644
index 000000000000..13cc6d406707
--- /dev/null
+++ b/python/python-ty/resources/intellij.python.ty.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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