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 })
1221 }
1222 _ => {}
1223 }
1224 activation_script
1225 }
1226}
1227
1228async fn venv_to_toolchain(venv: PythonEnvironment, fs: &dyn Fs) -> Option<Toolchain> {
1229 let mut name = String::from("Python");
1230 if let Some(ref version) = venv.version {
1231 _ = write!(name, " {version}");
1232 }
1233
1234 let name_and_kind = match (&venv.name, &venv.kind) {
1235 (Some(name), Some(kind)) => Some(format!("({name}; {})", python_env_kind_display(kind))),
1236 (Some(name), None) => Some(format!("({name})")),
1237 (None, Some(kind)) => Some(format!("({})", python_env_kind_display(kind))),
1238 (None, None) => None,
1239 };
1240
1241 if let Some(nk) = name_and_kind {
1242 _ = write!(name, " {nk}");
1243 }
1244
1245 let mut activation_scripts = HashMap::default();
1246 match venv.kind {
1247 Some(PythonEnvironmentKind::Venv | PythonEnvironmentKind::VirtualEnv) => {
1248 resolve_venv_activation_scripts(&venv, fs, &mut activation_scripts).await
1249 }
1250 _ => {}
1251 }
1252 let data = PythonToolchainData {
1253 environment: venv,
1254 activation_scripts: Some(activation_scripts),
1255 };
1256
1257 Some(Toolchain {
1258 name: name.into(),
1259 path: data
1260 .environment
1261 .executable
1262 .as_ref()?
1263 .to_str()?
1264 .to_owned()
1265 .into(),
1266 language_name: LanguageName::new("Python"),
1267 as_json: serde_json::to_value(data).ok()?,
1268 })
1269}
1270
1271async fn resolve_venv_activation_scripts(
1272 venv: &PythonEnvironment,
1273 fs: &dyn Fs,
1274 activation_scripts: &mut HashMap<ShellKind, PathBuf>,
1275) {
1276 log::debug!("(Python) Resolving activation scripts for venv toolchain {venv:?}");
1277 if let Some(prefix) = &venv.prefix {
1278 for (shell_kind, script_name) in &[
1279 (ShellKind::Posix, "activate"),
1280 (ShellKind::Rc, "activate"),
1281 (ShellKind::Csh, "activate.csh"),
1282 (ShellKind::Tcsh, "activate.csh"),
1283 (ShellKind::Fish, "activate.fish"),
1284 (ShellKind::Nushell, "activate.nu"),
1285 (ShellKind::PowerShell, "activate.ps1"),
1286 (ShellKind::Cmd, "activate.bat"),
1287 (ShellKind::Xonsh, "activate.xsh"),
1288 ] {
1289 let path = prefix.join(BINARY_DIR).join(script_name);
1290
1291 log::debug!("Trying path: {}", path.display());
1292
1293 if fs.is_file(&path).await {
1294 activation_scripts.insert(*shell_kind, path);
1295 }
1296 }
1297 }
1298}
1299
1300pub struct EnvironmentApi<'a> {
1301 global_search_locations: Arc<Mutex<Vec<PathBuf>>>,
1302 project_env: &'a HashMap<String, String>,
1303 pet_env: pet_core::os_environment::EnvironmentApi,
1304}
1305
1306impl<'a> EnvironmentApi<'a> {
1307 pub fn from_env(project_env: &'a HashMap<String, String>) -> Self {
1308 let paths = project_env
1309 .get("PATH")
1310 .map(|p| std::env::split_paths(p).collect())
1311 .unwrap_or_default();
1312
1313 EnvironmentApi {
1314 global_search_locations: Arc::new(Mutex::new(paths)),
1315 project_env,
1316 pet_env: pet_core::os_environment::EnvironmentApi::new(),
1317 }
1318 }
1319
1320 fn user_home(&self) -> Option<PathBuf> {
1321 self.project_env
1322 .get("HOME")
1323 .or_else(|| self.project_env.get("USERPROFILE"))
1324 .map(|home| pet_fs::path::norm_case(PathBuf::from(home)))
1325 .or_else(|| self.pet_env.get_user_home())
1326 }
1327}
1328
1329impl pet_core::os_environment::Environment for EnvironmentApi<'_> {
1330 fn get_user_home(&self) -> Option<PathBuf> {
1331 self.user_home()
1332 }
1333
1334 fn get_root(&self) -> Option<PathBuf> {
1335 None
1336 }
1337
1338 fn get_env_var(&self, key: String) -> Option<String> {
1339 self.project_env
1340 .get(&key)
1341 .cloned()
1342 .or_else(|| self.pet_env.get_env_var(key))
1343 }
1344
1345 fn get_know_global_search_locations(&self) -> Vec<PathBuf> {
1346 if self.global_search_locations.lock().is_empty() {
1347 let mut paths = std::env::split_paths(
1348 &self
1349 .get_env_var("PATH".to_string())
1350 .or_else(|| self.get_env_var("Path".to_string()))
1351 .unwrap_or_default(),
1352 )
1353 .collect::<Vec<PathBuf>>();
1354
1355 log::trace!("Env PATH: {:?}", paths);
1356 for p in self.pet_env.get_know_global_search_locations() {
1357 if !paths.contains(&p) {
1358 paths.push(p);
1359 }
1360 }
1361
1362 let mut paths = paths
1363 .into_iter()
1364 .filter(|p| p.exists())
1365 .collect::<Vec<PathBuf>>();
1366
1367 self.global_search_locations.lock().append(&mut paths);
1368 }
1369 self.global_search_locations.lock().clone()
1370 }
1371}
1372
1373pub(crate) struct PyLspAdapter {
1374 python_venv_base: OnceCell<Result<Arc<Path>, String>>,
1375}
1376impl PyLspAdapter {
1377 const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("pylsp");
1378 pub(crate) fn new() -> Self {
1379 Self {
1380 python_venv_base: OnceCell::new(),
1381 }
1382 }
1383 async fn ensure_venv(delegate: &dyn LspAdapterDelegate) -> Result<Arc<Path>> {
1384 let python_path = Self::find_base_python(delegate)
1385 .await
1386 .with_context(|| {
1387 let mut message = "Could not find Python installation for PyLSP".to_owned();
1388 if cfg!(windows){
1389 message.push_str(". Install Python from the Microsoft Store, or manually from https://www.python.org/downloads/windows.")
1390 }
1391 message
1392 })?;
1393 let work_dir = delegate
1394 .language_server_download_dir(&Self::SERVER_NAME)
1395 .await
1396 .context("Could not get working directory for PyLSP")?;
1397 let mut path = PathBuf::from(work_dir.as_ref());
1398 path.push("pylsp-venv");
1399 if !path.exists() {
1400 util::command::new_smol_command(python_path)
1401 .arg("-m")
1402 .arg("venv")
1403 .arg("pylsp-venv")
1404 .current_dir(work_dir)
1405 .spawn()?
1406 .output()
1407 .await?;
1408 }
1409
1410 Ok(path.into())
1411 }
1412 // Find "baseline", user python version from which we'll create our own venv.
1413 async fn find_base_python(delegate: &dyn LspAdapterDelegate) -> Option<PathBuf> {
1414 for path in ["python3", "python"] {
1415 let Some(path) = delegate.which(path.as_ref()).await else {
1416 continue;
1417 };
1418 // Try to detect situations where `python3` exists but is not a real Python interpreter.
1419 // Notably, on fresh Windows installs, `python3` is a shim that opens the Microsoft Store app
1420 // when run with no arguments, and just fails otherwise.
1421 let Some(output) = new_smol_command(&path)
1422 .args(["-c", "print(1 + 2)"])
1423 .output()
1424 .await
1425 .ok()
1426 else {
1427 continue;
1428 };
1429 if output.stdout.trim_ascii() != b"3" {
1430 continue;
1431 }
1432 return Some(path);
1433 }
1434 None
1435 }
1436
1437 async fn base_venv(&self, delegate: &dyn LspAdapterDelegate) -> Result<Arc<Path>, String> {
1438 self.python_venv_base
1439 .get_or_init(move || async move {
1440 Self::ensure_venv(delegate)
1441 .await
1442 .map_err(|e| format!("{e}"))
1443 })
1444 .await
1445 .clone()
1446 }
1447}
1448
1449const BINARY_DIR: &str = if cfg!(target_os = "windows") {
1450 "Scripts"
1451} else {
1452 "bin"
1453};
1454
1455#[async_trait(?Send)]
1456impl LspAdapter for PyLspAdapter {
1457 fn name(&self) -> LanguageServerName {
1458 Self::SERVER_NAME
1459 }
1460
1461 async fn process_completions(&self, _items: &mut [lsp::CompletionItem]) {}
1462
1463 async fn label_for_completion(
1464 &self,
1465 item: &lsp::CompletionItem,
1466 language: &Arc<language::Language>,
1467 ) -> Option<language::CodeLabel> {
1468 let label = &item.label;
1469 let grammar = language.grammar()?;
1470 let highlight_id = match item.kind? {
1471 lsp::CompletionItemKind::METHOD => grammar.highlight_id_for_name("function.method")?,
1472 lsp::CompletionItemKind::FUNCTION => grammar.highlight_id_for_name("function")?,
1473 lsp::CompletionItemKind::CLASS => grammar.highlight_id_for_name("type")?,
1474 lsp::CompletionItemKind::CONSTANT => grammar.highlight_id_for_name("constant")?,
1475 _ => return None,
1476 };
1477 Some(language::CodeLabel::filtered(
1478 label.clone(),
1479 item.filter_text.as_deref(),
1480 vec![(0..label.len(), highlight_id)],
1481 ))
1482 }
1483
1484 async fn label_for_symbol(
1485 &self,
1486 name: &str,
1487 kind: lsp::SymbolKind,
1488 language: &Arc<language::Language>,
1489 ) -> Option<language::CodeLabel> {
1490 let (text, filter_range, display_range) = match kind {
1491 lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => {
1492 let text = format!("def {}():\n", name);
1493 let filter_range = 4..4 + name.len();
1494 let display_range = 0..filter_range.end;
1495 (text, filter_range, display_range)
1496 }
1497 lsp::SymbolKind::CLASS => {
1498 let text = format!("class {}:", name);
1499 let filter_range = 6..6 + name.len();
1500 let display_range = 0..filter_range.end;
1501 (text, filter_range, display_range)
1502 }
1503 lsp::SymbolKind::CONSTANT => {
1504 let text = format!("{} = 0", name);
1505 let filter_range = 0..name.len();
1506 let display_range = 0..filter_range.end;
1507 (text, filter_range, display_range)
1508 }
1509 _ => return None,
1510 };
1511 Some(language::CodeLabel::new(
1512 text[display_range.clone()].to_string(),
1513 filter_range,
1514 language.highlight_text(&text.as_str().into(), display_range),
1515 ))
1516 }
1517
1518 async fn workspace_configuration(
1519 self: Arc<Self>,
1520 adapter: &Arc<dyn LspAdapterDelegate>,
1521 toolchain: Option<Toolchain>,
1522 cx: &mut AsyncApp,
1523 ) -> Result<Value> {
1524 cx.update(move |cx| {
1525 let mut user_settings =
1526 language_server_settings(adapter.as_ref(), &Self::SERVER_NAME, cx)
1527 .and_then(|s| s.settings.clone())
1528 .unwrap_or_else(|| {
1529 json!({
1530 "plugins": {
1531 "pycodestyle": {"enabled": false},
1532 "rope_autoimport": {"enabled": true, "memory": true},
1533 "pylsp_mypy": {"enabled": false}
1534 },
1535 "rope": {
1536 "ropeFolder": null
1537 },
1538 })
1539 });
1540
1541 // If user did not explicitly modify their python venv, use one from picker.
1542 if let Some(toolchain) = toolchain {
1543 if !user_settings.is_object() {
1544 user_settings = Value::Object(serde_json::Map::default());
1545 }
1546 let object = user_settings.as_object_mut().unwrap();
1547 if let Some(python) = object
1548 .entry("plugins")
1549 .or_insert(Value::Object(serde_json::Map::default()))
1550 .as_object_mut()
1551 {
1552 if let Some(jedi) = python
1553 .entry("jedi")
1554 .or_insert(Value::Object(serde_json::Map::default()))
1555 .as_object_mut()
1556 {
1557 jedi.entry("environment".to_string())
1558 .or_insert_with(|| Value::String(toolchain.path.clone().into()));
1559 }
1560 if let Some(pylint) = python
1561 .entry("pylsp_mypy")
1562 .or_insert(Value::Object(serde_json::Map::default()))
1563 .as_object_mut()
1564 {
1565 pylint.entry("overrides".to_string()).or_insert_with(|| {
1566 Value::Array(vec![
1567 Value::String("--python-executable".into()),
1568 Value::String(toolchain.path.into()),
1569 Value::String("--cache-dir=/dev/null".into()),
1570 Value::Bool(true),
1571 ])
1572 });
1573 }
1574 }
1575 }
1576 user_settings = Value::Object(serde_json::Map::from_iter([(
1577 "pylsp".to_string(),
1578 user_settings,
1579 )]));
1580
1581 user_settings
1582 })
1583 }
1584}
1585
1586impl LspInstaller for PyLspAdapter {
1587 type BinaryVersion = ();
1588 async fn check_if_user_installed(
1589 &self,
1590 delegate: &dyn LspAdapterDelegate,
1591 toolchain: Option<Toolchain>,
1592 _: &AsyncApp,
1593 ) -> Option<LanguageServerBinary> {
1594 if let Some(pylsp_bin) = delegate.which(Self::SERVER_NAME.as_ref()).await {
1595 let env = delegate.shell_env().await;
1596 Some(LanguageServerBinary {
1597 path: pylsp_bin,
1598 env: Some(env),
1599 arguments: vec![],
1600 })
1601 } else {
1602 let toolchain = toolchain?;
1603 let pylsp_path = Path::new(toolchain.path.as_ref()).parent()?.join("pylsp");
1604 pylsp_path.exists().then(|| LanguageServerBinary {
1605 path: toolchain.path.to_string().into(),
1606 arguments: vec![pylsp_path.into()],
1607 env: None,
1608 })
1609 }
1610 }
1611
1612 async fn fetch_latest_server_version(
1613 &self,
1614 _: &dyn LspAdapterDelegate,
1615 _: bool,
1616 _: &mut AsyncApp,
1617 ) -> Result<()> {
1618 Ok(())
1619 }
1620
1621 async fn fetch_server_binary(
1622 &self,
1623 _: (),
1624 _: PathBuf,
1625 delegate: &dyn LspAdapterDelegate,
1626 ) -> Result<LanguageServerBinary> {
1627 let venv = self.base_venv(delegate).await.map_err(|e| anyhow!(e))?;
1628 let pip_path = venv.join(BINARY_DIR).join("pip3");
1629 ensure!(
1630 util::command::new_smol_command(pip_path.as_path())
1631 .arg("install")
1632 .arg("python-lsp-server[all]")
1633 .arg("--upgrade")
1634 .output()
1635 .await?
1636 .status
1637 .success(),
1638 "python-lsp-server[all] installation failed"
1639 );
1640 ensure!(
1641 util::command::new_smol_command(pip_path)
1642 .arg("install")
1643 .arg("pylsp-mypy")
1644 .arg("--upgrade")
1645 .output()
1646 .await?
1647 .status
1648 .success(),
1649 "pylsp-mypy installation failed"
1650 );
1651 let pylsp = venv.join(BINARY_DIR).join("pylsp");
1652 ensure!(
1653 delegate.which(pylsp.as_os_str()).await.is_some(),
1654 "pylsp installation was incomplete"
1655 );
1656 Ok(LanguageServerBinary {
1657 path: pylsp,
1658 env: None,
1659 arguments: vec![],
1660 })
1661 }
1662
1663 async fn cached_server_binary(
1664 &self,
1665 _: PathBuf,
1666 delegate: &dyn LspAdapterDelegate,
1667 ) -> Option<LanguageServerBinary> {
1668 let venv = self.base_venv(delegate).await.ok()?;
1669 let pylsp = venv.join(BINARY_DIR).join("pylsp");
1670 delegate.which(pylsp.as_os_str()).await?;
1671 Some(LanguageServerBinary {
1672 path: pylsp,
1673 env: None,
1674 arguments: vec![],
1675 })
1676 }
1677}
1678
1679pub(crate) struct BasedPyrightLspAdapter {
1680 node: NodeRuntime,
1681}
1682
1683impl BasedPyrightLspAdapter {
1684 const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("basedpyright");
1685 const BINARY_NAME: &'static str = "basedpyright-langserver";
1686 const SERVER_PATH: &str = "node_modules/basedpyright/langserver.index.js";
1687 const NODE_MODULE_RELATIVE_SERVER_PATH: &str = "basedpyright/langserver.index.js";
1688
1689 pub(crate) fn new(node: NodeRuntime) -> Self {
1690 BasedPyrightLspAdapter { node }
1691 }
1692
1693 async fn get_cached_server_binary(
1694 container_dir: PathBuf,
1695 node: &NodeRuntime,
1696 ) -> Option<LanguageServerBinary> {
1697 let server_path = container_dir.join(Self::SERVER_PATH);
1698 if server_path.exists() {
1699 Some(LanguageServerBinary {
1700 path: node.binary_path().await.log_err()?,
1701 env: None,
1702 arguments: vec![server_path.into(), "--stdio".into()],
1703 })
1704 } else {
1705 log::error!("missing executable in directory {:?}", server_path);
1706 None
1707 }
1708 }
1709}
1710
1711#[async_trait(?Send)]
1712impl LspAdapter for BasedPyrightLspAdapter {
1713 fn name(&self) -> LanguageServerName {
1714 Self::SERVER_NAME
1715 }
1716
1717 async fn initialization_options(
1718 self: Arc<Self>,
1719 _: &Arc<dyn LspAdapterDelegate>,
1720 ) -> Result<Option<Value>> {
1721 // Provide minimal initialization options
1722 // Virtual environment configuration will be handled through workspace configuration
1723 Ok(Some(json!({
1724 "python": {
1725 "analysis": {
1726 "autoSearchPaths": true,
1727 "useLibraryCodeForTypes": true,
1728 "autoImportCompletions": true
1729 }
1730 }
1731 })))
1732 }
1733
1734 async fn process_completions(&self, items: &mut [lsp::CompletionItem]) {
1735 process_pyright_completions(items);
1736 }
1737
1738 async fn label_for_completion(
1739 &self,
1740 item: &lsp::CompletionItem,
1741 language: &Arc<language::Language>,
1742 ) -> Option<language::CodeLabel> {
1743 let label = &item.label;
1744 let grammar = language.grammar()?;
1745 let highlight_id = match item.kind? {
1746 lsp::CompletionItemKind::METHOD => grammar.highlight_id_for_name("function.method"),
1747 lsp::CompletionItemKind::FUNCTION => grammar.highlight_id_for_name("function"),
1748 lsp::CompletionItemKind::CLASS => grammar.highlight_id_for_name("type"),
1749 lsp::CompletionItemKind::CONSTANT => grammar.highlight_id_for_name("constant"),
1750 lsp::CompletionItemKind::VARIABLE => grammar.highlight_id_for_name("variable"),
1751 _ => {
1752 return None;
1753 }
1754 };
1755 let mut text = label.clone();
1756 if let Some(completion_details) = item
1757 .label_details
1758 .as_ref()
1759 .and_then(|details| details.description.as_ref())
1760 {
1761 write!(&mut text, " {}", completion_details).ok();
1762 }
1763 Some(language::CodeLabel::filtered(
1764 text,
1765 item.filter_text.as_deref(),
1766 highlight_id
1767 .map(|id| (0..label.len(), id))
1768 .into_iter()
1769 .collect(),
1770 ))
1771 }
1772
1773 async fn label_for_symbol(
1774 &self,
1775 name: &str,
1776 kind: lsp::SymbolKind,
1777 language: &Arc<language::Language>,
1778 ) -> Option<language::CodeLabel> {
1779 let (text, filter_range, display_range) = match kind {
1780 lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => {
1781 let text = format!("def {}():\n", name);
1782 let filter_range = 4..4 + name.len();
1783 let display_range = 0..filter_range.end;
1784 (text, filter_range, display_range)
1785 }
1786 lsp::SymbolKind::CLASS => {
1787 let text = format!("class {}:", name);
1788 let filter_range = 6..6 + name.len();
1789 let display_range = 0..filter_range.end;
1790 (text, filter_range, display_range)
1791 }
1792 lsp::SymbolKind::CONSTANT => {
1793 let text = format!("{} = 0", name);
1794 let filter_range = 0..name.len();
1795 let display_range = 0..filter_range.end;
1796 (text, filter_range, display_range)
1797 }
1798 _ => return None,
1799 };
1800 Some(language::CodeLabel::new(
1801 text[display_range.clone()].to_string(),
1802 filter_range,
1803 language.highlight_text(&text.as_str().into(), display_range),
1804 ))
1805 }
1806
1807 async fn workspace_configuration(
1808 self: Arc<Self>,
1809 adapter: &Arc<dyn LspAdapterDelegate>,
1810 toolchain: Option<Toolchain>,
1811 cx: &mut AsyncApp,
1812 ) -> Result<Value> {
1813 cx.update(move |cx| {
1814 let mut user_settings =
1815 language_server_settings(adapter.as_ref(), &Self::SERVER_NAME, cx)
1816 .and_then(|s| s.settings.clone())
1817 .unwrap_or_default();
1818
1819 // If we have a detected toolchain, configure Pyright to use it
1820 if let Some(toolchain) = toolchain
1821 && let Ok(env) = serde_json::from_value::<
1822 pet_core::python_environment::PythonEnvironment,
1823 >(toolchain.as_json.clone())
1824 {
1825 if !user_settings.is_object() {
1826 user_settings = Value::Object(serde_json::Map::default());
1827 }
1828 let object = user_settings.as_object_mut().unwrap();
1829
1830 let interpreter_path = toolchain.path.to_string();
1831 if let Some(venv_dir) = env.prefix {
1832 // Set venvPath and venv at the root level
1833 // This matches the format of a pyrightconfig.json file
1834 if let Some(parent) = venv_dir.parent() {
1835 // Use relative path if the venv is inside the workspace
1836 let venv_path = if parent == adapter.worktree_root_path() {
1837 ".".to_string()
1838 } else {
1839 parent.to_string_lossy().into_owned()
1840 };
1841 object.insert("venvPath".to_string(), Value::String(venv_path));
1842 }
1843
1844 if let Some(venv_name) = venv_dir.file_name() {
1845 object.insert(
1846 "venv".to_owned(),
1847 Value::String(venv_name.to_string_lossy().into_owned()),
1848 );
1849 }
1850 }
1851
1852 // Set both pythonPath and defaultInterpreterPath for compatibility
1853 if let Some(python) = object
1854 .entry("python")
1855 .or_insert(Value::Object(serde_json::Map::default()))
1856 .as_object_mut()
1857 {
1858 python.insert(
1859 "pythonPath".to_owned(),
1860 Value::String(interpreter_path.clone()),
1861 );
1862 python.insert(
1863 "defaultInterpreterPath".to_owned(),
1864 Value::String(interpreter_path),
1865 );
1866 }
1867 // Basedpyright by default uses `strict` type checking, we tone it down as to not surpris users
1868 maybe!({
1869 let basedpyright = object
1870 .entry("basedpyright")
1871 .or_insert(Value::Object(serde_json::Map::default()));
1872 let analysis = basedpyright
1873 .as_object_mut()?
1874 .entry("analysis")
1875 .or_insert(Value::Object(serde_json::Map::default()));
1876 if let serde_json::map::Entry::Vacant(v) =
1877 analysis.as_object_mut()?.entry("typeCheckingMode")
1878 {
1879 v.insert(Value::String("standard".to_owned()));
1880 }
1881 Some(())
1882 });
1883 }
1884
1885 user_settings
1886 })
1887 }
1888}
1889
1890impl LspInstaller for BasedPyrightLspAdapter {
1891 type BinaryVersion = String;
1892
1893 async fn fetch_latest_server_version(
1894 &self,
1895 _: &dyn LspAdapterDelegate,
1896 _: bool,
1897 _: &mut AsyncApp,
1898 ) -> Result<String> {
1899 self.node
1900 .npm_package_latest_version(Self::SERVER_NAME.as_ref())
1901 .await
1902 }
1903
1904 async fn check_if_user_installed(
1905 &self,
1906 delegate: &dyn LspAdapterDelegate,
1907 _: Option<Toolchain>,
1908 _: &AsyncApp,
1909 ) -> Option<LanguageServerBinary> {
1910 if let Some(path) = delegate.which(Self::BINARY_NAME.as_ref()).await {
1911 let env = delegate.shell_env().await;
1912 Some(LanguageServerBinary {
1913 path,
1914 env: Some(env),
1915 arguments: vec!["--stdio".into()],
1916 })
1917 } else {
1918 // TODO shouldn't this be self.node.binary_path()?
1919 let node = delegate.which("node".as_ref()).await?;
1920 let (node_modules_path, _) = delegate
1921 .npm_package_installed_version(Self::SERVER_NAME.as_ref())
1922 .await
1923 .log_err()??;
1924
1925 let path = node_modules_path.join(Self::NODE_MODULE_RELATIVE_SERVER_PATH);
1926
1927 let env = delegate.shell_env().await;
1928 Some(LanguageServerBinary {
1929 path: node,
1930 env: Some(env),
1931 arguments: vec![path.into(), "--stdio".into()],
1932 })
1933 }
1934 }
1935
1936 async fn fetch_server_binary(
1937 &self,
1938 latest_version: Self::BinaryVersion,
1939 container_dir: PathBuf,
1940 delegate: &dyn LspAdapterDelegate,
1941 ) -> Result<LanguageServerBinary> {
1942 let server_path = container_dir.join(Self::SERVER_PATH);
1943
1944 self.node
1945 .npm_install_packages(
1946 &container_dir,
1947 &[(Self::SERVER_NAME.as_ref(), latest_version.as_str())],
1948 )
1949 .await?;
1950
1951 let env = delegate.shell_env().await;
1952 Ok(LanguageServerBinary {
1953 path: self.node.binary_path().await?,
1954 env: Some(env),
1955 arguments: vec![server_path.into(), "--stdio".into()],
1956 })
1957 }
1958
1959 async fn check_if_version_installed(
1960 &self,
1961 version: &Self::BinaryVersion,
1962 container_dir: &PathBuf,
1963 delegate: &dyn LspAdapterDelegate,
1964 ) -> Option<LanguageServerBinary> {
1965 let server_path = container_dir.join(Self::SERVER_PATH);
1966
1967 let should_install_language_server = self
1968 .node
1969 .should_install_npm_package(
1970 Self::SERVER_NAME.as_ref(),
1971 &server_path,
1972 container_dir,
1973 VersionStrategy::Latest(version),
1974 )
1975 .await;
1976
1977 if should_install_language_server {
1978 None
1979 } else {
1980 let env = delegate.shell_env().await;
1981 Some(LanguageServerBinary {
1982 path: self.node.binary_path().await.ok()?,
1983 env: Some(env),
1984 arguments: vec![server_path.into(), "--stdio".into()],
1985 })
1986 }
1987 }
1988
1989 async fn cached_server_binary(
1990 &self,
1991 container_dir: PathBuf,
1992 delegate: &dyn LspAdapterDelegate,
1993 ) -> Option<LanguageServerBinary> {
1994 let mut binary = Self::get_cached_server_binary(container_dir, &self.node).await?;
1995 binary.env = Some(delegate.shell_env().await);
1996 Some(binary)
1997 }
1998}
1999
2000pub(crate) struct RuffLspAdapter {
2001 fs: Arc<dyn Fs>,
2002}
2003
2004#[cfg(target_os = "macos")]
2005impl RuffLspAdapter {
2006 const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
2007 const ARCH_SERVER_NAME: &str = "apple-darwin";
2008}
2009
2010#[cfg(target_os = "linux")]
2011impl RuffLspAdapter {
2012 const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
2013 const ARCH_SERVER_NAME: &str = "unknown-linux-gnu";
2014}
2015
2016#[cfg(target_os = "freebsd")]
2017impl RuffLspAdapter {
2018 const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
2019 const ARCH_SERVER_NAME: &str = "unknown-freebsd";
2020}
2021
2022#[cfg(target_os = "windows")]
2023impl RuffLspAdapter {
2024 const GITHUB_ASSET_KIND: AssetKind = AssetKind::Zip;
2025 const ARCH_SERVER_NAME: &str = "pc-windows-msvc";
2026}
2027
2028impl RuffLspAdapter {
2029 const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("ruff");
2030
2031 pub fn new(fs: Arc<dyn Fs>) -> RuffLspAdapter {
2032 RuffLspAdapter { fs }
2033 }
2034
2035 fn build_asset_name() -> Result<(String, String)> {
2036 let arch = match consts::ARCH {
2037 "x86" => "i686",
2038 _ => consts::ARCH,
2039 };
2040 let os = Self::ARCH_SERVER_NAME;
2041 let suffix = match consts::OS {
2042 "windows" => "zip",
2043 _ => "tar.gz",
2044 };
2045 let asset_name = format!("ruff-{arch}-{os}.{suffix}");
2046 let asset_stem = format!("ruff-{arch}-{os}");
2047 Ok((asset_stem, asset_name))
2048 }
2049}
2050
2051#[async_trait(?Send)]
2052impl LspAdapter for RuffLspAdapter {
2053 fn name(&self) -> LanguageServerName {
2054 Self::SERVER_NAME
2055 }
2056}
2057
2058impl LspInstaller for RuffLspAdapter {
2059 type BinaryVersion = GitHubLspBinaryVersion;
2060 async fn check_if_user_installed(
2061 &self,
2062 delegate: &dyn LspAdapterDelegate,
2063 toolchain: Option<Toolchain>,
2064 _: &AsyncApp,
2065 ) -> Option<LanguageServerBinary> {
2066 let ruff_in_venv = if let Some(toolchain) = toolchain
2067 && toolchain.language_name.as_ref() == "Python"
2068 {
2069 Path::new(toolchain.path.as_str())
2070 .parent()
2071 .map(|path| path.join("ruff"))
2072 } else {
2073 None
2074 };
2075
2076 for path in ruff_in_venv.into_iter().chain(["ruff".into()]) {
2077 if let Some(ruff_bin) = delegate.which(path.as_os_str()).await {
2078 let env = delegate.shell_env().await;
2079 return Some(LanguageServerBinary {
2080 path: ruff_bin,
2081 env: Some(env),
2082 arguments: vec!["server".into()],
2083 });
2084 }
2085 }
2086
2087 None
2088 }
2089
2090 async fn fetch_latest_server_version(
2091 &self,
2092 delegate: &dyn LspAdapterDelegate,
2093 _: bool,
2094 _: &mut AsyncApp,
2095 ) -> Result<GitHubLspBinaryVersion> {
2096 let release =
2097 latest_github_release("astral-sh/ruff", true, false, delegate.http_client()).await?;
2098 let (_, asset_name) = Self::build_asset_name()?;
2099 let asset = release
2100 .assets
2101 .into_iter()
2102 .find(|asset| asset.name == asset_name)
2103 .with_context(|| format!("no asset found matching `{asset_name:?}`"))?;
2104 Ok(GitHubLspBinaryVersion {
2105 name: release.tag_name,
2106 url: asset.browser_download_url,
2107 digest: asset.digest,
2108 })
2109 }
2110
2111 async fn fetch_server_binary(
2112 &self,
2113 latest_version: GitHubLspBinaryVersion,
2114 container_dir: PathBuf,
2115 delegate: &dyn LspAdapterDelegate,
2116 ) -> Result<LanguageServerBinary> {
2117 let GitHubLspBinaryVersion {
2118 name,
2119 url,
2120 digest: expected_digest,
2121 } = latest_version;
2122 let destination_path = container_dir.join(format!("ruff-{name}"));
2123 let server_path = match Self::GITHUB_ASSET_KIND {
2124 AssetKind::TarGz | AssetKind::Gz => destination_path
2125 .join(Self::build_asset_name()?.0)
2126 .join("ruff"),
2127 AssetKind::Zip => destination_path.clone().join("ruff.exe"),
2128 };
2129
2130 let binary = LanguageServerBinary {
2131 path: server_path.clone(),
2132 env: None,
2133 arguments: vec!["server".into()],
2134 };
2135
2136 let metadata_path = destination_path.with_extension("metadata");
2137 let metadata = GithubBinaryMetadata::read_from_file(&metadata_path)
2138 .await
2139 .ok();
2140 if let Some(metadata) = metadata {
2141 let validity_check = async || {
2142 delegate
2143 .try_exec(LanguageServerBinary {
2144 path: server_path.clone(),
2145 arguments: vec!["--version".into()],
2146 env: None,
2147 })
2148 .await
2149 .inspect_err(|err| {
2150 log::warn!("Unable to run {server_path:?} asset, redownloading: {err}",)
2151 })
2152 };
2153 if let (Some(actual_digest), Some(expected_digest)) =
2154 (&metadata.digest, &expected_digest)
2155 {
2156 if actual_digest == expected_digest {
2157 if validity_check().await.is_ok() {
2158 return Ok(binary);
2159 }
2160 } else {
2161 log::info!(
2162 "SHA-256 mismatch for {destination_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}"
2163 );
2164 }
2165 } else if validity_check().await.is_ok() {
2166 return Ok(binary);
2167 }
2168 }
2169
2170 download_server_binary(
2171 &*delegate.http_client(),
2172 &url,
2173 expected_digest.as_deref(),
2174 &destination_path,
2175 Self::GITHUB_ASSET_KIND,
2176 )
2177 .await?;
2178 make_file_executable(&server_path).await?;
2179 remove_matching(&container_dir, |path| path != destination_path).await;
2180 GithubBinaryMetadata::write_to_file(
2181 &GithubBinaryMetadata {
2182 metadata_version: 1,
2183 digest: expected_digest,
2184 },
2185 &metadata_path,
2186 )
2187 .await?;
2188
2189 Ok(LanguageServerBinary {
2190 path: server_path,
2191 env: None,
2192 arguments: vec!["server".into()],
2193 })
2194 }
2195
2196 async fn cached_server_binary(
2197 &self,
2198 container_dir: PathBuf,
2199 _: &dyn LspAdapterDelegate,
2200 ) -> Option<LanguageServerBinary> {
2201 maybe!(async {
2202 let mut last = None;
2203 let mut entries = self.fs.read_dir(&container_dir).await?;
2204 while let Some(entry) = entries.next().await {
2205 let path = entry?;
2206 if path.extension().is_some_and(|ext| ext == "metadata") {
2207 continue;
2208 }
2209 last = Some(path);
2210 }
2211
2212 let path = last.context("no cached binary")?;
2213 let path = match Self::GITHUB_ASSET_KIND {
2214 AssetKind::TarGz | AssetKind::Gz => {
2215 path.join(Self::build_asset_name()?.0).join("ruff")
2216 }
2217 AssetKind::Zip => path.join("ruff.exe"),
2218 };
2219
2220 anyhow::Ok(LanguageServerBinary {
2221 path,
2222 env: None,
2223 arguments: vec!["server".into()],
2224 })
2225 })
2226 .await
2227 .log_err()
2228 }
2229}
2230
2231#[cfg(test)]
2232mod tests {
2233 use gpui::{AppContext as _, BorrowAppContext, Context, TestAppContext};
2234 use language::{AutoindentMode, Buffer};
2235 use settings::SettingsStore;
2236 use std::num::NonZeroU32;
2237
2238 #[gpui::test]
2239 async fn test_python_autoindent(cx: &mut TestAppContext) {
2240 cx.executor().set_block_on_ticks(usize::MAX..=usize::MAX);
2241 let language = crate::language("python", tree_sitter_python::LANGUAGE.into());
2242 cx.update(|cx| {
2243 let test_settings = SettingsStore::test(cx);
2244 cx.set_global(test_settings);
2245 language::init(cx);
2246 cx.update_global::<SettingsStore, _>(|store, cx| {
2247 store.update_user_settings(cx, |s| {
2248 s.project.all_languages.defaults.tab_size = NonZeroU32::new(2);
2249 });
2250 });
2251 });
2252
2253 cx.new(|cx| {
2254 let mut buffer = Buffer::local("", cx).with_language(language, cx);
2255 let append = |buffer: &mut Buffer, text: &str, cx: &mut Context<Buffer>| {
2256 let ix = buffer.len();
2257 buffer.edit([(ix..ix, text)], Some(AutoindentMode::EachLine), cx);
2258 };
2259
2260 // indent after "def():"
2261 append(&mut buffer, "def a():\n", cx);
2262 assert_eq!(buffer.text(), "def a():\n ");
2263
2264 // preserve indent after blank line
2265 append(&mut buffer, "\n ", cx);
2266 assert_eq!(buffer.text(), "def a():\n \n ");
2267
2268 // indent after "if"
2269 append(&mut buffer, "if a:\n ", cx);
2270 assert_eq!(buffer.text(), "def a():\n \n if a:\n ");
2271
2272 // preserve indent after statement
2273 append(&mut buffer, "b()\n", cx);
2274 assert_eq!(buffer.text(), "def a():\n \n if a:\n b()\n ");
2275
2276 // preserve indent after statement
2277 append(&mut buffer, "else", cx);
2278 assert_eq!(buffer.text(), "def a():\n \n if a:\n b()\n else");
2279
2280 // dedent "else""
2281 append(&mut buffer, ":", cx);
2282 assert_eq!(buffer.text(), "def a():\n \n if a:\n b()\n else:");
2283
2284 // indent lines after else
2285 append(&mut buffer, "\n", cx);
2286 assert_eq!(
2287 buffer.text(),
2288 "def a():\n \n if a:\n b()\n else:\n "
2289 );
2290
2291 // indent after an open paren. the closing paren is not indented
2292 // because there is another token before it on the same line.
2293 append(&mut buffer, "foo(\n1)", cx);
2294 assert_eq!(
2295 buffer.text(),
2296 "def a():\n \n if a:\n b()\n else:\n foo(\n 1)"
2297 );
2298
2299 // dedent the closing paren if it is shifted to the beginning of the line
2300 let argument_ix = buffer.text().find('1').unwrap();
2301 buffer.edit(
2302 [(argument_ix..argument_ix + 1, "")],
2303 Some(AutoindentMode::EachLine),
2304 cx,
2305 );
2306 assert_eq!(
2307 buffer.text(),
2308 "def a():\n \n if a:\n b()\n else:\n foo(\n )"
2309 );
2310
2311 // preserve indent after the close paren
2312 append(&mut buffer, "\n", cx);
2313 assert_eq!(
2314 buffer.text(),
2315 "def a():\n \n if a:\n b()\n else:\n foo(\n )\n "
2316 );
2317
2318 // manually outdent the last line
2319 let end_whitespace_ix = buffer.len() - 4;
2320 buffer.edit(
2321 [(end_whitespace_ix..buffer.len(), "")],
2322 Some(AutoindentMode::EachLine),
2323 cx,
2324 );
2325 assert_eq!(
2326 buffer.text(),
2327 "def a():\n \n if a:\n b()\n else:\n foo(\n )\n"
2328 );
2329
2330 // preserve the newly reduced indentation on the next newline
2331 append(&mut buffer, "\n", cx);
2332 assert_eq!(
2333 buffer.text(),
2334 "def a():\n \n if a:\n b()\n else:\n foo(\n )\n\n"
2335 );
2336
2337 // reset to a for loop statement
2338 let statement = "for i in range(10):\n print(i)\n";
2339 buffer.edit([(0..buffer.len(), statement)], None, cx);
2340
2341 // insert single line comment after each line
2342 let eol_ixs = statement
2343 .char_indices()
2344 .filter_map(|(ix, c)| if c == '\n' { Some(ix) } else { None })
2345 .collect::<Vec<usize>>();
2346 let editions = eol_ixs
2347 .iter()
2348 .enumerate()
2349 .map(|(i, &eol_ix)| (eol_ix..eol_ix, format!(" # comment {}", i + 1)))
2350 .collect::<Vec<(std::ops::Range<usize>, String)>>();
2351 buffer.edit(editions, Some(AutoindentMode::EachLine), cx);
2352 assert_eq!(
2353 buffer.text(),
2354 "for i in range(10): # comment 1\n print(i) # comment 2\n"
2355 );
2356
2357 // reset to a simple if statement
2358 buffer.edit([(0..buffer.len(), "if a:\n b(\n )")], None, cx);
2359
2360 // dedent "else" on the line after a closing paren
2361 append(&mut buffer, "\n else:\n", cx);
2362 assert_eq!(buffer.text(), "if a:\n b(\n )\nelse:\n ");
2363
2364 buffer
2365 });
2366 }
2367}