1use anyhow::{Context as _, ensure};
2use anyhow::{Result, anyhow};
3use async_trait::async_trait;
4use collections::HashMap;
5use futures::{AsyncBufReadExt, StreamExt as _};
6use gpui::{App, AsyncApp, SharedString, Task};
7use http_client::github::{AssetKind, GitHubLspBinaryVersion, latest_github_release};
8use language::language_settings::language_settings;
9use language::{ContextLocation, LanguageToolchainStore, LspInstaller};
10use language::{ContextProvider, LspAdapter, LspAdapterDelegate};
11use language::{LanguageName, ManifestName, ManifestProvider, ManifestQuery};
12use language::{Toolchain, ToolchainList, ToolchainLister, ToolchainMetadata};
13use lsp::LanguageServerName;
14use lsp::{LanguageServerBinary, Uri};
15use node_runtime::{NodeRuntime, VersionStrategy};
16use pet_core::Configuration;
17use pet_core::os_environment::Environment;
18use pet_core::python_environment::{PythonEnvironment, PythonEnvironmentKind};
19use pet_virtualenv::is_virtualenv_dir;
20use project::Fs;
21use project::lsp_store::language_server_settings;
22use semver::Version;
23use serde::{Deserialize, Serialize};
24use serde_json::{Value, json};
25use settings::Settings;
26use smol::lock::OnceCell;
27use std::cmp::{Ordering, Reverse};
28use std::env::consts;
29use terminal::terminal_settings::TerminalSettings;
30use util::command::new_smol_command;
31use util::fs::{make_file_executable, remove_matching};
32use util::paths::PathStyle;
33use util::rel_path::RelPath;
34
35use http_client::github_download::{GithubBinaryMetadata, download_server_binary};
36use parking_lot::Mutex;
37use std::str::FromStr;
38use std::{
39 borrow::Cow,
40 fmt::Write,
41 path::{Path, PathBuf},
42 sync::Arc,
43};
44use task::{ShellKind, TaskTemplate, TaskTemplates, VariableName};
45use util::{ResultExt, maybe};
46
47#[derive(Debug, Serialize, Deserialize)]
48pub(crate) struct PythonToolchainData {
49 #[serde(flatten)]
50 environment: PythonEnvironment,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 activation_scripts: Option<HashMap<ShellKind, PathBuf>>,
53}
54
55pub(crate) struct PyprojectTomlManifestProvider;
56
57impl ManifestProvider for PyprojectTomlManifestProvider {
58 fn name(&self) -> ManifestName {
59 SharedString::new_static("pyproject.toml").into()
60 }
61
62 fn search(
63 &self,
64 ManifestQuery {
65 path,
66 depth,
67 delegate,
68 }: ManifestQuery,
69 ) -> Option<Arc<RelPath>> {
70 for path in path.ancestors().take(depth) {
71 let p = path.join(RelPath::unix("pyproject.toml").unwrap());
72 if delegate.exists(&p, Some(false)) {
73 return Some(path.into());
74 }
75 }
76
77 None
78 }
79}
80
81enum TestRunner {
82 UNITTEST,
83 PYTEST,
84}
85
86impl FromStr for TestRunner {
87 type Err = ();
88
89 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
90 match s {
91 "unittest" => Ok(Self::UNITTEST),
92 "pytest" => Ok(Self::PYTEST),
93 _ => Err(()),
94 }
95 }
96}
97
98/// Pyright assigns each completion item a `sortText` of the form `XX.YYYY.name`.
99/// Where `XX` is the sorting category, `YYYY` is based on most recent usage,
100/// and `name` is the symbol name itself.
101///
102/// 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),
103/// which - long story short - makes completion items list non-stable. Pyright probably relies on VSCode's implementation detail.
104/// see https://github.com/microsoft/pyright/blob/95ef4e103b9b2f129c9320427e51b73ea7cf78bd/packages/pyright-internal/src/languageService/completionProvider.ts#LL2873
105///
106/// upd 02.12.25:
107/// Decided to ignore Pyright's sortText() completely and to manually sort all entries
108fn process_pyright_completions(items: &mut [lsp::CompletionItem]) {
109 for item in items {
110 let is_dunder = item.label.starts_with("__") && item.label.ends_with("__");
111
112 let visibility_priority = if is_dunder {
113 '3'
114 } else if item.label.starts_with("__") {
115 '2' // private non-dunder
116 } else if item.label.starts_with('_') {
117 '1' // protected
118 } else {
119 '0' // public
120 };
121
122 // Kind priority within same visibility level
123 let kind_priority = match item.kind {
124 Some(lsp::CompletionItemKind::ENUM_MEMBER) => '0',
125 Some(lsp::CompletionItemKind::FIELD) => '1',
126 Some(lsp::CompletionItemKind::PROPERTY) => '2',
127 Some(lsp::CompletionItemKind::VARIABLE) => '3',
128 Some(lsp::CompletionItemKind::CONSTANT) => '4',
129 Some(lsp::CompletionItemKind::METHOD) => '5',
130 Some(lsp::CompletionItemKind::FUNCTION) => '5',
131 Some(lsp::CompletionItemKind::CLASS) => '6',
132 Some(lsp::CompletionItemKind::MODULE) => '7',
133 _ => '8',
134 };
135
136 item.sort_text = Some(format!(
137 "{}{}{}",
138 visibility_priority, kind_priority, item.label
139 ));
140 }
141}
142
143pub struct TyLspAdapter {
144 fs: Arc<dyn Fs>,
145}
146
147#[cfg(target_os = "macos")]
148impl TyLspAdapter {
149 const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
150 const ARCH_SERVER_NAME: &str = "apple-darwin";
151}
152
153#[cfg(target_os = "linux")]
154impl TyLspAdapter {
155 const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
156 const ARCH_SERVER_NAME: &str = "unknown-linux-gnu";
157}
158
159#[cfg(target_os = "freebsd")]
160impl TyLspAdapter {
161 const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
162 const ARCH_SERVER_NAME: &str = "unknown-freebsd";
163}
164
165#[cfg(target_os = "windows")]
166impl TyLspAdapter {
167 const GITHUB_ASSET_KIND: AssetKind = AssetKind::Zip;
168 const ARCH_SERVER_NAME: &str = "pc-windows-msvc";
169}
170
171impl TyLspAdapter {
172 const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("ty");
173
174 pub fn new(fs: Arc<dyn Fs>) -> TyLspAdapter {
175 TyLspAdapter { fs }
176 }
177
178 fn build_asset_name() -> Result<(String, String)> {
179 let arch = match consts::ARCH {
180 "x86" => "i686",
181 _ => consts::ARCH,
182 };
183 let os = Self::ARCH_SERVER_NAME;
184 let suffix = match consts::OS {
185 "windows" => "zip",
186 _ => "tar.gz",
187 };
188 let asset_name = format!("ty-{arch}-{os}.{suffix}");
189 let asset_stem = format!("ty-{arch}-{os}");
190 Ok((asset_stem, asset_name))
191 }
192}
193
194#[async_trait(?Send)]
195impl LspAdapter for TyLspAdapter {
196 fn name(&self) -> LanguageServerName {
197 Self::SERVER_NAME
198 }
199
200 async fn label_for_completion(
201 &self,
202 item: &lsp::CompletionItem,
203 language: &Arc<language::Language>,
204 ) -> Option<language::CodeLabel> {
205 let label = &item.label;
206 let label_len = label.len();
207 let grammar = language.grammar()?;
208 let highlight_id = match item.kind? {
209 lsp::CompletionItemKind::METHOD => grammar.highlight_id_for_name("function.method"),
210 lsp::CompletionItemKind::FUNCTION => grammar.highlight_id_for_name("function"),
211 lsp::CompletionItemKind::CLASS => grammar.highlight_id_for_name("type"),
212 lsp::CompletionItemKind::CONSTANT => grammar.highlight_id_for_name("constant"),
213 lsp::CompletionItemKind::VARIABLE => grammar.highlight_id_for_name("variable"),
214 _ => {
215 return None;
216 }
217 };
218
219 let mut text = label.clone();
220 if let Some(completion_details) = item
221 .label_details
222 .as_ref()
223 .and_then(|details| details.detail.as_ref())
224 {
225 write!(&mut text, " {}", completion_details).ok();
226 }
227
228 Some(language::CodeLabel::filtered(
229 text,
230 label_len,
231 item.filter_text.as_deref(),
232 highlight_id
233 .map(|id| (0..label_len, id))
234 .into_iter()
235 .collect(),
236 ))
237 }
238
239 async fn workspace_configuration(
240 self: Arc<Self>,
241 delegate: &Arc<dyn LspAdapterDelegate>,
242 toolchain: Option<Toolchain>,
243 _: Option<Uri>,
244 cx: &mut AsyncApp,
245 ) -> Result<Value> {
246 let mut ret = cx
247 .update(|cx| {
248 language_server_settings(delegate.as_ref(), &self.name(), cx)
249 .and_then(|s| s.settings.clone())
250 })?
251 .unwrap_or_else(|| json!({}));
252 if let Some(toolchain) = toolchain.and_then(|toolchain| {
253 serde_json::from_value::<PythonToolchainData>(toolchain.as_json).ok()
254 }) {
255 _ = maybe!({
256 let uri =
257 url::Url::from_file_path(toolchain.environment.executable.as_ref()?).ok()?;
258 let sys_prefix = toolchain.environment.prefix.clone()?;
259 let environment = json!({
260 "executable": {
261 "uri": uri,
262 "sysPrefix": sys_prefix
263 }
264 });
265 ret.as_object_mut()?
266 .entry("pythonExtension")
267 .or_insert_with(|| json!({ "activeEnvironment": environment }));
268 Some(())
269 });
270 }
271 Ok(json!({"ty": ret}))
272 }
273}
274
275impl LspInstaller for TyLspAdapter {
276 type BinaryVersion = GitHubLspBinaryVersion;
277 async fn fetch_latest_server_version(
278 &self,
279 delegate: &dyn LspAdapterDelegate,
280 _: bool,
281 _: &mut AsyncApp,
282 ) -> Result<Self::BinaryVersion> {
283 let release =
284 latest_github_release("astral-sh/ty", true, false, delegate.http_client()).await?;
285 let (_, asset_name) = Self::build_asset_name()?;
286 let asset = release
287 .assets
288 .into_iter()
289 .find(|asset| asset.name == asset_name)
290 .with_context(|| format!("no asset found matching `{asset_name:?}`"))?;
291 Ok(GitHubLspBinaryVersion {
292 name: release.tag_name,
293 url: asset.browser_download_url,
294 digest: asset.digest,
295 })
296 }
297
298 async fn fetch_server_binary(
299 &self,
300 latest_version: Self::BinaryVersion,
301 container_dir: PathBuf,
302 delegate: &dyn LspAdapterDelegate,
303 ) -> Result<LanguageServerBinary> {
304 let GitHubLspBinaryVersion {
305 name,
306 url,
307 digest: expected_digest,
308 } = latest_version;
309 let destination_path = container_dir.join(format!("ty-{name}"));
310
311 async_fs::create_dir_all(&destination_path).await?;
312
313 let server_path = match Self::GITHUB_ASSET_KIND {
314 AssetKind::TarGz | AssetKind::Gz => destination_path
315 .join(Self::build_asset_name()?.0)
316 .join("ty"),
317 AssetKind::Zip => destination_path.clone().join("ty.exe"),
318 };
319
320 let binary = LanguageServerBinary {
321 path: server_path.clone(),
322 env: None,
323 arguments: vec!["server".into()],
324 };
325
326 let metadata_path = destination_path.with_extension("metadata");
327 let metadata = GithubBinaryMetadata::read_from_file(&metadata_path)
328 .await
329 .ok();
330 if let Some(metadata) = metadata {
331 let validity_check = async || {
332 delegate
333 .try_exec(LanguageServerBinary {
334 path: server_path.clone(),
335 arguments: vec!["--version".into()],
336 env: None,
337 })
338 .await
339 .inspect_err(|err| {
340 log::warn!("Unable to run {server_path:?} asset, redownloading: {err:#}",)
341 })
342 };
343 if let (Some(actual_digest), Some(expected_digest)) =
344 (&metadata.digest, &expected_digest)
345 {
346 if actual_digest == expected_digest {
347 if validity_check().await.is_ok() {
348 return Ok(binary);
349 }
350 } else {
351 log::info!(
352 "SHA-256 mismatch for {destination_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}"
353 );
354 }
355 } else if validity_check().await.is_ok() {
356 return Ok(binary);
357 }
358 }
359
360 download_server_binary(
361 &*delegate.http_client(),
362 &url,
363 expected_digest.as_deref(),
364 &destination_path,
365 Self::GITHUB_ASSET_KIND,
366 )
367 .await?;
368 make_file_executable(&server_path).await?;
369 remove_matching(&container_dir, |path| path != destination_path).await;
370 GithubBinaryMetadata::write_to_file(
371 &GithubBinaryMetadata {
372 metadata_version: 1,
373 digest: expected_digest,
374 },
375 &metadata_path,
376 )
377 .await?;
378
379 Ok(LanguageServerBinary {
380 path: server_path,
381 env: None,
382 arguments: vec!["server".into()],
383 })
384 }
385
386 async fn cached_server_binary(
387 &self,
388 container_dir: PathBuf,
389 _: &dyn LspAdapterDelegate,
390 ) -> Option<LanguageServerBinary> {
391 maybe!(async {
392 let mut last = None;
393 let mut entries = self.fs.read_dir(&container_dir).await?;
394 while let Some(entry) = entries.next().await {
395 let path = entry?;
396 if path.extension().is_some_and(|ext| ext == "metadata") {
397 continue;
398 }
399 last = Some(path);
400 }
401
402 let path = last.context("no cached binary")?;
403 let path = match TyLspAdapter::GITHUB_ASSET_KIND {
404 AssetKind::TarGz | AssetKind::Gz => {
405 path.join(Self::build_asset_name()?.0).join("ty")
406 }
407 AssetKind::Zip => path.join("ty.exe"),
408 };
409
410 anyhow::Ok(LanguageServerBinary {
411 path,
412 env: None,
413 arguments: vec!["server".into()],
414 })
415 })
416 .await
417 .log_err()
418 }
419}
420
421pub struct PyrightLspAdapter {
422 node: NodeRuntime,
423}
424
425impl PyrightLspAdapter {
426 const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("pyright");
427 const SERVER_PATH: &str = "node_modules/pyright/langserver.index.js";
428 const NODE_MODULE_RELATIVE_SERVER_PATH: &str = "pyright/langserver.index.js";
429
430 pub fn new(node: NodeRuntime) -> Self {
431 PyrightLspAdapter { node }
432 }
433
434 async fn get_cached_server_binary(
435 container_dir: PathBuf,
436 node: &NodeRuntime,
437 ) -> Option<LanguageServerBinary> {
438 let server_path = container_dir.join(Self::SERVER_PATH);
439 if server_path.exists() {
440 Some(LanguageServerBinary {
441 path: node.binary_path().await.log_err()?,
442 env: None,
443 arguments: vec![server_path.into(), "--stdio".into()],
444 })
445 } else {
446 log::error!("missing executable in directory {:?}", server_path);
447 None
448 }
449 }
450}
451
452#[async_trait(?Send)]
453impl LspAdapter for PyrightLspAdapter {
454 fn name(&self) -> LanguageServerName {
455 Self::SERVER_NAME
456 }
457
458 async fn initialization_options(
459 self: Arc<Self>,
460 _: &Arc<dyn LspAdapterDelegate>,
461 ) -> Result<Option<Value>> {
462 // Provide minimal initialization options
463 // Virtual environment configuration will be handled through workspace configuration
464 Ok(Some(json!({
465 "python": {
466 "analysis": {
467 "autoSearchPaths": true,
468 "useLibraryCodeForTypes": true,
469 "autoImportCompletions": true
470 }
471 }
472 })))
473 }
474
475 async fn process_completions(&self, items: &mut [lsp::CompletionItem]) {
476 process_pyright_completions(items);
477 }
478
479 async fn label_for_completion(
480 &self,
481 item: &lsp::CompletionItem,
482 language: &Arc<language::Language>,
483 ) -> Option<language::CodeLabel> {
484 let label = &item.label;
485 let label_len = label.len();
486 let grammar = language.grammar()?;
487 let highlight_id = match item.kind? {
488 lsp::CompletionItemKind::METHOD => grammar.highlight_id_for_name("function.method"),
489 lsp::CompletionItemKind::FUNCTION => grammar.highlight_id_for_name("function"),
490 lsp::CompletionItemKind::CLASS => grammar.highlight_id_for_name("type"),
491 lsp::CompletionItemKind::CONSTANT => grammar.highlight_id_for_name("constant"),
492 lsp::CompletionItemKind::VARIABLE => grammar.highlight_id_for_name("variable"),
493 _ => {
494 return None;
495 }
496 };
497 let mut text = label.clone();
498 if let Some(completion_details) = item
499 .label_details
500 .as_ref()
501 .and_then(|details| details.description.as_ref())
502 {
503 write!(&mut text, " {}", completion_details).ok();
504 }
505 Some(language::CodeLabel::filtered(
506 text,
507 label_len,
508 item.filter_text.as_deref(),
509 highlight_id
510 .map(|id| (0..label_len, id))
511 .into_iter()
512 .collect(),
513 ))
514 }
515
516 async fn label_for_symbol(
517 &self,
518 name: &str,
519 kind: lsp::SymbolKind,
520 language: &Arc<language::Language>,
521 ) -> Option<language::CodeLabel> {
522 let (text, filter_range, display_range) = match kind {
523 lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => {
524 let text = format!("def {}():\n", name);
525 let filter_range = 4..4 + name.len();
526 let display_range = 0..filter_range.end;
527 (text, filter_range, display_range)
528 }
529 lsp::SymbolKind::CLASS => {
530 let text = format!("class {}:", name);
531 let filter_range = 6..6 + name.len();
532 let display_range = 0..filter_range.end;
533 (text, filter_range, display_range)
534 }
535 lsp::SymbolKind::CONSTANT => {
536 let text = format!("{} = 0", name);
537 let filter_range = 0..name.len();
538 let display_range = 0..filter_range.end;
539 (text, filter_range, display_range)
540 }
541 _ => return None,
542 };
543
544 Some(language::CodeLabel::new(
545 text[display_range.clone()].to_string(),
546 filter_range,
547 language.highlight_text(&text.as_str().into(), display_range),
548 ))
549 }
550
551 async fn workspace_configuration(
552 self: Arc<Self>,
553 adapter: &Arc<dyn LspAdapterDelegate>,
554 toolchain: Option<Toolchain>,
555 _: Option<Uri>,
556 cx: &mut AsyncApp,
557 ) -> Result<Value> {
558 cx.update(move |cx| {
559 let mut user_settings =
560 language_server_settings(adapter.as_ref(), &Self::SERVER_NAME, cx)
561 .and_then(|s| s.settings.clone())
562 .unwrap_or_default();
563
564 // If we have a detected toolchain, configure Pyright to use it
565 if let Some(toolchain) = toolchain
566 && let Ok(env) =
567 serde_json::from_value::<PythonToolchainData>(toolchain.as_json.clone())
568 {
569 if !user_settings.is_object() {
570 user_settings = Value::Object(serde_json::Map::default());
571 }
572 let object = user_settings.as_object_mut().unwrap();
573
574 let interpreter_path = toolchain.path.to_string();
575 if let Some(venv_dir) = &env.environment.prefix {
576 // Set venvPath and venv at the root level
577 // This matches the format of a pyrightconfig.json file
578 if let Some(parent) = venv_dir.parent() {
579 // Use relative path if the venv is inside the workspace
580 let venv_path = if parent == adapter.worktree_root_path() {
581 ".".to_string()
582 } else {
583 parent.to_string_lossy().into_owned()
584 };
585 object.insert("venvPath".to_string(), Value::String(venv_path));
586 }
587
588 if let Some(venv_name) = venv_dir.file_name() {
589 object.insert(
590 "venv".to_owned(),
591 Value::String(venv_name.to_string_lossy().into_owned()),
592 );
593 }
594 }
595
596 // Always set the python interpreter path
597 // Get or create the python section
598 let python = object
599 .entry("python")
600 .and_modify(|v| {
601 if !v.is_object() {
602 *v = Value::Object(serde_json::Map::default());
603 }
604 })
605 .or_insert(Value::Object(serde_json::Map::default()));
606 let python = python.as_object_mut().unwrap();
607
608 // Set both pythonPath and defaultInterpreterPath for compatibility
609 python.insert(
610 "pythonPath".to_owned(),
611 Value::String(interpreter_path.clone()),
612 );
613 python.insert(
614 "defaultInterpreterPath".to_owned(),
615 Value::String(interpreter_path),
616 );
617 }
618
619 user_settings
620 })
621 }
622}
623
624impl LspInstaller for PyrightLspAdapter {
625 type BinaryVersion = Version;
626
627 async fn fetch_latest_server_version(
628 &self,
629 _: &dyn LspAdapterDelegate,
630 _: bool,
631 _: &mut AsyncApp,
632 ) -> Result<Self::BinaryVersion> {
633 self.node
634 .npm_package_latest_version(Self::SERVER_NAME.as_ref())
635 .await
636 }
637
638 async fn check_if_user_installed(
639 &self,
640 delegate: &dyn LspAdapterDelegate,
641 _: Option<Toolchain>,
642 _: &AsyncApp,
643 ) -> Option<LanguageServerBinary> {
644 if let Some(pyright_bin) = delegate.which("pyright-langserver".as_ref()).await {
645 let env = delegate.shell_env().await;
646 Some(LanguageServerBinary {
647 path: pyright_bin,
648 env: Some(env),
649 arguments: vec!["--stdio".into()],
650 })
651 } else {
652 let node = delegate.which("node".as_ref()).await?;
653 let (node_modules_path, _) = delegate
654 .npm_package_installed_version(Self::SERVER_NAME.as_ref())
655 .await
656 .log_err()??;
657
658 let path = node_modules_path.join(Self::NODE_MODULE_RELATIVE_SERVER_PATH);
659
660 let env = delegate.shell_env().await;
661 Some(LanguageServerBinary {
662 path: node,
663 env: Some(env),
664 arguments: vec![path.into(), "--stdio".into()],
665 })
666 }
667 }
668
669 async fn fetch_server_binary(
670 &self,
671 latest_version: Self::BinaryVersion,
672 container_dir: PathBuf,
673 delegate: &dyn LspAdapterDelegate,
674 ) -> Result<LanguageServerBinary> {
675 let server_path = container_dir.join(Self::SERVER_PATH);
676 let latest_version = latest_version.to_string();
677
678 self.node
679 .npm_install_packages(
680 &container_dir,
681 &[(Self::SERVER_NAME.as_ref(), latest_version.as_str())],
682 )
683 .await?;
684
685 let env = delegate.shell_env().await;
686 Ok(LanguageServerBinary {
687 path: self.node.binary_path().await?,
688 env: Some(env),
689 arguments: vec![server_path.into(), "--stdio".into()],
690 })
691 }
692
693 async fn check_if_version_installed(
694 &self,
695 version: &Self::BinaryVersion,
696 container_dir: &PathBuf,
697 delegate: &dyn LspAdapterDelegate,
698 ) -> Option<LanguageServerBinary> {
699 let server_path = container_dir.join(Self::SERVER_PATH);
700
701 let should_install_language_server = self
702 .node
703 .should_install_npm_package(
704 Self::SERVER_NAME.as_ref(),
705 &server_path,
706 container_dir,
707 VersionStrategy::Latest(version),
708 )
709 .await;
710
711 if should_install_language_server {
712 None
713 } else {
714 let env = delegate.shell_env().await;
715 Some(LanguageServerBinary {
716 path: self.node.binary_path().await.ok()?,
717 env: Some(env),
718 arguments: vec![server_path.into(), "--stdio".into()],
719 })
720 }
721 }
722
723 async fn cached_server_binary(
724 &self,
725 container_dir: PathBuf,
726 delegate: &dyn LspAdapterDelegate,
727 ) -> Option<LanguageServerBinary> {
728 let mut binary = Self::get_cached_server_binary(container_dir, &self.node).await?;
729 binary.env = Some(delegate.shell_env().await);
730 Some(binary)
731 }
732}
733
734pub(crate) struct PythonContextProvider;
735
736const PYTHON_TEST_TARGET_TASK_VARIABLE: VariableName =
737 VariableName::Custom(Cow::Borrowed("PYTHON_TEST_TARGET"));
738
739const PYTHON_ACTIVE_TOOLCHAIN_PATH: VariableName =
740 VariableName::Custom(Cow::Borrowed("PYTHON_ACTIVE_ZED_TOOLCHAIN"));
741
742const PYTHON_MODULE_NAME_TASK_VARIABLE: VariableName =
743 VariableName::Custom(Cow::Borrowed("PYTHON_MODULE_NAME"));
744
745impl ContextProvider for PythonContextProvider {
746 fn build_context(
747 &self,
748 variables: &task::TaskVariables,
749 location: ContextLocation<'_>,
750 _: Option<HashMap<String, String>>,
751 toolchains: Arc<dyn LanguageToolchainStore>,
752 cx: &mut gpui::App,
753 ) -> Task<Result<task::TaskVariables>> {
754 let test_target =
755 match selected_test_runner(location.file_location.buffer.read(cx).file(), cx) {
756 TestRunner::UNITTEST => self.build_unittest_target(variables),
757 TestRunner::PYTEST => self.build_pytest_target(variables),
758 };
759
760 let module_target = self.build_module_target(variables);
761 let location_file = location.file_location.buffer.read(cx).file().cloned();
762 let worktree_id = location_file.as_ref().map(|f| f.worktree_id(cx));
763
764 cx.spawn(async move |cx| {
765 let active_toolchain = if let Some(worktree_id) = worktree_id {
766 let file_path = location_file
767 .as_ref()
768 .and_then(|f| f.path().parent())
769 .map(Arc::from)
770 .unwrap_or_else(|| RelPath::empty().into());
771
772 toolchains
773 .active_toolchain(worktree_id, file_path, "Python".into(), cx)
774 .await
775 .map_or_else(
776 || String::from("python3"),
777 |toolchain| toolchain.path.to_string(),
778 )
779 } else {
780 String::from("python3")
781 };
782
783 let toolchain = (PYTHON_ACTIVE_TOOLCHAIN_PATH, active_toolchain);
784
785 Ok(task::TaskVariables::from_iter(
786 test_target
787 .into_iter()
788 .chain(module_target.into_iter())
789 .chain([toolchain]),
790 ))
791 })
792 }
793
794 fn associated_tasks(
795 &self,
796 file: Option<Arc<dyn language::File>>,
797 cx: &App,
798 ) -> Task<Option<TaskTemplates>> {
799 let test_runner = selected_test_runner(file.as_ref(), cx);
800
801 let mut tasks = vec![
802 // Execute a selection
803 TaskTemplate {
804 label: "execute selection".to_owned(),
805 command: PYTHON_ACTIVE_TOOLCHAIN_PATH.template_value(),
806 args: vec![
807 "-c".to_owned(),
808 VariableName::SelectedText.template_value_with_whitespace(),
809 ],
810 cwd: Some(VariableName::WorktreeRoot.template_value()),
811 ..TaskTemplate::default()
812 },
813 // Execute an entire file
814 TaskTemplate {
815 label: format!("run '{}'", VariableName::File.template_value()),
816 command: PYTHON_ACTIVE_TOOLCHAIN_PATH.template_value(),
817 args: vec![VariableName::File.template_value_with_whitespace()],
818 cwd: Some(VariableName::WorktreeRoot.template_value()),
819 ..TaskTemplate::default()
820 },
821 // Execute a file as module
822 TaskTemplate {
823 label: format!("run module '{}'", VariableName::File.template_value()),
824 command: PYTHON_ACTIVE_TOOLCHAIN_PATH.template_value(),
825 args: vec![
826 "-m".to_owned(),
827 PYTHON_MODULE_NAME_TASK_VARIABLE.template_value(),
828 ],
829 cwd: Some(VariableName::WorktreeRoot.template_value()),
830 tags: vec!["python-module-main-method".to_owned()],
831 ..TaskTemplate::default()
832 },
833 ];
834
835 tasks.extend(match test_runner {
836 TestRunner::UNITTEST => {
837 [
838 // Run tests for an entire file
839 TaskTemplate {
840 label: format!("unittest '{}'", VariableName::File.template_value()),
841 command: PYTHON_ACTIVE_TOOLCHAIN_PATH.template_value(),
842 args: vec![
843 "-m".to_owned(),
844 "unittest".to_owned(),
845 VariableName::File.template_value_with_whitespace(),
846 ],
847 cwd: Some(VariableName::WorktreeRoot.template_value()),
848 ..TaskTemplate::default()
849 },
850 // Run test(s) for a specific target within a file
851 TaskTemplate {
852 label: "unittest $ZED_CUSTOM_PYTHON_TEST_TARGET".to_owned(),
853 command: PYTHON_ACTIVE_TOOLCHAIN_PATH.template_value(),
854 args: vec![
855 "-m".to_owned(),
856 "unittest".to_owned(),
857 PYTHON_TEST_TARGET_TASK_VARIABLE.template_value_with_whitespace(),
858 ],
859 tags: vec![
860 "python-unittest-class".to_owned(),
861 "python-unittest-method".to_owned(),
862 ],
863 cwd: Some(VariableName::WorktreeRoot.template_value()),
864 ..TaskTemplate::default()
865 },
866 ]
867 }
868 TestRunner::PYTEST => {
869 [
870 // Run tests for an entire file
871 TaskTemplate {
872 label: format!("pytest '{}'", VariableName::File.template_value()),
873 command: PYTHON_ACTIVE_TOOLCHAIN_PATH.template_value(),
874 args: vec![
875 "-m".to_owned(),
876 "pytest".to_owned(),
877 VariableName::File.template_value_with_whitespace(),
878 ],
879 cwd: Some(VariableName::WorktreeRoot.template_value()),
880 ..TaskTemplate::default()
881 },
882 // Run test(s) for a specific target within a file
883 TaskTemplate {
884 label: "pytest $ZED_CUSTOM_PYTHON_TEST_TARGET".to_owned(),
885 command: PYTHON_ACTIVE_TOOLCHAIN_PATH.template_value(),
886 args: vec![
887 "-m".to_owned(),
888 "pytest".to_owned(),
889 PYTHON_TEST_TARGET_TASK_VARIABLE.template_value_with_whitespace(),
890 ],
891 cwd: Some(VariableName::WorktreeRoot.template_value()),
892 tags: vec![
893 "python-pytest-class".to_owned(),
894 "python-pytest-method".to_owned(),
895 ],
896 ..TaskTemplate::default()
897 },
898 ]
899 }
900 });
901
902 Task::ready(Some(TaskTemplates(tasks)))
903 }
904}
905
906fn selected_test_runner(location: Option<&Arc<dyn language::File>>, cx: &App) -> TestRunner {
907 const TEST_RUNNER_VARIABLE: &str = "TEST_RUNNER";
908 language_settings(Some(LanguageName::new_static("Python")), location, cx)
909 .tasks
910 .variables
911 .get(TEST_RUNNER_VARIABLE)
912 .and_then(|val| TestRunner::from_str(val).ok())
913 .unwrap_or(TestRunner::PYTEST)
914}
915
916impl PythonContextProvider {
917 fn build_unittest_target(
918 &self,
919 variables: &task::TaskVariables,
920 ) -> Option<(VariableName, String)> {
921 let python_module_name =
922 python_module_name_from_relative_path(variables.get(&VariableName::RelativeFile)?)?;
923
924 let unittest_class_name =
925 variables.get(&VariableName::Custom(Cow::Borrowed("_unittest_class_name")));
926
927 let unittest_method_name = variables.get(&VariableName::Custom(Cow::Borrowed(
928 "_unittest_method_name",
929 )));
930
931 let unittest_target_str = match (unittest_class_name, unittest_method_name) {
932 (Some(class_name), Some(method_name)) => {
933 format!("{python_module_name}.{class_name}.{method_name}")
934 }
935 (Some(class_name), None) => format!("{python_module_name}.{class_name}"),
936 (None, None) => python_module_name,
937 // should never happen, a TestCase class is the unit of testing
938 (None, Some(_)) => return None,
939 };
940
941 Some((
942 PYTHON_TEST_TARGET_TASK_VARIABLE.clone(),
943 unittest_target_str,
944 ))
945 }
946
947 fn build_pytest_target(
948 &self,
949 variables: &task::TaskVariables,
950 ) -> Option<(VariableName, String)> {
951 let file_path = variables.get(&VariableName::RelativeFile)?;
952
953 let pytest_class_name =
954 variables.get(&VariableName::Custom(Cow::Borrowed("_pytest_class_name")));
955
956 let pytest_method_name =
957 variables.get(&VariableName::Custom(Cow::Borrowed("_pytest_method_name")));
958
959 let pytest_target_str = match (pytest_class_name, pytest_method_name) {
960 (Some(class_name), Some(method_name)) => {
961 format!("{file_path}::{class_name}::{method_name}")
962 }
963 (Some(class_name), None) => {
964 format!("{file_path}::{class_name}")
965 }
966 (None, Some(method_name)) => {
967 format!("{file_path}::{method_name}")
968 }
969 (None, None) => file_path.to_string(),
970 };
971
972 Some((PYTHON_TEST_TARGET_TASK_VARIABLE.clone(), pytest_target_str))
973 }
974
975 fn build_module_target(
976 &self,
977 variables: &task::TaskVariables,
978 ) -> Result<(VariableName, String)> {
979 let python_module_name = variables
980 .get(&VariableName::RelativeFile)
981 .and_then(|module| python_module_name_from_relative_path(module))
982 .unwrap_or_default();
983
984 let module_target = (PYTHON_MODULE_NAME_TASK_VARIABLE.clone(), python_module_name);
985
986 Ok(module_target)
987 }
988}
989
990fn python_module_name_from_relative_path(relative_path: &str) -> Option<String> {
991 let rel_path = RelPath::new(relative_path.as_ref(), PathStyle::local()).ok()?;
992 let path_with_dots = rel_path.display(PathStyle::Posix).replace('/', ".");
993 Some(
994 path_with_dots
995 .strip_suffix(".py")
996 .map(ToOwned::to_owned)
997 .unwrap_or(path_with_dots),
998 )
999}
1000
1001fn is_python_env_global(k: &PythonEnvironmentKind) -> bool {
1002 matches!(
1003 k,
1004 PythonEnvironmentKind::Homebrew
1005 | PythonEnvironmentKind::Pyenv
1006 | PythonEnvironmentKind::GlobalPaths
1007 | PythonEnvironmentKind::MacPythonOrg
1008 | PythonEnvironmentKind::MacCommandLineTools
1009 | PythonEnvironmentKind::LinuxGlobal
1010 | PythonEnvironmentKind::MacXCode
1011 | PythonEnvironmentKind::WindowsStore
1012 | PythonEnvironmentKind::WindowsRegistry
1013 )
1014}
1015
1016fn python_env_kind_display(k: &PythonEnvironmentKind) -> &'static str {
1017 match k {
1018 PythonEnvironmentKind::Conda => "Conda",
1019 PythonEnvironmentKind::Pixi => "pixi",
1020 PythonEnvironmentKind::Homebrew => "Homebrew",
1021 PythonEnvironmentKind::Pyenv => "global (Pyenv)",
1022 PythonEnvironmentKind::GlobalPaths => "global",
1023 PythonEnvironmentKind::PyenvVirtualEnv => "Pyenv",
1024 PythonEnvironmentKind::Pipenv => "Pipenv",
1025 PythonEnvironmentKind::Poetry => "Poetry",
1026 PythonEnvironmentKind::MacPythonOrg => "global (Python.org)",
1027 PythonEnvironmentKind::MacCommandLineTools => "global (Command Line Tools for Xcode)",
1028 PythonEnvironmentKind::LinuxGlobal => "global",
1029 PythonEnvironmentKind::MacXCode => "global (Xcode)",
1030 PythonEnvironmentKind::Venv => "venv",
1031 PythonEnvironmentKind::VirtualEnv => "virtualenv",
1032 PythonEnvironmentKind::VirtualEnvWrapper => "virtualenvwrapper",
1033 PythonEnvironmentKind::WindowsStore => "global (Windows Store)",
1034 PythonEnvironmentKind::WindowsRegistry => "global (Windows Registry)",
1035 PythonEnvironmentKind::Uv => "uv",
1036 PythonEnvironmentKind::UvWorkspace => "uv (Workspace)",
1037 }
1038}
1039
1040pub(crate) struct PythonToolchainProvider;
1041
1042static ENV_PRIORITY_LIST: &[PythonEnvironmentKind] = &[
1043 // Prioritize non-Conda environments.
1044 PythonEnvironmentKind::UvWorkspace,
1045 PythonEnvironmentKind::Uv,
1046 PythonEnvironmentKind::Poetry,
1047 PythonEnvironmentKind::Pipenv,
1048 PythonEnvironmentKind::VirtualEnvWrapper,
1049 PythonEnvironmentKind::Venv,
1050 PythonEnvironmentKind::VirtualEnv,
1051 PythonEnvironmentKind::PyenvVirtualEnv,
1052 PythonEnvironmentKind::Pixi,
1053 PythonEnvironmentKind::Conda,
1054 PythonEnvironmentKind::Pyenv,
1055 PythonEnvironmentKind::GlobalPaths,
1056 PythonEnvironmentKind::Homebrew,
1057];
1058
1059fn env_priority(kind: Option<PythonEnvironmentKind>) -> usize {
1060 if let Some(kind) = kind {
1061 ENV_PRIORITY_LIST
1062 .iter()
1063 .position(|blessed_env| blessed_env == &kind)
1064 .unwrap_or(ENV_PRIORITY_LIST.len())
1065 } else {
1066 // Unknown toolchains are less useful than non-blessed ones.
1067 ENV_PRIORITY_LIST.len() + 1
1068 }
1069}
1070
1071/// Return the name of environment declared in <worktree-root/.venv.
1072///
1073/// https://virtualfish.readthedocs.io/en/latest/plugins.html#auto-activation-auto-activation
1074async fn get_worktree_venv_declaration(worktree_root: &Path) -> Option<String> {
1075 let file = async_fs::File::open(worktree_root.join(".venv"))
1076 .await
1077 .ok()?;
1078 let mut venv_name = String::new();
1079 smol::io::BufReader::new(file)
1080 .read_line(&mut venv_name)
1081 .await
1082 .ok()?;
1083 Some(venv_name.trim().to_string())
1084}
1085
1086fn get_venv_parent_dir(env: &PythonEnvironment) -> Option<PathBuf> {
1087 // If global, we aren't a virtual environment
1088 if let Some(kind) = env.kind
1089 && is_python_env_global(&kind)
1090 {
1091 return None;
1092 }
1093
1094 // Check to be sure we are a virtual environment using pet's most generic
1095 // virtual environment type, VirtualEnv
1096 let venv = env
1097 .executable
1098 .as_ref()
1099 .and_then(|p| p.parent())
1100 .and_then(|p| p.parent())
1101 .filter(|p| is_virtualenv_dir(p))?;
1102
1103 venv.parent().map(|parent| parent.to_path_buf())
1104}
1105
1106// How far is this venv from the root of our current project?
1107#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
1108enum SubprojectDistance {
1109 WithinSubproject(Reverse<usize>),
1110 WithinWorktree(Reverse<usize>),
1111 NotInWorktree,
1112}
1113
1114fn wr_distance(
1115 wr: &PathBuf,
1116 subroot_relative_path: &RelPath,
1117 venv: Option<&PathBuf>,
1118) -> SubprojectDistance {
1119 if let Some(venv) = venv
1120 && let Ok(p) = venv.strip_prefix(wr)
1121 {
1122 if subroot_relative_path.components().next().is_some()
1123 && let Ok(distance) = p
1124 .strip_prefix(subroot_relative_path.as_std_path())
1125 .map(|p| p.components().count())
1126 {
1127 SubprojectDistance::WithinSubproject(Reverse(distance))
1128 } else {
1129 SubprojectDistance::WithinWorktree(Reverse(p.components().count()))
1130 }
1131 } else {
1132 SubprojectDistance::NotInWorktree
1133 }
1134}
1135
1136fn micromamba_shell_name(kind: ShellKind) -> &'static str {
1137 match kind {
1138 ShellKind::Csh => "csh",
1139 ShellKind::Fish => "fish",
1140 ShellKind::Nushell => "nu",
1141 ShellKind::PowerShell => "powershell",
1142 ShellKind::Cmd => "cmd.exe",
1143 // default / catch-all:
1144 _ => "posix",
1145 }
1146}
1147
1148#[async_trait]
1149impl ToolchainLister for PythonToolchainProvider {
1150 async fn list(
1151 &self,
1152 worktree_root: PathBuf,
1153 subroot_relative_path: Arc<RelPath>,
1154 project_env: Option<HashMap<String, String>>,
1155 fs: &dyn Fs,
1156 ) -> ToolchainList {
1157 let env = project_env.unwrap_or_default();
1158 let environment = EnvironmentApi::from_env(&env);
1159 let locators = pet::locators::create_locators(
1160 Arc::new(pet_conda::Conda::from(&environment)),
1161 Arc::new(pet_poetry::Poetry::from(&environment)),
1162 &environment,
1163 );
1164 let mut config = Configuration::default();
1165
1166 // `.ancestors()` will yield at least one path, so in case of empty `subroot_relative_path`, we'll just use
1167 // worktree root as the workspace directory.
1168 config.workspace_directories = Some(
1169 subroot_relative_path
1170 .ancestors()
1171 .map(|ancestor| worktree_root.join(ancestor.as_std_path()))
1172 .collect(),
1173 );
1174 for locator in locators.iter() {
1175 locator.configure(&config);
1176 }
1177
1178 let reporter = pet_reporter::collect::create_reporter();
1179 pet::find::find_and_report_envs(&reporter, config, &locators, &environment, None);
1180
1181 let mut toolchains = reporter
1182 .environments
1183 .lock()
1184 .map_or(Vec::new(), |mut guard| std::mem::take(&mut guard));
1185
1186 let wr = worktree_root;
1187 let wr_venv = get_worktree_venv_declaration(&wr).await;
1188 // Sort detected environments by:
1189 // environment name matching activation file (<workdir>/.venv)
1190 // environment project dir matching worktree_root
1191 // general env priority
1192 // environment path matching the CONDA_PREFIX env var
1193 // executable path
1194 toolchains.sort_by(|lhs, rhs| {
1195 // Compare venv names against worktree .venv file
1196 let venv_ordering =
1197 wr_venv
1198 .as_ref()
1199 .map_or(Ordering::Equal, |venv| match (&lhs.name, &rhs.name) {
1200 (Some(l), Some(r)) => (r == venv).cmp(&(l == venv)),
1201 (Some(l), None) if l == venv => Ordering::Less,
1202 (None, Some(r)) if r == venv => Ordering::Greater,
1203 _ => Ordering::Equal,
1204 });
1205
1206 // Compare project paths against worktree root
1207 let proj_ordering =
1208 || {
1209 let lhs_project = lhs.project.clone().or_else(|| get_venv_parent_dir(lhs));
1210 let rhs_project = rhs.project.clone().or_else(|| get_venv_parent_dir(rhs));
1211 wr_distance(&wr, &subroot_relative_path, lhs_project.as_ref()).cmp(
1212 &wr_distance(&wr, &subroot_relative_path, rhs_project.as_ref()),
1213 )
1214 };
1215
1216 // Compare environment priorities
1217 let priority_ordering = || env_priority(lhs.kind).cmp(&env_priority(rhs.kind));
1218
1219 // Compare conda prefixes
1220 let conda_ordering = || {
1221 if lhs.kind == Some(PythonEnvironmentKind::Conda) {
1222 environment
1223 .get_env_var("CONDA_PREFIX".to_string())
1224 .map(|conda_prefix| {
1225 let is_match = |exe: &Option<PathBuf>| {
1226 exe.as_ref().is_some_and(|e| e.starts_with(&conda_prefix))
1227 };
1228 match (is_match(&lhs.executable), is_match(&rhs.executable)) {
1229 (true, false) => Ordering::Less,
1230 (false, true) => Ordering::Greater,
1231 _ => Ordering::Equal,
1232 }
1233 })
1234 .unwrap_or(Ordering::Equal)
1235 } else {
1236 Ordering::Equal
1237 }
1238 };
1239
1240 // Compare Python executables
1241 let exe_ordering = || lhs.executable.cmp(&rhs.executable);
1242
1243 venv_ordering
1244 .then_with(proj_ordering)
1245 .then_with(priority_ordering)
1246 .then_with(conda_ordering)
1247 .then_with(exe_ordering)
1248 });
1249
1250 let mut out_toolchains = Vec::new();
1251 for toolchain in toolchains {
1252 let Some(toolchain) = venv_to_toolchain(toolchain, fs).await else {
1253 continue;
1254 };
1255 out_toolchains.push(toolchain);
1256 }
1257 out_toolchains.dedup();
1258 ToolchainList {
1259 toolchains: out_toolchains,
1260 default: None,
1261 groups: Default::default(),
1262 }
1263 }
1264 fn meta(&self) -> ToolchainMetadata {
1265 ToolchainMetadata {
1266 term: SharedString::new_static("Virtual Environment"),
1267 new_toolchain_placeholder: SharedString::new_static(
1268 "A path to the python3 executable within a virtual environment, or path to virtual environment itself",
1269 ),
1270 manifest_name: ManifestName::from(SharedString::new_static("pyproject.toml")),
1271 }
1272 }
1273
1274 async fn resolve(
1275 &self,
1276 path: PathBuf,
1277 env: Option<HashMap<String, String>>,
1278 fs: &dyn Fs,
1279 ) -> anyhow::Result<Toolchain> {
1280 let env = env.unwrap_or_default();
1281 let environment = EnvironmentApi::from_env(&env);
1282 let locators = pet::locators::create_locators(
1283 Arc::new(pet_conda::Conda::from(&environment)),
1284 Arc::new(pet_poetry::Poetry::from(&environment)),
1285 &environment,
1286 );
1287 let toolchain = pet::resolve::resolve_environment(&path, &locators, &environment)
1288 .context("Could not find a virtual environment in provided path")?;
1289 let venv = toolchain.resolved.unwrap_or(toolchain.discovered);
1290 venv_to_toolchain(venv, fs)
1291 .await
1292 .context("Could not convert a venv into a toolchain")
1293 }
1294
1295 fn activation_script(&self, toolchain: &Toolchain, shell: ShellKind, cx: &App) -> Vec<String> {
1296 let Ok(toolchain) =
1297 serde_json::from_value::<PythonToolchainData>(toolchain.as_json.clone())
1298 else {
1299 return vec![];
1300 };
1301
1302 log::debug!("(Python) Composing activation script for toolchain {toolchain:?}");
1303
1304 let mut activation_script = vec![];
1305
1306 match toolchain.environment.kind {
1307 Some(PythonEnvironmentKind::Conda) => {
1308 let settings = TerminalSettings::get_global(cx);
1309 let conda_manager = settings
1310 .detect_venv
1311 .as_option()
1312 .map(|venv| venv.conda_manager)
1313 .unwrap_or(settings::CondaManager::Auto);
1314 let manager = match conda_manager {
1315 settings::CondaManager::Conda => "conda",
1316 settings::CondaManager::Mamba => "mamba",
1317 settings::CondaManager::Micromamba => "micromamba",
1318 settings::CondaManager::Auto => toolchain
1319 .environment
1320 .manager
1321 .as_ref()
1322 .and_then(|m| m.executable.file_name())
1323 .and_then(|name| name.to_str())
1324 .filter(|name| matches!(*name, "conda" | "mamba" | "micromamba"))
1325 .unwrap_or("conda"),
1326 };
1327
1328 // Activate micromamba shell in the child shell
1329 // [required for micromamba]
1330 if manager == "micromamba" {
1331 let shell = micromamba_shell_name(shell);
1332 activation_script
1333 .push(format!(r#"eval "$({manager} shell hook --shell {shell})""#));
1334 }
1335
1336 if let Some(name) = &toolchain.environment.name {
1337 activation_script.push(format!("{manager} activate {name}"));
1338 } else {
1339 activation_script.push(format!("{manager} activate base"));
1340 }
1341 }
1342 Some(PythonEnvironmentKind::Venv | PythonEnvironmentKind::VirtualEnv) => {
1343 if let Some(activation_scripts) = &toolchain.activation_scripts {
1344 if let Some(activate_script_path) = activation_scripts.get(&shell) {
1345 let activate_keyword = shell.activate_keyword();
1346 if let Some(quoted) =
1347 shell.try_quote(&activate_script_path.to_string_lossy())
1348 {
1349 activation_script.push(format!("{activate_keyword} {quoted}"));
1350 }
1351 }
1352 }
1353 }
1354 Some(PythonEnvironmentKind::Pyenv) => {
1355 let Some(manager) = &toolchain.environment.manager else {
1356 return vec![];
1357 };
1358 let version = toolchain.environment.version.as_deref().unwrap_or("system");
1359 let pyenv = &manager.executable;
1360 let pyenv = pyenv.display();
1361 activation_script.extend(match shell {
1362 ShellKind::Fish => Some(format!("\"{pyenv}\" shell - fish {version}")),
1363 ShellKind::Posix => Some(format!("\"{pyenv}\" shell - sh {version}")),
1364 ShellKind::Nushell => Some(format!("^\"{pyenv}\" shell - nu {version}")),
1365 ShellKind::PowerShell | ShellKind::Pwsh => None,
1366 ShellKind::Csh => None,
1367 ShellKind::Tcsh => None,
1368 ShellKind::Cmd => None,
1369 ShellKind::Rc => None,
1370 ShellKind::Xonsh => None,
1371 ShellKind::Elvish => None,
1372 })
1373 }
1374 _ => {}
1375 }
1376 activation_script
1377 }
1378}
1379
1380async fn venv_to_toolchain(venv: PythonEnvironment, fs: &dyn Fs) -> Option<Toolchain> {
1381 let mut name = String::from("Python");
1382 if let Some(ref version) = venv.version {
1383 _ = write!(name, " {version}");
1384 }
1385
1386 let name_and_kind = match (&venv.name, &venv.kind) {
1387 (Some(name), Some(kind)) => Some(format!("({name}; {})", python_env_kind_display(kind))),
1388 (Some(name), None) => Some(format!("({name})")),
1389 (None, Some(kind)) => Some(format!("({})", python_env_kind_display(kind))),
1390 (None, None) => None,
1391 };
1392
1393 if let Some(nk) = name_and_kind {
1394 _ = write!(name, " {nk}");
1395 }
1396
1397 let mut activation_scripts = HashMap::default();
1398 match venv.kind {
1399 Some(PythonEnvironmentKind::Venv | PythonEnvironmentKind::VirtualEnv) => {
1400 resolve_venv_activation_scripts(&venv, fs, &mut activation_scripts).await
1401 }
1402 _ => {}
1403 }
1404 let data = PythonToolchainData {
1405 environment: venv,
1406 activation_scripts: Some(activation_scripts),
1407 };
1408
1409 Some(Toolchain {
1410 name: name.into(),
1411 path: data
1412 .environment
1413 .executable
1414 .as_ref()?
1415 .to_str()?
1416 .to_owned()
1417 .into(),
1418 language_name: LanguageName::new_static("Python"),
1419 as_json: serde_json::to_value(data).ok()?,
1420 })
1421}
1422
1423async fn resolve_venv_activation_scripts(
1424 venv: &PythonEnvironment,
1425 fs: &dyn Fs,
1426 activation_scripts: &mut HashMap<ShellKind, PathBuf>,
1427) {
1428 log::debug!("(Python) Resolving activation scripts for venv toolchain {venv:?}");
1429 if let Some(prefix) = &venv.prefix {
1430 for (shell_kind, script_name) in &[
1431 (ShellKind::Posix, "activate"),
1432 (ShellKind::Rc, "activate"),
1433 (ShellKind::Csh, "activate.csh"),
1434 (ShellKind::Tcsh, "activate.csh"),
1435 (ShellKind::Fish, "activate.fish"),
1436 (ShellKind::Nushell, "activate.nu"),
1437 (ShellKind::PowerShell, "activate.ps1"),
1438 (ShellKind::Cmd, "activate.bat"),
1439 (ShellKind::Xonsh, "activate.xsh"),
1440 ] {
1441 let path = prefix.join(BINARY_DIR).join(script_name);
1442
1443 log::debug!("Trying path: {}", path.display());
1444
1445 if fs.is_file(&path).await {
1446 activation_scripts.insert(*shell_kind, path);
1447 }
1448 }
1449 }
1450}
1451
1452pub struct EnvironmentApi<'a> {
1453 global_search_locations: Arc<Mutex<Vec<PathBuf>>>,
1454 project_env: &'a HashMap<String, String>,
1455 pet_env: pet_core::os_environment::EnvironmentApi,
1456}
1457
1458impl<'a> EnvironmentApi<'a> {
1459 pub fn from_env(project_env: &'a HashMap<String, String>) -> Self {
1460 let paths = project_env
1461 .get("PATH")
1462 .map(|p| std::env::split_paths(p).collect())
1463 .unwrap_or_default();
1464
1465 EnvironmentApi {
1466 global_search_locations: Arc::new(Mutex::new(paths)),
1467 project_env,
1468 pet_env: pet_core::os_environment::EnvironmentApi::new(),
1469 }
1470 }
1471
1472 fn user_home(&self) -> Option<PathBuf> {
1473 self.project_env
1474 .get("HOME")
1475 .or_else(|| self.project_env.get("USERPROFILE"))
1476 .map(|home| pet_fs::path::norm_case(PathBuf::from(home)))
1477 .or_else(|| self.pet_env.get_user_home())
1478 }
1479}
1480
1481impl pet_core::os_environment::Environment for EnvironmentApi<'_> {
1482 fn get_user_home(&self) -> Option<PathBuf> {
1483 self.user_home()
1484 }
1485
1486 fn get_root(&self) -> Option<PathBuf> {
1487 None
1488 }
1489
1490 fn get_env_var(&self, key: String) -> Option<String> {
1491 self.project_env
1492 .get(&key)
1493 .cloned()
1494 .or_else(|| self.pet_env.get_env_var(key))
1495 }
1496
1497 fn get_know_global_search_locations(&self) -> Vec<PathBuf> {
1498 if self.global_search_locations.lock().is_empty() {
1499 let mut paths = std::env::split_paths(
1500 &self
1501 .get_env_var("PATH".to_string())
1502 .or_else(|| self.get_env_var("Path".to_string()))
1503 .unwrap_or_default(),
1504 )
1505 .collect::<Vec<PathBuf>>();
1506
1507 log::trace!("Env PATH: {:?}", paths);
1508 for p in self.pet_env.get_know_global_search_locations() {
1509 if !paths.contains(&p) {
1510 paths.push(p);
1511 }
1512 }
1513
1514 let mut paths = paths
1515 .into_iter()
1516 .filter(|p| p.exists())
1517 .collect::<Vec<PathBuf>>();
1518
1519 self.global_search_locations.lock().append(&mut paths);
1520 }
1521 self.global_search_locations.lock().clone()
1522 }
1523}
1524
1525pub(crate) struct PyLspAdapter {
1526 python_venv_base: OnceCell<Result<Arc<Path>, String>>,
1527}
1528impl PyLspAdapter {
1529 const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("pylsp");
1530 pub(crate) fn new() -> Self {
1531 Self {
1532 python_venv_base: OnceCell::new(),
1533 }
1534 }
1535 async fn ensure_venv(delegate: &dyn LspAdapterDelegate) -> Result<Arc<Path>> {
1536 let python_path = Self::find_base_python(delegate)
1537 .await
1538 .with_context(|| {
1539 let mut message = "Could not find Python installation for PyLSP".to_owned();
1540 if cfg!(windows){
1541 message.push_str(". Install Python from the Microsoft Store, or manually from https://www.python.org/downloads/windows.")
1542 }
1543 message
1544 })?;
1545 let work_dir = delegate
1546 .language_server_download_dir(&Self::SERVER_NAME)
1547 .await
1548 .context("Could not get working directory for PyLSP")?;
1549 let mut path = PathBuf::from(work_dir.as_ref());
1550 path.push("pylsp-venv");
1551 if !path.exists() {
1552 util::command::new_smol_command(python_path)
1553 .arg("-m")
1554 .arg("venv")
1555 .arg("pylsp-venv")
1556 .current_dir(work_dir)
1557 .spawn()?
1558 .output()
1559 .await?;
1560 }
1561
1562 Ok(path.into())
1563 }
1564 // Find "baseline", user python version from which we'll create our own venv.
1565 async fn find_base_python(delegate: &dyn LspAdapterDelegate) -> Option<PathBuf> {
1566 for path in ["python3", "python"] {
1567 let Some(path) = delegate.which(path.as_ref()).await else {
1568 continue;
1569 };
1570 // Try to detect situations where `python3` exists but is not a real Python interpreter.
1571 // Notably, on fresh Windows installs, `python3` is a shim that opens the Microsoft Store app
1572 // when run with no arguments, and just fails otherwise.
1573 let Some(output) = new_smol_command(&path)
1574 .args(["-c", "print(1 + 2)"])
1575 .output()
1576 .await
1577 .ok()
1578 else {
1579 continue;
1580 };
1581 if output.stdout.trim_ascii() != b"3" {
1582 continue;
1583 }
1584 return Some(path);
1585 }
1586 None
1587 }
1588
1589 async fn base_venv(&self, delegate: &dyn LspAdapterDelegate) -> Result<Arc<Path>, String> {
1590 self.python_venv_base
1591 .get_or_init(move || async move {
1592 Self::ensure_venv(delegate)
1593 .await
1594 .map_err(|e| format!("{e}"))
1595 })
1596 .await
1597 .clone()
1598 }
1599}
1600
1601const BINARY_DIR: &str = if cfg!(target_os = "windows") {
1602 "Scripts"
1603} else {
1604 "bin"
1605};
1606
1607#[async_trait(?Send)]
1608impl LspAdapter for PyLspAdapter {
1609 fn name(&self) -> LanguageServerName {
1610 Self::SERVER_NAME
1611 }
1612
1613 async fn process_completions(&self, _items: &mut [lsp::CompletionItem]) {}
1614
1615 async fn label_for_completion(
1616 &self,
1617 item: &lsp::CompletionItem,
1618 language: &Arc<language::Language>,
1619 ) -> Option<language::CodeLabel> {
1620 let label = &item.label;
1621 let label_len = label.len();
1622 let grammar = language.grammar()?;
1623 let highlight_id = match item.kind? {
1624 lsp::CompletionItemKind::METHOD => grammar.highlight_id_for_name("function.method")?,
1625 lsp::CompletionItemKind::FUNCTION => grammar.highlight_id_for_name("function")?,
1626 lsp::CompletionItemKind::CLASS => grammar.highlight_id_for_name("type")?,
1627 lsp::CompletionItemKind::CONSTANT => grammar.highlight_id_for_name("constant")?,
1628 _ => return None,
1629 };
1630 Some(language::CodeLabel::filtered(
1631 label.clone(),
1632 label_len,
1633 item.filter_text.as_deref(),
1634 vec![(0..label.len(), highlight_id)],
1635 ))
1636 }
1637
1638 async fn label_for_symbol(
1639 &self,
1640 name: &str,
1641 kind: lsp::SymbolKind,
1642 language: &Arc<language::Language>,
1643 ) -> Option<language::CodeLabel> {
1644 let (text, filter_range, display_range) = match kind {
1645 lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => {
1646 let text = format!("def {}():\n", name);
1647 let filter_range = 4..4 + name.len();
1648 let display_range = 0..filter_range.end;
1649 (text, filter_range, display_range)
1650 }
1651 lsp::SymbolKind::CLASS => {
1652 let text = format!("class {}:", name);
1653 let filter_range = 6..6 + name.len();
1654 let display_range = 0..filter_range.end;
1655 (text, filter_range, display_range)
1656 }
1657 lsp::SymbolKind::CONSTANT => {
1658 let text = format!("{} = 0", name);
1659 let filter_range = 0..name.len();
1660 let display_range = 0..filter_range.end;
1661 (text, filter_range, display_range)
1662 }
1663 _ => return None,
1664 };
1665 Some(language::CodeLabel::new(
1666 text[display_range.clone()].to_string(),
1667 filter_range,
1668 language.highlight_text(&text.as_str().into(), display_range),
1669 ))
1670 }
1671
1672 async fn workspace_configuration(
1673 self: Arc<Self>,
1674 adapter: &Arc<dyn LspAdapterDelegate>,
1675 toolchain: Option<Toolchain>,
1676 _: Option<Uri>,
1677 cx: &mut AsyncApp,
1678 ) -> Result<Value> {
1679 cx.update(move |cx| {
1680 let mut user_settings =
1681 language_server_settings(adapter.as_ref(), &Self::SERVER_NAME, cx)
1682 .and_then(|s| s.settings.clone())
1683 .unwrap_or_else(|| {
1684 json!({
1685 "plugins": {
1686 "pycodestyle": {"enabled": false},
1687 "rope_autoimport": {"enabled": true, "memory": true},
1688 "pylsp_mypy": {"enabled": false}
1689 },
1690 "rope": {
1691 "ropeFolder": null
1692 },
1693 })
1694 });
1695
1696 // If user did not explicitly modify their python venv, use one from picker.
1697 if let Some(toolchain) = toolchain {
1698 if !user_settings.is_object() {
1699 user_settings = Value::Object(serde_json::Map::default());
1700 }
1701 let object = user_settings.as_object_mut().unwrap();
1702 if let Some(python) = object
1703 .entry("plugins")
1704 .or_insert(Value::Object(serde_json::Map::default()))
1705 .as_object_mut()
1706 {
1707 if let Some(jedi) = python
1708 .entry("jedi")
1709 .or_insert(Value::Object(serde_json::Map::default()))
1710 .as_object_mut()
1711 {
1712 jedi.entry("environment".to_string())
1713 .or_insert_with(|| Value::String(toolchain.path.clone().into()));
1714 }
1715 if let Some(pylint) = python
1716 .entry("pylsp_mypy")
1717 .or_insert(Value::Object(serde_json::Map::default()))
1718 .as_object_mut()
1719 {
1720 pylint.entry("overrides".to_string()).or_insert_with(|| {
1721 Value::Array(vec![
1722 Value::String("--python-executable".into()),
1723 Value::String(toolchain.path.into()),
1724 Value::String("--cache-dir=/dev/null".into()),
1725 Value::Bool(true),
1726 ])
1727 });
1728 }
1729 }
1730 }
1731 user_settings = Value::Object(serde_json::Map::from_iter([(
1732 "pylsp".to_string(),
1733 user_settings,
1734 )]));
1735
1736 user_settings
1737 })
1738 }
1739}
1740
1741impl LspInstaller for PyLspAdapter {
1742 type BinaryVersion = ();
1743 async fn check_if_user_installed(
1744 &self,
1745 delegate: &dyn LspAdapterDelegate,
1746 toolchain: Option<Toolchain>,
1747 _: &AsyncApp,
1748 ) -> Option<LanguageServerBinary> {
1749 if let Some(pylsp_bin) = delegate.which(Self::SERVER_NAME.as_ref()).await {
1750 let env = delegate.shell_env().await;
1751 Some(LanguageServerBinary {
1752 path: pylsp_bin,
1753 env: Some(env),
1754 arguments: vec![],
1755 })
1756 } else {
1757 let toolchain = toolchain?;
1758 let pylsp_path = Path::new(toolchain.path.as_ref()).parent()?.join("pylsp");
1759 pylsp_path.exists().then(|| LanguageServerBinary {
1760 path: toolchain.path.to_string().into(),
1761 arguments: vec![pylsp_path.into()],
1762 env: None,
1763 })
1764 }
1765 }
1766
1767 async fn fetch_latest_server_version(
1768 &self,
1769 _: &dyn LspAdapterDelegate,
1770 _: bool,
1771 _: &mut AsyncApp,
1772 ) -> Result<()> {
1773 Ok(())
1774 }
1775
1776 async fn fetch_server_binary(
1777 &self,
1778 _: (),
1779 _: PathBuf,
1780 delegate: &dyn LspAdapterDelegate,
1781 ) -> Result<LanguageServerBinary> {
1782 let venv = self.base_venv(delegate).await.map_err(|e| anyhow!(e))?;
1783 let pip_path = venv.join(BINARY_DIR).join("pip3");
1784 ensure!(
1785 util::command::new_smol_command(pip_path.as_path())
1786 .arg("install")
1787 .arg("python-lsp-server[all]")
1788 .arg("--upgrade")
1789 .output()
1790 .await?
1791 .status
1792 .success(),
1793 "python-lsp-server[all] installation failed"
1794 );
1795 ensure!(
1796 util::command::new_smol_command(pip_path)
1797 .arg("install")
1798 .arg("pylsp-mypy")
1799 .arg("--upgrade")
1800 .output()
1801 .await?
1802 .status
1803 .success(),
1804 "pylsp-mypy installation failed"
1805 );
1806 let pylsp = venv.join(BINARY_DIR).join("pylsp");
1807 ensure!(
1808 delegate.which(pylsp.as_os_str()).await.is_some(),
1809 "pylsp installation was incomplete"
1810 );
1811 Ok(LanguageServerBinary {
1812 path: pylsp,
1813 env: None,
1814 arguments: vec![],
1815 })
1816 }
1817
1818 async fn cached_server_binary(
1819 &self,
1820 _: PathBuf,
1821 delegate: &dyn LspAdapterDelegate,
1822 ) -> Option<LanguageServerBinary> {
1823 let venv = self.base_venv(delegate).await.ok()?;
1824 let pylsp = venv.join(BINARY_DIR).join("pylsp");
1825 delegate.which(pylsp.as_os_str()).await?;
1826 Some(LanguageServerBinary {
1827 path: pylsp,
1828 env: None,
1829 arguments: vec![],
1830 })
1831 }
1832}
1833
1834pub(crate) struct BasedPyrightLspAdapter {
1835 node: NodeRuntime,
1836}
1837
1838impl BasedPyrightLspAdapter {
1839 const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("basedpyright");
1840 const BINARY_NAME: &'static str = "basedpyright-langserver";
1841 const SERVER_PATH: &str = "node_modules/basedpyright/langserver.index.js";
1842 const NODE_MODULE_RELATIVE_SERVER_PATH: &str = "basedpyright/langserver.index.js";
1843
1844 pub(crate) fn new(node: NodeRuntime) -> Self {
1845 BasedPyrightLspAdapter { node }
1846 }
1847
1848 async fn get_cached_server_binary(
1849 container_dir: PathBuf,
1850 node: &NodeRuntime,
1851 ) -> Option<LanguageServerBinary> {
1852 let server_path = container_dir.join(Self::SERVER_PATH);
1853 if server_path.exists() {
1854 Some(LanguageServerBinary {
1855 path: node.binary_path().await.log_err()?,
1856 env: None,
1857 arguments: vec![server_path.into(), "--stdio".into()],
1858 })
1859 } else {
1860 log::error!("missing executable in directory {:?}", server_path);
1861 None
1862 }
1863 }
1864}
1865
1866#[async_trait(?Send)]
1867impl LspAdapter for BasedPyrightLspAdapter {
1868 fn name(&self) -> LanguageServerName {
1869 Self::SERVER_NAME
1870 }
1871
1872 async fn initialization_options(
1873 self: Arc<Self>,
1874 _: &Arc<dyn LspAdapterDelegate>,
1875 ) -> Result<Option<Value>> {
1876 // Provide minimal initialization options
1877 // Virtual environment configuration will be handled through workspace configuration
1878 Ok(Some(json!({
1879 "python": {
1880 "analysis": {
1881 "autoSearchPaths": true,
1882 "useLibraryCodeForTypes": true,
1883 "autoImportCompletions": true
1884 }
1885 }
1886 })))
1887 }
1888
1889 async fn process_completions(&self, items: &mut [lsp::CompletionItem]) {
1890 process_pyright_completions(items);
1891 }
1892
1893 async fn label_for_completion(
1894 &self,
1895 item: &lsp::CompletionItem,
1896 language: &Arc<language::Language>,
1897 ) -> Option<language::CodeLabel> {
1898 let label = &item.label;
1899 let label_len = label.len();
1900 let grammar = language.grammar()?;
1901 let highlight_id = match item.kind? {
1902 lsp::CompletionItemKind::METHOD => grammar.highlight_id_for_name("function.method"),
1903 lsp::CompletionItemKind::FUNCTION => grammar.highlight_id_for_name("function"),
1904 lsp::CompletionItemKind::CLASS => grammar.highlight_id_for_name("type"),
1905 lsp::CompletionItemKind::CONSTANT => grammar.highlight_id_for_name("constant"),
1906 lsp::CompletionItemKind::VARIABLE => grammar.highlight_id_for_name("variable"),
1907 _ => {
1908 return None;
1909 }
1910 };
1911 let mut text = label.clone();
1912 if let Some(completion_details) = item
1913 .label_details
1914 .as_ref()
1915 .and_then(|details| details.description.as_ref())
1916 {
1917 write!(&mut text, " {}", completion_details).ok();
1918 }
1919 Some(language::CodeLabel::filtered(
1920 text,
1921 label_len,
1922 item.filter_text.as_deref(),
1923 highlight_id
1924 .map(|id| (0..label.len(), id))
1925 .into_iter()
1926 .collect(),
1927 ))
1928 }
1929
1930 async fn label_for_symbol(
1931 &self,
1932 name: &str,
1933 kind: lsp::SymbolKind,
1934 language: &Arc<language::Language>,
1935 ) -> Option<language::CodeLabel> {
1936 let (text, filter_range, display_range) = match kind {
1937 lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => {
1938 let text = format!("def {}():\n", name);
1939 let filter_range = 4..4 + name.len();
1940 let display_range = 0..filter_range.end;
1941 (text, filter_range, display_range)
1942 }
1943 lsp::SymbolKind::CLASS => {
1944 let text = format!("class {}:", name);
1945 let filter_range = 6..6 + name.len();
1946 let display_range = 0..filter_range.end;
1947 (text, filter_range, display_range)
1948 }
1949 lsp::SymbolKind::CONSTANT => {
1950 let text = format!("{} = 0", name);
1951 let filter_range = 0..name.len();
1952 let display_range = 0..filter_range.end;
1953 (text, filter_range, display_range)
1954 }
1955 _ => return None,
1956 };
1957 Some(language::CodeLabel::new(
1958 text[display_range.clone()].to_string(),
1959 filter_range,
1960 language.highlight_text(&text.as_str().into(), display_range),
1961 ))
1962 }
1963
1964 async fn workspace_configuration(
1965 self: Arc<Self>,
1966 adapter: &Arc<dyn LspAdapterDelegate>,
1967 toolchain: Option<Toolchain>,
1968 _: Option<Uri>,
1969 cx: &mut AsyncApp,
1970 ) -> Result<Value> {
1971 cx.update(move |cx| {
1972 let mut user_settings =
1973 language_server_settings(adapter.as_ref(), &Self::SERVER_NAME, cx)
1974 .and_then(|s| s.settings.clone())
1975 .unwrap_or_default();
1976
1977 // If we have a detected toolchain, configure Pyright to use it
1978 if let Some(toolchain) = toolchain
1979 && let Ok(env) = serde_json::from_value::<
1980 pet_core::python_environment::PythonEnvironment,
1981 >(toolchain.as_json.clone())
1982 {
1983 if !user_settings.is_object() {
1984 user_settings = Value::Object(serde_json::Map::default());
1985 }
1986 let object = user_settings.as_object_mut().unwrap();
1987
1988 let interpreter_path = toolchain.path.to_string();
1989 if let Some(venv_dir) = env.prefix {
1990 // Set venvPath and venv at the root level
1991 // This matches the format of a pyrightconfig.json file
1992 if let Some(parent) = venv_dir.parent() {
1993 // Use relative path if the venv is inside the workspace
1994 let venv_path = if parent == adapter.worktree_root_path() {
1995 ".".to_string()
1996 } else {
1997 parent.to_string_lossy().into_owned()
1998 };
1999 object.insert("venvPath".to_string(), Value::String(venv_path));
2000 }
2001
2002 if let Some(venv_name) = venv_dir.file_name() {
2003 object.insert(
2004 "venv".to_owned(),
2005 Value::String(venv_name.to_string_lossy().into_owned()),
2006 );
2007 }
2008 }
2009
2010 // Set both pythonPath and defaultInterpreterPath for compatibility
2011 if let Some(python) = object
2012 .entry("python")
2013 .or_insert(Value::Object(serde_json::Map::default()))
2014 .as_object_mut()
2015 {
2016 python.insert(
2017 "pythonPath".to_owned(),
2018 Value::String(interpreter_path.clone()),
2019 );
2020 python.insert(
2021 "defaultInterpreterPath".to_owned(),
2022 Value::String(interpreter_path),
2023 );
2024 }
2025 // Basedpyright by default uses `strict` type checking, we tone it down as to not surpris users
2026 maybe!({
2027 let analysis = object
2028 .entry("basedpyright.analysis")
2029 .or_insert(Value::Object(serde_json::Map::default()));
2030 if let serde_json::map::Entry::Vacant(v) =
2031 analysis.as_object_mut()?.entry("typeCheckingMode")
2032 {
2033 v.insert(Value::String("standard".to_owned()));
2034 }
2035 Some(())
2036 });
2037 }
2038
2039 user_settings
2040 })
2041 }
2042}
2043
2044impl LspInstaller for BasedPyrightLspAdapter {
2045 type BinaryVersion = Version;
2046
2047 async fn fetch_latest_server_version(
2048 &self,
2049 _: &dyn LspAdapterDelegate,
2050 _: bool,
2051 _: &mut AsyncApp,
2052 ) -> Result<Self::BinaryVersion> {
2053 self.node
2054 .npm_package_latest_version(Self::SERVER_NAME.as_ref())
2055 .await
2056 }
2057
2058 async fn check_if_user_installed(
2059 &self,
2060 delegate: &dyn LspAdapterDelegate,
2061 _: Option<Toolchain>,
2062 _: &AsyncApp,
2063 ) -> Option<LanguageServerBinary> {
2064 if let Some(path) = delegate.which(Self::BINARY_NAME.as_ref()).await {
2065 let env = delegate.shell_env().await;
2066 Some(LanguageServerBinary {
2067 path,
2068 env: Some(env),
2069 arguments: vec!["--stdio".into()],
2070 })
2071 } else {
2072 // TODO shouldn't this be self.node.binary_path()?
2073 let node = delegate.which("node".as_ref()).await?;
2074 let (node_modules_path, _) = delegate
2075 .npm_package_installed_version(Self::SERVER_NAME.as_ref())
2076 .await
2077 .log_err()??;
2078
2079 let path = node_modules_path.join(Self::NODE_MODULE_RELATIVE_SERVER_PATH);
2080
2081 let env = delegate.shell_env().await;
2082 Some(LanguageServerBinary {
2083 path: node,
2084 env: Some(env),
2085 arguments: vec![path.into(), "--stdio".into()],
2086 })
2087 }
2088 }
2089
2090 async fn fetch_server_binary(
2091 &self,
2092 latest_version: Self::BinaryVersion,
2093 container_dir: PathBuf,
2094 delegate: &dyn LspAdapterDelegate,
2095 ) -> Result<LanguageServerBinary> {
2096 let server_path = container_dir.join(Self::SERVER_PATH);
2097 let latest_version = latest_version.to_string();
2098
2099 self.node
2100 .npm_install_packages(
2101 &container_dir,
2102 &[(Self::SERVER_NAME.as_ref(), latest_version.as_str())],
2103 )
2104 .await?;
2105
2106 let env = delegate.shell_env().await;
2107 Ok(LanguageServerBinary {
2108 path: self.node.binary_path().await?,
2109 env: Some(env),
2110 arguments: vec![server_path.into(), "--stdio".into()],
2111 })
2112 }
2113
2114 async fn check_if_version_installed(
2115 &self,
2116 version: &Self::BinaryVersion,
2117 container_dir: &PathBuf,
2118 delegate: &dyn LspAdapterDelegate,
2119 ) -> Option<LanguageServerBinary> {
2120 let server_path = container_dir.join(Self::SERVER_PATH);
2121
2122 let should_install_language_server = self
2123 .node
2124 .should_install_npm_package(
2125 Self::SERVER_NAME.as_ref(),
2126 &server_path,
2127 container_dir,
2128 VersionStrategy::Latest(version),
2129 )
2130 .await;
2131
2132 if should_install_language_server {
2133 None
2134 } else {
2135 let env = delegate.shell_env().await;
2136 Some(LanguageServerBinary {
2137 path: self.node.binary_path().await.ok()?,
2138 env: Some(env),
2139 arguments: vec![server_path.into(), "--stdio".into()],
2140 })
2141 }
2142 }
2143
2144 async fn cached_server_binary(
2145 &self,
2146 container_dir: PathBuf,
2147 delegate: &dyn LspAdapterDelegate,
2148 ) -> Option<LanguageServerBinary> {
2149 let mut binary = Self::get_cached_server_binary(container_dir, &self.node).await?;
2150 binary.env = Some(delegate.shell_env().await);
2151 Some(binary)
2152 }
2153}
2154
2155pub(crate) struct RuffLspAdapter {
2156 fs: Arc<dyn Fs>,
2157}
2158
2159#[cfg(target_os = "macos")]
2160impl RuffLspAdapter {
2161 const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
2162 const ARCH_SERVER_NAME: &str = "apple-darwin";
2163}
2164
2165#[cfg(target_os = "linux")]
2166impl RuffLspAdapter {
2167 const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
2168 const ARCH_SERVER_NAME: &str = "unknown-linux-gnu";
2169}
2170
2171#[cfg(target_os = "freebsd")]
2172impl RuffLspAdapter {
2173 const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
2174 const ARCH_SERVER_NAME: &str = "unknown-freebsd";
2175}
2176
2177#[cfg(target_os = "windows")]
2178impl RuffLspAdapter {
2179 const GITHUB_ASSET_KIND: AssetKind = AssetKind::Zip;
2180 const ARCH_SERVER_NAME: &str = "pc-windows-msvc";
2181}
2182
2183impl RuffLspAdapter {
2184 const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("ruff");
2185
2186 pub fn new(fs: Arc<dyn Fs>) -> RuffLspAdapter {
2187 RuffLspAdapter { fs }
2188 }
2189
2190 fn build_asset_name() -> Result<(String, String)> {
2191 let arch = match consts::ARCH {
2192 "x86" => "i686",
2193 _ => consts::ARCH,
2194 };
2195 let os = Self::ARCH_SERVER_NAME;
2196 let suffix = match consts::OS {
2197 "windows" => "zip",
2198 _ => "tar.gz",
2199 };
2200 let asset_name = format!("ruff-{arch}-{os}.{suffix}");
2201 let asset_stem = format!("ruff-{arch}-{os}");
2202 Ok((asset_stem, asset_name))
2203 }
2204}
2205
2206#[async_trait(?Send)]
2207impl LspAdapter for RuffLspAdapter {
2208 fn name(&self) -> LanguageServerName {
2209 Self::SERVER_NAME
2210 }
2211}
2212
2213impl LspInstaller for RuffLspAdapter {
2214 type BinaryVersion = GitHubLspBinaryVersion;
2215 async fn check_if_user_installed(
2216 &self,
2217 delegate: &dyn LspAdapterDelegate,
2218 toolchain: Option<Toolchain>,
2219 _: &AsyncApp,
2220 ) -> Option<LanguageServerBinary> {
2221 let ruff_in_venv = if let Some(toolchain) = toolchain
2222 && toolchain.language_name.as_ref() == "Python"
2223 {
2224 Path::new(toolchain.path.as_str())
2225 .parent()
2226 .map(|path| path.join("ruff"))
2227 } else {
2228 None
2229 };
2230
2231 for path in ruff_in_venv.into_iter().chain(["ruff".into()]) {
2232 if let Some(ruff_bin) = delegate.which(path.as_os_str()).await {
2233 let env = delegate.shell_env().await;
2234 return Some(LanguageServerBinary {
2235 path: ruff_bin,
2236 env: Some(env),
2237 arguments: vec!["server".into()],
2238 });
2239 }
2240 }
2241
2242 None
2243 }
2244
2245 async fn fetch_latest_server_version(
2246 &self,
2247 delegate: &dyn LspAdapterDelegate,
2248 _: bool,
2249 _: &mut AsyncApp,
2250 ) -> Result<GitHubLspBinaryVersion> {
2251 let release =
2252 latest_github_release("astral-sh/ruff", true, false, delegate.http_client()).await?;
2253 let (_, asset_name) = Self::build_asset_name()?;
2254 let asset = release
2255 .assets
2256 .into_iter()
2257 .find(|asset| asset.name == asset_name)
2258 .with_context(|| format!("no asset found matching `{asset_name:?}`"))?;
2259 Ok(GitHubLspBinaryVersion {
2260 name: release.tag_name,
2261 url: asset.browser_download_url,
2262 digest: asset.digest,
2263 })
2264 }
2265
2266 async fn fetch_server_binary(
2267 &self,
2268 latest_version: GitHubLspBinaryVersion,
2269 container_dir: PathBuf,
2270 delegate: &dyn LspAdapterDelegate,
2271 ) -> Result<LanguageServerBinary> {
2272 let GitHubLspBinaryVersion {
2273 name,
2274 url,
2275 digest: expected_digest,
2276 } = latest_version;
2277 let destination_path = container_dir.join(format!("ruff-{name}"));
2278 let server_path = match Self::GITHUB_ASSET_KIND {
2279 AssetKind::TarGz | AssetKind::Gz => destination_path
2280 .join(Self::build_asset_name()?.0)
2281 .join("ruff"),
2282 AssetKind::Zip => destination_path.clone().join("ruff.exe"),
2283 };
2284
2285 let binary = LanguageServerBinary {
2286 path: server_path.clone(),
2287 env: None,
2288 arguments: vec!["server".into()],
2289 };
2290
2291 let metadata_path = destination_path.with_extension("metadata");
2292 let metadata = GithubBinaryMetadata::read_from_file(&metadata_path)
2293 .await
2294 .ok();
2295 if let Some(metadata) = metadata {
2296 let validity_check = async || {
2297 delegate
2298 .try_exec(LanguageServerBinary {
2299 path: server_path.clone(),
2300 arguments: vec!["--version".into()],
2301 env: None,
2302 })
2303 .await
2304 .inspect_err(|err| {
2305 log::warn!("Unable to run {server_path:?} asset, redownloading: {err:#}",)
2306 })
2307 };
2308 if let (Some(actual_digest), Some(expected_digest)) =
2309 (&metadata.digest, &expected_digest)
2310 {
2311 if actual_digest == expected_digest {
2312 if validity_check().await.is_ok() {
2313 return Ok(binary);
2314 }
2315 } else {
2316 log::info!(
2317 "SHA-256 mismatch for {destination_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}"
2318 );
2319 }
2320 } else if validity_check().await.is_ok() {
2321 return Ok(binary);
2322 }
2323 }
2324
2325 download_server_binary(
2326 &*delegate.http_client(),
2327 &url,
2328 expected_digest.as_deref(),
2329 &destination_path,
2330 Self::GITHUB_ASSET_KIND,
2331 )
2332 .await?;
2333 make_file_executable(&server_path).await?;
2334 remove_matching(&container_dir, |path| path != destination_path).await;
2335 GithubBinaryMetadata::write_to_file(
2336 &GithubBinaryMetadata {
2337 metadata_version: 1,
2338 digest: expected_digest,
2339 },
2340 &metadata_path,
2341 )
2342 .await?;
2343
2344 Ok(LanguageServerBinary {
2345 path: server_path,
2346 env: None,
2347 arguments: vec!["server".into()],
2348 })
2349 }
2350
2351 async fn cached_server_binary(
2352 &self,
2353 container_dir: PathBuf,
2354 _: &dyn LspAdapterDelegate,
2355 ) -> Option<LanguageServerBinary> {
2356 maybe!(async {
2357 let mut last = None;
2358 let mut entries = self.fs.read_dir(&container_dir).await?;
2359 while let Some(entry) = entries.next().await {
2360 let path = entry?;
2361 if path.extension().is_some_and(|ext| ext == "metadata") {
2362 continue;
2363 }
2364 last = Some(path);
2365 }
2366
2367 let path = last.context("no cached binary")?;
2368 let path = match Self::GITHUB_ASSET_KIND {
2369 AssetKind::TarGz | AssetKind::Gz => {
2370 path.join(Self::build_asset_name()?.0).join("ruff")
2371 }
2372 AssetKind::Zip => path.join("ruff.exe"),
2373 };
2374
2375 anyhow::Ok(LanguageServerBinary {
2376 path,
2377 env: None,
2378 arguments: vec!["server".into()],
2379 })
2380 })
2381 .await
2382 .log_err()
2383 }
2384}
2385
2386#[cfg(test)]
2387mod tests {
2388 use gpui::{AppContext as _, BorrowAppContext, Context, TestAppContext};
2389 use language::{AutoindentMode, Buffer};
2390 use settings::SettingsStore;
2391 use std::num::NonZeroU32;
2392
2393 use crate::python::python_module_name_from_relative_path;
2394
2395 #[gpui::test]
2396 async fn test_python_autoindent(cx: &mut TestAppContext) {
2397 cx.executor().set_block_on_ticks(usize::MAX..=usize::MAX);
2398 let language = crate::language("python", tree_sitter_python::LANGUAGE.into());
2399 cx.update(|cx| {
2400 let test_settings = SettingsStore::test(cx);
2401 cx.set_global(test_settings);
2402 cx.update_global::<SettingsStore, _>(|store, cx| {
2403 store.update_user_settings(cx, |s| {
2404 s.project.all_languages.defaults.tab_size = NonZeroU32::new(2);
2405 });
2406 });
2407 });
2408
2409 cx.new(|cx| {
2410 let mut buffer = Buffer::local("", cx).with_language(language, cx);
2411 let append = |buffer: &mut Buffer, text: &str, cx: &mut Context<Buffer>| {
2412 let ix = buffer.len();
2413 buffer.edit([(ix..ix, text)], Some(AutoindentMode::EachLine), cx);
2414 };
2415
2416 // indent after "def():"
2417 append(&mut buffer, "def a():\n", cx);
2418 assert_eq!(buffer.text(), "def a():\n ");
2419
2420 // preserve indent after blank line
2421 append(&mut buffer, "\n ", cx);
2422 assert_eq!(buffer.text(), "def a():\n \n ");
2423
2424 // indent after "if"
2425 append(&mut buffer, "if a:\n ", cx);
2426 assert_eq!(buffer.text(), "def a():\n \n if a:\n ");
2427
2428 // preserve indent after statement
2429 append(&mut buffer, "b()\n", cx);
2430 assert_eq!(buffer.text(), "def a():\n \n if a:\n b()\n ");
2431
2432 // preserve indent after statement
2433 append(&mut buffer, "else", cx);
2434 assert_eq!(buffer.text(), "def a():\n \n if a:\n b()\n else");
2435
2436 // dedent "else""
2437 append(&mut buffer, ":", cx);
2438 assert_eq!(buffer.text(), "def a():\n \n if a:\n b()\n else:");
2439
2440 // indent lines after else
2441 append(&mut buffer, "\n", cx);
2442 assert_eq!(
2443 buffer.text(),
2444 "def a():\n \n if a:\n b()\n else:\n "
2445 );
2446
2447 // indent after an open paren. the closing paren is not indented
2448 // because there is another token before it on the same line.
2449 append(&mut buffer, "foo(\n1)", cx);
2450 assert_eq!(
2451 buffer.text(),
2452 "def a():\n \n if a:\n b()\n else:\n foo(\n 1)"
2453 );
2454
2455 // dedent the closing paren if it is shifted to the beginning of the line
2456 let argument_ix = buffer.text().find('1').unwrap();
2457 buffer.edit(
2458 [(argument_ix..argument_ix + 1, "")],
2459 Some(AutoindentMode::EachLine),
2460 cx,
2461 );
2462 assert_eq!(
2463 buffer.text(),
2464 "def a():\n \n if a:\n b()\n else:\n foo(\n )"
2465 );
2466
2467 // preserve indent after the close paren
2468 append(&mut buffer, "\n", cx);
2469 assert_eq!(
2470 buffer.text(),
2471 "def a():\n \n if a:\n b()\n else:\n foo(\n )\n "
2472 );
2473
2474 // manually outdent the last line
2475 let end_whitespace_ix = buffer.len() - 4;
2476 buffer.edit(
2477 [(end_whitespace_ix..buffer.len(), "")],
2478 Some(AutoindentMode::EachLine),
2479 cx,
2480 );
2481 assert_eq!(
2482 buffer.text(),
2483 "def a():\n \n if a:\n b()\n else:\n foo(\n )\n"
2484 );
2485
2486 // preserve the newly reduced indentation on the next newline
2487 append(&mut buffer, "\n", cx);
2488 assert_eq!(
2489 buffer.text(),
2490 "def a():\n \n if a:\n b()\n else:\n foo(\n )\n\n"
2491 );
2492
2493 // reset to a for loop statement
2494 let statement = "for i in range(10):\n print(i)\n";
2495 buffer.edit([(0..buffer.len(), statement)], None, cx);
2496
2497 // insert single line comment after each line
2498 let eol_ixs = statement
2499 .char_indices()
2500 .filter_map(|(ix, c)| if c == '\n' { Some(ix) } else { None })
2501 .collect::<Vec<usize>>();
2502 let editions = eol_ixs
2503 .iter()
2504 .enumerate()
2505 .map(|(i, &eol_ix)| (eol_ix..eol_ix, format!(" # comment {}", i + 1)))
2506 .collect::<Vec<(std::ops::Range<usize>, String)>>();
2507 buffer.edit(editions, Some(AutoindentMode::EachLine), cx);
2508 assert_eq!(
2509 buffer.text(),
2510 "for i in range(10): # comment 1\n print(i) # comment 2\n"
2511 );
2512
2513 // reset to a simple if statement
2514 buffer.edit([(0..buffer.len(), "if a:\n b(\n )")], None, cx);
2515
2516 // dedent "else" on the line after a closing paren
2517 append(&mut buffer, "\n else:\n", cx);
2518 assert_eq!(buffer.text(), "if a:\n b(\n )\nelse:\n ");
2519
2520 buffer
2521 });
2522 }
2523
2524 #[test]
2525 fn test_python_module_name_from_relative_path() {
2526 assert_eq!(
2527 python_module_name_from_relative_path("foo/bar.py"),
2528 Some("foo.bar".to_string())
2529 );
2530 assert_eq!(
2531 python_module_name_from_relative_path("foo/bar"),
2532 Some("foo.bar".to_string())
2533 );
2534 if cfg!(windows) {
2535 assert_eq!(
2536 python_module_name_from_relative_path("foo\\bar.py"),
2537 Some("foo.bar".to_string())
2538 );
2539 assert_eq!(
2540 python_module_name_from_relative_path("foo\\bar"),
2541 Some("foo.bar".to_string())
2542 );
2543 } else {
2544 assert_eq!(
2545 python_module_name_from_relative_path("foo\\bar.py"),
2546 Some("foo\\bar".to_string())
2547 );
2548 assert_eq!(
2549 python_module_name_from_relative_path("foo\\bar"),
2550 Some("foo\\bar".to_string())
2551 );
2552 }
2553 }
2554}