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