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