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 serde::{Deserialize, Serialize};
23use serde_json::{Value, json};
24use smol::lock::OnceCell;
25use std::cmp::Ordering;
26use std::env::consts;
27use util::command::new_smol_command;
28use util::fs::{make_file_executable, remove_matching};
29use util::rel_path::RelPath;
30
31use http_client::github_download::{GithubBinaryMetadata, download_server_binary};
32use parking_lot::Mutex;
33use std::str::FromStr;
34use std::{
35 borrow::Cow,
36 fmt::Write,
37 path::{Path, PathBuf},
38 sync::Arc,
39};
40use task::{ShellKind, TaskTemplate, TaskTemplates, VariableName};
41use util::{ResultExt, maybe};
42
43#[derive(Debug, Serialize, Deserialize)]
44pub(crate) struct PythonToolchainData {
45 #[serde(flatten)]
46 environment: PythonEnvironment,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 activation_scripts: Option<HashMap<ShellKind, PathBuf>>,
49}
50
51pub(crate) struct PyprojectTomlManifestProvider;
52
53impl ManifestProvider for PyprojectTomlManifestProvider {
54 fn name(&self) -> ManifestName {
55 SharedString::new_static("pyproject.toml").into()
56 }
57
58 fn search(
59 &self,
60 ManifestQuery {
61 path,
62 depth,
63 delegate,
64 }: ManifestQuery,
65 ) -> Option<Arc<RelPath>> {
66 for path in path.ancestors().take(depth) {
67 let p = path.join(RelPath::unix("pyproject.toml").unwrap());
68 if delegate.exists(&p, Some(false)) {
69 return Some(path.into());
70 }
71 }
72
73 None
74 }
75}
76
77enum TestRunner {
78 UNITTEST,
79 PYTEST,
80}
81
82impl FromStr for TestRunner {
83 type Err = ();
84
85 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
86 match s {
87 "unittest" => Ok(Self::UNITTEST),
88 "pytest" => Ok(Self::PYTEST),
89 _ => Err(()),
90 }
91 }
92}
93
94/// Pyright assigns each completion item a `sortText` of the form `XX.YYYY.name`.
95/// Where `XX` is the sorting category, `YYYY` is based on most recent usage,
96/// and `name` is the symbol name itself.
97///
98/// 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),
99/// which - long story short - makes completion items list non-stable. Pyright probably relies on VSCode's implementation detail.
100/// see https://github.com/microsoft/pyright/blob/95ef4e103b9b2f129c9320427e51b73ea7cf78bd/packages/pyright-internal/src/languageService/completionProvider.ts#LL2873
101fn process_pyright_completions(items: &mut [lsp::CompletionItem]) {
102 for item in items {
103 item.sort_text.take();
104 }
105}
106
107pub struct TyLspAdapter {
108 fs: Arc<dyn Fs>,
109}
110
111#[cfg(target_os = "macos")]
112impl TyLspAdapter {
113 const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
114 const ARCH_SERVER_NAME: &str = "apple-darwin";
115}
116
117#[cfg(target_os = "linux")]
118impl TyLspAdapter {
119 const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
120 const ARCH_SERVER_NAME: &str = "unknown-linux-gnu";
121}
122
123#[cfg(target_os = "freebsd")]
124impl TyLspAdapter {
125 const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
126 const ARCH_SERVER_NAME: &str = "unknown-freebsd";
127}
128
129#[cfg(target_os = "windows")]
130impl TyLspAdapter {
131 const GITHUB_ASSET_KIND: AssetKind = AssetKind::Zip;
132 const ARCH_SERVER_NAME: &str = "pc-windows-msvc";
133}
134
135impl TyLspAdapter {
136 const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("ty");
137
138 pub fn new(fs: Arc<dyn Fs>) -> TyLspAdapter {
139 TyLspAdapter { fs }
140 }
141
142 fn build_asset_name() -> Result<(String, String)> {
143 let arch = match consts::ARCH {
144 "x86" => "i686",
145 _ => consts::ARCH,
146 };
147 let os = Self::ARCH_SERVER_NAME;
148 let suffix = match consts::OS {
149 "windows" => "zip",
150 _ => "tar.gz",
151 };
152 let asset_name = format!("ty-{arch}-{os}.{suffix}");
153 let asset_stem = format!("ty-{arch}-{os}");
154 Ok((asset_stem, asset_name))
155 }
156}
157
158#[async_trait(?Send)]
159impl LspAdapter for TyLspAdapter {
160 fn name(&self) -> LanguageServerName {
161 Self::SERVER_NAME
162 }
163
164 async fn workspace_configuration(
165 self: Arc<Self>,
166 delegate: &Arc<dyn LspAdapterDelegate>,
167 toolchain: Option<Toolchain>,
168 cx: &mut AsyncApp,
169 ) -> Result<Value> {
170 let mut ret = cx
171 .update(|cx| {
172 language_server_settings(delegate.as_ref(), &self.name(), cx)
173 .and_then(|s| s.settings.clone())
174 })?
175 .unwrap_or_else(|| json!({}));
176 if let Some(toolchain) = toolchain.and_then(|toolchain| {
177 serde_json::from_value::<PythonToolchainData>(toolchain.as_json).ok()
178 }) {
179 _ = maybe!({
180 let uri =
181 url::Url::from_file_path(toolchain.environment.executable.as_ref()?).ok()?;
182 let sys_prefix = toolchain.environment.prefix.clone()?;
183 let environment = json!({
184 "executable": {
185 "uri": uri,
186 "sysPrefix": sys_prefix
187 }
188 });
189 ret.as_object_mut()?
190 .entry("pythonExtension")
191 .or_insert_with(|| json!({ "activeEnvironment": environment }));
192 Some(())
193 });
194 }
195 Ok(json!({"ty": ret}))
196 }
197}
198
199impl LspInstaller for TyLspAdapter {
200 type BinaryVersion = GitHubLspBinaryVersion;
201 async fn fetch_latest_server_version(
202 &self,
203 delegate: &dyn LspAdapterDelegate,
204 _: bool,
205 _: &mut AsyncApp,
206 ) -> Result<Self::BinaryVersion> {
207 let release =
208 latest_github_release("astral-sh/ty", true, true, delegate.http_client()).await?;
209 let (_, asset_name) = Self::build_asset_name()?;
210 let asset = release
211 .assets
212 .into_iter()
213 .find(|asset| asset.name == asset_name)
214 .with_context(|| format!("no asset found matching `{asset_name:?}`"))?;
215 Ok(GitHubLspBinaryVersion {
216 name: release.tag_name,
217 url: asset.browser_download_url,
218 digest: asset.digest,
219 })
220 }
221
222 async fn fetch_server_binary(
223 &self,
224 latest_version: Self::BinaryVersion,
225 container_dir: PathBuf,
226 delegate: &dyn LspAdapterDelegate,
227 ) -> Result<LanguageServerBinary> {
228 let GitHubLspBinaryVersion {
229 name,
230 url,
231 digest: expected_digest,
232 } = latest_version;
233 let destination_path = container_dir.join(format!("ty-{name}"));
234
235 async_fs::create_dir_all(&destination_path).await?;
236
237 let server_path = match Self::GITHUB_ASSET_KIND {
238 AssetKind::TarGz | AssetKind::Gz => destination_path
239 .join(Self::build_asset_name()?.0)
240 .join("ty"),
241 AssetKind::Zip => destination_path.clone().join("ty.exe"),
242 };
243
244 let binary = LanguageServerBinary {
245 path: server_path.clone(),
246 env: None,
247 arguments: vec!["server".into()],
248 };
249
250 let metadata_path = destination_path.with_extension("metadata");
251 let metadata = GithubBinaryMetadata::read_from_file(&metadata_path)
252 .await
253 .ok();
254 if let Some(metadata) = metadata {
255 let validity_check = async || {
256 delegate
257 .try_exec(LanguageServerBinary {
258 path: server_path.clone(),
259 arguments: vec!["--version".into()],
260 env: None,
261 })
262 .await
263 .inspect_err(|err| {
264 log::warn!("Unable to run {server_path:?} asset, redownloading: {err}",)
265 })
266 };
267 if let (Some(actual_digest), Some(expected_digest)) =
268 (&metadata.digest, &expected_digest)
269 {
270 if actual_digest == expected_digest {
271 if validity_check().await.is_ok() {
272 return Ok(binary);
273 }
274 } else {
275 log::info!(
276 "SHA-256 mismatch for {destination_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}"
277 );
278 }
279 } else if validity_check().await.is_ok() {
280 return Ok(binary);
281 }
282 }
283
284 download_server_binary(
285 &*delegate.http_client(),
286 &url,
287 expected_digest.as_deref(),
288 &destination_path,
289 Self::GITHUB_ASSET_KIND,
290 )
291 .await?;
292 make_file_executable(&server_path).await?;
293 remove_matching(&container_dir, |path| path != destination_path).await;
294 GithubBinaryMetadata::write_to_file(
295 &GithubBinaryMetadata {
296 metadata_version: 1,
297 digest: expected_digest,
298 },
299 &metadata_path,
300 )
301 .await?;
302
303 Ok(LanguageServerBinary {
304 path: server_path,
305 env: None,
306 arguments: vec!["server".into()],
307 })
308 }
309
310 async fn cached_server_binary(
311 &self,
312 container_dir: PathBuf,
313 _: &dyn LspAdapterDelegate,
314 ) -> Option<LanguageServerBinary> {
315 maybe!(async {
316 let mut last = None;
317 let mut entries = self.fs.read_dir(&container_dir).await?;
318 while let Some(entry) = entries.next().await {
319 let path = entry?;
320 if path.extension().is_some_and(|ext| ext == "metadata") {
321 continue;
322 }
323 last = Some(path);
324 }
325
326 let path = last.context("no cached binary")?;
327 let path = match TyLspAdapter::GITHUB_ASSET_KIND {
328 AssetKind::TarGz | AssetKind::Gz => {
329 path.join(Self::build_asset_name()?.0).join("ty")
330 }
331 AssetKind::Zip => path.join("ty.exe"),
332 };
333
334 anyhow::Ok(LanguageServerBinary {
335 path,
336 env: None,
337 arguments: vec!["server".into()],
338 })
339 })
340 .await
341 .log_err()
342 }
343}
344
345pub struct PyrightLspAdapter {
346 node: NodeRuntime,
347}
348
349impl PyrightLspAdapter {
350 const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("pyright");
351 const SERVER_PATH: &str = "node_modules/pyright/langserver.index.js";
352 const NODE_MODULE_RELATIVE_SERVER_PATH: &str = "pyright/langserver.index.js";
353
354 pub fn new(node: NodeRuntime) -> Self {
355 PyrightLspAdapter { node }
356 }
357
358 async fn get_cached_server_binary(
359 container_dir: PathBuf,
360 node: &NodeRuntime,
361 ) -> Option<LanguageServerBinary> {
362 let server_path = container_dir.join(Self::SERVER_PATH);
363 if server_path.exists() {
364 Some(LanguageServerBinary {
365 path: node.binary_path().await.log_err()?,
366 env: None,
367 arguments: vec![server_path.into(), "--stdio".into()],
368 })
369 } else {
370 log::error!("missing executable in directory {:?}", server_path);
371 None
372 }
373 }
374}
375
376#[async_trait(?Send)]
377impl LspAdapter for PyrightLspAdapter {
378 fn name(&self) -> LanguageServerName {
379 Self::SERVER_NAME
380 }
381
382 async fn initialization_options(
383 self: Arc<Self>,
384 _: &Arc<dyn LspAdapterDelegate>,
385 ) -> Result<Option<Value>> {
386 // Provide minimal initialization options
387 // Virtual environment configuration will be handled through workspace configuration
388 Ok(Some(json!({
389 "python": {
390 "analysis": {
391 "autoSearchPaths": true,
392 "useLibraryCodeForTypes": true,
393 "autoImportCompletions": true
394 }
395 }
396 })))
397 }
398
399 async fn process_completions(&self, items: &mut [lsp::CompletionItem]) {
400 process_pyright_completions(items);
401 }
402
403 async fn label_for_completion(
404 &self,
405 item: &lsp::CompletionItem,
406 language: &Arc<language::Language>,
407 ) -> Option<language::CodeLabel> {
408 let label = &item.label;
409 let grammar = language.grammar()?;
410 let highlight_id = match item.kind? {
411 lsp::CompletionItemKind::METHOD => grammar.highlight_id_for_name("function.method"),
412 lsp::CompletionItemKind::FUNCTION => grammar.highlight_id_for_name("function"),
413 lsp::CompletionItemKind::CLASS => grammar.highlight_id_for_name("type"),
414 lsp::CompletionItemKind::CONSTANT => grammar.highlight_id_for_name("constant"),
415 lsp::CompletionItemKind::VARIABLE => grammar.highlight_id_for_name("variable"),
416 _ => {
417 return None;
418 }
419 };
420 let mut text = label.clone();
421 if let Some(completion_details) = item
422 .label_details
423 .as_ref()
424 .and_then(|details| details.description.as_ref())
425 {
426 write!(&mut text, " {}", completion_details).ok();
427 }
428 Some(language::CodeLabel::filtered(
429 text,
430 item.filter_text.as_deref(),
431 highlight_id
432 .map(|id| (0..label.len(), id))
433 .into_iter()
434 .collect(),
435 ))
436 }
437
438 async fn label_for_symbol(
439 &self,
440 name: &str,
441 kind: lsp::SymbolKind,
442 language: &Arc<language::Language>,
443 ) -> Option<language::CodeLabel> {
444 let (text, filter_range, display_range) = match kind {
445 lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => {
446 let text = format!("def {}():\n", name);
447 let filter_range = 4..4 + name.len();
448 let display_range = 0..filter_range.end;
449 (text, filter_range, display_range)
450 }
451 lsp::SymbolKind::CLASS => {
452 let text = format!("class {}:", name);
453 let filter_range = 6..6 + name.len();
454 let display_range = 0..filter_range.end;
455 (text, filter_range, display_range)
456 }
457 lsp::SymbolKind::CONSTANT => {
458 let text = format!("{} = 0", name);
459 let filter_range = 0..name.len();
460 let display_range = 0..filter_range.end;
461 (text, filter_range, display_range)
462 }
463 _ => return None,
464 };
465
466 Some(language::CodeLabel::new(
467 text[display_range.clone()].to_string(),
468 filter_range,
469 language.highlight_text(&text.as_str().into(), display_range),
470 ))
471 }
472
473 async fn workspace_configuration(
474 self: Arc<Self>,
475 adapter: &Arc<dyn LspAdapterDelegate>,
476 toolchain: Option<Toolchain>,
477 cx: &mut AsyncApp,
478 ) -> Result<Value> {
479 cx.update(move |cx| {
480 let mut user_settings =
481 language_server_settings(adapter.as_ref(), &Self::SERVER_NAME, cx)
482 .and_then(|s| s.settings.clone())
483 .unwrap_or_default();
484
485 // If we have a detected toolchain, configure Pyright to use it
486 if let Some(toolchain) = toolchain
487 && let Ok(env) =
488 serde_json::from_value::<PythonToolchainData>(toolchain.as_json.clone())
489 {
490 if !user_settings.is_object() {
491 user_settings = Value::Object(serde_json::Map::default());
492 }
493 let object = user_settings.as_object_mut().unwrap();
494
495 let interpreter_path = toolchain.path.to_string();
496 if let Some(venv_dir) = &env.environment.prefix {
497 // Set venvPath and venv at the root level
498 // This matches the format of a pyrightconfig.json file
499 if let Some(parent) = venv_dir.parent() {
500 // Use relative path if the venv is inside the workspace
501 let venv_path = if parent == adapter.worktree_root_path() {
502 ".".to_string()
503 } else {
504 parent.to_string_lossy().into_owned()
505 };
506 object.insert("venvPath".to_string(), Value::String(venv_path));
507 }
508
509 if let Some(venv_name) = venv_dir.file_name() {
510 object.insert(
511 "venv".to_owned(),
512 Value::String(venv_name.to_string_lossy().into_owned()),
513 );
514 }
515 }
516
517 // Always set the python interpreter path
518 // Get or create the python section
519 let python = object
520 .entry("python")
521 .and_modify(|v| {
522 if !v.is_object() {
523 *v = Value::Object(serde_json::Map::default());
524 }
525 })
526 .or_insert(Value::Object(serde_json::Map::default()));
527 let python = python.as_object_mut().unwrap();
528
529 // Set both pythonPath and defaultInterpreterPath for compatibility
530 python.insert(
531 "pythonPath".to_owned(),
532 Value::String(interpreter_path.clone()),
533 );
534 python.insert(
535 "defaultInterpreterPath".to_owned(),
536 Value::String(interpreter_path),
537 );
538 }
539
540 user_settings
541 })
542 }
543}
544
545impl LspInstaller for PyrightLspAdapter {
546 type BinaryVersion = String;
547
548 async fn fetch_latest_server_version(
549 &self,
550 _: &dyn LspAdapterDelegate,
551 _: bool,
552 _: &mut AsyncApp,
553 ) -> Result<String> {
554 self.node
555 .npm_package_latest_version(Self::SERVER_NAME.as_ref())
556 .await
557 }
558
559 async fn check_if_user_installed(
560 &self,
561 delegate: &dyn LspAdapterDelegate,
562 _: Option<Toolchain>,
563 _: &AsyncApp,
564 ) -> Option<LanguageServerBinary> {
565 if let Some(pyright_bin) = delegate.which("pyright-langserver".as_ref()).await {
566 let env = delegate.shell_env().await;
567 Some(LanguageServerBinary {
568 path: pyright_bin,
569 env: Some(env),
570 arguments: vec!["--stdio".into()],
571 })
572 } else {
573 let node = delegate.which("node".as_ref()).await?;
574 let (node_modules_path, _) = delegate
575 .npm_package_installed_version(Self::SERVER_NAME.as_ref())
576 .await
577 .log_err()??;
578
579 let path = node_modules_path.join(Self::NODE_MODULE_RELATIVE_SERVER_PATH);
580
581 let env = delegate.shell_env().await;
582 Some(LanguageServerBinary {
583 path: node,
584 env: Some(env),
585 arguments: vec![path.into(), "--stdio".into()],
586 })
587 }
588 }
589
590 async fn fetch_server_binary(
591 &self,
592 latest_version: Self::BinaryVersion,
593 container_dir: PathBuf,
594 delegate: &dyn LspAdapterDelegate,
595 ) -> Result<LanguageServerBinary> {
596 let server_path = container_dir.join(Self::SERVER_PATH);
597
598 self.node
599 .npm_install_packages(
600 &container_dir,
601 &[(Self::SERVER_NAME.as_ref(), latest_version.as_str())],
602 )
603 .await?;
604
605 let env = delegate.shell_env().await;
606 Ok(LanguageServerBinary {
607 path: self.node.binary_path().await?,
608 env: Some(env),
609 arguments: vec![server_path.into(), "--stdio".into()],
610 })
611 }
612
613 async fn check_if_version_installed(
614 &self,
615 version: &Self::BinaryVersion,
616 container_dir: &PathBuf,
617 delegate: &dyn LspAdapterDelegate,
618 ) -> Option<LanguageServerBinary> {
619 let server_path = container_dir.join(Self::SERVER_PATH);
620
621 let should_install_language_server = self
622 .node
623 .should_install_npm_package(
624 Self::SERVER_NAME.as_ref(),
625 &server_path,
626 container_dir,
627 VersionStrategy::Latest(version),
628 )
629 .await;
630
631 if should_install_language_server {
632 None
633 } else {
634 let env = delegate.shell_env().await;
635 Some(LanguageServerBinary {
636 path: self.node.binary_path().await.ok()?,
637 env: Some(env),
638 arguments: vec![server_path.into(), "--stdio".into()],
639 })
640 }
641 }
642
643 async fn cached_server_binary(
644 &self,
645 container_dir: PathBuf,
646 delegate: &dyn LspAdapterDelegate,
647 ) -> Option<LanguageServerBinary> {
648 let mut binary = Self::get_cached_server_binary(container_dir, &self.node).await?;
649 binary.env = Some(delegate.shell_env().await);
650 Some(binary)
651 }
652}
653
654pub(crate) struct PythonContextProvider;
655
656const PYTHON_TEST_TARGET_TASK_VARIABLE: VariableName =
657 VariableName::Custom(Cow::Borrowed("PYTHON_TEST_TARGET"));
658
659const PYTHON_ACTIVE_TOOLCHAIN_PATH: VariableName =
660 VariableName::Custom(Cow::Borrowed("PYTHON_ACTIVE_ZED_TOOLCHAIN"));
661
662const PYTHON_MODULE_NAME_TASK_VARIABLE: VariableName =
663 VariableName::Custom(Cow::Borrowed("PYTHON_MODULE_NAME"));
664
665impl ContextProvider for PythonContextProvider {
666 fn build_context(
667 &self,
668 variables: &task::TaskVariables,
669 location: ContextLocation<'_>,
670 _: Option<HashMap<String, String>>,
671 toolchains: Arc<dyn LanguageToolchainStore>,
672 cx: &mut gpui::App,
673 ) -> Task<Result<task::TaskVariables>> {
674 let test_target =
675 match selected_test_runner(location.file_location.buffer.read(cx).file(), cx) {
676 TestRunner::UNITTEST => self.build_unittest_target(variables),
677 TestRunner::PYTEST => self.build_pytest_target(variables),
678 };
679
680 let module_target = self.build_module_target(variables);
681 let location_file = location.file_location.buffer.read(cx).file().cloned();
682 let worktree_id = location_file.as_ref().map(|f| f.worktree_id(cx));
683
684 cx.spawn(async move |cx| {
685 let active_toolchain = if let Some(worktree_id) = worktree_id {
686 let file_path = location_file
687 .as_ref()
688 .and_then(|f| f.path().parent())
689 .map(Arc::from)
690 .unwrap_or_else(|| RelPath::empty().into());
691
692 toolchains
693 .active_toolchain(worktree_id, file_path, "Python".into(), cx)
694 .await
695 .map_or_else(
696 || String::from("python3"),
697 |toolchain| toolchain.path.to_string(),
698 )
699 } else {
700 String::from("python3")
701 };
702
703 let toolchain = (PYTHON_ACTIVE_TOOLCHAIN_PATH, active_toolchain);
704
705 Ok(task::TaskVariables::from_iter(
706 test_target
707 .into_iter()
708 .chain(module_target.into_iter())
709 .chain([toolchain]),
710 ))
711 })
712 }
713
714 fn associated_tasks(
715 &self,
716 file: Option<Arc<dyn language::File>>,
717 cx: &App,
718 ) -> Task<Option<TaskTemplates>> {
719 let test_runner = selected_test_runner(file.as_ref(), cx);
720
721 let mut tasks = vec![
722 // Execute a selection
723 TaskTemplate {
724 label: "execute selection".to_owned(),
725 command: PYTHON_ACTIVE_TOOLCHAIN_PATH.template_value(),
726 args: vec![
727 "-c".to_owned(),
728 VariableName::SelectedText.template_value_with_whitespace(),
729 ],
730 cwd: Some(VariableName::WorktreeRoot.template_value()),
731 ..TaskTemplate::default()
732 },
733 // Execute an entire file
734 TaskTemplate {
735 label: format!("run '{}'", VariableName::File.template_value()),
736 command: PYTHON_ACTIVE_TOOLCHAIN_PATH.template_value(),
737 args: vec![VariableName::File.template_value_with_whitespace()],
738 cwd: Some(VariableName::WorktreeRoot.template_value()),
739 ..TaskTemplate::default()
740 },
741 // Execute a file as module
742 TaskTemplate {
743 label: format!("run module '{}'", VariableName::File.template_value()),
744 command: PYTHON_ACTIVE_TOOLCHAIN_PATH.template_value(),
745 args: vec![
746 "-m".to_owned(),
747 PYTHON_MODULE_NAME_TASK_VARIABLE.template_value(),
748 ],
749 cwd: Some(VariableName::WorktreeRoot.template_value()),
750 tags: vec!["python-module-main-method".to_owned()],
751 ..TaskTemplate::default()
752 },
753 ];
754
755 tasks.extend(match test_runner {
756 TestRunner::UNITTEST => {
757 [
758 // Run tests for an entire file
759 TaskTemplate {
760 label: format!("unittest '{}'", VariableName::File.template_value()),
761 command: PYTHON_ACTIVE_TOOLCHAIN_PATH.template_value(),
762 args: vec![
763 "-m".to_owned(),
764 "unittest".to_owned(),
765 VariableName::File.template_value_with_whitespace(),
766 ],
767 cwd: Some(VariableName::WorktreeRoot.template_value()),
768 ..TaskTemplate::default()
769 },
770 // Run test(s) for a specific target within a file
771 TaskTemplate {
772 label: "unittest $ZED_CUSTOM_PYTHON_TEST_TARGET".to_owned(),
773 command: PYTHON_ACTIVE_TOOLCHAIN_PATH.template_value(),
774 args: vec![
775 "-m".to_owned(),
776 "unittest".to_owned(),
777 PYTHON_TEST_TARGET_TASK_VARIABLE.template_value_with_whitespace(),
778 ],
779 tags: vec![
780 "python-unittest-class".to_owned(),
781 "python-unittest-method".to_owned(),
782 ],
783 cwd: Some(VariableName::WorktreeRoot.template_value()),
784 ..TaskTemplate::default()
785 },
786 ]
787 }
788 TestRunner::PYTEST => {
789 [
790 // Run tests for an entire file
791 TaskTemplate {
792 label: format!("pytest '{}'", VariableName::File.template_value()),
793 command: PYTHON_ACTIVE_TOOLCHAIN_PATH.template_value(),
794 args: vec![
795 "-m".to_owned(),
796 "pytest".to_owned(),
797 VariableName::File.template_value_with_whitespace(),
798 ],
799 cwd: Some(VariableName::WorktreeRoot.template_value()),
800 ..TaskTemplate::default()
801 },
802 // Run test(s) for a specific target within a file
803 TaskTemplate {
804 label: "pytest $ZED_CUSTOM_PYTHON_TEST_TARGET".to_owned(),
805 command: PYTHON_ACTIVE_TOOLCHAIN_PATH.template_value(),
806 args: vec![
807 "-m".to_owned(),
808 "pytest".to_owned(),
809 PYTHON_TEST_TARGET_TASK_VARIABLE.template_value_with_whitespace(),
810 ],
811 cwd: Some(VariableName::WorktreeRoot.template_value()),
812 tags: vec![
813 "python-pytest-class".to_owned(),
814 "python-pytest-method".to_owned(),
815 ],
816 ..TaskTemplate::default()
817 },
818 ]
819 }
820 });
821
822 Task::ready(Some(TaskTemplates(tasks)))
823 }
824}
825
826fn selected_test_runner(location: Option<&Arc<dyn language::File>>, cx: &App) -> TestRunner {
827 const TEST_RUNNER_VARIABLE: &str = "TEST_RUNNER";
828 language_settings(Some(LanguageName::new("Python")), location, cx)
829 .tasks
830 .variables
831 .get(TEST_RUNNER_VARIABLE)
832 .and_then(|val| TestRunner::from_str(val).ok())
833 .unwrap_or(TestRunner::PYTEST)
834}
835
836impl PythonContextProvider {
837 fn build_unittest_target(
838 &self,
839 variables: &task::TaskVariables,
840 ) -> Option<(VariableName, String)> {
841 let python_module_name =
842 python_module_name_from_relative_path(variables.get(&VariableName::RelativeFile)?);
843
844 let unittest_class_name =
845 variables.get(&VariableName::Custom(Cow::Borrowed("_unittest_class_name")));
846
847 let unittest_method_name = variables.get(&VariableName::Custom(Cow::Borrowed(
848 "_unittest_method_name",
849 )));
850
851 let unittest_target_str = match (unittest_class_name, unittest_method_name) {
852 (Some(class_name), Some(method_name)) => {
853 format!("{python_module_name}.{class_name}.{method_name}")
854 }
855 (Some(class_name), None) => format!("{python_module_name}.{class_name}"),
856 (None, None) => python_module_name,
857 // should never happen, a TestCase class is the unit of testing
858 (None, Some(_)) => return None,
859 };
860
861 Some((
862 PYTHON_TEST_TARGET_TASK_VARIABLE.clone(),
863 unittest_target_str,
864 ))
865 }
866
867 fn build_pytest_target(
868 &self,
869 variables: &task::TaskVariables,
870 ) -> Option<(VariableName, String)> {
871 let file_path = variables.get(&VariableName::RelativeFile)?;
872
873 let pytest_class_name =
874 variables.get(&VariableName::Custom(Cow::Borrowed("_pytest_class_name")));
875
876 let pytest_method_name =
877 variables.get(&VariableName::Custom(Cow::Borrowed("_pytest_method_name")));
878
879 let pytest_target_str = match (pytest_class_name, pytest_method_name) {
880 (Some(class_name), Some(method_name)) => {
881 format!("{file_path}::{class_name}::{method_name}")
882 }
883 (Some(class_name), None) => {
884 format!("{file_path}::{class_name}")
885 }
886 (None, Some(method_name)) => {
887 format!("{file_path}::{method_name}")
888 }
889 (None, None) => file_path.to_string(),
890 };
891
892 Some((PYTHON_TEST_TARGET_TASK_VARIABLE.clone(), pytest_target_str))
893 }
894
895 fn build_module_target(
896 &self,
897 variables: &task::TaskVariables,
898 ) -> Result<(VariableName, String)> {
899 let python_module_name = python_module_name_from_relative_path(
900 variables.get(&VariableName::RelativeFile).unwrap_or(""),
901 );
902
903 let module_target = (PYTHON_MODULE_NAME_TASK_VARIABLE.clone(), python_module_name);
904
905 Ok(module_target)
906 }
907}
908
909fn python_module_name_from_relative_path(relative_path: &str) -> String {
910 let path_with_dots = relative_path.replace('/', ".");
911 path_with_dots
912 .strip_suffix(".py")
913 .unwrap_or(&path_with_dots)
914 .to_string()
915}
916
917fn is_python_env_global(k: &PythonEnvironmentKind) -> bool {
918 matches!(
919 k,
920 PythonEnvironmentKind::Homebrew
921 | PythonEnvironmentKind::Pyenv
922 | PythonEnvironmentKind::GlobalPaths
923 | PythonEnvironmentKind::MacPythonOrg
924 | PythonEnvironmentKind::MacCommandLineTools
925 | PythonEnvironmentKind::LinuxGlobal
926 | PythonEnvironmentKind::MacXCode
927 | PythonEnvironmentKind::WindowsStore
928 | PythonEnvironmentKind::WindowsRegistry
929 )
930}
931
932fn python_env_kind_display(k: &PythonEnvironmentKind) -> &'static str {
933 match k {
934 PythonEnvironmentKind::Conda => "Conda",
935 PythonEnvironmentKind::Pixi => "pixi",
936 PythonEnvironmentKind::Homebrew => "Homebrew",
937 PythonEnvironmentKind::Pyenv => "global (Pyenv)",
938 PythonEnvironmentKind::GlobalPaths => "global",
939 PythonEnvironmentKind::PyenvVirtualEnv => "Pyenv",
940 PythonEnvironmentKind::Pipenv => "Pipenv",
941 PythonEnvironmentKind::Poetry => "Poetry",
942 PythonEnvironmentKind::MacPythonOrg => "global (Python.org)",
943 PythonEnvironmentKind::MacCommandLineTools => "global (Command Line Tools for Xcode)",
944 PythonEnvironmentKind::LinuxGlobal => "global",
945 PythonEnvironmentKind::MacXCode => "global (Xcode)",
946 PythonEnvironmentKind::Venv => "venv",
947 PythonEnvironmentKind::VirtualEnv => "virtualenv",
948 PythonEnvironmentKind::VirtualEnvWrapper => "virtualenvwrapper",
949 PythonEnvironmentKind::WindowsStore => "global (Windows Store)",
950 PythonEnvironmentKind::WindowsRegistry => "global (Windows Registry)",
951 }
952}
953
954pub(crate) struct PythonToolchainProvider;
955
956static ENV_PRIORITY_LIST: &[PythonEnvironmentKind] = &[
957 // Prioritize non-Conda environments.
958 PythonEnvironmentKind::Poetry,
959 PythonEnvironmentKind::Pipenv,
960 PythonEnvironmentKind::VirtualEnvWrapper,
961 PythonEnvironmentKind::Venv,
962 PythonEnvironmentKind::VirtualEnv,
963 PythonEnvironmentKind::PyenvVirtualEnv,
964 PythonEnvironmentKind::Pixi,
965 PythonEnvironmentKind::Conda,
966 PythonEnvironmentKind::Pyenv,
967 PythonEnvironmentKind::GlobalPaths,
968 PythonEnvironmentKind::Homebrew,
969];
970
971fn env_priority(kind: Option<PythonEnvironmentKind>) -> usize {
972 if let Some(kind) = kind {
973 ENV_PRIORITY_LIST
974 .iter()
975 .position(|blessed_env| blessed_env == &kind)
976 .unwrap_or(ENV_PRIORITY_LIST.len())
977 } else {
978 // Unknown toolchains are less useful than non-blessed ones.
979 ENV_PRIORITY_LIST.len() + 1
980 }
981}
982
983/// Return the name of environment declared in <worktree-root/.venv.
984///
985/// https://virtualfish.readthedocs.io/en/latest/plugins.html#auto-activation-auto-activation
986async fn get_worktree_venv_declaration(worktree_root: &Path) -> Option<String> {
987 let file = async_fs::File::open(worktree_root.join(".venv"))
988 .await
989 .ok()?;
990 let mut venv_name = String::new();
991 smol::io::BufReader::new(file)
992 .read_line(&mut venv_name)
993 .await
994 .ok()?;
995 Some(venv_name.trim().to_string())
996}
997
998fn get_venv_parent_dir(env: &PythonEnvironment) -> Option<PathBuf> {
999 // If global, we aren't a virtual environment
1000 if let Some(kind) = env.kind
1001 && is_python_env_global(&kind)
1002 {
1003 return None;
1004 }
1005
1006 // Check to be sure we are a virtual environment using pet's most generic
1007 // virtual environment type, VirtualEnv
1008 let venv = env
1009 .executable
1010 .as_ref()
1011 .and_then(|p| p.parent())
1012 .and_then(|p| p.parent())
1013 .filter(|p| is_virtualenv_dir(p))?;
1014
1015 venv.parent().map(|parent| parent.to_path_buf())
1016}
1017
1018fn wr_distance(wr: &PathBuf, venv: Option<&PathBuf>) -> usize {
1019 if let Some(venv) = venv
1020 && let Ok(p) = venv.strip_prefix(wr)
1021 {
1022 p.components().count()
1023 } else {
1024 usize::MAX
1025 }
1026}
1027
1028#[async_trait]
1029impl ToolchainLister for PythonToolchainProvider {
1030 async fn list(
1031 &self,
1032 worktree_root: PathBuf,
1033 subroot_relative_path: Arc<RelPath>,
1034 project_env: Option<HashMap<String, String>>,
1035 fs: &dyn Fs,
1036 ) -> ToolchainList {
1037 let env = project_env.unwrap_or_default();
1038 let environment = EnvironmentApi::from_env(&env);
1039 let locators = pet::locators::create_locators(
1040 Arc::new(pet_conda::Conda::from(&environment)),
1041 Arc::new(pet_poetry::Poetry::from(&environment)),
1042 &environment,
1043 );
1044 let mut config = Configuration::default();
1045
1046 // `.ancestors()` will yield at least one path, so in case of empty `subroot_relative_path`, we'll just use
1047 // worktree root as the workspace directory.
1048 config.workspace_directories = Some(
1049 subroot_relative_path
1050 .ancestors()
1051 .map(|ancestor| worktree_root.join(ancestor.as_std_path()))
1052 .collect(),
1053 );
1054 for locator in locators.iter() {
1055 locator.configure(&config);
1056 }
1057
1058 let reporter = pet_reporter::collect::create_reporter();
1059 pet::find::find_and_report_envs(&reporter, config, &locators, &environment, None);
1060
1061 let mut toolchains = reporter
1062 .environments
1063 .lock()
1064 .map_or(Vec::new(), |mut guard| std::mem::take(&mut guard));
1065
1066 let wr = worktree_root;
1067 let wr_venv = get_worktree_venv_declaration(&wr).await;
1068 // Sort detected environments by:
1069 // environment name matching activation file (<workdir>/.venv)
1070 // environment project dir matching worktree_root
1071 // general env priority
1072 // environment path matching the CONDA_PREFIX env var
1073 // executable path
1074 toolchains.sort_by(|lhs, rhs| {
1075 // Compare venv names against worktree .venv file
1076 let venv_ordering =
1077 wr_venv
1078 .as_ref()
1079 .map_or(Ordering::Equal, |venv| match (&lhs.name, &rhs.name) {
1080 (Some(l), Some(r)) => (r == venv).cmp(&(l == venv)),
1081 (Some(l), None) if l == venv => Ordering::Less,
1082 (None, Some(r)) if r == venv => Ordering::Greater,
1083 _ => Ordering::Equal,
1084 });
1085
1086 // Compare project paths against worktree root
1087 let proj_ordering = || {
1088 let lhs_project = lhs.project.clone().or_else(|| get_venv_parent_dir(lhs));
1089 let rhs_project = rhs.project.clone().or_else(|| get_venv_parent_dir(rhs));
1090 wr_distance(&wr, lhs_project.as_ref()).cmp(&wr_distance(&wr, rhs_project.as_ref()))
1091 };
1092
1093 // Compare environment priorities
1094 let priority_ordering = || env_priority(lhs.kind).cmp(&env_priority(rhs.kind));
1095
1096 // Compare conda prefixes
1097 let conda_ordering = || {
1098 if lhs.kind == Some(PythonEnvironmentKind::Conda) {
1099 environment
1100 .get_env_var("CONDA_PREFIX".to_string())
1101 .map(|conda_prefix| {
1102 let is_match = |exe: &Option<PathBuf>| {
1103 exe.as_ref().is_some_and(|e| e.starts_with(&conda_prefix))
1104 };
1105 match (is_match(&lhs.executable), is_match(&rhs.executable)) {
1106 (true, false) => Ordering::Less,
1107 (false, true) => Ordering::Greater,
1108 _ => Ordering::Equal,
1109 }
1110 })
1111 .unwrap_or(Ordering::Equal)
1112 } else {
1113 Ordering::Equal
1114 }
1115 };
1116
1117 // Compare Python executables
1118 let exe_ordering = || lhs.executable.cmp(&rhs.executable);
1119
1120 venv_ordering
1121 .then_with(proj_ordering)
1122 .then_with(priority_ordering)
1123 .then_with(conda_ordering)
1124 .then_with(exe_ordering)
1125 });
1126
1127 let mut out_toolchains = Vec::new();
1128 for toolchain in toolchains {
1129 let Some(toolchain) = venv_to_toolchain(toolchain, fs).await else {
1130 continue;
1131 };
1132 out_toolchains.push(toolchain);
1133 }
1134 out_toolchains.dedup();
1135 ToolchainList {
1136 toolchains: out_toolchains,
1137 default: None,
1138 groups: Default::default(),
1139 }
1140 }
1141 fn meta(&self) -> ToolchainMetadata {
1142 ToolchainMetadata {
1143 term: SharedString::new_static("Virtual Environment"),
1144 new_toolchain_placeholder: SharedString::new_static(
1145 "A path to the python3 executable within a virtual environment, or path to virtual environment itself",
1146 ),
1147 manifest_name: ManifestName::from(SharedString::new_static("pyproject.toml")),
1148 }
1149 }
1150
1151 async fn resolve(
1152 &self,
1153 path: PathBuf,
1154 env: Option<HashMap<String, String>>,
1155 fs: &dyn Fs,
1156 ) -> anyhow::Result<Toolchain> {
1157 let env = env.unwrap_or_default();
1158 let environment = EnvironmentApi::from_env(&env);
1159 let locators = pet::locators::create_locators(
1160 Arc::new(pet_conda::Conda::from(&environment)),
1161 Arc::new(pet_poetry::Poetry::from(&environment)),
1162 &environment,
1163 );
1164 let toolchain = pet::resolve::resolve_environment(&path, &locators, &environment)
1165 .context("Could not find a virtual environment in provided path")?;
1166 let venv = toolchain.resolved.unwrap_or(toolchain.discovered);
1167 venv_to_toolchain(venv, fs)
1168 .await
1169 .context("Could not convert a venv into a toolchain")
1170 }
1171
1172 fn activation_script(&self, toolchain: &Toolchain, shell: ShellKind) -> Vec<String> {
1173 let Ok(toolchain) =
1174 serde_json::from_value::<PythonToolchainData>(toolchain.as_json.clone())
1175 else {
1176 return vec![];
1177 };
1178
1179 log::debug!("(Python) Composing activation script for toolchain {toolchain:?}");
1180
1181 let mut activation_script = vec![];
1182
1183 match toolchain.environment.kind {
1184 Some(PythonEnvironmentKind::Conda) => {
1185 if let Some(name) = &toolchain.environment.name {
1186 activation_script.push(format!("conda activate {name}"));
1187 } else {
1188 activation_script.push("conda activate".to_string());
1189 }
1190 }
1191 Some(PythonEnvironmentKind::Venv | PythonEnvironmentKind::VirtualEnv) => {
1192 if let Some(activation_scripts) = &toolchain.activation_scripts {
1193 if let Some(activate_script_path) = activation_scripts.get(&shell) {
1194 let activate_keyword = shell.activate_keyword();
1195 if let Some(quoted) =
1196 shell.try_quote(&activate_script_path.to_string_lossy())
1197 {
1198 activation_script.push(format!("{activate_keyword} {quoted}"));
1199 }
1200 }
1201 }
1202 }
1203 Some(PythonEnvironmentKind::Pyenv) => {
1204 let Some(manager) = &toolchain.environment.manager else {
1205 return vec![];
1206 };
1207 let version = toolchain.environment.version.as_deref().unwrap_or("system");
1208 let pyenv = &manager.executable;
1209 let pyenv = pyenv.display();
1210 activation_script.extend(match shell {
1211 ShellKind::Fish => Some(format!("\"{pyenv}\" shell - fish {version}")),
1212 ShellKind::Posix => Some(format!("\"{pyenv}\" shell - sh {version}")),
1213 ShellKind::Nushell => Some(format!("^\"{pyenv}\" shell - nu {version}")),
1214 ShellKind::PowerShell => None,
1215 ShellKind::Csh => None,
1216 ShellKind::Tcsh => None,
1217 ShellKind::Cmd => None,
1218 ShellKind::Rc => None,
1219 ShellKind::Xonsh => None,
1220 ShellKind::Elvish => 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(&text.as_str().into(), 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(&text.as_str().into(), 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}