1+ from __future__ import annotations
2+
3+ import typing as t
4+ from pathlib import Path
5+
6+ import lsp_types
7+ from lsp_types .pool import LSPProcessPool
8+ from lsp_types .process import LSPProcess , ProcessLaunchInfo
9+
10+ from .config_schema import Model as PyreflyConfig
11+
12+
13+ class PyreflySession (lsp_types .Session ):
14+ """
15+ Pyrefly LSP session implementation with process pooling support.
16+
17+ Pyrefly is Facebook's fast Python type checker written in Rust with built-in LSP support.
18+ """
19+
20+ @classmethod
21+ async def create (
22+ cls ,
23+ * ,
24+ base_path : Path = Path ("." ),
25+ initial_code : str = "" ,
26+ options : PyreflyConfig = {},
27+ pool : LSPProcessPool | None = None ,
28+ ) -> t .Self :
29+ """Create a new Pyrefly session using a process pool."""
30+ base_path = base_path .resolve ()
31+ base_path_str = str (base_path )
32+
33+ # Create pyrefly.toml config file (Pyrefly's configuration format)
34+ config_path = base_path / "pyrefly.toml"
35+
36+ # Convert options to TOML format (basic implementation)
37+ # Note: Pyrefly's config format is still evolving, so we keep it minimal
38+ toml_content = ""
39+ if options .get ("verbose" ):
40+ toml_content += "verbose = true\n "
41+ if "threads" in options and options ["threads" ] is not None :
42+ toml_content += f"threads = { options ['threads' ]} \n "
43+ if "color" in options :
44+ toml_content += f"color = \" { options ['color' ]} \" \n "
45+ if "indexing_mode" in options :
46+ toml_content += f"indexing-mode = \" { options ['indexing_mode' ]} \" \n "
47+
48+ config_path .write_text (toml_content )
49+
50+ async def create_lsp_process ():
51+ # Build command args for Pyrefly LSP server
52+ cmd_args = ["pyrefly" , "lsp" ]
53+
54+ # Add CLI options based on configuration
55+ if options .get ("verbose" ):
56+ cmd_args .append ("--verbose" )
57+ if "threads" in options and options ["threads" ] is not None :
58+ cmd_args .extend (["--threads" , str (options ["threads" ])])
59+ if "indexing_mode" in options :
60+ cmd_args .extend (["--indexing-mode" , options ["indexing_mode" ]])
61+
62+ # NOTE: requires pyrefly to be installed and accessible
63+ proc_info = ProcessLaunchInfo (cmd = cmd_args )
64+ lsp_process = LSPProcess (proc_info )
65+ await lsp_process .start ()
66+
67+ # Initialize LSP connection with capabilities similar to Pyright
68+ await lsp_process .send .initialize (
69+ {
70+ "processId" : None ,
71+ "rootUri" : f"file://{ base_path } " ,
72+ "rootPath" : base_path_str ,
73+ "capabilities" : {
74+ "textDocument" : {
75+ "publishDiagnostics" : {
76+ "versionSupport" : True ,
77+ "tagSupport" : {
78+ "valueSet" : [
79+ lsp_types .DiagnosticTag .Unnecessary ,
80+ lsp_types .DiagnosticTag .Deprecated ,
81+ ]
82+ },
83+ },
84+ "hover" : {
85+ "contentFormat" : [
86+ lsp_types .MarkupKind .Markdown ,
87+ lsp_types .MarkupKind .PlainText ,
88+ ],
89+ },
90+ "signatureHelp" : {},
91+ "completion" : {},
92+ "definition" : {},
93+ "references" : {},
94+ }
95+ },
96+ }
97+ )
98+
99+ # Pyrefly requires initialized event
100+ await lsp_process .notify .initialized ({})
101+
102+ return lsp_process
103+
104+ # Use pool if provided, otherwise create a default non-pooling pool
105+ if pool is None :
106+ pool = LSPProcessPool (max_size = 0 ) # No recycling, immediate shutdown
107+
108+ lsp_process = await pool .acquire (create_lsp_process , base_path_str )
109+ pyrefly_session = cls (lsp_process , pool = pool )
110+
111+ # Update settings via didChangeConfiguration
112+ # Pyrefly might not support all workspace configuration changes yet
113+ await lsp_process .notify .workspace_did_change_configuration (
114+ {"settings" : options }
115+ )
116+
117+ # Simulate opening a document
118+ await pyrefly_session ._open_document (initial_code )
119+
120+ return pyrefly_session
121+
122+ def __init__ (
123+ self ,
124+ lsp_process : LSPProcess ,
125+ * ,
126+ pool : LSPProcessPool ,
127+ ):
128+ self ._process = lsp_process
129+ self ._document_uri = "file:///test.py"
130+ self ._document_version = 1
131+ self ._document_text = ""
132+
133+ self ._pool = pool
134+
135+ # region - Session methods
136+
137+ async def shutdown (self ) -> None :
138+ """Shutdown and recycle the session back to the pool"""
139+ if self ._pool is None :
140+ return # Already recycled
141+
142+ # Release back to pool (document cleanup handled by pool/process reset)
143+ # For max_size=0 pools, this will immediately shutdown the process
144+ await self ._pool .release (self ._process )
145+
146+ # Clear references to prevent further use
147+ self ._pool = None
148+
149+ async def update_code (self , code : str ) -> int :
150+ """Update the code in the current document"""
151+ self ._document_version += 1
152+ self ._document_text = code
153+
154+ document_version = self ._document_version
155+ await self ._process .notify .did_change_text_document (
156+ {
157+ "textDocument" : {
158+ "uri" : self ._document_uri ,
159+ "version" : self ._document_version ,
160+ },
161+ "contentChanges" : [{"text" : code }],
162+ }
163+ )
164+
165+ return document_version
166+
167+ async def get_diagnostics (self ):
168+ """Get diagnostics for the given code"""
169+ # FIXME: riddled with race conditions
170+ # As a bare minimum, cache the diagnostics per document version
171+ # When diagnostics are requested twice, it would hang otherwise
172+ return await self ._process .notify .on_publish_diagnostics ()
173+
174+ async def get_hover_info (
175+ self , position : lsp_types .Position
176+ ) -> lsp_types .Hover | None :
177+ """Get hover information at the given position"""
178+ return await self ._process .send .hover (
179+ {"textDocument" : {"uri" : self ._document_uri }, "position" : position }
180+ )
181+
182+ async def get_rename_edits (
183+ self , position : lsp_types .Position , new_name : str
184+ ) -> lsp_types .WorkspaceEdit | None :
185+ """Get rename edits for the given position"""
186+ return await self ._process .send .rename (
187+ {
188+ "textDocument" : {"uri" : self ._document_uri },
189+ "position" : position ,
190+ "newName" : new_name ,
191+ }
192+ )
193+
194+ async def get_signature_help (
195+ self , position : lsp_types .Position
196+ ) -> lsp_types .SignatureHelp | None :
197+ """Get signature help at the given position"""
198+ return await self ._process .send .signature_help (
199+ {"textDocument" : {"uri" : self ._document_uri }, "position" : position }
200+ )
201+
202+ async def get_completion (
203+ self , position : lsp_types .Position
204+ ) -> lsp_types .CompletionList | list [lsp_types .CompletionItem ] | None :
205+ """Get completion items at the given position"""
206+ return await self ._process .send .completion (
207+ {"textDocument" : {"uri" : self ._document_uri }, "position" : position }
208+ )
209+
210+ async def resolve_completion (
211+ self , completion_item : lsp_types .CompletionItem
212+ ) -> lsp_types .CompletionItem :
213+ """Resolve the given completion item"""
214+ return await self ._process .send .resolve_completion_item (completion_item )
215+
216+ async def get_semantic_tokens (self ) -> lsp_types .SemanticTokens | None :
217+ """Get semantic tokens for the current document"""
218+ return await self ._process .send .semantic_tokens_full (
219+ {"textDocument" : {"uri" : self ._document_uri }}
220+ )
221+
222+ # endregion
223+
224+ # Private methods
225+
226+ async def _open_document (self , code : str ) -> None :
227+ """Open a document with the given code"""
228+ self ._document_text = code
229+ await self ._process .notify .did_open_text_document (
230+ {
231+ "textDocument" : {
232+ "languageId" : lsp_types .LanguageKind .Python ,
233+ "version" : self ._document_version ,
234+ "uri" : self ._document_uri ,
235+ "text" : code ,
236+ }
237+ }
238+ )
239+ # Track the opened document
240+ self ._process .track_document_open (self ._document_uri )
0 commit comments