11from __future__ import annotations
22
3+ import os
34import typing as typ
45from pathlib import Path
56
67from collections .abc import Generator
78from typing import Callable , Optional
89
10+ import jedi
11+ from jedi .api import environment as jedi_environment
912from lsprotocol import types as lsptyp
1013from lsprotocol .types import WorkspaceFolder
1114from pygls import uris , workspace
1215from pygls .workspace .text_document import TextDocument
1316
17+ from pylsp import _utils
18+
1419if typ .TYPE_CHECKING :
1520 from pygls .server import LanguageServer
1621 from pylsp .server .settings import ServerConfig
1722
1823
24+ # Default auto-import modules for Jedi
25+ DEFAULT_AUTO_IMPORT_MODULES = ["numpy" ]
26+
27+
1928class Workspace (workspace .Workspace ):
2029 """Custom Workspace class for pylsp."""
2130
2231 def __init__ (self , server : LanguageServer , * args , ** kwargs ):
2332 self ._server = server
2433 super ().__init__ (* args , ** kwargs )
2534 self ._config = None
35+ # Cache jedi environments per configured path
36+ self ._environments : dict [str , typ .Any ] = {}
2637
2738 def get_document_folder (self , doc_uri : str ) -> WorkspaceFolder | None :
2839 """Get the workspace folder for a given document URI.
@@ -36,20 +47,42 @@ def get_document_folder(self, doc_uri: str) -> WorkspaceFolder | None:
3647 WorkspaceFolder | None: The workspace folder containing the document, or
3748 None if not found.
3849 """
39- best_match_len = float ( "inf" )
50+ best_match_len = - 1
4051 best_match = None
4152 document_path = Path (uris .to_fs_path (doc_uri ) or "" )
4253 for folder_uri , folder in self ._folders .items ():
4354 folder_path = Path (uris .to_fs_path (folder_uri ) or "" )
44- if (
45- match_len := len (document_path .relative_to (folder_path ).parts )
46- < best_match_len
47- ):
55+ try :
56+ document_path .relative_to (folder_path )
57+ except Exception :
58+ continue
59+ # prefer the longest matching folder
60+ match_len = len (str (folder_path ))
61+ if match_len > best_match_len :
4862 best_match_len = match_len
4963 best_match = folder
5064
5165 return best_match
5266
67+ @property
68+ def config (self ) -> ServerConfig | None :
69+ return self ._config
70+
71+ def attach_config (self , config : ServerConfig ):
72+ self ._config = config
73+
74+ def source_roots (self , document_path : str ) -> list [str ]:
75+ """Return source roots for the given document.
76+
77+ Searches for project files (setup.py, pyproject.toml) upwards and
78+ returns their directories, falling back to the workspace root.
79+ """
80+ files = _utils .find_parents (
81+ self .root_path , document_path , ["setup.py" , "pyproject.toml" ]
82+ ) or []
83+ roots = list ({str (Path (f ).parent ) for f in files })
84+ return roots or [self .root_path ]
85+
5386 def _create_text_document (
5487 self ,
5588 doc_uri : str ,
@@ -84,19 +117,6 @@ async def save(self, doc_uri: str) -> None:
84117 with open (path , "w" , encoding = "utf-8" ) as f :
85118 f .write (document .source )
86119
87- # ---- pylsp plugin compatibility helpers ----
88-
89- @property
90- def config (self ) -> ServerConfig :
91- return self ._config
92-
93- @property
94- def root_uri (self ) -> str | None :
95- return self ._root_uri
96-
97- def attach_config (self , config : ServerConfig ) -> None :
98- self ._config = config
99-
100120 def report_progress (
101121 self ,
102122 title : str ,
@@ -136,11 +156,12 @@ def _progress(msg: str, pct: Optional[int] = None) -> None:
136156 self ._server .progress (token , lsptyp .WorkDoneProgressEnd ()) # type: ignore[attr-defined]
137157
138158
139-
140159class Document (TextDocument ):
141160 def __init__ (self , workspace : Workspace , * args , ** kwargs ):
142161 self ._workspace = workspace
143162 super ().__init__ (* args , ** kwargs )
163+ # Extra data used by some plugins during resolve steps
164+ self .shared_data : dict [str , typ .Any ] = {}
144165
145166 @property
146167 def workspace (self ) -> Workspace :
@@ -149,3 +170,87 @@ def workspace(self) -> Workspace:
149170 @property
150171 def workspace_folder (self ) -> WorkspaceFolder | None :
151172 return self ._workspace .get_document_folder (self .uri )
173+
174+ @property
175+ def dot_path (self ) -> str :
176+ return _utils .path_to_dot_name (self .path )
177+
178+ # ---- Jedi helpers ----
179+
180+ def jedi_names (
181+ self , all_scopes : bool = False , definitions : bool = True , references : bool = False
182+ ):
183+ script = self .jedi_script ()
184+ return script .get_names (
185+ all_scopes = all_scopes , definitions = definitions , references = references
186+ )
187+
188+ def jedi_script (
189+ self , use_document_path : bool = False
190+ ) -> jedi .Script :
191+ extra_paths : list [str ] = []
192+ environment_path : str | None = None
193+ env_vars : dict [str , str ] | None = None
194+ prioritize_extra_paths = False
195+
196+ # Read Jedi-related settings
197+ cfg = self ._workspace .config
198+ if cfg :
199+ jedi_settings = cfg .plugin_settings ("jedi" , document_path = self .path )
200+ jedi .settings .auto_import_modules = jedi_settings .get (
201+ "auto_import_modules" , DEFAULT_AUTO_IMPORT_MODULES
202+ )
203+ environment_path = jedi_settings .get ("environment" )
204+ if environment_path and os .name != "nt" :
205+ environment_path = os .path .expanduser (environment_path )
206+ extra_paths = jedi_settings .get ("extra_paths" ) or []
207+ env_vars = jedi_settings .get ("env_vars" )
208+ prioritize_extra_paths = jedi_settings .get ("prioritize_extra_paths" , False )
209+
210+ # Ensure Jedi starts without PYTHONPATH collisions
211+ if env_vars is None :
212+ env_vars = os .environ .copy ()
213+ env_vars .pop ("PYTHONPATH" , None )
214+
215+ environment = self .get_enviroment (environment_path , env_vars = env_vars )
216+
217+ # Build sys_path: project roots + environment + configured extra paths
218+ sys_path = [] # type: list[str]
219+ sys_path .extend (self ._workspace .source_roots (self .path ))
220+ sys_path .extend (environment .get_sys_path ())
221+ if use_document_path :
222+ sys_path .append (os .path .normpath (os .path .dirname (self .path )))
223+ if prioritize_extra_paths :
224+ sys_path = list (extra_paths ) + sys_path
225+ else :
226+ sys_path = sys_path + list (extra_paths )
227+
228+ # Determine project path
229+ project_path = self ._workspace .root_path
230+
231+ kwargs : dict [str , typ .Any ] = {
232+ "code" : self .source ,
233+ "path" : self .path ,
234+ "environment" : environment if environment_path else None ,
235+ "project" : jedi .Project (path = project_path , sys_path = sys_path ),
236+ }
237+
238+ # Position is passed to API calls (infer/complete), not Script ctor, so ignored here
239+ return jedi .Script (** kwargs )
240+
241+ def get_enviroment (
242+ self , environment_path : str | None = None , env_vars : dict [str , str ] | None = None
243+ ):
244+ """Return a cached Jedi environment or create a new one.
245+
246+ Note: keep method name for backward compatibility (typo preserved).
247+ """
248+ if environment_path is None :
249+ return jedi_environment .get_cached_default_environment ()
250+ if environment_path in self ._workspace ._environments :
251+ return self ._workspace ._environments [environment_path ]
252+ env = jedi_environment .create_environment (
253+ path = environment_path , safe = False , env_vars = env_vars
254+ )
255+ self ._workspace ._environments [environment_path ] = env
256+ return env
0 commit comments