Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:28:44.407Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

python.rs

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