Merge pull request #1614 from 0x1997/main

Add pyrefly as alternative lsp for python
This commit is contained in:
Michael Panchenko authored and GitHub committed 2026-06-28 15:34:12 +02:00
commit befc642f34
6 files changed
+319 -2

No files matched your search

@@ -125,6 +125,11 @@ Some languages require additional installations or setup steps, as noted.
we also support [Phpactor](https://github.com/phpactor/phpactor) (language `php_phpactor`), which requires PHP 8.1+;
and the experimental [PHPantom](https://github.com/PHPantom-dev/phpantom_lsp) backend (language `php_phpantom`)
* **Python**
(by default, uses [Pyright](https://github.com/microsoft/pyright) (language `python`);
alternatives: [ty](https://github.com/astral-sh/ty) (language `python_ty`),
[pyrefly](https://github.com/facebook/pyrefly) (language `python_pyrefly`),
[Jedi](https://github.com/palotas/jedi-language-server) (language `python_jedi`);
ty and pyrefly require `uv`/`uvx` in PATH)
* **R**
(requires installation of the `languageserver` R package)
* **Ruby**
@@ -0,0 +1,303 @@
"""
Python language server integration using Meta's ``pyrefly``.
You can pass the following entries in ``ls_specific_settings["python_pyrefly"]``:
- ls_path: Override the executable used to start ``pyrefly``.
- pyrefly_version: Override the pinned ``pyrefly`` version used with ``uvx`` / ``uv x``
(default: the bundled Serena version).
- indexing_mode: Override pyrefly's LSP indexing mode (e.g. ``lazy-blocking``).
- workspace_indexing_limit: Override pyrefly's workspace indexing limit.
"""
import logging
import os
import pathlib
import sys
import threading
import time
from typing import Any, cast
from typing_extensions import override
from solidlsp import ls_types
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderUvx, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_exceptions import SolidLSPException
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams, LSPErrorCodes
from solidlsp.lsp_protocol_handler.server import LSPError, PayloadLike
from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
PYREFLY_VERSION = "1.1.1"
PYREFLY_CONFIG_DOC_URL = "https://pyrefly.org/en/docs/configuration/"
# Pyrefly cancels in-flight requests with these error codes whenever its workspace state mutates
# (e.g. background re-indexing triggered by opening files). Such cancellations are transient.
_MUTATION_RETRY_ERROR_CODES = (LSPErrorCodes.RequestCancelled, LSPErrorCodes.ContentModified)
class PyreflyLanguageServer(SolidLanguageServer):
"""
Provides Python specific instantiation of the LanguageServer class using ``pyrefly``.
"""
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
"""
Creates a PyreflyLanguageServer instance. This class is not meant to be instantiated directly.
Use LanguageServer.create() instead.
"""
super().__init__(
config,
repository_root_path,
None,
str(config.code_language),
solidlsp_settings,
)
self._ensure_workspace_pyrefly_config()
self._indexing_complete = threading.Event()
self._active_progress_tokens: set[Any] = set()
def _ensure_workspace_pyrefly_config(self) -> None:
"""
Ensure pyrefly has a stable workspace boundary by creating an empty ``pyrefly.toml`` when
neither ``pyrefly.toml`` nor ``pyproject.toml`` exists in the repository root.
"""
if not os.path.isdir(self.repository_root_path):
return
pyrefly_config_path = os.path.join(self.repository_root_path, "pyrefly.toml")
pyproject_config_path = os.path.join(self.repository_root_path, "pyproject.toml")
if not os.path.exists(pyrefly_config_path) and not os.path.exists(pyproject_config_path):
try:
pathlib.Path(pyrefly_config_path).touch()
log.warning(
"No config found in repository root (%s): neither `pyrefly.toml` nor `pyproject.toml` exists. "
"Created empty `%s` as a fallback so pyrefly won't search parent directories and mis-resolve imports. "
"See pyrefly config docs: %s",
self.repository_root_path,
pyrefly_config_path,
PYREFLY_CONFIG_DOC_URL,
)
except OSError as exc:
log.warning("Failed to create fallback pyrefly config at %s: %s", pyrefly_config_path, exc)
def _install_mutation_retry(self, max_retries: int = 5, retry_delay: float = 0.2) -> None:
"""
Wraps the underlying ``send_request`` so that requests canceled by pyrefly due to a
concurrent workspace mutation are transparently retried.
Pyrefly performs background re-indexing (e.g. when files are opened) and cancels any
in-flight request with ``RequestCancelled`` (-32800) or ``ContentModified`` (-32801)
while that mutation is in progress. These cancellations are transient, so we re-issue
the request after a short delay instead of surfacing the error to the caller.
"""
inner_send_request = self.server.send_request
def send_request_with_retry(method: str, params: dict | None = None) -> PayloadLike:
last_exc: SolidLSPException | None = None
for attempt in range(1, max_retries + 1):
try:
return inner_send_request(method, params)
except SolidLSPException as e:
cause = e.cause
if not (isinstance(cause, LSPError) and int(cause.code) in _MUTATION_RETRY_ERROR_CODES):
raise
last_exc = e
log.info(
"Pyrefly request %s canceled by concurrent mutation (code=%s); retrying (%d/%d)",
method,
cause.code,
attempt,
max_retries,
)
time.sleep(retry_delay)
assert last_exc is not None
raise last_exc
self.server.send_request = send_request_with_retry # type: ignore[method-assign]
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
extra_args = ["lsp"]
indexing_mode = self._custom_settings.get("indexing_mode")
if indexing_mode is not None:
extra_args.extend(["--indexing-mode", str(indexing_mode)])
workspace_indexing_limit = self._custom_settings.get("workspace_indexing_limit")
if workspace_indexing_limit is not None:
extra_args.extend(["--workspace-indexing-limit", str(workspace_indexing_limit)])
return LanguageServerDependencyProviderUvx(
self._custom_settings,
self._ls_resources_dir,
package="pyrefly",
entrypoint="pyrefly",
default_version=PYREFLY_VERSION,
version_setting_key="pyrefly_version",
extra_args=extra_args,
)
@override
def is_ignored_dirname(self, dirname: str) -> bool:
return super().is_ignored_dirname(dirname) or dirname in ["venv", "__pycache__"]
@override
def request_defining_symbol(
self,
relative_file_path: str,
line: int,
column: int,
include_body: bool = False,
) -> ls_types.UnifiedSymbolInformation | None:
symbol = super().request_defining_symbol(relative_file_path, line, column, include_body=include_body)
if symbol is None or symbol.get("name") != "__init__":
return symbol
# Pyrefly resolves a constructor call (e.g. ``User(...)``) to the class's ``__init__`` method,
# whereas jedi/pyright resolve it to the class itself. For parity, return the enclosing class
# unless the caller pointed directly at the ``__init__`` definition.
location = symbol.get("location")
if location is not None:
def_path = location.get("relativePath")
def_line = location.get("range", {}).get("start", {}).get("line")
if (def_path, def_line) == (relative_file_path, line):
return symbol
enclosing = self.request_container_of_symbol(symbol, include_body=include_body)
if enclosing is not None and enclosing.get("kind") == ls_types.SymbolKind.Class:
return enclosing
return symbol
@override
def _get_language_id_for_file(self, relative_file_path: str) -> str:
return "python"
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
"""
Returns the initialize params for the pyrefly language server.
"""
root_uri = pathlib.Path(repository_absolute_path).as_uri()
initialize_params = {
"processId": os.getpid(),
"clientInfo": {"name": "Serena", "version": "0.1.0"},
"rootPath": repository_absolute_path,
"rootUri": root_uri,
"capabilities": {
"window": {
"workDoneProgress": True,
},
"workspace": {
"workspaceEdit": {"documentChanges": True},
"configuration": True,
"workspaceFolders": True,
"didChangeConfiguration": {"dynamicRegistration": True},
"didChangeWatchedFiles": {"dynamicRegistration": True},
"symbol": {
"dynamicRegistration": True,
"symbolKind": {"valueSet": list(range(1, 27))},
},
},
"textDocument": {
"synchronization": {
"dynamicRegistration": True,
"willSave": True,
"willSaveWaitUntil": True,
"didSave": True,
},
"hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]},
"definition": {"dynamicRegistration": True, "linkSupport": True},
"references": {"dynamicRegistration": True},
"documentSymbol": {
"dynamicRegistration": True,
"symbolKind": {"valueSet": list(range(1, 27))},
"hierarchicalDocumentSymbolSupport": True,
},
"implementation": {"dynamicRegistration": True, "linkSupport": True},
"publishDiagnostics": {"relatedInformation": True},
},
},
"workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}],
}
return cast(InitializeParams, initialize_params)
def _start_server(self) -> None:
"""
Starts the pyrefly language server and waits for initial workspace indexing to complete.
Pyrefly defers indexing until it receives a workspace/configuration response,
then reports progress via $/progress notifications.
"""
def execute_client_command_handler(params: dict) -> list:
return []
def do_nothing(params: dict) -> None:
return
def window_log_message(msg: dict) -> None:
log.info("LSP: window/logMessage: %s", msg.get("message", ""))
def workspace_configuration_handler(params: dict) -> list[dict]:
items = params.get("items", []) if isinstance(params, dict) else []
log.info("Pyrefly workspace/configuration request received: %d items", len(items))
config = {
"pythonPath": sys.executable,
"pyrefly": {
# Ensure workspace-wide analysis parity with VSCode's project mode behavior.
"diagnosticMode": "workspace",
},
}
return [config.copy() for _ in items]
def work_done_progress_create(params: dict) -> None:
return
def on_progress(params: dict) -> None:
token = params.get("token")
value = params.get("value", {})
kind = value.get("kind")
if kind == "begin":
self._active_progress_tokens.add(token)
log.info("Pyrefly progress begin: %s (token=%s)", value.get("title", ""), token)
elif kind == "end":
self._active_progress_tokens.discard(token)
log.info("Pyrefly progress end (token=%s)", token)
if not self._active_progress_tokens:
self._indexing_complete.set()
# setting up handlers
self.server.on_request("client/registerCapability", do_nothing)
self.server.on_request("workspace/configuration", workspace_configuration_handler)
self.server.on_request("window/workDoneProgress/create", work_done_progress_create)
self.server.on_notification("language/status", do_nothing)
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_request("workspace/executeClientCommand", execute_client_command_handler)
self.server.on_notification("$/progress", on_progress)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
# starting and initializing the server
log.info("Starting pyrefly language server process")
self.server.start()
# retry requests that pyrefly cancels while re-indexing the workspace in the background
self._install_mutation_retry()
initialize_params = self._get_initialize_params(self.repository_root_path)
log.info("Sending initialize request from LSP client to pyrefly server and awaiting response")
init_response = self.server.send.initialize(initialize_params)
log.info("Received initialize response from pyrefly server: %s", init_response)
capabilities = init_response["capabilities"]
assert "textDocumentSync" in capabilities
assert "definitionProvider" in capabilities
assert "referencesProvider" in capabilities
assert "documentSymbolProvider" in capabilities
# completing the initialization handshake
self.server.notify.initialized({})
# wait for pyrefly to finish initial workspace indexing
log.info("Waiting for pyrefly to complete initial workspace indexing...")
if self._indexing_complete.wait(timeout=30.0):
log.info("Pyrefly initial indexing complete, server ready")
else:
log.warning("Timeout waiting for pyrefly indexing completion, proceeding anyway")
+8 -1
View File
@@ -142,6 +142,8 @@ class Language(str, Enum):
"""Jedi language server for Python (instead of pyright, which is the default)"""
PYTHON_TY = "python_ty"
"""Ty language server for Python (instead of pyright, which is the default)."""
PYTHON_PYREFLY = "python_pyrefly"
"""Pyrefly language server for Python (instead of pyright, which is the default)."""
CSHARP_OMNISHARP = "csharp_omnisharp"
"""OmniSharp language server for C# (instead of the default csharp-ls by microsoft).
Currently has problems with finding references, and generally seems less stable and performant.
@@ -253,6 +255,7 @@ class Language(str, Enum):
self.TYPESCRIPT_VTS,
self.PYTHON_JEDI,
self.PYTHON_TY,
self.PYTHON_PYREFLY,
self.CSHARP_OMNISHARP,
self.RUBY_SOLARGRAPH,
self.PHP_PHPACTOR,
@@ -304,7 +307,7 @@ class Language(str, Enum):
def get_source_fn_matcher(self) -> FilenameMatcher:
match self:
case self.PYTHON | self.PYTHON_JEDI | self.PYTHON_TY:
case self.PYTHON | self.PYTHON_JEDI | self.PYTHON_TY | self.PYTHON_PYREFLY:
return FilenameMatcher(".py", ".pyi")
case self.JAVA:
return FilenameMatcher(".java")
@@ -547,6 +550,10 @@ class Language(str, Enum):
from solidlsp.language_servers.ty_server import TyLanguageServer
return TyLanguageServer
case self.PYTHON_PYREFLY:
from solidlsp.language_servers.pyrefly_server import PyreflyLanguageServer
return PyreflyLanguageServer
case self.JAVA:
from solidlsp.language_servers.eclipse_jdtls import EclipseJDTLS
+2
View File
@@ -46,6 +46,7 @@ _LANGUAGE_REPO_ALIASES: dict[Language, Language] = {
Language.PYTHON_TY: Language.PYTHON,
Language.RUBY_SOLARGRAPH: Language.RUBY,
Language.PYTHON_TY: Language.PYTHON,
Language.PYTHON_PYREFLY: Language.PYTHON,
}
PYTHON_LANGUAGE_BACKENDS = [Language.PYTHON, Language.PYTHON_TY]
@@ -266,6 +267,7 @@ _LANGUAGE_PYTEST_MARKERS: dict[Language, list[MarkDecorator | Mark]] = {
Language.PYTHON: [pytest.mark.python],
Language.PYTHON_JEDI: [pytest.mark.python],
Language.PYTHON_TY: [pytest.mark.python],
Language.PYTHON_PYREFLY: [pytest.mark.python],
Language.RUST: [pytest.mark.rust],
Language.TYPESCRIPT: [pytest.mark.typescript],
Language.BSL: [
Whitespace-only changes.
+1 -1
View File
@@ -4,7 +4,7 @@ from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation
PYTHON_BACKEND_LANGUAGES = [Language.PYTHON, Language.PYTHON_TY]
PYTHON_BACKEND_LANGUAGES = [Language.PYTHON, Language.PYTHON_TY, Language.PYTHON_PYREFLY]
def read_repo_file(language_server: SolidLanguageServer, relative_path: str) -> str: