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