Repository: python-lsp/python-lsp-server
Pull request #645 (2025-05-13 12:08:49)

Add support for type definition
# Description
Using jedi's `infer` to support checking type definition.
The same approach is used by [jedi-language-server](https://github.com/pappasam/jedi-language-server/blob/main/jedi_language_server/server.py#L439).

This PR, combined with smheidrich's [Goto Implementation PR](https://github.com/python-lsp/python-lsp-server/pull/644), should fix [issue 97](https://github.com/python-lsp/python-lsp-server/issues/97).

Also added some unit tests.

Hoblovski: Renamed plugin to *jedi_*type_definition, removed debug lines, fixed copyright lines.

Patch:
diff --git a/CONFIGURATION.md b/CONFIGURATION.md
index 93b828b..ec2a9a6 100644
--- a/CONFIGURATION.md
+++ b/CONFIGURATION.md
@@ -42,6 +42,7 @@ This server can be configured using the `workspace/didChangeConfiguration` metho
 | `pylsp.plugins.jedi_symbols.enabled` | `boolean` | Enable or disable the plugin. | `true` |
 | `pylsp.plugins.jedi_symbols.all_scopes` | `boolean` | If True lists the names of all scopes instead of only the module namespace. | `true` |
 | `pylsp.plugins.jedi_symbols.include_import_symbols` | `boolean` | If True includes symbols imported from other libraries. | `true` |
+| `pylsp.plugins.jedi_type_definition.enabled` | `boolean` | Enable or disable the plugin. | `true` |
 | `pylsp.plugins.mccabe.enabled` | `boolean` | Enable or disable the plugin. | `true` |
 | `pylsp.plugins.mccabe.threshold` | `integer` | The minimum threshold that triggers warnings about cyclomatic complexity. | `15` |
 | `pylsp.plugins.preload.enabled` | `boolean` | Enable or disable the plugin. | `true` |
diff --git a/pylsp/config/schema.json b/pylsp/config/schema.json
index c4aec46..a0caa38 100644
--- a/pylsp/config/schema.json
+++ b/pylsp/config/schema.json
@@ -270,6 +270,11 @@
       "default": true,
       "description": "If True includes symbols imported from other libraries."
     },
+    "pylsp.plugins.jedi_type_definition.enabled": {
+      "type": "boolean",
+      "default": true,
+      "description": "Enable or disable the plugin."
+    },
     "pylsp.plugins.mccabe.enabled": {
       "type": "boolean",
       "default": true,
diff --git a/pylsp/hookspecs.py b/pylsp/hookspecs.py
index 41508be..e7e7ce4 100644
--- a/pylsp/hookspecs.py
+++ b/pylsp/hookspecs.py
@@ -38,6 +38,11 @@ def pylsp_definitions(config, workspace, document, position) -> None:
     pass
 
 
+@hookspec(firstresult=True)
+def pylsp_type_definition(config, document, position):
+    pass
+
+
 @hookspec
 def pylsp_dispatchers(config, workspace) -> None:
     pass
diff --git a/pylsp/plugins/type_definition.py b/pylsp/plugins/type_definition.py
new file mode 100644
index 0000000..5fe0a89
--- /dev/null
+++ b/pylsp/plugins/type_definition.py
@@ -0,0 +1,38 @@
+# Copyright 2021- Python Language Server Contributors.
+
+import logging
+
+from pylsp import _utils, hookimpl
+
+log = logging.getLogger(__name__)
+
+
+def lsp_location(name):
+    module_path = name.module_path
+    if module_path is None or name.line is None or name.column is None:
+        return None
+    uri = module_path.as_uri()
+    return {
+        "uri": str(uri),
+        "range": {
+            "start": {"line": name.line - 1, "character": name.column},
+            "end": {"line": name.line - 1, "character": name.column + len(name.name)},
+        },
+    }
+
+
+@hookimpl
+def pylsp_type_definition(config, document, position):
+    try:
+        kwargs = _utils.position_to_jedi_linecolumn(document, position)
+        script = document.jedi_script()
+        names = script.infer(**kwargs)
+        definitions = [
+            definition
+            for definition in [lsp_location(name) for name in names]
+            if definition is not None
+        ]
+        return definitions
+    except Exception as e:
+        log.debug("Failed to run type_definition: %s", e)
+        return []
diff --git a/pylsp/python_lsp.py b/pylsp/python_lsp.py
index f4c9ec2..3626589 100644
--- a/pylsp/python_lsp.py
+++ b/pylsp/python_lsp.py
@@ -284,6 +284,7 @@ class PythonLSPServer(MethodDispatcher):
             "documentRangeFormattingProvider": True,
             "documentSymbolProvider": True,
             "definitionProvider": True,
+            "typeDefinitionProvider": True,
             "executeCommandProvider": {
                 "commands": flatten(self._hook("pylsp_commands"))
             },
@@ -420,6 +421,9 @@ class PythonLSPServer(MethodDispatcher):
     def definitions(self, doc_uri, position):
         return flatten(self._hook("pylsp_definitions", doc_uri, position=position))
 
+    def type_definition(self, doc_uri, position):
+        return self._hook("pylsp_type_definition", doc_uri, position=position)
+
     def document_symbols(self, doc_uri):
         return flatten(self._hook("pylsp_document_symbols", doc_uri))
 
@@ -770,6 +774,11 @@ class PythonLSPServer(MethodDispatcher):
             return self._cell_document__definition(document, position, **_kwargs)
         return self.definitions(textDocument["uri"], position)
 
+    def m_text_document__type_definition(
+        self, textDocument=None, position=None, **_kwargs
+    ):
+        return self.type_definition(textDocument["uri"], position)
+
     def m_text_document__document_highlight(
         self, textDocument=None, position=None, **_kwargs
     ):
diff --git a/pyproject.toml b/pyproject.toml
index 27fa122..7b0c205 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -68,6 +68,7 @@ folding = "pylsp.plugins.folding"
 flake8 = "pylsp.plugins.flake8_lint"
 jedi_completion = "pylsp.plugins.jedi_completion"
 jedi_definition = "pylsp.plugins.definition"
+jedi_type_definition = "pylsp.plugins.type_definition"
 jedi_hover = "pylsp.plugins.hover"
 jedi_highlight = "pylsp.plugins.highlight"
 jedi_references = "pylsp.plugins.references"


Test patch:
diff --git a/test/plugins/test_type_definition.py b/test/plugins/test_type_definition.py
new file mode 100644
index 0000000..b433fc6
--- /dev/null
+++ b/test/plugins/test_type_definition.py
@@ -0,0 +1,96 @@
+# Copyright 2021- Python Language Server Contributors.
+
+from pylsp import uris
+from pylsp.plugins.type_definition import pylsp_type_definition
+from pylsp.workspace import Document
+
+DOC_URI = uris.from_fs_path(__file__)
+DOC = """\
+from dataclasses import dataclass
+
+@dataclass
+class IntPair:
+    a: int
+    b: int
+
+def main() -> None:
+    l0 = list(1, 2)
+
+    my_pair = IntPair(a=10, b=20)
+    print(f"Original pair: {my_pair}")
+"""
+
+
+def test_type_definitions(config, workspace) -> None:
+    # Over 'IntPair' in 'main'
+    cursor_pos = {"line": 10, "character": 14}
+
+    # The definition of 'IntPair'
+    def_range = {
+        "start": {"line": 3, "character": 6},
+        "end": {"line": 3, "character": 13},
+    }
+
+    doc = Document(DOC_URI, workspace, DOC)
+    assert [{"uri": DOC_URI, "range": def_range}] == pylsp_type_definition(
+        config, doc, cursor_pos
+    )
+
+
+def test_builtin_definition(config, workspace) -> None:
+    # Over 'list' in main
+    cursor_pos = {"line": 8, "character": 9}
+
+    doc = Document(DOC_URI, workspace, DOC)
+
+    defns = pylsp_type_definition(config, doc, cursor_pos)
+    assert len(defns) == 1
+    assert defns[0]["uri"].endswith("builtins.pyi")
+
+
+def test_mutli_file_type_definitions(config, workspace, tmpdir) -> None:
+    # Create a dummy module out of the workspace's root_path and try to get
+    # a definition on it in another file placed next to it.
+    module_content = """\
+from dataclasses import dataclass
+
+@dataclass
+class IntPair:
+    a: int
+    b: int
+"""
+    p1 = tmpdir.join("intpair.py")
+    p1.write(module_content)
+    # The uri for intpair.py
+    module_path = str(p1)
+    module_uri = uris.from_fs_path(module_path)
+
+    # Content of doc to test type definition
+    doc_content = """\
+from intpair import IntPair
+
+def main() -> None:
+    l0 = list(1, 2)
+
+    my_pair = IntPair(a=10, b=20)
+    print(f"Original pair: {my_pair}")
+"""
+    p2 = tmpdir.join("main.py")
+    p2.write(doc_content)
+    doc_path = str(p2)
+    doc_uri = uris.from_fs_path(doc_path)
+
+    doc = Document(doc_uri, workspace, doc_content)
+
+    # The range where IntPair is defined in intpair.py
+    def_range = {
+        "start": {"line": 3, "character": 6},
+        "end": {"line": 3, "character": 13},
+    }
+
+    # The position where IntPair is called in main.py
+    cursor_pos = {"line": 5, "character": 14}
+
+    assert [{"uri": module_uri, "range": def_range}] == pylsp_type_definition(
+        config, doc, cursor_pos
+    )