Skip to repository content3382 lines · 123.4 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:28:44.407Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
python.rs
1use anyhow::Result;
2use anyhow::{Context as _, ensure};
3use async_trait::async_trait;
4use collections::HashMap;
5use futures::future::BoxFuture;
6use futures::lock::OwnedMutexGuard;
7use futures::{AsyncBufReadExt, StreamExt as _};
8use gpui::{App, AsyncApp, Entity, SharedString, Task};
9use http_client::github::{AssetKind, GitHubLspBinaryVersion, latest_github_release};
10use language::language_settings::LanguageSettings;
11use language::{
12 Buffer, ContextLocation, DynLspInstaller, LanguageToolchainStore, LspInstaller, Symbol,
13};
14use language::{ContextProvider, LspAdapter, LspAdapterDelegate};
15use language::{LanguageName, ManifestName, ManifestProvider, ManifestQuery};
16use language::{Toolchain, ToolchainList, ToolchainLister, ToolchainMetadata};
17use lsp::{CompletionItemKind, LanguageServerBinary, Uri};
18use lsp::{LanguageServerBinaryOptions, LanguageServerName};
19use node_runtime::{NodeRuntime, VersionStrategy};
20use pet_core::Configuration;
21use pet_core::os_environment::Environment;
22use pet_core::python_environment::{PythonEnvironment, PythonEnvironmentKind};
23use pet_virtualenv::is_virtualenv_dir;
24use project::Fs;
25use project::lsp_store::language_server_settings;
26use semver::Version;
27use serde::{Deserialize, Serialize};
28use serde_json::{Value, json};
29use settings::{SemanticTokenRules, Settings};
30use terminal::terminal_settings::TerminalSettings;
31
32use smol::lock::OnceCell;
33use std::cmp::{Ordering, Reverse};
34use std::env::consts;
35use util::command::Stdio;
36
37use util::command::new_command;
38use util::fs::{make_file_executable, remove_matching};
39use util::paths::PathStyle;
40use util::rel_path::RelPath;
41
42use http_client::github_download::{GithubBinaryMetadata, download_server_binary};
43use parking_lot::Mutex;
44use std::str::FromStr;
45use std::{
46 borrow::Cow,
47 fmt::Write,
48 future::Future,
49 path::{Path, PathBuf},
50 sync::Arc,
51};
52use task::{ShellKind, TaskTemplate, TaskTemplates, VariableName};
53use util::{ResultExt, maybe};
54
55pub(crate) fn semantic_token_rules() -> SemanticTokenRules {
56 let content = grammars::get_file("python/semantic_token_rules.json")
57 .expect("missing python/semantic_token_rules.json");
58 let json = std::str::from_utf8(&content.data).expect("invalid utf-8 in semantic_token_rules");
59 settings::parse_json_with_comments::<SemanticTokenRules>(json)
60 .expect("failed to parse python semantic_token_rules.json")
61}
62
63#[derive(Debug, Serialize, Deserialize)]
64pub(crate) struct PythonToolchainData {
65 #[serde(flatten)]
66 environment: PythonEnvironment,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 activation_scripts: Option<HashMap<ShellKind, PathBuf>>,
69}
70
71pub(crate) struct PyprojectTomlManifestProvider;
72
73impl ManifestProvider for PyprojectTomlManifestProvider {
74 fn name(&self) -> ManifestName {
75 SharedString::new_static("pyproject.toml").into()
76 }
77
78 fn search(
79 &self,
80 ManifestQuery {
81 path,
82 depth,
83 delegate,
84 }: ManifestQuery,
85 ) -> Option<Arc<RelPath>> {
86 const WORKSPACE_LOCKFILES: &[&str] =
87 &["uv.lock", "poetry.lock", "pdm.lock", "Pipfile.lock"];
88
89 let mut innermost_pyproject = None;
90 let mut outermost_workspace_root = None;
91
92 for path in path.ancestors().take(depth) {
93 let pyproject_path = path.join(RelPath::from_unix_str("pyproject.toml").unwrap());
94 if delegate.exists(&pyproject_path, Some(false)) {
95 if innermost_pyproject.is_none() {
96 innermost_pyproject = Some(Arc::from(path));
97 }
98
99 let has_lockfile = WORKSPACE_LOCKFILES.iter().any(|lockfile| {
100 let lockfile_path = path.join(RelPath::from_unix_str(lockfile).unwrap());
101 delegate.exists(&lockfile_path, Some(false))
102 });
103 if has_lockfile {
104 outermost_workspace_root = Some(Arc::from(path));
105 }
106 }
107 }
108
109 outermost_workspace_root.or(innermost_pyproject)
110 }
111}
112
113enum TestRunner {
114 UNITTEST,
115 PYTEST,
116}
117
118impl FromStr for TestRunner {
119 type Err = ();
120
121 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
122 match s {
123 "unittest" => Ok(Self::UNITTEST),
124 "pytest" => Ok(Self::PYTEST),
125 _ => Err(()),
126 }
127 }
128}
129
130/// Pyright assigns each completion item a `sortText` of the form `XX.YYYY.name`.
131/// Where `XX` is the sorting category, `YYYY` is based on most recent usage,
132/// and `name` is the symbol name itself.
133///
134/// The problem with it is that Pyright adjusts the sort text based on previous resolutions (items for which we've issued `completion/resolve` call have their sortText adjusted),
135/// which - long story short - makes completion items list non-stable. Pyright probably relies on VSCode's implementation detail.
136/// see https://github.com/microsoft/pyright/blob/95ef4e103b9b2f129c9320427e51b73ea7cf78bd/packages/pyright-internal/src/languageService/completionProvider.ts#LL2873
137///
138/// upd 02.12.25:
139/// Decided to ignore Pyright's sortText() completely and to manually sort all entries
140fn process_pyright_completions(items: &mut [lsp::CompletionItem]) {
141 for item in items {
142 let is_named_argument = item.label.ends_with('=');
143
144 let is_dunder = item.label.starts_with("__") && item.label.ends_with("__");
145
146 let visibility_priority = if is_dunder {
147 '3'
148 } else if item.label.starts_with("__") {
149 '2' // private non-dunder
150 } else if item.label.starts_with('_') {
151 '1' // protected
152 } else {
153 '0' // public
154 };
155
156 let is_external = item
157 .detail
158 .as_ref()
159 .is_some_and(|detail| detail == "Auto-import");
160
161 let source_priority = if is_external { '1' } else { '0' };
162
163 // Kind priority within same visibility level
164 let kind_priority = match item.kind {
165 Some(lsp::CompletionItemKind::KEYWORD) => '0',
166 Some(lsp::CompletionItemKind::ENUM_MEMBER) => '1',
167 Some(lsp::CompletionItemKind::FIELD) => '2',
168 Some(lsp::CompletionItemKind::PROPERTY) => '3',
169 Some(lsp::CompletionItemKind::VARIABLE) => '4',
170 Some(lsp::CompletionItemKind::CONSTANT) => '5',
171 Some(lsp::CompletionItemKind::METHOD) => '6',
172 Some(lsp::CompletionItemKind::FUNCTION) => '6',
173 Some(lsp::CompletionItemKind::CLASS) => '7',
174 Some(lsp::CompletionItemKind::MODULE) => '8',
175
176 _ => 'z',
177 };
178
179 // Named arguments get higher priority
180 let argument_priority = if is_named_argument { '0' } else { '1' };
181
182 item.sort_text = Some(format!(
183 "{}{}{}{}{}",
184 argument_priority, source_priority, visibility_priority, kind_priority, item.label
185 ));
186 }
187}
188
189fn label_for_pyright_completion(
190 item: &lsp::CompletionItem,
191 language: &Arc<language::Language>,
192) -> Option<language::CodeLabel> {
193 let label = &item.label;
194 let label_len = label.len();
195 let grammar = language.grammar()?;
196 let highlight_id = highlight_id_for_completion(item.kind?, grammar)?;
197
198 let mut text = label.clone();
199 if let Some(completion_details) = item
200 .label_details
201 .as_ref()
202 .and_then(|details| details.description.as_ref())
203 {
204 write!(&mut text, " {}", completion_details).ok();
205 }
206 Some(language::CodeLabel::filtered(
207 text,
208 label_len,
209 item.filter_text.as_deref(),
210 highlight_id
211 .map(|id| (0..label_len, id))
212 .into_iter()
213 .collect(),
214 ))
215}
216
217fn label_for_python_symbol(
218 symbol: &Symbol,
219 language: &Arc<language::Language>,
220) -> Option<language::CodeLabel> {
221 let name = &symbol.name;
222 let (text, filter_range, display_range) = match symbol.kind {
223 language::SymbolKind::Method | language::SymbolKind::Function => {
224 let text = format!("def {}():\n", name);
225 let filter_range = 4..4 + name.len();
226 let display_range = 0..filter_range.end;
227 (text, filter_range, display_range)
228 }
229 language::SymbolKind::Class => {
230 let text = format!("class {}:", name);
231 let filter_range = 6..6 + name.len();
232 let display_range = 0..filter_range.end;
233 (text, filter_range, display_range)
234 }
235 language::SymbolKind::Constant => {
236 let text = format!("{} = 0", name);
237 let filter_range = 0..name.len();
238 let display_range = 0..filter_range.end;
239 (text, filter_range, display_range)
240 }
241 _ => return None,
242 };
243 Some(language::CodeLabel::new(
244 text[display_range.clone()].to_string(),
245 filter_range,
246 language.highlight_text(&text.as_str().into(), display_range),
247 ))
248}
249
250/// Returns the highlight ID for the given completion item kind, if it is supported.
251///
252/// The outer `Option` is `None` if the item kind returned by the language server is not covered.
253/// The inner `Option` is `None` if the item kind is covered, but the highlight name is not present in the grammar.
254fn highlight_id_for_completion(
255 item_kind: CompletionItemKind,
256 grammar: &Arc<language::Grammar>,
257) -> Option<Option<language::HighlightId>> {
258 match item_kind {
259 CompletionItemKind::METHOD => Some(grammar.highlight_id_for_name("function.method.call")),
260 CompletionItemKind::FUNCTION => Some(grammar.highlight_id_for_name("function.call")),
261 CompletionItemKind::CLASS => Some(grammar.highlight_id_for_name("type")),
262 CompletionItemKind::CONSTANT => Some(grammar.highlight_id_for_name("constant")),
263 CompletionItemKind::VARIABLE => Some(grammar.highlight_id_for_name("variable")),
264 _ => None,
265 }
266}
267
268pub struct TyLspAdapter {
269 fs: Arc<dyn Fs>,
270}
271
272#[cfg(target_os = "macos")]
273impl TyLspAdapter {
274 const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
275 const ARCH_SERVER_NAME: &str = "apple-darwin";
276}
277
278#[cfg(target_os = "linux")]
279impl TyLspAdapter {
280 const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
281 const ARCH_SERVER_NAME: &str = "unknown-linux-gnu";
282}
283
284#[cfg(target_os = "freebsd")]
285impl TyLspAdapter {
286 const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
287 const ARCH_SERVER_NAME: &str = "unknown-freebsd";
288}
289
290#[cfg(target_os = "windows")]
291impl TyLspAdapter {
292 const GITHUB_ASSET_KIND: AssetKind = AssetKind::Zip;
293 const ARCH_SERVER_NAME: &str = "pc-windows-msvc";
294}
295
296impl TyLspAdapter {
297 const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("ty");
298
299 pub fn new(fs: Arc<dyn Fs>) -> TyLspAdapter {
300 TyLspAdapter { fs }
301 }
302
303 fn build_asset_name() -> Result<(String, String)> {
304 let arch = match consts::ARCH {
305 "x86" => "i686",
306 _ => consts::ARCH,
307 };
308 let os = Self::ARCH_SERVER_NAME;
309 let suffix = match consts::OS {
310 "windows" => "zip",
311 _ => "tar.gz",
312 };
313 let asset_name = format!("ty-{arch}-{os}.{suffix}");
314 let asset_stem = format!("ty-{arch}-{os}");
315 Ok((asset_stem, asset_name))
316 }
317}
318
319#[async_trait(?Send)]
320impl LspAdapter for TyLspAdapter {
321 fn name(&self) -> LanguageServerName {
322 Self::SERVER_NAME
323 }
324
325 async fn label_for_completion(
326 &self,
327 item: &lsp::CompletionItem,
328 language: &Arc<language::Language>,
329 ) -> Option<language::CodeLabel> {
330 let label = &item.label;
331 let label_len = label.len();
332 let grammar = language.grammar()?;
333 let highlight_id = highlight_id_for_completion(item.kind?, grammar)?;
334
335 let mut text = label.clone();
336 if let Some(completion_details) = item
337 .label_details
338 .as_ref()
339 .and_then(|details| details.detail.as_ref())
340 {
341 write!(&mut text, " {}", completion_details).ok();
342 }
343
344 Some(language::CodeLabel::filtered(
345 text,
346 label_len,
347 item.filter_text.as_deref(),
348 highlight_id
349 .map(|id| (0..label_len, id))
350 .into_iter()
351 .collect(),
352 ))
353 }
354
355 async fn label_for_symbol(
356 &self,
357 symbol: &language::Symbol,
358 language: &Arc<language::Language>,
359 ) -> Option<language::CodeLabel> {
360 label_for_python_symbol(symbol, language)
361 }
362
363 async fn workspace_configuration(
364 self: Arc<Self>,
365 delegate: &Arc<dyn LspAdapterDelegate>,
366 toolchain: Option<Toolchain>,
367 _: Option<Uri>,
368 cx: &mut AsyncApp,
369 ) -> Result<Value> {
370 let mut ret = cx
371 .update(|cx| {
372 language_server_settings(delegate.as_ref(), &self.name(), cx)
373 .and_then(|s| s.settings.clone())
374 })
375 .unwrap_or_else(|| json!({}));
376 if let Some(toolchain) = toolchain.and_then(|toolchain| {
377 serde_json::from_value::<PythonToolchainData>(toolchain.as_json).ok()
378 }) {
379 _ = maybe!({
380 let uri =
381 url::Url::from_file_path(toolchain.environment.executable.as_ref()?).ok()?;
382 let sys_prefix = toolchain.environment.prefix.clone()?;
383 let environment = json!({
384 "executable": {
385 "uri": uri,
386 "sysPrefix": sys_prefix
387 }
388 });
389 ret.as_object_mut()?
390 .entry("pythonExtension")
391 .or_insert_with(|| json!({ "activeEnvironment": environment }));
392 Some(())
393 });
394 }
395 Ok(json!({"ty": ret}))
396 }
397}
398
399impl LspInstaller for TyLspAdapter {
400 type BinaryVersion = GitHubLspBinaryVersion;
401 async fn fetch_latest_server_version(
402 &self,
403 delegate: &Arc<dyn LspAdapterDelegate>,
404 _: bool,
405 _: &mut AsyncApp,
406 ) -> Result<Self::BinaryVersion> {
407 let release =
408 latest_github_release("astral-sh/ty", true, false, delegate.http_client()).await?;
409 let (_, asset_name) = Self::build_asset_name()?;
410 let asset = release
411 .assets
412 .into_iter()
413 .find(|asset| asset.name == asset_name)
414 .with_context(|| format!("no asset found matching `{asset_name:?}`"))?;
415 Ok(GitHubLspBinaryVersion {
416 name: release.tag_name,
417 url: asset.browser_download_url,
418 digest: asset.digest,
419 })
420 }
421
422 async fn check_if_user_installed(
423 &self,
424 delegate: &Arc<dyn LspAdapterDelegate>,
425 toolchain: Option<Toolchain>,
426 _: &AsyncApp,
427 ) -> Option<LanguageServerBinary> {
428 let ty_in_venv = if let Some(toolchain) = toolchain
429 && toolchain.language_name.as_ref() == "Python"
430 {
431 Path::new(toolchain.path.as_str())
432 .parent()
433 .map(|path| path.join("ty"))
434 } else {
435 None
436 };
437
438 for path in ty_in_venv.into_iter().chain(["ty".into()]) {
439 if let Some(ty_bin) = delegate.which(path.as_os_str()).await {
440 let env = delegate.shell_env().await;
441 return Some(LanguageServerBinary {
442 path: ty_bin,
443 env: Some(env),
444 arguments: vec!["server".into()],
445 });
446 }
447 }
448
449 None
450 }
451
452 fn fetch_server_binary(
453 &self,
454 latest_version: Self::BinaryVersion,
455 container_dir: PathBuf,
456 delegate: &Arc<dyn LspAdapterDelegate>,
457 ) -> impl Send + Future<Output = Result<LanguageServerBinary>> + use<> {
458 let delegate = delegate.clone();
459
460 async move {
461 let GitHubLspBinaryVersion {
462 name,
463 url,
464 digest: expected_digest,
465 } = latest_version;
466 let destination_path = container_dir.join(format!("ty-{name}"));
467
468 async_fs::create_dir_all(&destination_path).await?;
469
470 let server_path = match Self::GITHUB_ASSET_KIND {
471 AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => destination_path
472 .join(Self::build_asset_name()?.0)
473 .join("ty"),
474 AssetKind::Zip => destination_path.clone().join("ty.exe"),
475 };
476
477 let binary = LanguageServerBinary {
478 path: server_path.clone(),
479 env: None,
480 arguments: vec!["server".into()],
481 };
482
483 let metadata_path = destination_path.with_extension("metadata");
484 let metadata = GithubBinaryMetadata::read_from_file(&metadata_path)
485 .await
486 .ok();
487 if let Some(metadata) = metadata {
488 let validity_check = async || {
489 delegate
490 .try_exec(LanguageServerBinary {
491 path: server_path.clone(),
492 arguments: vec!["--version".into()],
493 env: None,
494 })
495 .await
496 .inspect_err(|err| {
497 log::warn!(
498 "Unable to run {server_path:?} asset, redownloading: {err:#}",
499 )
500 })
501 };
502 if let (Some(actual_digest), Some(expected_digest)) =
503 (&metadata.digest, &expected_digest)
504 {
505 if actual_digest == expected_digest {
506 if validity_check().await.is_ok() {
507 return Ok(binary);
508 }
509 } else {
510 log::info!(
511 "SHA-256 mismatch for {destination_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}"
512 );
513 }
514 } else if validity_check().await.is_ok() {
515 return Ok(binary);
516 }
517 }
518
519 download_server_binary(
520 &*delegate.http_client(),
521 &url,
522 expected_digest.as_deref(),
523 &destination_path,
524 Self::GITHUB_ASSET_KIND,
525 )
526 .await?;
527 make_file_executable(&server_path).await?;
528 remove_matching(&container_dir, |path| path != destination_path).await;
529 GithubBinaryMetadata::write_to_file(
530 &GithubBinaryMetadata {
531 metadata_version: 1,
532 digest: expected_digest,
533 },
534 &metadata_path,
535 )
536 .await?;
537
538 Ok(LanguageServerBinary {
539 path: server_path,
540 env: None,
541 arguments: vec!["server".into()],
542 })
543 }
544 }
545
546 async fn cached_server_binary(
547 &self,
548 container_dir: PathBuf,
549 _: &dyn LspAdapterDelegate,
550 ) -> Option<LanguageServerBinary> {
551 maybe!(async {
552 let mut last = None;
553 let mut entries = self.fs.read_dir(&container_dir).await?;
554 while let Some(entry) = entries.next().await {
555 let path = entry?;
556 if path.extension().is_some_and(|ext| ext == "metadata") {
557 continue;
558 }
559 last = Some(path);
560 }
561
562 let path = last.context("no cached binary")?;
563 let path = match TyLspAdapter::GITHUB_ASSET_KIND {
564 AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => {
565 path.join(Self::build_asset_name()?.0).join("ty")
566 }
567 AssetKind::Zip => path.join("ty.exe"),
568 };
569
570 anyhow::Ok(LanguageServerBinary {
571 path,
572 env: None,
573 arguments: vec!["server".into()],
574 })
575 })
576 .await
577 .log_err()
578 }
579}
580
581pub struct PyrightLspAdapter {
582 node: NodeRuntime,
583}
584
585impl PyrightLspAdapter {
586 const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("pyright");
587 const SERVER_PATH: &str = "node_modules/pyright/langserver.index.js";
588 const NODE_MODULE_RELATIVE_SERVER_PATH: &str = "pyright/langserver.index.js";
589
590 pub fn new(node: NodeRuntime) -> Self {
591 PyrightLspAdapter { node }
592 }
593
594 async fn get_cached_server_binary(
595 container_dir: PathBuf,
596 node: &NodeRuntime,
597 ) -> Option<LanguageServerBinary> {
598 let server_path = container_dir.join(Self::SERVER_PATH);
599 if server_path.exists() {
600 Some(LanguageServerBinary {
601 path: node.binary_path().await.log_err()?,
602 env: None,
603 arguments: vec![server_path.into(), "--stdio".into()],
604 })
605 } else {
606 log::error!("missing executable in directory {:?}", server_path);
607 None
608 }
609 }
610}
611
612#[async_trait(?Send)]
613impl LspAdapter for PyrightLspAdapter {
614 fn name(&self) -> LanguageServerName {
615 Self::SERVER_NAME
616 }
617
618 async fn initialization_options(
619 self: Arc<Self>,
620 _: &Arc<dyn LspAdapterDelegate>,
621 _: &mut AsyncApp,
622 ) -> Result<Option<Value>> {
623 // Provide minimal initialization options
624 // Virtual environment configuration will be handled through workspace configuration
625 Ok(Some(json!({
626 "python": {
627 "analysis": {
628 "autoSearchPaths": true,
629 "useLibraryCodeForTypes": true,
630 "autoImportCompletions": true
631 }
632 }
633 })))
634 }
635
636 async fn process_completions(&self, items: &mut [lsp::CompletionItem]) {
637 process_pyright_completions(items);
638 }
639
640 async fn label_for_completion(
641 &self,
642 item: &lsp::CompletionItem,
643 language: &Arc<language::Language>,
644 ) -> Option<language::CodeLabel> {
645 label_for_pyright_completion(item, language)
646 }
647
648 async fn label_for_symbol(
649 &self,
650 symbol: &language::Symbol,
651 language: &Arc<language::Language>,
652 ) -> Option<language::CodeLabel> {
653 label_for_python_symbol(symbol, language)
654 }
655
656 async fn workspace_configuration(
657 self: Arc<Self>,
658 adapter: &Arc<dyn LspAdapterDelegate>,
659 toolchain: Option<Toolchain>,
660 _: Option<Uri>,
661 cx: &mut AsyncApp,
662 ) -> Result<Value> {
663 Ok(cx.update(move |cx| {
664 let mut user_settings =
665 language_server_settings(adapter.as_ref(), &Self::SERVER_NAME, cx)
666 .and_then(|s| s.settings.clone())
667 .unwrap_or_default();
668
669 // If we have a detected toolchain, configure Pyright to use it - unless the user sets it themselves.
670 let should_insert_toolchain = || {
671 user_settings.as_object().is_none_or(|object| {
672 ![
673 "venvPath",
674 "venv",
675 "python",
676 "pythonPath",
677 "defaultInterpreterPath",
678 ]
679 .into_iter()
680 .any(|known_key| object.contains_key(known_key))
681 })
682 };
683 if let Some(toolchain) = toolchain
684 && should_insert_toolchain()
685 && let Ok(env) =
686 serde_json::from_value::<PythonToolchainData>(toolchain.as_json.clone())
687 {
688 if !user_settings.is_object() {
689 user_settings = Value::Object(serde_json::Map::default());
690 }
691 let object = user_settings.as_object_mut().unwrap();
692
693 let interpreter_path = toolchain.path.to_string();
694 if let Some(venv_dir) = &env.environment.prefix {
695 // Set venvPath and venv at the root level
696 // This matches the format of a pyrightconfig.json file
697 if let Some(parent) = venv_dir.parent() {
698 // Use relative path if the venv is inside the workspace
699 let venv_path = if parent == adapter.worktree_root_path() {
700 ".".to_string()
701 } else {
702 parent.to_string_lossy().into_owned()
703 };
704 object.insert("venvPath".to_string(), Value::String(venv_path));
705 }
706
707 if let Some(venv_name) = venv_dir.file_name() {
708 object.insert(
709 "venv".to_owned(),
710 Value::String(venv_name.to_string_lossy().into_owned()),
711 );
712 }
713 }
714
715 // Always set the python interpreter path
716 // Get or create the python section
717 let python = object
718 .entry("python")
719 .and_modify(|v| {
720 if !v.is_object() {
721 *v = Value::Object(serde_json::Map::default());
722 }
723 })
724 .or_insert(Value::Object(serde_json::Map::default()));
725 let python = python.as_object_mut().unwrap();
726
727 // Set both pythonPath and defaultInterpreterPath for compatibility
728 python.insert(
729 "pythonPath".to_owned(),
730 Value::String(interpreter_path.clone()),
731 );
732 python.insert(
733 "defaultInterpreterPath".to_owned(),
734 Value::String(interpreter_path),
735 );
736 }
737
738 user_settings
739 }))
740 }
741}
742
743impl LspInstaller for PyrightLspAdapter {
744 type BinaryVersion = Version;
745
746 async fn fetch_latest_server_version(
747 &self,
748 _: &Arc<dyn LspAdapterDelegate>,
749 _: bool,
750 _: &mut AsyncApp,
751 ) -> Result<Self::BinaryVersion> {
752 self.node
753 .npm_package_latest_version(Self::SERVER_NAME.as_ref())
754 .await
755 }
756
757 async fn check_if_user_installed(
758 &self,
759 delegate: &Arc<dyn LspAdapterDelegate>,
760 _: Option<Toolchain>,
761 _: &AsyncApp,
762 ) -> Option<LanguageServerBinary> {
763 if let Some(pyright_bin) = delegate.which("pyright-langserver".as_ref()).await {
764 let env = delegate.shell_env().await;
765 Some(LanguageServerBinary {
766 path: pyright_bin,
767 env: Some(env),
768 arguments: vec!["--stdio".into()],
769 })
770 } else {
771 let node = delegate.which("node".as_ref()).await?;
772 let (node_modules_path, _) = delegate
773 .npm_package_installed_version(Self::SERVER_NAME.as_ref())
774 .await
775 .log_err()??;
776
777 let path = node_modules_path.join(Self::NODE_MODULE_RELATIVE_SERVER_PATH);
778
779 let env = delegate.shell_env().await;
780 Some(LanguageServerBinary {
781 path: node,
782 env: Some(env),
783 arguments: vec![path.into(), "--stdio".into()],
784 })
785 }
786 }
787
788 fn fetch_server_binary(
789 &self,
790 _latest_version: Self::BinaryVersion,
791 container_dir: PathBuf,
792 delegate: &Arc<dyn LspAdapterDelegate>,
793 ) -> impl Send + Future<Output = Result<LanguageServerBinary>> + use<> {
794 let delegate = delegate.clone();
795 let node = self.node.clone();
796
797 async move {
798 let server_path = container_dir.join(Self::SERVER_PATH);
799 node.npm_install_latest_packages(&container_dir, &[Self::SERVER_NAME.as_ref()])
800 .await?;
801
802 let env = delegate.shell_env().await;
803 Ok(LanguageServerBinary {
804 path: node.binary_path().await?,
805 env: Some(env),
806 arguments: vec![server_path.into(), "--stdio".into()],
807 })
808 }
809 }
810
811 fn check_if_version_installed(
812 &self,
813 version: &Self::BinaryVersion,
814 container_dir: &PathBuf,
815 delegate: &Arc<dyn LspAdapterDelegate>,
816 ) -> impl Send + Future<Output = Option<LanguageServerBinary>> + use<> {
817 let delegate = delegate.clone();
818 let node = self.node.clone();
819 let version = version.clone();
820 let container_dir = container_dir.clone();
821
822 async move {
823 let server_path = container_dir.join(Self::SERVER_PATH);
824
825 let should_install_language_server = node
826 .should_install_npm_package(
827 Self::SERVER_NAME.as_ref(),
828 &server_path,
829 &container_dir,
830 VersionStrategy::Latest(&version),
831 )
832 .await;
833
834 if should_install_language_server {
835 None
836 } else {
837 let env = delegate.shell_env().await;
838 Some(LanguageServerBinary {
839 path: node.binary_path().await.ok()?,
840 env: Some(env),
841 arguments: vec![server_path.into(), "--stdio".into()],
842 })
843 }
844 }
845 }
846
847 async fn cached_server_binary(
848 &self,
849 container_dir: PathBuf,
850 delegate: &dyn LspAdapterDelegate,
851 ) -> Option<LanguageServerBinary> {
852 let mut binary = Self::get_cached_server_binary(container_dir, &self.node).await?;
853 binary.env = Some(delegate.shell_env().await);
854 Some(binary)
855 }
856}
857
858pub(crate) struct PythonContextProvider;
859
860const PYTHON_TEST_TARGET_TASK_VARIABLE: VariableName =
861 VariableName::Custom(Cow::Borrowed("PYTHON_TEST_TARGET"));
862
863const PYTHON_ACTIVE_TOOLCHAIN_PATH: VariableName =
864 VariableName::Custom(Cow::Borrowed("PYTHON_ACTIVE_ZED_TOOLCHAIN"));
865
866const PYTHON_MODULE_NAME_TASK_VARIABLE: VariableName =
867 VariableName::Custom(Cow::Borrowed("PYTHON_MODULE_NAME"));
868
869impl ContextProvider for PythonContextProvider {
870 fn build_context(
871 &self,
872 variables: &task::TaskVariables,
873 location: ContextLocation<'_>,
874 _: Option<HashMap<String, String>>,
875 toolchains: Arc<dyn LanguageToolchainStore>,
876 cx: &mut gpui::App,
877 ) -> Task<Result<task::TaskVariables>> {
878 let test_target = match selected_test_runner(Some(&location.file_location.buffer), cx) {
879 TestRunner::UNITTEST => self.build_unittest_target(variables),
880 TestRunner::PYTEST => self.build_pytest_target(variables),
881 };
882
883 let module_target = self.build_module_target(variables);
884 let location_file = location.file_location.buffer.read(cx).file().cloned();
885 let worktree_id = location_file.as_ref().map(|f| f.worktree_id(cx));
886
887 cx.spawn(async move |cx| {
888 let active_toolchain = if let Some(worktree_id) = worktree_id {
889 let file_path = location_file
890 .as_ref()
891 .and_then(|f| f.path().parent())
892 .map(Arc::from)
893 .unwrap_or_else(|| RelPath::empty_arc());
894
895 toolchains
896 .active_toolchain(worktree_id, file_path, "Python".into(), cx)
897 .await
898 .map_or_else(
899 || String::from("python3"),
900 |toolchain| toolchain.path.to_string(),
901 )
902 } else {
903 String::from("python3")
904 };
905
906 let toolchain = (PYTHON_ACTIVE_TOOLCHAIN_PATH, active_toolchain);
907
908 Ok(task::TaskVariables::from_iter(
909 test_target
910 .into_iter()
911 .chain(module_target)
912 .chain([toolchain]),
913 ))
914 })
915 }
916
917 fn associated_tasks(
918 &self,
919 buffer: Option<Entity<Buffer>>,
920 cx: &App,
921 ) -> Task<Option<TaskTemplates>> {
922 let test_runner = selected_test_runner(buffer.as_ref(), cx);
923
924 let mut tasks = vec![
925 // Execute a selection
926 TaskTemplate {
927 label: "execute selection".to_owned(),
928 command: PYTHON_ACTIVE_TOOLCHAIN_PATH.template_value(),
929 args: vec![
930 "-c".to_owned(),
931 VariableName::SelectedText.template_value_with_whitespace(),
932 ],
933 cwd: Some(VariableName::WorktreeRoot.template_value()),
934 ..TaskTemplate::default()
935 },
936 // Execute an entire file
937 TaskTemplate {
938 label: format!("run '{}'", VariableName::File.template_value()),
939 command: PYTHON_ACTIVE_TOOLCHAIN_PATH.template_value(),
940 args: vec![VariableName::File.template_value_with_whitespace()],
941 cwd: Some(VariableName::WorktreeRoot.template_value()),
942 ..TaskTemplate::default()
943 },
944 // Execute a file as module
945 TaskTemplate {
946 label: format!("run module '{}'", VariableName::File.template_value()),
947 command: PYTHON_ACTIVE_TOOLCHAIN_PATH.template_value(),
948 args: vec![
949 "-m".to_owned(),
950 PYTHON_MODULE_NAME_TASK_VARIABLE.template_value(),
951 ],
952 cwd: Some(VariableName::WorktreeRoot.template_value()),
953 tags: vec!["python-module-main-method".to_owned()],
954 ..TaskTemplate::default()
955 },
956 ];
957
958 tasks.extend(match test_runner {
959 TestRunner::UNITTEST => {
960 [
961 // Run tests for an entire file
962 TaskTemplate {
963 label: format!("unittest '{}'", VariableName::File.template_value()),
964 command: PYTHON_ACTIVE_TOOLCHAIN_PATH.template_value(),
965 args: vec![
966 "-m".to_owned(),
967 "unittest".to_owned(),
968 VariableName::File.template_value_with_whitespace(),
969 ],
970 cwd: Some(VariableName::WorktreeRoot.template_value()),
971 ..TaskTemplate::default()
972 },
973 // Run test(s) for a specific target within a file
974 TaskTemplate {
975 label: "unittest $ZED_CUSTOM_PYTHON_TEST_TARGET".to_owned(),
976 command: PYTHON_ACTIVE_TOOLCHAIN_PATH.template_value(),
977 args: vec![
978 "-m".to_owned(),
979 "unittest".to_owned(),
980 PYTHON_TEST_TARGET_TASK_VARIABLE.template_value_with_whitespace(),
981 ],
982 tags: vec![
983 "python-unittest-class".to_owned(),
984 "python-unittest-method".to_owned(),
985 ],
986 cwd: Some(VariableName::WorktreeRoot.template_value()),
987 ..TaskTemplate::default()
988 },
989 ]
990 }
991 TestRunner::PYTEST => {
992 [
993 // Run tests for an entire file
994 TaskTemplate {
995 label: format!("pytest '{}'", VariableName::File.template_value()),
996 command: PYTHON_ACTIVE_TOOLCHAIN_PATH.template_value(),
997 args: vec![
998 "-m".to_owned(),
999 "pytest".to_owned(),
1000 VariableName::File.template_value_with_whitespace(),
1001 ],
1002 cwd: Some(VariableName::WorktreeRoot.template_value()),
1003 ..TaskTemplate::default()
1004 },
1005 // Run test(s) for a specific target within a file
1006 TaskTemplate {
1007 label: "pytest $ZED_CUSTOM_PYTHON_TEST_TARGET".to_owned(),
1008 command: PYTHON_ACTIVE_TOOLCHAIN_PATH.template_value(),
1009 args: vec![
1010 "-m".to_owned(),
1011 "pytest".to_owned(),
1012 PYTHON_TEST_TARGET_TASK_VARIABLE.template_value_with_whitespace(),
1013 ],
1014 cwd: Some(VariableName::WorktreeRoot.template_value()),
1015 tags: vec![
1016 "python-pytest-class".to_owned(),
1017 "python-pytest-method".to_owned(),
1018 ],
1019 ..TaskTemplate::default()
1020 },
1021 ]
1022 }
1023 });
1024
1025 Task::ready(Some(TaskTemplates(tasks)))
1026 }
1027}
1028
1029fn selected_test_runner(location: Option<&Entity<Buffer>>, cx: &App) -> TestRunner {
1030 const TEST_RUNNER_VARIABLE: &str = "TEST_RUNNER";
1031 let language = LanguageName::new_static("Python");
1032 let settings = LanguageSettings::resolve(location.map(|b| b.read(cx)), Some(&language), cx);
1033 settings
1034 .tasks
1035 .variables
1036 .get(TEST_RUNNER_VARIABLE)
1037 .and_then(|val| TestRunner::from_str(val).ok())
1038 .unwrap_or(TestRunner::PYTEST)
1039}
1040
1041impl PythonContextProvider {
1042 fn build_unittest_target(
1043 &self,
1044 variables: &task::TaskVariables,
1045 ) -> Option<(VariableName, String)> {
1046 let python_module_name =
1047 python_module_name_from_relative_path(variables.get(&VariableName::RelativeFile)?)?;
1048
1049 let unittest_class_name =
1050 variables.get(&VariableName::Custom(Cow::Borrowed("_unittest_class_name")));
1051
1052 let unittest_method_name = variables.get(&VariableName::Custom(Cow::Borrowed(
1053 "_unittest_method_name",
1054 )));
1055
1056 let unittest_target_str = match (unittest_class_name, unittest_method_name) {
1057 (Some(class_name), Some(method_name)) => {
1058 format!("{python_module_name}.{class_name}.{method_name}")
1059 }
1060 (Some(class_name), None) => format!("{python_module_name}.{class_name}"),
1061 (None, None) => python_module_name,
1062 // should never happen, a TestCase class is the unit of testing
1063 (None, Some(_)) => return None,
1064 };
1065
1066 Some((
1067 PYTHON_TEST_TARGET_TASK_VARIABLE.clone(),
1068 unittest_target_str,
1069 ))
1070 }
1071
1072 fn build_pytest_target(
1073 &self,
1074 variables: &task::TaskVariables,
1075 ) -> Option<(VariableName, String)> {
1076 let file_path = variables.get(&VariableName::RelativeFile)?;
1077
1078 let pytest_class_name =
1079 variables.get(&VariableName::Custom(Cow::Borrowed("_pytest_class_name")));
1080
1081 let pytest_method_name =
1082 variables.get(&VariableName::Custom(Cow::Borrowed("_pytest_method_name")));
1083
1084 let pytest_target_str = match (pytest_class_name, pytest_method_name) {
1085 (Some(class_name), Some(method_name)) => {
1086 format!("{file_path}::{class_name}::{method_name}")
1087 }
1088 (Some(class_name), None) => {
1089 format!("{file_path}::{class_name}")
1090 }
1091 (None, Some(method_name)) => {
1092 format!("{file_path}::{method_name}")
1093 }
1094 (None, None) => file_path.to_string(),
1095 };
1096
1097 Some((PYTHON_TEST_TARGET_TASK_VARIABLE.clone(), pytest_target_str))
1098 }
1099
1100 fn build_module_target(
1101 &self,
1102 variables: &task::TaskVariables,
1103 ) -> Result<(VariableName, String)> {
1104 let python_module_name = variables
1105 .get(&VariableName::RelativeFile)
1106 .and_then(|module| python_module_name_from_relative_path(module))
1107 .unwrap_or_default();
1108
1109 let module_target = (PYTHON_MODULE_NAME_TASK_VARIABLE.clone(), python_module_name);
1110
1111 Ok(module_target)
1112 }
1113}
1114
1115fn python_module_name_from_relative_path(relative_path: &str) -> Option<String> {
1116 let rel_path = RelPath::new(relative_path.as_ref(), PathStyle::local()).ok()?;
1117 let path_with_dots = rel_path.display(PathStyle::Unix).replace('/', ".");
1118 Some(
1119 path_with_dots
1120 .strip_suffix(".py")
1121 .map(ToOwned::to_owned)
1122 .unwrap_or(path_with_dots),
1123 )
1124}
1125
1126fn is_python_env_global(k: &PythonEnvironmentKind) -> bool {
1127 matches!(
1128 k,
1129 PythonEnvironmentKind::Homebrew
1130 | PythonEnvironmentKind::Pyenv
1131 | PythonEnvironmentKind::GlobalPaths
1132 | PythonEnvironmentKind::MacPythonOrg
1133 | PythonEnvironmentKind::MacCommandLineTools
1134 | PythonEnvironmentKind::LinuxGlobal
1135 | PythonEnvironmentKind::MacXCode
1136 | PythonEnvironmentKind::WindowsStore
1137 | PythonEnvironmentKind::WindowsRegistry
1138 )
1139}
1140
1141fn python_env_kind_display(k: &PythonEnvironmentKind) -> &'static str {
1142 match k {
1143 PythonEnvironmentKind::Conda => "Conda",
1144 PythonEnvironmentKind::Pixi => "pixi",
1145 PythonEnvironmentKind::Homebrew => "Homebrew",
1146 PythonEnvironmentKind::Pyenv => "global (Pyenv)",
1147 PythonEnvironmentKind::GlobalPaths => "global",
1148 PythonEnvironmentKind::PyenvVirtualEnv => "Pyenv",
1149 PythonEnvironmentKind::Pipenv => "Pipenv",
1150 PythonEnvironmentKind::Poetry => "Poetry",
1151 PythonEnvironmentKind::MacPythonOrg => "global (Python.org)",
1152 PythonEnvironmentKind::MacCommandLineTools => "global (Command Line Tools for Xcode)",
1153 PythonEnvironmentKind::LinuxGlobal => "global",
1154 PythonEnvironmentKind::MacXCode => "global (Xcode)",
1155 PythonEnvironmentKind::Venv => "venv",
1156 PythonEnvironmentKind::VirtualEnv => "virtualenv",
1157 PythonEnvironmentKind::VirtualEnvWrapper => "virtualenvwrapper",
1158 PythonEnvironmentKind::WinPython => "WinPython",
1159 PythonEnvironmentKind::WindowsStore => "global (Windows Store)",
1160 PythonEnvironmentKind::WindowsRegistry => "global (Windows Registry)",
1161 PythonEnvironmentKind::Uv => "uv",
1162 PythonEnvironmentKind::UvWorkspace => "uv (Workspace)",
1163 }
1164}
1165
1166pub(crate) struct PythonToolchainProvider {
1167 fs: Arc<dyn Fs>,
1168}
1169
1170impl PythonToolchainProvider {
1171 pub fn new(fs: Arc<dyn Fs>) -> Self {
1172 Self { fs }
1173 }
1174}
1175
1176static ENV_PRIORITY_LIST: &[PythonEnvironmentKind] = &[
1177 // Prioritize non-Conda environments.
1178 PythonEnvironmentKind::UvWorkspace,
1179 PythonEnvironmentKind::Uv,
1180 PythonEnvironmentKind::Poetry,
1181 PythonEnvironmentKind::Pipenv,
1182 PythonEnvironmentKind::VirtualEnvWrapper,
1183 PythonEnvironmentKind::Venv,
1184 PythonEnvironmentKind::VirtualEnv,
1185 PythonEnvironmentKind::PyenvVirtualEnv,
1186 PythonEnvironmentKind::Pixi,
1187 PythonEnvironmentKind::Conda,
1188 PythonEnvironmentKind::Pyenv,
1189 PythonEnvironmentKind::GlobalPaths,
1190 PythonEnvironmentKind::Homebrew,
1191];
1192
1193fn env_priority(kind: Option<PythonEnvironmentKind>) -> usize {
1194 if let Some(kind) = kind {
1195 ENV_PRIORITY_LIST
1196 .iter()
1197 .position(|blessed_env| blessed_env == &kind)
1198 .unwrap_or(ENV_PRIORITY_LIST.len())
1199 } else {
1200 // Unknown toolchains are less useful than non-blessed ones.
1201 ENV_PRIORITY_LIST.len() + 1
1202 }
1203}
1204
1205/// Return the name of environment declared in <worktree-root/.venv.
1206///
1207/// https://virtualfish.readthedocs.io/en/latest/plugins.html#auto-activation-auto-activation
1208async fn get_worktree_venv_declaration(worktree_root: &Path) -> Option<String> {
1209 let file = async_fs::File::open(worktree_root.join(".venv"))
1210 .await
1211 .ok()?;
1212 let mut venv_name = String::new();
1213 smol::io::BufReader::new(file)
1214 .read_line(&mut venv_name)
1215 .await
1216 .ok()?;
1217 Some(venv_name.trim().to_string())
1218}
1219
1220fn get_venv_parent_dir(env: &PythonEnvironment) -> Option<PathBuf> {
1221 // If global, we aren't a virtual environment
1222 if let Some(kind) = env.kind
1223 && is_python_env_global(&kind)
1224 {
1225 return None;
1226 }
1227
1228 // Check to be sure we are a virtual environment using pet's most generic
1229 // virtual environment type, VirtualEnv
1230 let venv = env
1231 .executable
1232 .as_ref()
1233 .and_then(|p| p.parent())
1234 .and_then(|p| p.parent())
1235 .filter(|p| is_virtualenv_dir(p))?;
1236
1237 venv.parent().map(|parent| parent.to_path_buf())
1238}
1239
1240// How far is this venv from the root of our current project?
1241#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
1242enum SubprojectDistance {
1243 WithinSubproject(Reverse<usize>),
1244 WithinWorktree(Reverse<usize>),
1245 NotInWorktree,
1246}
1247
1248fn wr_distance(
1249 wr: &PathBuf,
1250 subroot_relative_path: &RelPath,
1251 venv: Option<&PathBuf>,
1252) -> SubprojectDistance {
1253 if let Some(venv) = venv
1254 && let Ok(p) = venv.strip_prefix(wr)
1255 {
1256 if subroot_relative_path.components().next().is_some()
1257 && let Ok(distance) = p
1258 .strip_prefix(subroot_relative_path.as_std_path())
1259 .map(|p| p.components().count())
1260 {
1261 SubprojectDistance::WithinSubproject(Reverse(distance))
1262 } else {
1263 SubprojectDistance::WithinWorktree(Reverse(p.components().count()))
1264 }
1265 } else {
1266 SubprojectDistance::NotInWorktree
1267 }
1268}
1269
1270fn micromamba_shell_name(kind: ShellKind) -> &'static str {
1271 match kind {
1272 ShellKind::Csh => "csh",
1273 ShellKind::Fish => "fish",
1274 ShellKind::Nushell => "nu",
1275 ShellKind::PowerShell | ShellKind::Pwsh => "powershell",
1276 ShellKind::Cmd => "cmd.exe",
1277 // default / catch-all:
1278 _ => "posix",
1279 }
1280}
1281
1282#[async_trait]
1283impl ToolchainLister for PythonToolchainProvider {
1284 async fn list(
1285 &self,
1286 worktree_root: PathBuf,
1287 subroot_relative_path: Arc<RelPath>,
1288 project_env: Option<HashMap<String, String>>,
1289 ) -> ToolchainList {
1290 let fs = &*self.fs;
1291 let env = project_env.unwrap_or_default();
1292 let environment = EnvironmentApi::from_env(&env);
1293 let locators = pet::locators::create_locators(
1294 Arc::new(pet_conda::Conda::from(&environment)),
1295 Arc::new(pet_poetry::Poetry::from(&environment)),
1296 &environment,
1297 );
1298 let mut config = Configuration::default();
1299
1300 // `.ancestors()` will yield at least one path, so in case of empty `subroot_relative_path`, we'll just use
1301 // worktree root as the workspace directory.
1302 config.workspace_directories = Some(
1303 subroot_relative_path
1304 .ancestors()
1305 .map(|ancestor| {
1306 // remove trailing separator as it alters the environment name hash used by Poetry.
1307 let path = worktree_root.join(ancestor.as_std_path());
1308 let path_str = path.to_string_lossy();
1309 if path_str.ends_with(std::path::MAIN_SEPARATOR) && path_str.len() > 1 {
1310 PathBuf::from(path_str.trim_end_matches(std::path::MAIN_SEPARATOR))
1311 } else {
1312 path
1313 }
1314 })
1315 .collect(),
1316 );
1317 for locator in locators.iter() {
1318 locator.configure(&config);
1319 }
1320
1321 let reporter = pet_reporter::collect::create_reporter();
1322 pet::find::find_and_report_envs(&reporter, config, &locators, &environment, None);
1323
1324 let mut toolchains = reporter
1325 .environments
1326 .lock()
1327 .map_or(Vec::new(), |mut guard| std::mem::take(&mut guard));
1328
1329 let wr = worktree_root;
1330 let wr_venv = get_worktree_venv_declaration(&wr).await;
1331 // Sort detected environments by:
1332 // environment name matching activation file (<workdir>/.venv)
1333 // environment project dir matching worktree_root
1334 // general env priority
1335 // environment path matching the CONDA_PREFIX env var
1336 // executable path
1337 toolchains.sort_by(|lhs, rhs| {
1338 // Compare venv names against worktree .venv file
1339 let venv_ordering =
1340 wr_venv
1341 .as_ref()
1342 .map_or(Ordering::Equal, |venv| match (&lhs.name, &rhs.name) {
1343 (Some(l), Some(r)) => (r == venv).cmp(&(l == venv)),
1344 (Some(l), None) if l == venv => Ordering::Less,
1345 (None, Some(r)) if r == venv => Ordering::Greater,
1346 _ => Ordering::Equal,
1347 });
1348
1349 // Compare project paths against worktree root
1350 let proj_ordering =
1351 || {
1352 let lhs_project = lhs.project.clone().or_else(|| get_venv_parent_dir(lhs));
1353 let rhs_project = rhs.project.clone().or_else(|| get_venv_parent_dir(rhs));
1354 wr_distance(&wr, &subroot_relative_path, lhs_project.as_ref()).cmp(
1355 &wr_distance(&wr, &subroot_relative_path, rhs_project.as_ref()),
1356 )
1357 };
1358
1359 // Compare environment priorities
1360 let priority_ordering = || env_priority(lhs.kind).cmp(&env_priority(rhs.kind));
1361
1362 // Compare conda prefixes
1363 let conda_ordering = || {
1364 if lhs.kind == Some(PythonEnvironmentKind::Conda) {
1365 environment
1366 .get_env_var("CONDA_PREFIX".to_string())
1367 .map(|conda_prefix| {
1368 let is_match = |exe: &Option<PathBuf>| {
1369 exe.as_ref().is_some_and(|e| e.starts_with(&conda_prefix))
1370 };
1371 match (is_match(&lhs.executable), is_match(&rhs.executable)) {
1372 (true, false) => Ordering::Less,
1373 (false, true) => Ordering::Greater,
1374 _ => Ordering::Equal,
1375 }
1376 })
1377 .unwrap_or(Ordering::Equal)
1378 } else {
1379 Ordering::Equal
1380 }
1381 };
1382
1383 // Compare Python executables
1384 let exe_ordering = || lhs.executable.cmp(&rhs.executable);
1385
1386 venv_ordering
1387 .then_with(proj_ordering)
1388 .then_with(priority_ordering)
1389 .then_with(conda_ordering)
1390 .then_with(exe_ordering)
1391 });
1392
1393 let mut out_toolchains = Vec::new();
1394 for toolchain in toolchains {
1395 let Some(toolchain) = venv_to_toolchain(toolchain, fs).await else {
1396 continue;
1397 };
1398 out_toolchains.push(toolchain);
1399 }
1400 out_toolchains.dedup();
1401 ToolchainList {
1402 toolchains: out_toolchains,
1403 default: None,
1404 groups: Default::default(),
1405 }
1406 }
1407 fn meta(&self) -> ToolchainMetadata {
1408 ToolchainMetadata {
1409 term: SharedString::new_static("Virtual Environment"),
1410 new_toolchain_placeholder: SharedString::new_static(
1411 "A path to the python3 executable within a virtual environment, or path to virtual environment itself",
1412 ),
1413 manifest_name: ManifestName::from(SharedString::new_static("pyproject.toml")),
1414 }
1415 }
1416
1417 async fn resolve(
1418 &self,
1419 path: PathBuf,
1420 env: Option<HashMap<String, String>>,
1421 ) -> anyhow::Result<Toolchain> {
1422 let fs = &*self.fs;
1423 let env = env.unwrap_or_default();
1424 let environment = EnvironmentApi::from_env(&env);
1425 let locators = pet::locators::create_locators(
1426 Arc::new(pet_conda::Conda::from(&environment)),
1427 Arc::new(pet_poetry::Poetry::from(&environment)),
1428 &environment,
1429 );
1430 let toolchain = pet::resolve::resolve_environment(&path, &locators, &environment)
1431 .context("Could not find a virtual environment in provided path")?;
1432 let venv = toolchain.resolved.unwrap_or(toolchain.discovered);
1433 venv_to_toolchain(venv, fs)
1434 .await
1435 .context("Could not convert a venv into a toolchain")
1436 }
1437
1438 fn activation_script(
1439 &self,
1440 toolchain: &Toolchain,
1441 shell: ShellKind,
1442 cx: &App,
1443 ) -> BoxFuture<'static, Vec<String>> {
1444 let settings = TerminalSettings::get_global(cx);
1445 let conda_manager = settings
1446 .detect_venv
1447 .as_option()
1448 .map(|venv| venv.conda_manager)
1449 .unwrap_or(settings::CondaManager::Auto);
1450
1451 let toolchain_clone = toolchain.clone();
1452 Box::pin(async move {
1453 let Ok(toolchain) =
1454 serde_json::from_value::<PythonToolchainData>(toolchain_clone.as_json.clone())
1455 else {
1456 return vec![];
1457 };
1458
1459 log::debug!("(Python) Composing activation script for toolchain {toolchain:?}");
1460
1461 let mut activation_script = vec![];
1462
1463 match toolchain.environment.kind {
1464 Some(PythonEnvironmentKind::Conda) => {
1465 if toolchain.environment.manager.is_none() {
1466 return vec![];
1467 };
1468
1469 let manager = match conda_manager {
1470 settings::CondaManager::Conda => "conda",
1471 settings::CondaManager::Mamba => "mamba",
1472 settings::CondaManager::Micromamba => "micromamba",
1473 settings::CondaManager::Auto => toolchain
1474 .environment
1475 .manager
1476 .as_ref()
1477 .and_then(|m| m.executable.file_name())
1478 .and_then(|name| name.to_str())
1479 .filter(|name| matches!(*name, "conda" | "mamba" | "micromamba"))
1480 .unwrap_or("conda"),
1481 };
1482
1483 // Activate micromamba shell in the child shell
1484 // [required for micromamba]
1485 if manager == "micromamba" {
1486 match shell {
1487 ShellKind::PowerShell | ShellKind::Pwsh => {
1488 activation_script.push(format!(r#"(& {manager} shell hook --shell powershell) | Out-String | Invoke-Expression"#));
1489 }
1490 _ => {
1491 let shell_name = micromamba_shell_name(shell);
1492 activation_script.push(format!(
1493 r#"eval "$({manager} shell hook --shell {shell_name})""#
1494 ));
1495 }
1496 }
1497 }
1498
1499 // Only inject `{manager} activate <name>` when we have a
1500 // safely-quotable name. Never silently fall back to
1501 // `activate base`: a user with miniforge installed but a
1502 // local uv/venv project should not have their terminal
1503 // hijacked just because we couldn't resolve a name.
1504 if let Some(name) = &toolchain.environment.name {
1505 if let Some(quoted_name) = shell.try_quote(name) {
1506 activation_script.push(format!("{manager} activate {quoted_name}"));
1507 } else {
1508 log::warn!(
1509 "Conda environment name {:?} could not be safely quoted; \
1510 skipping terminal activation",
1511 name
1512 );
1513 }
1514 } else {
1515 log::warn!("Conda toolchain has no name; skipping terminal activation");
1516 }
1517 }
1518 Some(
1519 PythonEnvironmentKind::Venv
1520 | PythonEnvironmentKind::VirtualEnv
1521 | PythonEnvironmentKind::Uv
1522 | PythonEnvironmentKind::UvWorkspace
1523 | PythonEnvironmentKind::Poetry,
1524 ) => {
1525 if let Some(activation_scripts) = &toolchain.activation_scripts {
1526 if let Some(activate_script_path) = activation_scripts.get(&shell) {
1527 let activate_keyword = shell.activate_keyword();
1528 if let Some(quoted) =
1529 shell.try_quote(&activate_script_path.to_string_lossy())
1530 {
1531 activation_script.push(format!("{activate_keyword} {quoted}"));
1532 }
1533 }
1534 }
1535 }
1536 Some(PythonEnvironmentKind::Pyenv) => {
1537 let Some(manager) = &toolchain.environment.manager else {
1538 return vec![];
1539 };
1540 let version = toolchain.environment.version.as_deref().unwrap_or("system");
1541 let pyenv = &manager.executable;
1542 let pyenv = pyenv.display();
1543 activation_script.extend(match shell {
1544 ShellKind::Fish => Some(format!("\"{pyenv}\" shell - fish {version}")),
1545 ShellKind::Posix => Some(format!("\"{pyenv}\" shell - sh {version}")),
1546 ShellKind::Nushell => Some(format!("^\"{pyenv}\" shell - nu {version}")),
1547 ShellKind::PowerShell | ShellKind::Pwsh => None,
1548 ShellKind::Csh => None,
1549 ShellKind::Tcsh => None,
1550 ShellKind::Cmd => None,
1551 ShellKind::Rc => None,
1552 ShellKind::Xonsh => None,
1553 ShellKind::Elvish => None,
1554 })
1555 }
1556 _ => {}
1557 }
1558 activation_script
1559 })
1560 }
1561}
1562
1563async fn venv_to_toolchain(venv: PythonEnvironment, fs: &dyn Fs) -> Option<Toolchain> {
1564 let mut name = String::from("Python");
1565 if let Some(ref version) = venv.version {
1566 _ = write!(name, " {version}");
1567 }
1568
1569 let name_and_kind = match (&venv.name, &venv.kind) {
1570 (Some(name), Some(kind)) => Some(format!("({name}; {})", python_env_kind_display(kind))),
1571 (Some(name), None) => Some(format!("({name})")),
1572 (None, Some(kind)) => Some(format!("({})", python_env_kind_display(kind))),
1573 (None, None) => None,
1574 };
1575
1576 if let Some(nk) = name_and_kind {
1577 _ = write!(name, " {nk}");
1578 }
1579
1580 let mut activation_scripts = HashMap::default();
1581 match venv.kind {
1582 Some(
1583 PythonEnvironmentKind::Venv
1584 | PythonEnvironmentKind::VirtualEnv
1585 | PythonEnvironmentKind::Uv
1586 | PythonEnvironmentKind::UvWorkspace
1587 | PythonEnvironmentKind::Poetry,
1588 ) => resolve_venv_activation_scripts(&venv, fs, &mut activation_scripts).await,
1589 _ => {}
1590 }
1591 let data = PythonToolchainData {
1592 environment: venv,
1593 activation_scripts: Some(activation_scripts),
1594 };
1595
1596 Some(Toolchain {
1597 name: name.into(),
1598 path: data
1599 .environment
1600 .executable
1601 .as_ref()?
1602 .to_str()?
1603 .to_owned()
1604 .into(),
1605 language_name: LanguageName::new_static("Python"),
1606 as_json: serde_json::to_value(data).ok()?,
1607 })
1608}
1609
1610async fn resolve_venv_activation_scripts(
1611 venv: &PythonEnvironment,
1612 fs: &dyn Fs,
1613 activation_scripts: &mut HashMap<ShellKind, PathBuf>,
1614) {
1615 log::debug!("(Python) Resolving activation scripts for venv toolchain {venv:?}");
1616 if let Some(prefix) = &venv.prefix {
1617 for (shell_kind, script_name) in &[
1618 (ShellKind::Posix, "activate"),
1619 (ShellKind::Rc, "activate"),
1620 (ShellKind::Csh, "activate.csh"),
1621 (ShellKind::Tcsh, "activate.csh"),
1622 (ShellKind::Fish, "activate.fish"),
1623 (ShellKind::Nushell, "activate.nu"),
1624 (ShellKind::PowerShell, "activate.ps1"),
1625 (ShellKind::Pwsh, "activate.ps1"),
1626 (ShellKind::Cmd, "activate.bat"),
1627 (ShellKind::Xonsh, "activate.xsh"),
1628 ] {
1629 let path = prefix.join(BINARY_DIR).join(script_name);
1630
1631 log::debug!("Trying path: {}", path.display());
1632
1633 if fs.is_file(&path).await {
1634 activation_scripts.insert(*shell_kind, path);
1635 }
1636 }
1637 }
1638}
1639
1640pub struct EnvironmentApi<'a> {
1641 global_search_locations: Arc<Mutex<Vec<PathBuf>>>,
1642 project_env: &'a HashMap<String, String>,
1643 pet_env: pet_core::os_environment::EnvironmentApi,
1644}
1645
1646impl<'a> EnvironmentApi<'a> {
1647 pub fn from_env(project_env: &'a HashMap<String, String>) -> Self {
1648 let paths = project_env
1649 .get("PATH")
1650 .map(|p| std::env::split_paths(p).collect())
1651 .unwrap_or_default();
1652
1653 EnvironmentApi {
1654 global_search_locations: Arc::new(Mutex::new(paths)),
1655 project_env,
1656 pet_env: pet_core::os_environment::EnvironmentApi::new(),
1657 }
1658 }
1659
1660 fn user_home(&self) -> Option<PathBuf> {
1661 self.project_env
1662 .get("HOME")
1663 .or_else(|| self.project_env.get("USERPROFILE"))
1664 .map(|home| pet_fs::path::norm_case(PathBuf::from(home)))
1665 .or_else(|| self.pet_env.get_user_home())
1666 }
1667}
1668
1669impl pet_core::os_environment::Environment for EnvironmentApi<'_> {
1670 fn get_user_home(&self) -> Option<PathBuf> {
1671 self.user_home()
1672 }
1673
1674 fn get_root(&self) -> Option<PathBuf> {
1675 None
1676 }
1677
1678 fn get_env_var(&self, key: String) -> Option<String> {
1679 self.project_env
1680 .get(&key)
1681 .cloned()
1682 .or_else(|| self.pet_env.get_env_var(key))
1683 }
1684
1685 fn get_know_global_search_locations(&self) -> Vec<PathBuf> {
1686 if self.global_search_locations.lock().is_empty() {
1687 let mut paths = std::env::split_paths(
1688 &self
1689 .get_env_var("PATH".to_string())
1690 .or_else(|| self.get_env_var("Path".to_string()))
1691 .unwrap_or_default(),
1692 )
1693 .collect::<Vec<PathBuf>>();
1694
1695 log::trace!("Env PATH: {:?}", paths);
1696 for p in self.pet_env.get_know_global_search_locations() {
1697 if !paths.contains(&p) {
1698 paths.push(p);
1699 }
1700 }
1701
1702 let mut paths = paths
1703 .into_iter()
1704 .filter(|p| p.exists())
1705 .collect::<Vec<PathBuf>>();
1706
1707 self.global_search_locations.lock().append(&mut paths);
1708 }
1709 self.global_search_locations.lock().clone()
1710 }
1711}
1712
1713pub(crate) struct PyLspAdapter {
1714 python_venv_base: OnceCell<Result<Arc<Path>, String>>,
1715}
1716impl PyLspAdapter {
1717 const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("pylsp");
1718 pub(crate) fn new() -> Self {
1719 Self {
1720 python_venv_base: OnceCell::new(),
1721 }
1722 }
1723 async fn ensure_venv(delegate: &dyn LspAdapterDelegate) -> Result<Arc<Path>> {
1724 let python_path = Self::find_base_python(delegate)
1725 .await
1726 .with_context(|| {
1727 let mut message = "Could not find Python installation for PyLSP".to_owned();
1728 if cfg!(windows){
1729 message.push_str(". Install Python from the Microsoft Store, or manually from https://www.python.org/downloads/windows.")
1730 }
1731 message
1732 })?;
1733 let work_dir = delegate
1734 .language_server_download_dir(&Self::SERVER_NAME)
1735 .await
1736 .context("Could not get working directory for PyLSP")?;
1737 let mut path = PathBuf::from(work_dir.as_ref());
1738 path.push("pylsp-venv");
1739 if !path.exists() {
1740 util::command::new_command(python_path)
1741 .arg("-m")
1742 .arg("venv")
1743 .arg("pylsp-venv")
1744 .current_dir(work_dir)
1745 .spawn()?
1746 .output()
1747 .await?;
1748 }
1749
1750 Ok(path.into())
1751 }
1752 // Find "baseline", user python version from which we'll create our own venv.
1753 async fn find_base_python(delegate: &dyn LspAdapterDelegate) -> Option<PathBuf> {
1754 for path in ["python3", "python"] {
1755 let Some(path) = delegate.which(path.as_ref()).await else {
1756 continue;
1757 };
1758 // Try to detect situations where `python3` exists but is not a real Python interpreter.
1759 // Notably, on fresh Windows installs, `python3` is a shim that opens the Microsoft Store app
1760 // when run with no arguments, and just fails otherwise.
1761 let Some(output) = new_command(&path)
1762 .args(["-c", "print(1 + 2)"])
1763 .output()
1764 .await
1765 .ok()
1766 else {
1767 continue;
1768 };
1769 if output.stdout.trim_ascii() != b"3" {
1770 continue;
1771 }
1772 return Some(path);
1773 }
1774 None
1775 }
1776
1777 async fn base_venv(&self, delegate: &dyn LspAdapterDelegate) -> Result<Arc<Path>, String> {
1778 self.python_venv_base
1779 .get_or_init(move || async move {
1780 Self::ensure_venv(delegate)
1781 .await
1782 .map_err(|e| format!("{e}"))
1783 })
1784 .await
1785 .clone()
1786 }
1787}
1788
1789const BINARY_DIR: &str = if cfg!(target_os = "windows") {
1790 "Scripts"
1791} else {
1792 "bin"
1793};
1794
1795#[async_trait(?Send)]
1796impl LspAdapter for PyLspAdapter {
1797 fn name(&self) -> LanguageServerName {
1798 Self::SERVER_NAME
1799 }
1800
1801 async fn process_completions(&self, items: &mut [lsp::CompletionItem]) {
1802 for item in items {
1803 let is_named_argument = item.label.ends_with('=');
1804 let priority = if is_named_argument { '0' } else { '1' };
1805 let sort_text = item.sort_text.take().unwrap_or_else(|| item.label.clone());
1806 item.sort_text = Some(format!("{}{}", priority, sort_text));
1807 }
1808 }
1809
1810 async fn label_for_completion(
1811 &self,
1812 item: &lsp::CompletionItem,
1813 language: &Arc<language::Language>,
1814 ) -> Option<language::CodeLabel> {
1815 let label = &item.label;
1816 let label_len = label.len();
1817 let grammar = language.grammar()?;
1818 let highlight_id = highlight_id_for_completion(item.kind?, grammar)??;
1819 Some(language::CodeLabel::filtered(
1820 label.clone(),
1821 label_len,
1822 item.filter_text.as_deref(),
1823 vec![(0..label.len(), highlight_id)],
1824 ))
1825 }
1826
1827 async fn label_for_symbol(
1828 &self,
1829 symbol: &language::Symbol,
1830 language: &Arc<language::Language>,
1831 ) -> Option<language::CodeLabel> {
1832 label_for_python_symbol(symbol, language)
1833 }
1834
1835 async fn workspace_configuration(
1836 self: Arc<Self>,
1837 adapter: &Arc<dyn LspAdapterDelegate>,
1838 toolchain: Option<Toolchain>,
1839 _: Option<Uri>,
1840 cx: &mut AsyncApp,
1841 ) -> Result<Value> {
1842 Ok(cx.update(move |cx| {
1843 let mut user_settings =
1844 language_server_settings(adapter.as_ref(), &Self::SERVER_NAME, cx)
1845 .and_then(|s| s.settings.clone())
1846 .unwrap_or_else(|| {
1847 json!({
1848 "plugins": {
1849 "pycodestyle": {"enabled": false},
1850 "rope_autoimport": {"enabled": true, "memory": true},
1851 "pylsp_mypy": {"enabled": false}
1852 },
1853 "rope": {
1854 "ropeFolder": null
1855 },
1856 })
1857 });
1858
1859 // If user did not explicitly modify their python venv, use one from picker.
1860 if let Some(toolchain) = toolchain {
1861 if !user_settings.is_object() {
1862 user_settings = Value::Object(serde_json::Map::default());
1863 }
1864 let object = user_settings.as_object_mut().unwrap();
1865 if let Some(python) = object
1866 .entry("plugins")
1867 .or_insert(Value::Object(serde_json::Map::default()))
1868 .as_object_mut()
1869 {
1870 if let Some(jedi) = python
1871 .entry("jedi")
1872 .or_insert(Value::Object(serde_json::Map::default()))
1873 .as_object_mut()
1874 {
1875 jedi.entry("environment".to_string())
1876 .or_insert_with(|| Value::String(toolchain.path.clone().into()));
1877 }
1878 if let Some(pylint) = python
1879 .entry("pylsp_mypy")
1880 .or_insert(Value::Object(serde_json::Map::default()))
1881 .as_object_mut()
1882 {
1883 pylint.entry("overrides".to_string()).or_insert_with(|| {
1884 Value::Array(vec![
1885 Value::String("--python-executable".into()),
1886 Value::String(toolchain.path.into()),
1887 Value::String("--cache-dir=/dev/null".into()),
1888 Value::Bool(true),
1889 ])
1890 });
1891 }
1892 }
1893 }
1894 user_settings = Value::Object(serde_json::Map::from_iter([(
1895 "pylsp".to_string(),
1896 user_settings,
1897 )]));
1898
1899 user_settings
1900 }))
1901 }
1902}
1903
1904impl LspInstaller for PyLspAdapter {
1905 type BinaryVersion = ();
1906 async fn check_if_user_installed(
1907 &self,
1908 delegate: &Arc<dyn LspAdapterDelegate>,
1909 toolchain: Option<Toolchain>,
1910 _: &AsyncApp,
1911 ) -> Option<LanguageServerBinary> {
1912 if let Some(pylsp_bin) = delegate.which(Self::SERVER_NAME.as_ref()).await {
1913 let env = delegate.shell_env().await;
1914 delegate
1915 .try_exec(LanguageServerBinary {
1916 path: pylsp_bin.clone(),
1917 arguments: vec!["--version".into()],
1918 env: Some(env.clone()),
1919 })
1920 .await
1921 .inspect_err(|err| {
1922 log::warn!("failed to validate user-installed pylsp at {pylsp_bin:?}: {err:#}")
1923 })
1924 .ok()?;
1925 Some(LanguageServerBinary {
1926 path: pylsp_bin,
1927 env: Some(env),
1928 arguments: vec![],
1929 })
1930 } else {
1931 let toolchain = toolchain?;
1932 let pylsp_path = Path::new(toolchain.path.as_ref()).parent()?.join("pylsp");
1933 if !pylsp_path.exists() {
1934 return None;
1935 }
1936 delegate
1937 .try_exec(LanguageServerBinary {
1938 path: toolchain.path.to_string().into(),
1939 arguments: vec![pylsp_path.clone().into(), "--version".into()],
1940 env: None,
1941 })
1942 .await
1943 .inspect_err(|err| {
1944 log::warn!("failed to validate toolchain pylsp at {pylsp_path:?}: {err:#}")
1945 })
1946 .ok()?;
1947 Some(LanguageServerBinary {
1948 path: toolchain.path.to_string().into(),
1949 arguments: vec![pylsp_path.into()],
1950 env: None,
1951 })
1952 }
1953 }
1954
1955 async fn fetch_latest_server_version(
1956 &self,
1957 _: &Arc<dyn LspAdapterDelegate>,
1958 _: bool,
1959 _: &mut AsyncApp,
1960 ) -> Result<()> {
1961 Ok(())
1962 }
1963
1964 fn fetch_server_binary(
1965 &self,
1966 _: (),
1967 _: PathBuf,
1968 delegate: &Arc<dyn LspAdapterDelegate>,
1969 ) -> impl Send + Future<Output = Result<LanguageServerBinary>> + use<> {
1970 let delegate = delegate.clone();
1971
1972 async move {
1973 let venv = Self::ensure_venv(delegate.as_ref()).await?;
1974 let pip_path = venv.join(BINARY_DIR).join("pip3");
1975 ensure!(
1976 util::command::new_command(pip_path.as_path())
1977 .arg("install")
1978 .arg("python-lsp-server[all]")
1979 .arg("--upgrade")
1980 .output()
1981 .await?
1982 .status
1983 .success(),
1984 "python-lsp-server[all] installation failed"
1985 );
1986 ensure!(
1987 util::command::new_command(pip_path)
1988 .arg("install")
1989 .arg("pylsp-mypy")
1990 .arg("--upgrade")
1991 .output()
1992 .await?
1993 .status
1994 .success(),
1995 "pylsp-mypy installation failed"
1996 );
1997 let pylsp = venv.join(BINARY_DIR).join("pylsp");
1998 ensure!(
1999 delegate.which(pylsp.as_os_str()).await.is_some(),
2000 "pylsp installation was incomplete"
2001 );
2002 Ok(LanguageServerBinary {
2003 path: pylsp,
2004 env: None,
2005 arguments: vec![],
2006 })
2007 }
2008 }
2009
2010 async fn cached_server_binary(
2011 &self,
2012 _: PathBuf,
2013 delegate: &dyn LspAdapterDelegate,
2014 ) -> Option<LanguageServerBinary> {
2015 let venv = self.base_venv(delegate).await.ok()?;
2016 let pylsp = venv.join(BINARY_DIR).join("pylsp");
2017 delegate.which(pylsp.as_os_str()).await?;
2018 Some(LanguageServerBinary {
2019 path: pylsp,
2020 env: None,
2021 arguments: vec![],
2022 })
2023 }
2024}
2025
2026pub(crate) struct BasedPyrightLspAdapter {
2027 node: NodeRuntime,
2028}
2029
2030impl BasedPyrightLspAdapter {
2031 const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("basedpyright");
2032 const BINARY_NAME: &'static str = "basedpyright-langserver";
2033 const SERVER_PATH: &str = "node_modules/basedpyright/langserver.index.js";
2034 const NODE_MODULE_RELATIVE_SERVER_PATH: &str = "basedpyright/langserver.index.js";
2035
2036 pub(crate) fn new(node: NodeRuntime) -> Self {
2037 BasedPyrightLspAdapter { node }
2038 }
2039
2040 async fn get_cached_server_binary(
2041 container_dir: PathBuf,
2042 node: &NodeRuntime,
2043 ) -> Option<LanguageServerBinary> {
2044 let server_path = container_dir.join(Self::SERVER_PATH);
2045 if server_path.exists() {
2046 Some(LanguageServerBinary {
2047 path: node.binary_path().await.log_err()?,
2048 env: None,
2049 arguments: vec![server_path.into(), "--stdio".into()],
2050 })
2051 } else {
2052 log::error!("missing executable in directory {:?}", server_path);
2053 None
2054 }
2055 }
2056}
2057
2058#[async_trait(?Send)]
2059impl LspAdapter for BasedPyrightLspAdapter {
2060 fn name(&self) -> LanguageServerName {
2061 Self::SERVER_NAME
2062 }
2063
2064 async fn initialization_options(
2065 self: Arc<Self>,
2066 _: &Arc<dyn LspAdapterDelegate>,
2067 _: &mut AsyncApp,
2068 ) -> Result<Option<Value>> {
2069 // Provide minimal initialization options
2070 // Virtual environment configuration will be handled through workspace configuration
2071 Ok(Some(json!({
2072 "python": {
2073 "analysis": {
2074 "autoSearchPaths": true,
2075 "useLibraryCodeForTypes": true,
2076 "autoImportCompletions": true
2077 }
2078 }
2079 })))
2080 }
2081
2082 async fn process_completions(&self, items: &mut [lsp::CompletionItem]) {
2083 process_pyright_completions(items);
2084 }
2085
2086 async fn label_for_completion(
2087 &self,
2088 item: &lsp::CompletionItem,
2089 language: &Arc<language::Language>,
2090 ) -> Option<language::CodeLabel> {
2091 label_for_pyright_completion(item, language)
2092 }
2093
2094 async fn label_for_symbol(
2095 &self,
2096 symbol: &Symbol,
2097 language: &Arc<language::Language>,
2098 ) -> Option<language::CodeLabel> {
2099 label_for_python_symbol(symbol, language)
2100 }
2101
2102 async fn workspace_configuration(
2103 self: Arc<Self>,
2104 adapter: &Arc<dyn LspAdapterDelegate>,
2105 toolchain: Option<Toolchain>,
2106 _: Option<Uri>,
2107 cx: &mut AsyncApp,
2108 ) -> Result<Value> {
2109 Ok(cx.update(move |cx| {
2110 let mut user_settings =
2111 language_server_settings(adapter.as_ref(), &Self::SERVER_NAME, cx)
2112 .and_then(|s| s.settings.clone())
2113 .unwrap_or_default();
2114
2115 // If we have a detected toolchain, configure BasedPyright to use it - unless the user sets it themselves.
2116 let should_insert_toolchain = || {
2117 user_settings.as_object().is_none_or(|object| {
2118 ![
2119 "venvPath",
2120 "venv",
2121 "python",
2122 "pythonPath",
2123 "defaultInterpreterPath",
2124 ]
2125 .into_iter()
2126 .any(|known_key| object.contains_key(known_key))
2127 })
2128 };
2129 if let Some(toolchain) = toolchain
2130 && should_insert_toolchain()
2131 && let Ok(env) = serde_json::from_value::<
2132 pet_core::python_environment::PythonEnvironment,
2133 >(toolchain.as_json.clone())
2134 {
2135 if !user_settings.is_object() {
2136 user_settings = Value::Object(serde_json::Map::default());
2137 }
2138 let object = user_settings.as_object_mut().unwrap();
2139
2140 let interpreter_path = toolchain.path.to_string();
2141 if let Some(venv_dir) = env.prefix {
2142 // Set venvPath and venv at the root level
2143 // This matches the format of a pyrightconfig.json file
2144 if let Some(parent) = venv_dir.parent() {
2145 // Use relative path if the venv is inside the workspace
2146 let venv_path = if parent == adapter.worktree_root_path() {
2147 ".".to_string()
2148 } else {
2149 parent.to_string_lossy().into_owned()
2150 };
2151 object.insert("venvPath".to_string(), Value::String(venv_path));
2152 }
2153
2154 if let Some(venv_name) = venv_dir.file_name() {
2155 object.insert(
2156 "venv".to_owned(),
2157 Value::String(venv_name.to_string_lossy().into_owned()),
2158 );
2159 }
2160 }
2161
2162 // Set both pythonPath and defaultInterpreterPath for compatibility
2163 if let Some(python) = object
2164 .entry("python")
2165 .or_insert(Value::Object(serde_json::Map::default()))
2166 .as_object_mut()
2167 {
2168 python.insert(
2169 "pythonPath".to_owned(),
2170 Value::String(interpreter_path.clone()),
2171 );
2172 python.insert(
2173 "defaultInterpreterPath".to_owned(),
2174 Value::String(interpreter_path),
2175 );
2176 }
2177 // Basedpyright by default uses `strict` type checking, we tone it down as to not surpris users
2178 maybe!({
2179 let analysis = object
2180 .entry("basedpyright.analysis")
2181 .or_insert(Value::Object(serde_json::Map::default()));
2182 if let serde_json::map::Entry::Vacant(v) =
2183 analysis.as_object_mut()?.entry("typeCheckingMode")
2184 {
2185 v.insert(Value::String("standard".to_owned()));
2186 }
2187 Some(())
2188 });
2189 // Disable basedpyright's organizeImports so ruff handles it instead
2190 if let serde_json::map::Entry::Vacant(v) =
2191 object.entry("basedpyright.disableOrganizeImports")
2192 {
2193 v.insert(Value::Bool(true));
2194 }
2195 }
2196
2197 user_settings
2198 }))
2199 }
2200}
2201
2202impl LspInstaller for BasedPyrightLspAdapter {
2203 type BinaryVersion = Version;
2204
2205 async fn fetch_latest_server_version(
2206 &self,
2207 _: &Arc<dyn LspAdapterDelegate>,
2208 _: bool,
2209 _: &mut AsyncApp,
2210 ) -> Result<Self::BinaryVersion> {
2211 self.node
2212 .npm_package_latest_version(Self::SERVER_NAME.as_ref())
2213 .await
2214 }
2215
2216 async fn check_if_user_installed(
2217 &self,
2218 delegate: &Arc<dyn LspAdapterDelegate>,
2219 _: Option<Toolchain>,
2220 _: &AsyncApp,
2221 ) -> Option<LanguageServerBinary> {
2222 if let Some(path) = delegate.which(Self::BINARY_NAME.as_ref()).await {
2223 let env = delegate.shell_env().await;
2224 Some(LanguageServerBinary {
2225 path,
2226 env: Some(env),
2227 arguments: vec!["--stdio".into()],
2228 })
2229 } else {
2230 // TODO shouldn't this be self.node.binary_path()?
2231 let node = delegate.which("node".as_ref()).await?;
2232 let (node_modules_path, _) = delegate
2233 .npm_package_installed_version(Self::SERVER_NAME.as_ref())
2234 .await
2235 .log_err()??;
2236
2237 let path = node_modules_path.join(Self::NODE_MODULE_RELATIVE_SERVER_PATH);
2238
2239 let env = delegate.shell_env().await;
2240 Some(LanguageServerBinary {
2241 path: node,
2242 env: Some(env),
2243 arguments: vec![path.into(), "--stdio".into()],
2244 })
2245 }
2246 }
2247
2248 fn fetch_server_binary(
2249 &self,
2250 _latest_version: Self::BinaryVersion,
2251 container_dir: PathBuf,
2252 delegate: &Arc<dyn LspAdapterDelegate>,
2253 ) -> impl Send + Future<Output = Result<LanguageServerBinary>> + use<> {
2254 let delegate = delegate.clone();
2255 let node = self.node.clone();
2256
2257 async move {
2258 let server_path = container_dir.join(Self::SERVER_PATH);
2259 node.npm_install_latest_packages(&container_dir, &[Self::SERVER_NAME.as_ref()])
2260 .await?;
2261
2262 let env = delegate.shell_env().await;
2263 Ok(LanguageServerBinary {
2264 path: node.binary_path().await?,
2265 env: Some(env),
2266 arguments: vec![server_path.into(), "--stdio".into()],
2267 })
2268 }
2269 }
2270
2271 fn check_if_version_installed(
2272 &self,
2273 version: &Self::BinaryVersion,
2274 container_dir: &PathBuf,
2275 delegate: &Arc<dyn LspAdapterDelegate>,
2276 ) -> impl Send + Future<Output = Option<LanguageServerBinary>> + use<> {
2277 let delegate = delegate.clone();
2278 let node = self.node.clone();
2279 let version = version.clone();
2280 let container_dir = container_dir.clone();
2281
2282 async move {
2283 let server_path = container_dir.join(Self::SERVER_PATH);
2284
2285 let should_install_language_server = node
2286 .should_install_npm_package(
2287 Self::SERVER_NAME.as_ref(),
2288 &server_path,
2289 &container_dir,
2290 VersionStrategy::Latest(&version),
2291 )
2292 .await;
2293
2294 if should_install_language_server {
2295 None
2296 } else {
2297 let env = delegate.shell_env().await;
2298 Some(LanguageServerBinary {
2299 path: node.binary_path().await.ok()?,
2300 env: Some(env),
2301 arguments: vec![server_path.into(), "--stdio".into()],
2302 })
2303 }
2304 }
2305 }
2306
2307 async fn cached_server_binary(
2308 &self,
2309 container_dir: PathBuf,
2310 delegate: &dyn LspAdapterDelegate,
2311 ) -> Option<LanguageServerBinary> {
2312 let mut binary = Self::get_cached_server_binary(container_dir, &self.node).await?;
2313 binary.env = Some(delegate.shell_env().await);
2314 Some(binary)
2315 }
2316}
2317
2318pub(crate) struct RuffLspAdapter {
2319 fs: Arc<dyn Fs>,
2320}
2321
2322impl RuffLspAdapter {
2323 fn convert_ruff_schema(raw_schema: &serde_json::Value) -> serde_json::Value {
2324 let Some(schema_object) = raw_schema.as_object() else {
2325 return raw_schema.clone();
2326 };
2327
2328 let mut root_properties = serde_json::Map::new();
2329
2330 for (key, value) in schema_object {
2331 let parts: Vec<&str> = key.split('.').collect();
2332
2333 if parts.is_empty() {
2334 continue;
2335 }
2336
2337 let mut current = &mut root_properties;
2338
2339 for (i, part) in parts.iter().enumerate() {
2340 let is_last = i == parts.len() - 1;
2341
2342 if is_last {
2343 let mut schema_entry = serde_json::Map::new();
2344
2345 if let Some(doc) = value.get("doc").and_then(|d| d.as_str()) {
2346 schema_entry.insert(
2347 "markdownDescription".to_string(),
2348 serde_json::Value::String(doc.to_string()),
2349 );
2350 }
2351
2352 if let Some(default_val) = value.get("default") {
2353 schema_entry.insert("default".to_string(), default_val.clone());
2354 }
2355
2356 if let Some(value_type) = value.get("value_type").and_then(|v| v.as_str()) {
2357 if value_type.contains('|') {
2358 let enum_values: Vec<serde_json::Value> = value_type
2359 .split('|')
2360 .map(|s| s.trim().trim_matches('"'))
2361 .filter(|s| !s.is_empty())
2362 .map(|s| serde_json::Value::String(s.to_string()))
2363 .collect();
2364
2365 if !enum_values.is_empty() {
2366 schema_entry
2367 .insert("type".to_string(), serde_json::json!("string"));
2368 schema_entry.insert(
2369 "enum".to_string(),
2370 serde_json::Value::Array(enum_values),
2371 );
2372 }
2373 } else if value_type.starts_with("list[") {
2374 schema_entry.insert("type".to_string(), serde_json::json!("array"));
2375 if let Some(item_type) = value_type
2376 .strip_prefix("list[")
2377 .and_then(|s| s.strip_suffix(']'))
2378 {
2379 let json_type = match item_type {
2380 "str" => "string",
2381 "int" => "integer",
2382 "bool" => "boolean",
2383 _ => "string",
2384 };
2385 schema_entry.insert(
2386 "items".to_string(),
2387 serde_json::json!({"type": json_type}),
2388 );
2389 }
2390 } else if value_type.starts_with("dict[") {
2391 schema_entry.insert("type".to_string(), serde_json::json!("object"));
2392 } else {
2393 let json_type = match value_type {
2394 "bool" => "boolean",
2395 "int" | "usize" => "integer",
2396 "str" => "string",
2397 _ => "string",
2398 };
2399 schema_entry.insert(
2400 "type".to_string(),
2401 serde_json::Value::String(json_type.to_string()),
2402 );
2403 }
2404 }
2405
2406 current.insert(part.to_string(), serde_json::Value::Object(schema_entry));
2407 } else {
2408 let next_current = current
2409 .entry(part.to_string())
2410 .or_insert_with(|| {
2411 serde_json::json!({
2412 "type": "object",
2413 "properties": {}
2414 })
2415 })
2416 .as_object_mut()
2417 .expect("should be an object")
2418 .entry("properties")
2419 .or_insert_with(|| serde_json::json!({}))
2420 .as_object_mut()
2421 .expect("properties should be an object");
2422
2423 current = next_current;
2424 }
2425 }
2426 }
2427
2428 serde_json::json!({
2429 "type": "object",
2430 "properties": root_properties
2431 })
2432 }
2433}
2434
2435#[cfg(target_os = "macos")]
2436impl RuffLspAdapter {
2437 const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
2438 const ARCH_SERVER_NAME: &str = "apple-darwin";
2439}
2440
2441#[cfg(target_os = "linux")]
2442impl RuffLspAdapter {
2443 const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
2444 const ARCH_SERVER_NAME: &str = "unknown-linux-gnu";
2445}
2446
2447#[cfg(target_os = "freebsd")]
2448impl RuffLspAdapter {
2449 const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
2450 const ARCH_SERVER_NAME: &str = "unknown-freebsd";
2451}
2452
2453#[cfg(target_os = "windows")]
2454impl RuffLspAdapter {
2455 const GITHUB_ASSET_KIND: AssetKind = AssetKind::Zip;
2456 const ARCH_SERVER_NAME: &str = "pc-windows-msvc";
2457}
2458
2459impl RuffLspAdapter {
2460 const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("ruff");
2461
2462 pub fn new(fs: Arc<dyn Fs>) -> RuffLspAdapter {
2463 RuffLspAdapter { fs }
2464 }
2465
2466 fn build_asset_name() -> Result<(String, String)> {
2467 let arch = match consts::ARCH {
2468 "x86" => "i686",
2469 _ => consts::ARCH,
2470 };
2471 let os = Self::ARCH_SERVER_NAME;
2472 let suffix = match consts::OS {
2473 "windows" => "zip",
2474 _ => "tar.gz",
2475 };
2476 let asset_name = format!("ruff-{arch}-{os}.{suffix}");
2477 let asset_stem = format!("ruff-{arch}-{os}");
2478 Ok((asset_stem, asset_name))
2479 }
2480}
2481
2482#[async_trait(?Send)]
2483impl LspAdapter for RuffLspAdapter {
2484 fn name(&self) -> LanguageServerName {
2485 Self::SERVER_NAME
2486 }
2487
2488 async fn initialization_options_schema(
2489 self: Arc<Self>,
2490 delegate: &Arc<dyn LspAdapterDelegate>,
2491 cached_binary: OwnedMutexGuard<Option<(bool, LanguageServerBinary)>>,
2492 cx: &mut AsyncApp,
2493 ) -> Option<serde_json::Value> {
2494 let binary = self
2495 .get_language_server_command(
2496 delegate.clone(),
2497 None,
2498 LanguageServerBinaryOptions {
2499 allow_path_lookup: true,
2500 allow_binary_download: false,
2501 pre_release: false,
2502 },
2503 cached_binary,
2504 cx.clone(),
2505 )
2506 .await
2507 .0
2508 .ok()?;
2509
2510 let mut command = util::command::new_command(&binary.path);
2511 command
2512 .args(&["config", "--output-format", "json"])
2513 .stdout(Stdio::piped())
2514 .stderr(Stdio::piped());
2515 let cmd = command
2516 .spawn()
2517 .map_err(|e| log::debug!("failed to spawn command {command:?}: {e}"))
2518 .ok()?;
2519 let output = cmd
2520 .output()
2521 .await
2522 .map_err(|e| log::debug!("failed to execute command {command:?}: {e}"))
2523 .ok()?;
2524 if !output.status.success() {
2525 return None;
2526 }
2527
2528 let raw_schema: serde_json::Value = serde_json::from_slice(output.stdout.as_slice())
2529 .map_err(|e| log::debug!("failed to parse ruff's JSON schema output: {e}"))
2530 .ok()?;
2531
2532 let converted_schema = Self::convert_ruff_schema(&raw_schema);
2533 Some(converted_schema)
2534 }
2535}
2536
2537impl LspInstaller for RuffLspAdapter {
2538 type BinaryVersion = GitHubLspBinaryVersion;
2539 async fn check_if_user_installed(
2540 &self,
2541 delegate: &Arc<dyn LspAdapterDelegate>,
2542 toolchain: Option<Toolchain>,
2543 _: &AsyncApp,
2544 ) -> Option<LanguageServerBinary> {
2545 let ruff_in_venv = if let Some(toolchain) = toolchain
2546 && toolchain.language_name.as_ref() == "Python"
2547 {
2548 Path::new(toolchain.path.as_str())
2549 .parent()
2550 .map(|path| path.join("ruff"))
2551 } else {
2552 None
2553 };
2554
2555 for path in ruff_in_venv.into_iter().chain(["ruff".into()]) {
2556 if let Some(ruff_bin) = delegate.which(path.as_os_str()).await {
2557 let env = delegate.shell_env().await;
2558 return Some(LanguageServerBinary {
2559 path: ruff_bin,
2560 env: Some(env),
2561 arguments: vec!["server".into()],
2562 });
2563 }
2564 }
2565
2566 None
2567 }
2568
2569 async fn fetch_latest_server_version(
2570 &self,
2571 delegate: &Arc<dyn LspAdapterDelegate>,
2572 _: bool,
2573 _: &mut AsyncApp,
2574 ) -> Result<GitHubLspBinaryVersion> {
2575 let release =
2576 latest_github_release("astral-sh/ruff", true, false, delegate.http_client()).await?;
2577 let (_, asset_name) = Self::build_asset_name()?;
2578 let asset = release
2579 .assets
2580 .into_iter()
2581 .find(|asset| asset.name == asset_name)
2582 .with_context(|| format!("no asset found matching `{asset_name:?}`"))?;
2583 Ok(GitHubLspBinaryVersion {
2584 name: release.tag_name,
2585 url: asset.browser_download_url,
2586 digest: asset.digest,
2587 })
2588 }
2589
2590 fn fetch_server_binary(
2591 &self,
2592 latest_version: GitHubLspBinaryVersion,
2593 container_dir: PathBuf,
2594 delegate: &Arc<dyn LspAdapterDelegate>,
2595 ) -> impl Send + Future<Output = Result<LanguageServerBinary>> + use<> {
2596 let delegate = delegate.clone();
2597
2598 async move {
2599 let GitHubLspBinaryVersion {
2600 name,
2601 url,
2602 digest: expected_digest,
2603 } = latest_version;
2604 let destination_path = container_dir.join(format!("ruff-{name}"));
2605 let server_path = match Self::GITHUB_ASSET_KIND {
2606 AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => destination_path
2607 .join(Self::build_asset_name()?.0)
2608 .join("ruff"),
2609 AssetKind::Zip => destination_path.clone().join("ruff.exe"),
2610 };
2611
2612 let binary = LanguageServerBinary {
2613 path: server_path.clone(),
2614 env: None,
2615 arguments: vec!["server".into()],
2616 };
2617
2618 let metadata_path = destination_path.with_extension("metadata");
2619 let metadata = GithubBinaryMetadata::read_from_file(&metadata_path)
2620 .await
2621 .ok();
2622 if let Some(metadata) = metadata {
2623 let validity_check = async || {
2624 delegate
2625 .try_exec(LanguageServerBinary {
2626 path: server_path.clone(),
2627 arguments: vec!["--version".into()],
2628 env: None,
2629 })
2630 .await
2631 .inspect_err(|err| {
2632 log::warn!(
2633 "Unable to run {server_path:?} asset, redownloading: {err:#}",
2634 )
2635 })
2636 };
2637 if let (Some(actual_digest), Some(expected_digest)) =
2638 (&metadata.digest, &expected_digest)
2639 {
2640 if actual_digest == expected_digest {
2641 if validity_check().await.is_ok() {
2642 return Ok(binary);
2643 }
2644 } else {
2645 log::info!(
2646 "SHA-256 mismatch for {destination_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}"
2647 );
2648 }
2649 } else if validity_check().await.is_ok() {
2650 return Ok(binary);
2651 }
2652 }
2653
2654 download_server_binary(
2655 &*delegate.http_client(),
2656 &url,
2657 expected_digest.as_deref(),
2658 &destination_path,
2659 Self::GITHUB_ASSET_KIND,
2660 )
2661 .await?;
2662 make_file_executable(&server_path).await?;
2663 remove_matching(&container_dir, |path| path != destination_path).await;
2664 GithubBinaryMetadata::write_to_file(
2665 &GithubBinaryMetadata {
2666 metadata_version: 1,
2667 digest: expected_digest,
2668 },
2669 &metadata_path,
2670 )
2671 .await?;
2672
2673 Ok(LanguageServerBinary {
2674 path: server_path,
2675 env: None,
2676 arguments: vec!["server".into()],
2677 })
2678 }
2679 }
2680
2681 async fn cached_server_binary(
2682 &self,
2683 container_dir: PathBuf,
2684 _: &dyn LspAdapterDelegate,
2685 ) -> Option<LanguageServerBinary> {
2686 maybe!(async {
2687 let mut last = None;
2688 let mut entries = self.fs.read_dir(&container_dir).await?;
2689 while let Some(entry) = entries.next().await {
2690 let path = entry?;
2691 if path.extension().is_some_and(|ext| ext == "metadata") {
2692 continue;
2693 }
2694 last = Some(path);
2695 }
2696
2697 let path = last.context("no cached binary")?;
2698 let path = match Self::GITHUB_ASSET_KIND {
2699 AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => {
2700 path.join(Self::build_asset_name()?.0).join("ruff")
2701 }
2702 AssetKind::Zip => path.join("ruff.exe"),
2703 };
2704
2705 anyhow::Ok(LanguageServerBinary {
2706 path,
2707 env: None,
2708 arguments: vec!["server".into()],
2709 })
2710 })
2711 .await
2712 .log_err()
2713 }
2714}
2715
2716#[cfg(test)]
2717mod tests {
2718 use gpui::{AppContext as _, BorrowAppContext, Context, TestAppContext};
2719 use language::{AutoindentMode, Buffer};
2720 use settings::SettingsStore;
2721 use std::num::NonZeroU32;
2722
2723 use crate::python::python_module_name_from_relative_path;
2724
2725 #[gpui::test]
2726 async fn test_conda_activation_script_injection(cx: &mut TestAppContext) {
2727 use language::{LanguageName, Toolchain, ToolchainLister};
2728 use settings::{CondaManager, VenvSettings};
2729 use task::ShellKind;
2730
2731 use crate::python::PythonToolchainProvider;
2732
2733 cx.executor().allow_parking();
2734
2735 cx.update(|cx| {
2736 let test_settings = SettingsStore::test(cx);
2737 cx.set_global(test_settings);
2738 cx.update_global::<SettingsStore, _>(|store, cx| {
2739 store.update_user_settings(cx, |s| {
2740 s.terminal
2741 .get_or_insert_with(Default::default)
2742 .project
2743 .detect_venv = Some(VenvSettings::On {
2744 activate_script: None,
2745 venv_name: None,
2746 directories: None,
2747 conda_manager: Some(CondaManager::Conda),
2748 });
2749 });
2750 });
2751 });
2752
2753 let fs = project::FakeFs::new(cx.executor());
2754 let provider = PythonToolchainProvider::new(fs);
2755 let malicious_name = "foo; rm -rf /";
2756
2757 let manager_executable = std::env::current_exe().unwrap();
2758
2759 let data = serde_json::json!({
2760 "name": malicious_name,
2761 "kind": "Conda",
2762 "executable": "/tmp/conda/bin/python",
2763 "version": serde_json::Value::Null,
2764 "prefix": serde_json::Value::Null,
2765 "arch": serde_json::Value::Null,
2766 "displayName": serde_json::Value::Null,
2767 "project": serde_json::Value::Null,
2768 "symlinks": serde_json::Value::Null,
2769 "manager": {
2770 "executable": manager_executable,
2771 "version": serde_json::Value::Null,
2772 "tool": "Conda",
2773 },
2774 });
2775
2776 let toolchain = Toolchain {
2777 name: "test".into(),
2778 path: "/tmp/conda".into(),
2779 language_name: LanguageName::new_static("Python"),
2780 as_json: data,
2781 };
2782
2783 let script = cx
2784 .update(|cx| provider.activation_script(&toolchain, ShellKind::Posix, cx))
2785 .await;
2786
2787 assert!(
2788 script
2789 .iter()
2790 .any(|s| s.contains("conda activate 'foo; rm -rf /'")),
2791 "Script should contain quoted malicious name, actual: {:?}",
2792 script
2793 );
2794 }
2795
2796 #[gpui::test]
2797 async fn test_conda_activation_skips_when_name_missing(cx: &mut TestAppContext) {
2798 use language::{LanguageName, Toolchain, ToolchainLister};
2799 use settings::{CondaManager, VenvSettings};
2800 use task::ShellKind;
2801
2802 use crate::python::PythonToolchainProvider;
2803
2804 cx.executor().allow_parking();
2805
2806 cx.update(|cx| {
2807 let test_settings = SettingsStore::test(cx);
2808 cx.set_global(test_settings);
2809 cx.update_global::<SettingsStore, _>(|store, cx| {
2810 store.update_user_settings(cx, |s| {
2811 s.terminal
2812 .get_or_insert_with(Default::default)
2813 .project
2814 .detect_venv = Some(VenvSettings::On {
2815 activate_script: None,
2816 venv_name: None,
2817 directories: None,
2818 conda_manager: Some(CondaManager::Conda),
2819 });
2820 });
2821 });
2822 });
2823
2824 let fs = project::FakeFs::new(cx.executor());
2825 let provider = PythonToolchainProvider::new(fs);
2826 let manager_executable = std::env::current_exe().unwrap();
2827
2828 let data = serde_json::json!({
2829 "name": serde_json::Value::Null,
2830 "kind": "Conda",
2831 "executable": "/tmp/conda/bin/python",
2832 "version": serde_json::Value::Null,
2833 "prefix": serde_json::Value::Null,
2834 "arch": serde_json::Value::Null,
2835 "displayName": serde_json::Value::Null,
2836 "project": serde_json::Value::Null,
2837 "symlinks": serde_json::Value::Null,
2838 "manager": {
2839 "executable": manager_executable,
2840 "version": serde_json::Value::Null,
2841 "tool": "Conda",
2842 },
2843 });
2844
2845 let toolchain = Toolchain {
2846 name: "test".into(),
2847 path: "/tmp/conda".into(),
2848 language_name: LanguageName::new_static("Python"),
2849 as_json: data,
2850 };
2851
2852 let script = cx
2853 .update(|cx| provider.activation_script(&toolchain, ShellKind::Posix, cx))
2854 .await;
2855
2856 assert!(
2857 script.is_empty(),
2858 "Nameless conda toolchains must not fall back to `conda activate base`, actual: {:?}",
2859 script
2860 );
2861 }
2862
2863 #[gpui::test]
2864 async fn test_conda_activation_skips_unquotable_name(cx: &mut TestAppContext) {
2865 use language::{LanguageName, Toolchain, ToolchainLister};
2866 use settings::{CondaManager, VenvSettings};
2867 use task::ShellKind;
2868
2869 use crate::python::PythonToolchainProvider;
2870
2871 cx.executor().allow_parking();
2872
2873 cx.update(|cx| {
2874 let test_settings = SettingsStore::test(cx);
2875 cx.set_global(test_settings);
2876 cx.update_global::<SettingsStore, _>(|store, cx| {
2877 store.update_user_settings(cx, |s| {
2878 s.terminal
2879 .get_or_insert_with(Default::default)
2880 .project
2881 .detect_venv = Some(VenvSettings::On {
2882 activate_script: None,
2883 venv_name: None,
2884 directories: None,
2885 conda_manager: Some(CondaManager::Conda),
2886 });
2887 });
2888 });
2889 });
2890
2891 let fs = project::FakeFs::new(cx.executor());
2892 let provider = PythonToolchainProvider::new(fs);
2893 // shlex::try_quote rejects strings containing a NUL byte, so this name
2894 // is guaranteed to fail the Posix quoting path.
2895 let unquotable_name = "foo\0bar";
2896 let manager_executable = std::env::current_exe().unwrap();
2897
2898 let data = serde_json::json!({
2899 "name": unquotable_name,
2900 "kind": "Conda",
2901 "executable": "/tmp/conda/bin/python",
2902 "version": serde_json::Value::Null,
2903 "prefix": serde_json::Value::Null,
2904 "arch": serde_json::Value::Null,
2905 "displayName": serde_json::Value::Null,
2906 "project": serde_json::Value::Null,
2907 "symlinks": serde_json::Value::Null,
2908 "manager": {
2909 "executable": manager_executable,
2910 "version": serde_json::Value::Null,
2911 "tool": "Conda",
2912 },
2913 });
2914
2915 let toolchain = Toolchain {
2916 name: "test".into(),
2917 path: "/tmp/conda".into(),
2918 language_name: LanguageName::new_static("Python"),
2919 as_json: data,
2920 };
2921
2922 let script = cx
2923 .update(|cx| provider.activation_script(&toolchain, ShellKind::Posix, cx))
2924 .await;
2925
2926 assert!(
2927 !script.iter().any(|s| s.contains("conda activate")),
2928 "Unquotable conda env names must not emit any `conda activate` line, actual: {:?}",
2929 script
2930 );
2931 }
2932
2933 #[gpui::test]
2934 async fn test_python_autoindent(cx: &mut TestAppContext) {
2935 cx.executor().set_block_on_ticks(usize::MAX..=usize::MAX);
2936 let language = crate::language("python", tree_sitter_python::LANGUAGE.into());
2937 cx.update(|cx| {
2938 let test_settings = SettingsStore::test(cx);
2939 cx.set_global(test_settings);
2940 cx.update_global::<SettingsStore, _>(|store, cx| {
2941 store.update_user_settings(cx, |s| {
2942 s.project.all_languages.defaults.tab_size = NonZeroU32::new(2);
2943 });
2944 });
2945 });
2946
2947 cx.new(|cx| {
2948 let mut buffer = Buffer::local("", cx).with_language(language, cx);
2949 let append = |buffer: &mut Buffer, text: &str, cx: &mut Context<Buffer>| {
2950 let ix = buffer.len();
2951 buffer.edit([(ix..ix, text)], Some(AutoindentMode::EachLine), cx);
2952 };
2953
2954 // indent after "def():"
2955 append(&mut buffer, "def a():\n", cx);
2956 assert_eq!(buffer.text(), "def a():\n ");
2957
2958 // preserve indent after blank line
2959 append(&mut buffer, "\n ", cx);
2960 assert_eq!(buffer.text(), "def a():\n \n ");
2961
2962 // indent after "if"
2963 append(&mut buffer, "if a:\n ", cx);
2964 assert_eq!(buffer.text(), "def a():\n \n if a:\n ");
2965
2966 // preserve indent after statement
2967 append(&mut buffer, "b()\n", cx);
2968 assert_eq!(buffer.text(), "def a():\n \n if a:\n b()\n ");
2969
2970 // preserve indent after statement
2971 append(&mut buffer, "else", cx);
2972 assert_eq!(buffer.text(), "def a():\n \n if a:\n b()\n else");
2973
2974 // dedent "else""
2975 append(&mut buffer, ":", cx);
2976 assert_eq!(buffer.text(), "def a():\n \n if a:\n b()\n else:");
2977
2978 // indent lines after else
2979 append(&mut buffer, "\n", cx);
2980 assert_eq!(
2981 buffer.text(),
2982 "def a():\n \n if a:\n b()\n else:\n "
2983 );
2984
2985 // indent after an open paren. the closing paren is not indented
2986 // because there is another token before it on the same line.
2987 append(&mut buffer, "foo(\n1)", cx);
2988 assert_eq!(
2989 buffer.text(),
2990 "def a():\n \n if a:\n b()\n else:\n foo(\n 1)"
2991 );
2992
2993 // dedent the closing paren if it is shifted to the beginning of the line
2994 let argument_ix = buffer.text().find('1').unwrap();
2995 buffer.edit(
2996 [(argument_ix..argument_ix + 1, "")],
2997 Some(AutoindentMode::EachLine),
2998 cx,
2999 );
3000 assert_eq!(
3001 buffer.text(),
3002 "def a():\n \n if a:\n b()\n else:\n foo(\n )"
3003 );
3004
3005 // preserve indent after the close paren
3006 append(&mut buffer, "\n", cx);
3007 assert_eq!(
3008 buffer.text(),
3009 "def a():\n \n if a:\n b()\n else:\n foo(\n )\n "
3010 );
3011
3012 // manually outdent the last line
3013 let end_whitespace_ix = buffer.len() - 4;
3014 buffer.edit(
3015 [(end_whitespace_ix..buffer.len(), "")],
3016 Some(AutoindentMode::EachLine),
3017 cx,
3018 );
3019 assert_eq!(
3020 buffer.text(),
3021 "def a():\n \n if a:\n b()\n else:\n foo(\n )\n"
3022 );
3023
3024 // preserve the newly reduced indentation on the next newline
3025 append(&mut buffer, "\n", cx);
3026 assert_eq!(
3027 buffer.text(),
3028 "def a():\n \n if a:\n b()\n else:\n foo(\n )\n\n"
3029 );
3030
3031 // reset to a for loop statement
3032 let statement = "for i in range(10):\n print(i)\n";
3033 buffer.edit([(0..buffer.len(), statement)], None, cx);
3034
3035 // insert single line comment after each line
3036 let eol_ixs = statement
3037 .char_indices()
3038 .filter_map(|(ix, c)| if c == '\n' { Some(ix) } else { None })
3039 .collect::<Vec<usize>>();
3040 let editions = eol_ixs
3041 .iter()
3042 .enumerate()
3043 .map(|(i, &eol_ix)| (eol_ix..eol_ix, format!(" # comment {}", i + 1)))
3044 .collect::<Vec<(std::ops::Range<usize>, String)>>();
3045 buffer.edit(editions, Some(AutoindentMode::EachLine), cx);
3046 assert_eq!(
3047 buffer.text(),
3048 "for i in range(10): # comment 1\n print(i) # comment 2\n"
3049 );
3050
3051 // reset to a simple if statement
3052 buffer.edit([(0..buffer.len(), "if a:\n b(\n )")], None, cx);
3053
3054 // dedent "else" on the line after a closing paren
3055 append(&mut buffer, "\n else:\n", cx);
3056 assert_eq!(buffer.text(), "if a:\n b(\n )\nelse:\n ");
3057
3058 buffer
3059 });
3060 }
3061
3062 #[test]
3063 fn test_python_module_name_from_relative_path() {
3064 assert_eq!(
3065 python_module_name_from_relative_path("foo/bar.py"),
3066 Some("foo.bar".to_string())
3067 );
3068 assert_eq!(
3069 python_module_name_from_relative_path("foo/bar"),
3070 Some("foo.bar".to_string())
3071 );
3072 if cfg!(windows) {
3073 assert_eq!(
3074 python_module_name_from_relative_path("foo\\bar.py"),
3075 Some("foo.bar".to_string())
3076 );
3077 assert_eq!(
3078 python_module_name_from_relative_path("foo\\bar"),
3079 Some("foo.bar".to_string())
3080 );
3081 } else {
3082 assert_eq!(
3083 python_module_name_from_relative_path("foo\\bar.py"),
3084 Some("foo\\bar".to_string())
3085 );
3086 assert_eq!(
3087 python_module_name_from_relative_path("foo\\bar"),
3088 Some("foo\\bar".to_string())
3089 );
3090 }
3091 }
3092
3093 #[test]
3094 fn test_convert_ruff_schema() {
3095 use super::RuffLspAdapter;
3096
3097 let raw_schema = serde_json::json!({
3098 "line-length": {
3099 "doc": "The line length to use when enforcing long-lines violations",
3100 "default": "88",
3101 "value_type": "int",
3102 "scope": null,
3103 "example": "line-length = 120",
3104 "deprecated": null
3105 },
3106 "lint.select": {
3107 "doc": "A list of rule codes or prefixes to enable",
3108 "default": "[\"E4\", \"E7\", \"E9\", \"F\"]",
3109 "value_type": "list[RuleSelector]",
3110 "scope": null,
3111 "example": "select = [\"E4\", \"E7\", \"E9\", \"F\", \"B\", \"Q\"]",
3112 "deprecated": null
3113 },
3114 "lint.isort.case-sensitive": {
3115 "doc": "Sort imports taking into account case sensitivity.",
3116 "default": "false",
3117 "value_type": "bool",
3118 "scope": null,
3119 "example": "case-sensitive = true",
3120 "deprecated": null
3121 },
3122 "format.quote-style": {
3123 "doc": "Configures the preferred quote character for strings.",
3124 "default": "\"double\"",
3125 "value_type": "\"double\" | \"single\" | \"preserve\"",
3126 "scope": null,
3127 "example": "quote-style = \"single\"",
3128 "deprecated": null
3129 }
3130 });
3131
3132 let converted = RuffLspAdapter::convert_ruff_schema(&raw_schema);
3133
3134 assert!(converted.is_object());
3135 assert_eq!(
3136 converted.get("type").and_then(|v| v.as_str()),
3137 Some("object")
3138 );
3139
3140 let properties = converted
3141 .get("properties")
3142 .expect("should have properties")
3143 .as_object()
3144 .expect("properties should be an object");
3145
3146 assert!(properties.contains_key("line-length"));
3147 assert!(properties.contains_key("lint"));
3148 assert!(properties.contains_key("format"));
3149
3150 let line_length = properties
3151 .get("line-length")
3152 .expect("should have line-length")
3153 .as_object()
3154 .expect("line-length should be an object");
3155
3156 assert_eq!(
3157 line_length.get("type").and_then(|v| v.as_str()),
3158 Some("integer")
3159 );
3160 assert_eq!(
3161 line_length.get("default").and_then(|v| v.as_str()),
3162 Some("88")
3163 );
3164
3165 let lint = properties
3166 .get("lint")
3167 .expect("should have lint")
3168 .as_object()
3169 .expect("lint should be an object");
3170
3171 let lint_props = lint
3172 .get("properties")
3173 .expect("lint should have properties")
3174 .as_object()
3175 .expect("lint properties should be an object");
3176
3177 assert!(lint_props.contains_key("select"));
3178 assert!(lint_props.contains_key("isort"));
3179
3180 let select = lint_props.get("select").expect("should have select");
3181 assert_eq!(select.get("type").and_then(|v| v.as_str()), Some("array"));
3182
3183 let isort = lint_props
3184 .get("isort")
3185 .expect("should have isort")
3186 .as_object()
3187 .expect("isort should be an object");
3188
3189 let isort_props = isort
3190 .get("properties")
3191 .expect("isort should have properties")
3192 .as_object()
3193 .expect("isort properties should be an object");
3194
3195 let case_sensitive = isort_props
3196 .get("case-sensitive")
3197 .expect("should have case-sensitive");
3198
3199 assert_eq!(
3200 case_sensitive.get("type").and_then(|v| v.as_str()),
3201 Some("boolean")
3202 );
3203 assert!(case_sensitive.get("markdownDescription").is_some());
3204
3205 let format = properties
3206 .get("format")
3207 .expect("should have format")
3208 .as_object()
3209 .expect("format should be an object");
3210
3211 let format_props = format
3212 .get("properties")
3213 .expect("format should have properties")
3214 .as_object()
3215 .expect("format properties should be an object");
3216
3217 let quote_style = format_props
3218 .get("quote-style")
3219 .expect("should have quote-style");
3220
3221 assert_eq!(
3222 quote_style.get("type").and_then(|v| v.as_str()),
3223 Some("string")
3224 );
3225
3226 let enum_values = quote_style
3227 .get("enum")
3228 .expect("should have enum")
3229 .as_array()
3230 .expect("enum should be an array");
3231
3232 assert_eq!(enum_values.len(), 3);
3233 assert!(enum_values.contains(&serde_json::json!("double")));
3234 assert!(enum_values.contains(&serde_json::json!("single")));
3235 assert!(enum_values.contains(&serde_json::json!("preserve")));
3236 }
3237
3238 mod pyproject_manifest_tests {
3239 use std::collections::HashSet;
3240 use std::sync::Arc;
3241
3242 use language::{ManifestDelegate, ManifestProvider, ManifestQuery};
3243 use settings::WorktreeId;
3244 use util::rel_path::RelPath;
3245
3246 use crate::python::PyprojectTomlManifestProvider;
3247
3248 struct FakeManifestDelegate {
3249 existing_files: HashSet<&'static str>,
3250 }
3251
3252 impl ManifestDelegate for FakeManifestDelegate {
3253 fn worktree_id(&self) -> WorktreeId {
3254 WorktreeId::from_usize(0)
3255 }
3256
3257 fn exists(&self, path: &RelPath, _is_dir: Option<bool>) -> bool {
3258 self.existing_files.contains(path.as_unix_str())
3259 }
3260 }
3261
3262 fn search(files: &[&'static str], query_path: &str) -> Option<Arc<RelPath>> {
3263 let delegate = Arc::new(FakeManifestDelegate {
3264 existing_files: files.iter().copied().collect(),
3265 });
3266 let provider = PyprojectTomlManifestProvider;
3267 provider.search(ManifestQuery {
3268 path: RelPath::from_unix_str(query_path).unwrap().into(),
3269 depth: 10,
3270 delegate,
3271 })
3272 }
3273
3274 #[test]
3275 fn test_simple_project_no_lockfile() {
3276 let result = search(&["project/pyproject.toml"], "project/src/main.py");
3277 assert_eq!(result.as_deref(), RelPath::from_unix_str("project").ok());
3278 }
3279
3280 #[test]
3281 fn test_uv_workspace_returns_root() {
3282 let result = search(
3283 &[
3284 "pyproject.toml",
3285 "uv.lock",
3286 "packages/subproject/pyproject.toml",
3287 ],
3288 "packages/subproject/src/main.py",
3289 );
3290 assert_eq!(result.as_deref(), RelPath::from_unix_str("").ok());
3291 }
3292
3293 #[test]
3294 fn test_poetry_workspace_returns_root() {
3295 let result = search(
3296 &["pyproject.toml", "poetry.lock", "libs/mylib/pyproject.toml"],
3297 "libs/mylib/src/main.py",
3298 );
3299 assert_eq!(result.as_deref(), RelPath::from_unix_str("").ok());
3300 }
3301
3302 #[test]
3303 fn test_pdm_workspace_returns_root() {
3304 let result = search(
3305 &[
3306 "pyproject.toml",
3307 "pdm.lock",
3308 "packages/mypackage/pyproject.toml",
3309 ],
3310 "packages/mypackage/src/main.py",
3311 );
3312 assert_eq!(result.as_deref(), RelPath::from_unix_str("").ok());
3313 }
3314
3315 #[test]
3316 fn test_independent_subprojects_no_lockfile_at_root() {
3317 let result_a = search(
3318 &["project-a/pyproject.toml", "project-b/pyproject.toml"],
3319 "project-a/src/main.py",
3320 );
3321 assert_eq!(
3322 result_a.as_deref(),
3323 RelPath::from_unix_str("project-a").ok()
3324 );
3325
3326 let result_b = search(
3327 &["project-a/pyproject.toml", "project-b/pyproject.toml"],
3328 "project-b/src/main.py",
3329 );
3330 assert_eq!(
3331 result_b.as_deref(),
3332 RelPath::from_unix_str("project-b").ok()
3333 );
3334 }
3335
3336 #[test]
3337 fn test_no_pyproject_returns_none() {
3338 let result = search(&[], "src/main.py");
3339 assert_eq!(result, None);
3340 }
3341
3342 #[test]
3343 fn test_subproject_with_own_lockfile_and_workspace_root() {
3344 // Both root and subproject have lockfiles; should return root (outermost)
3345 let result = search(
3346 &[
3347 "pyproject.toml",
3348 "uv.lock",
3349 "packages/sub/pyproject.toml",
3350 "packages/sub/uv.lock",
3351 ],
3352 "packages/sub/src/main.py",
3353 );
3354 assert_eq!(result.as_deref(), RelPath::from_unix_str("").ok());
3355 }
3356
3357 #[test]
3358 fn test_depth_limits_search() {
3359 let delegate = Arc::new(FakeManifestDelegate {
3360 existing_files: ["pyproject.toml", "uv.lock", "deep/nested/pyproject.toml"]
3361 .into_iter()
3362 .collect(),
3363 });
3364 let provider = PyprojectTomlManifestProvider;
3365 // depth=3 from "deep/nested/src/main.py" searches:
3366 // "deep/nested/src/main.py", "deep/nested/src", and "deep/nested"
3367 // It won't reach "deep" or root ""
3368 let result = provider.search(ManifestQuery {
3369 path: RelPath::from_unix_str("deep/nested/src/main.py")
3370 .unwrap()
3371 .into(),
3372 depth: 3,
3373 delegate,
3374 });
3375 assert_eq!(
3376 result.as_deref(),
3377 RelPath::from_unix_str("deep/nested").ok()
3378 );
3379 }
3380 }
3381}
3382