1use crate::*;
   2use anyhow::{Context as _, bail};
   3use collections::HashMap;
   4use dap::{DebugRequest, StartDebuggingRequestArguments, adapters::DebugTaskDefinition};
   5use fs::RemoveOptions;
   6use futures::{StreamExt, TryStreamExt};
   7use gpui::http_client::AsyncBody;
   8use gpui::{AsyncApp, SharedString};
   9use json_dotpath::DotPaths;
  10use language::{LanguageName, Toolchain};
  11use paths::debug_adapters_dir;
  12use serde_json::Value;
  13use smol::fs::File;
  14use smol::io::AsyncReadExt;
  15use smol::lock::OnceCell;
  16use std::ffi::OsString;
  17use std::net::Ipv4Addr;
  18use std::str::FromStr;
  19use std::{
  20    ffi::OsStr,
  21    path::{Path, PathBuf},
  22};
  23use util::command::new_smol_command;
  24use util::{ResultExt, paths::PathStyle, rel_path::RelPath};
  25
  26enum DebugpyLaunchMode<'a> {
  27    Normal,
  28    AttachWithConnect { host: Option<&'a str> },
  29}
  30
  31#[derive(Default)]
  32pub(crate) struct PythonDebugAdapter {
  33    base_venv_path: OnceCell<Result<Arc<Path>, String>>,
  34    debugpy_whl_base_path: OnceCell<Result<Arc<Path>, String>>,
  35}
  36
  37impl PythonDebugAdapter {
  38    const ADAPTER_NAME: &'static str = "Debugpy";
  39    const DEBUG_ADAPTER_NAME: DebugAdapterName =
  40        DebugAdapterName(SharedString::new_static(Self::ADAPTER_NAME));
  41
  42    const LANGUAGE_NAME: &'static str = "Python";
  43
  44    async fn generate_debugpy_arguments<'a>(
  45        host: &'a Ipv4Addr,
  46        port: u16,
  47        launch_mode: DebugpyLaunchMode<'a>,
  48        user_installed_path: Option<&'a Path>,
  49        user_args: Option<Vec<String>>,
  50    ) -> Result<Vec<String>> {
  51        let mut args = if let Some(user_installed_path) = user_installed_path {
  52            log::debug!(
  53                "Using user-installed debugpy adapter from: {}",
  54                user_installed_path.display()
  55            );
  56            vec![user_installed_path.to_string_lossy().into_owned()]
  57        } else {
  58            let adapter_path = paths::debug_adapters_dir().join(Self::DEBUG_ADAPTER_NAME.as_ref());
  59            let path = adapter_path
  60                .join("debugpy")
  61                .join("adapter")
  62                .to_string_lossy()
  63                .into_owned();
  64            log::debug!("Using pip debugpy adapter from: {path}");
  65            vec![path]
  66        };
  67
  68        args.extend(if let Some(args) = user_args {
  69            args
  70        } else {
  71            match launch_mode {
  72                DebugpyLaunchMode::Normal => {
  73                    vec![format!("--host={}", host), format!("--port={}", port)]
  74                }
  75                DebugpyLaunchMode::AttachWithConnect { host } => {
  76                    let mut args = vec!["connect".to_string()];
  77
  78                    if let Some(host) = host {
  79                        args.push(format!("{host}:"));
  80                    }
  81                    args.push(format!("{port}"));
  82                    args
  83                }
  84            }
  85        });
  86        Ok(args)
  87    }
  88
  89    async fn request_args(
  90        &self,
  91        delegate: &Arc<dyn DapDelegate>,
  92        task_definition: &DebugTaskDefinition,
  93    ) -> Result<StartDebuggingRequestArguments> {
  94        let request = self.request_kind(&task_definition.config).await?;
  95
  96        let mut configuration = task_definition.config.clone();
  97        if let Ok(console) = configuration.dot_get_mut("console") {
  98            // Use built-in Zed terminal if user did not explicitly provide a setting for console.
  99            if console.is_null() {
 100                *console = Value::String("integratedTerminal".into());
 101            }
 102        }
 103
 104        if let Some(obj) = configuration.as_object_mut() {
 105            obj.entry("cwd")
 106                .or_insert(delegate.worktree_root_path().to_string_lossy().into());
 107        }
 108
 109        Ok(StartDebuggingRequestArguments {
 110            configuration,
 111            request,
 112        })
 113    }
 114
 115    async fn fetch_wheel(
 116        &self,
 117        toolchain: Option<Toolchain>,
 118        delegate: &Arc<dyn DapDelegate>,
 119    ) -> Result<Arc<Path>> {
 120        let download_dir = debug_adapters_dir().join(Self::ADAPTER_NAME).join("wheels");
 121        std::fs::create_dir_all(&download_dir)?;
 122        let venv_python = self.base_venv_path(toolchain, delegate).await?;
 123
 124        let installation_succeeded = util::command::new_smol_command(venv_python.as_ref())
 125            .args([
 126                "-m",
 127                "pip",
 128                "download",
 129                "debugpy",
 130                "--only-binary=:all:",
 131                "-d",
 132                download_dir.to_string_lossy().as_ref(),
 133            ])
 134            .output()
 135            .await
 136            .context("spawn system python")?
 137            .status
 138            .success();
 139        if !installation_succeeded {
 140            bail!("debugpy installation failed (could not fetch Debugpy's wheel)");
 141        }
 142
 143        let wheel_path = std::fs::read_dir(&download_dir)?
 144            .find_map(|entry| {
 145                entry.ok().filter(|e| {
 146                    e.file_type().is_ok_and(|typ| typ.is_file())
 147                        && Path::new(&e.file_name()).extension() == Some("whl".as_ref())
 148                })
 149            })
 150            .with_context(|| format!("Did not find a .whl in {download_dir:?}"))?;
 151
 152        util::archive::extract_zip(
 153            &debug_adapters_dir().join(Self::ADAPTER_NAME),
 154            File::open(&wheel_path.path()).await?,
 155        )
 156        .await?;
 157
 158        Ok(Arc::from(wheel_path.path()))
 159    }
 160
 161    async fn maybe_fetch_new_wheel(
 162        &self,
 163        toolchain: Option<Toolchain>,
 164        delegate: &Arc<dyn DapDelegate>,
 165    ) -> Result<()> {
 166        let latest_release = delegate
 167            .http_client()
 168            .get(
 169                "https://pypi.org/pypi/debugpy/json",
 170                AsyncBody::empty(),
 171                false,
 172            )
 173            .await
 174            .log_err();
 175        let response = latest_release
 176            .filter(|response| response.status().is_success())
 177            .context("getting latest release")?;
 178
 179        let download_dir = debug_adapters_dir().join(Self::ADAPTER_NAME);
 180        std::fs::create_dir_all(&download_dir)?;
 181
 182        let mut output = String::new();
 183        response.into_body().read_to_string(&mut output).await?;
 184        let as_json = serde_json::Value::from_str(&output)?;
 185        let latest_version = as_json
 186            .get("info")
 187            .and_then(|info| {
 188                info.get("version")
 189                    .and_then(|version| version.as_str())
 190                    .map(ToOwned::to_owned)
 191            })
 192            .context("parsing latest release information")?;
 193        let dist_info_dirname: OsString = format!("debugpy-{latest_version}.dist-info").into();
 194        let is_up_to_date = delegate
 195            .fs()
 196            .read_dir(&debug_adapters_dir().join(Self::ADAPTER_NAME))
 197            .await?
 198            .into_stream()
 199            .any(async |entry| {
 200                entry.is_ok_and(|e| e.file_name().is_some_and(|name| name == dist_info_dirname))
 201            })
 202            .await;
 203
 204        if !is_up_to_date {
 205            delegate
 206                .fs()
 207                .remove_dir(
 208                    &debug_adapters_dir().join(Self::ADAPTER_NAME),
 209                    RemoveOptions {
 210                        recursive: true,
 211                        ignore_if_not_exists: true,
 212                    },
 213                )
 214                .await?;
 215            self.fetch_wheel(toolchain, delegate).await?;
 216        }
 217        anyhow::Ok(())
 218    }
 219
 220    async fn fetch_debugpy_whl(
 221        &self,
 222        toolchain: Option<Toolchain>,
 223        delegate: &Arc<dyn DapDelegate>,
 224    ) -> Result<Arc<Path>, String> {
 225        self.debugpy_whl_base_path
 226            .get_or_init(|| async move {
 227                self.maybe_fetch_new_wheel(toolchain, delegate)
 228                    .await
 229                    .map_err(|e| format!("{e}"))?;
 230                Ok(Arc::from(
 231                    debug_adapters_dir()
 232                        .join(Self::ADAPTER_NAME)
 233                        .join("debugpy")
 234                        .join("adapter")
 235                        .as_ref(),
 236                ))
 237            })
 238            .await
 239            .clone()
 240    }
 241
 242    async fn base_venv_path(
 243        &self,
 244        toolchain: Option<Toolchain>,
 245        delegate: &Arc<dyn DapDelegate>,
 246    ) -> Result<Arc<Path>> {
 247        let result = self.base_venv_path
 248            .get_or_init(|| async {
 249                let base_python = if let Some(toolchain) = toolchain {
 250                    toolchain.path.to_string()
 251                } else {
 252                    Self::system_python_name(delegate).await.ok_or_else(|| {
 253                        let mut message = "Could not find a Python installation".to_owned();
 254                        if cfg!(windows){
 255                            message.push_str(". Install Python from the Microsoft Store, or manually from https://www.python.org/downloads/windows.")
 256                        }
 257                        message
 258                    })?
 259                };
 260
 261                let debug_adapter_path = paths::debug_adapters_dir().join(Self::DEBUG_ADAPTER_NAME.as_ref());
 262                let output = util::command::new_smol_command(&base_python)
 263                    .args(["-m", "venv", "zed_base_venv"])
 264                    .current_dir(
 265                        &debug_adapter_path,
 266                    )
 267                    .spawn()
 268                    .map_err(|e| format!("{e:#?}"))?
 269                    .output()
 270                    .await
 271                    .map_err(|e| format!("{e:#?}"))?;
 272
 273                if !output.status.success() {
 274                    let stderr = String::from_utf8_lossy(&output.stderr);
 275                    let stdout = String::from_utf8_lossy(&output.stdout);
 276                    let debug_adapter_path = debug_adapter_path.display();
 277                    return Err(format!("Failed to create base virtual environment with {base_python} in:\n{debug_adapter_path}\nstderr:\n{stderr}\nstdout:\n{stdout}\n"));
 278                }
 279
 280                const PYTHON_PATH: &str = if cfg!(target_os = "windows") {
 281                    "Scripts/python.exe"
 282                } else {
 283                    "bin/python3"
 284                };
 285                Ok(Arc::from(
 286                    paths::debug_adapters_dir()
 287                        .join(Self::DEBUG_ADAPTER_NAME.as_ref())
 288                        .join("zed_base_venv")
 289                        .join(PYTHON_PATH)
 290                        .as_ref(),
 291                ))
 292            })
 293            .await
 294            .clone();
 295        match result {
 296            Ok(path) => Ok(path),
 297            Err(e) => Err(anyhow::anyhow!("{e}")),
 298        }
 299    }
 300    async fn system_python_name(delegate: &Arc<dyn DapDelegate>) -> Option<String> {
 301        const BINARY_NAMES: [&str; 3] = ["python3", "python", "py"];
 302        let mut name = None;
 303
 304        for cmd in BINARY_NAMES {
 305            let Some(path) = delegate.which(OsStr::new(cmd)).await else {
 306                continue;
 307            };
 308            // Try to detect situations where `python3` exists but is not a real Python interpreter.
 309            // Notably, on fresh Windows installs, `python3` is a shim that opens the Microsoft Store app
 310            // when run with no arguments, and just fails otherwise.
 311            let Some(output) = new_smol_command(&path)
 312                .args(["-c", "print(1 + 2)"])
 313                .output()
 314                .await
 315                .ok()
 316            else {
 317                continue;
 318            };
 319            if output.stdout.trim_ascii() != b"3" {
 320                continue;
 321            }
 322            name = Some(path.to_string_lossy().into_owned());
 323            break;
 324        }
 325        name
 326    }
 327
 328    async fn get_installed_binary(
 329        &self,
 330        delegate: &Arc<dyn DapDelegate>,
 331        config: &DebugTaskDefinition,
 332        user_installed_path: Option<PathBuf>,
 333        user_args: Option<Vec<String>>,
 334        user_env: Option<HashMap<String, String>>,
 335        python_from_toolchain: Option<String>,
 336    ) -> Result<DebugAdapterBinary> {
 337        let mut tcp_connection = config.tcp_connection.clone().unwrap_or_default();
 338
 339        let (config_port, config_host) = config
 340            .config
 341            .get("connect")
 342            .map(|value| {
 343                (
 344                    value
 345                        .get("port")
 346                        .and_then(|val| val.as_u64().map(|p| p as u16)),
 347                    value.get("host").and_then(|val| val.as_str()),
 348                )
 349            })
 350            .unwrap_or_else(|| {
 351                (
 352                    config
 353                        .config
 354                        .get("port")
 355                        .and_then(|port| port.as_u64().map(|p| p as u16)),
 356                    config.config.get("host").and_then(|host| host.as_str()),
 357                )
 358            });
 359
 360        let is_attach_with_connect = if config
 361            .config
 362            .get("request")
 363            .is_some_and(|val| val.as_str().is_some_and(|request| request == "attach"))
 364        {
 365            if tcp_connection.host.is_some() && config_host.is_some() {
 366                bail!("Cannot have two different hosts in debug configuration")
 367            } else if tcp_connection.port.is_some() && config_port.is_some() {
 368                bail!("Cannot have two different ports in debug configuration")
 369            }
 370
 371            tcp_connection.port = config_port;
 372            DebugpyLaunchMode::AttachWithConnect { host: config_host }
 373        } else {
 374            DebugpyLaunchMode::Normal
 375        };
 376
 377        let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?;
 378
 379        let python_path = if let Some(toolchain) = python_from_toolchain {
 380            Some(toolchain)
 381        } else {
 382            Self::system_python_name(delegate).await
 383        };
 384
 385        let python_command = python_path.context("failed to find binary path for Python")?;
 386        log::debug!("Using Python executable: {}", python_command);
 387
 388        let arguments = Self::generate_debugpy_arguments(
 389            &host,
 390            port,
 391            is_attach_with_connect,
 392            user_installed_path.as_deref(),
 393            user_args,
 394        )
 395        .await?;
 396
 397        log::debug!(
 398            "Starting debugpy adapter with command: {} {}",
 399            python_command,
 400            arguments.join(" ")
 401        );
 402
 403        Ok(DebugAdapterBinary {
 404            command: Some(python_command),
 405            arguments,
 406            connection: Some(adapters::TcpArguments {
 407                host,
 408                port,
 409                timeout,
 410            }),
 411            cwd: Some(delegate.worktree_root_path().to_path_buf()),
 412            envs: user_env.unwrap_or_default(),
 413            request_args: self.request_args(delegate, config).await?,
 414        })
 415    }
 416}
 417
 418#[async_trait(?Send)]
 419impl DebugAdapter for PythonDebugAdapter {
 420    fn name(&self) -> DebugAdapterName {
 421        Self::DEBUG_ADAPTER_NAME
 422    }
 423
 424    fn adapter_language_name(&self) -> Option<LanguageName> {
 425        Some(SharedString::new_static("Python").into())
 426    }
 427
 428    async fn config_from_zed_format(&self, zed_scenario: ZedDebugConfig) -> Result<DebugScenario> {
 429        let mut args = json!({
 430            "request": match zed_scenario.request {
 431                DebugRequest::Launch(_) => "launch",
 432                DebugRequest::Attach(_) => "attach",
 433            },
 434            "subProcess": true,
 435            "redirectOutput": true,
 436        });
 437
 438        let map = args.as_object_mut().unwrap();
 439        match &zed_scenario.request {
 440            DebugRequest::Attach(attach) => {
 441                map.insert("processId".into(), attach.process_id.into());
 442            }
 443            DebugRequest::Launch(launch) => {
 444                map.insert("program".into(), launch.program.clone().into());
 445                map.insert("args".into(), launch.args.clone().into());
 446                if !launch.env.is_empty() {
 447                    map.insert("env".into(), launch.env_json());
 448                }
 449
 450                if let Some(stop_on_entry) = zed_scenario.stop_on_entry {
 451                    map.insert("stopOnEntry".into(), stop_on_entry.into());
 452                }
 453                if let Some(cwd) = launch.cwd.as_ref() {
 454                    map.insert("cwd".into(), cwd.to_string_lossy().into_owned().into());
 455                }
 456            }
 457        }
 458
 459        Ok(DebugScenario {
 460            adapter: zed_scenario.adapter,
 461            label: zed_scenario.label,
 462            config: args,
 463            build: None,
 464            tcp_connection: None,
 465        })
 466    }
 467
 468    fn dap_schema(&self) -> serde_json::Value {
 469        json!({
 470            "properties": {
 471                "request": {
 472                    "type": "string",
 473                    "enum": ["attach", "launch"],
 474                    "description": "Debug adapter request type"
 475                },
 476                "autoReload": {
 477                    "default": {},
 478                    "description": "Configures automatic reload of code on edit.",
 479                    "properties": {
 480                        "enable": {
 481                            "default": false,
 482                            "description": "Automatically reload code on edit.",
 483                            "type": "boolean"
 484                        },
 485                        "exclude": {
 486                            "default": [
 487                                "**/.git/**",
 488                                "**/.metadata/**",
 489                                "**/__pycache__/**",
 490                                "**/node_modules/**",
 491                                "**/site-packages/**"
 492                            ],
 493                            "description": "Glob patterns of paths to exclude from auto reload.",
 494                            "items": {
 495                                "type": "string"
 496                            },
 497                            "type": "array"
 498                        },
 499                        "include": {
 500                            "default": [
 501                                "**/*.py",
 502                                "**/*.pyw"
 503                            ],
 504                            "description": "Glob patterns of paths to include in auto reload.",
 505                            "items": {
 506                                "type": "string"
 507                            },
 508                            "type": "array"
 509                        }
 510                    },
 511                    "type": "object"
 512                },
 513                "debugAdapterPath": {
 514                    "description": "Path (fully qualified) to the python debug adapter executable.",
 515                    "type": "string"
 516                },
 517                "django": {
 518                    "default": false,
 519                    "description": "Django debugging.",
 520                    "type": "boolean"
 521                },
 522                "jinja": {
 523                    "default": null,
 524                    "description": "Jinja template debugging (e.g. Flask).",
 525                    "enum": [
 526                        false,
 527                        null,
 528                        true
 529                    ]
 530                },
 531                "justMyCode": {
 532                    "default": true,
 533                    "description": "If true, show and debug only user-written code. If false, show and debug all code, including library calls.",
 534                    "type": "boolean"
 535                },
 536                "logToFile": {
 537                    "default": false,
 538                    "description": "Enable logging of debugger events to a log file. This file can be found in the debugpy extension install folder.",
 539                    "type": "boolean"
 540                },
 541                "pathMappings": {
 542                    "default": [],
 543                    "items": {
 544                        "label": "Path mapping",
 545                        "properties": {
 546                            "localRoot": {
 547                                "default": "${ZED_WORKTREE_ROOT}",
 548                                "label": "Local source root.",
 549                                "type": "string"
 550                            },
 551                            "remoteRoot": {
 552                                "default": "",
 553                                "label": "Remote source root.",
 554                                "type": "string"
 555                            }
 556                        },
 557                        "required": [
 558                            "localRoot",
 559                            "remoteRoot"
 560                        ],
 561                        "type": "object"
 562                    },
 563                    "label": "Path mappings.",
 564                    "type": "array"
 565                },
 566                "redirectOutput": {
 567                    "default": true,
 568                    "description": "Redirect output.",
 569                    "type": "boolean"
 570                },
 571                "showReturnValue": {
 572                    "default": true,
 573                    "description": "Show return value of functions when stepping.",
 574                    "type": "boolean"
 575                },
 576                "subProcess": {
 577                    "default": false,
 578                    "description": "Whether to enable Sub Process debugging",
 579                    "type": "boolean"
 580                },
 581                "consoleName": {
 582                    "default": "Python Debug Console",
 583                    "description": "Display name of the debug console or terminal",
 584                    "type": "string"
 585                },
 586                "clientOS": {
 587                    "default": null,
 588                    "description": "OS that VS code is using.",
 589                    "enum": [
 590                        "windows",
 591                        null,
 592                        "unix"
 593                    ]
 594                }
 595            },
 596            "required": ["request"],
 597            "allOf": [
 598                {
 599                    "if": {
 600                        "properties": {
 601                            "request": {
 602                                "enum": ["attach"]
 603                            }
 604                        }
 605                    },
 606                    "then": {
 607                        "properties": {
 608                            "connect": {
 609                                "label": "Attach by connecting to debugpy over a socket.",
 610                                "properties": {
 611                                    "host": {
 612                                        "default": "127.0.0.1",
 613                                        "description": "Hostname or IP address to connect to.",
 614                                        "type": "string"
 615                                    },
 616                                    "port": {
 617                                        "description": "Port to connect to.",
 618                                        "type": [
 619                                            "number",
 620                                            "string"
 621                                        ]
 622                                    }
 623                                },
 624                                "required": [
 625                                    "port"
 626                                ],
 627                                "type": "object"
 628                            },
 629                            "listen": {
 630                                "label": "Attach by listening for incoming socket connection from debugpy",
 631                                "properties": {
 632                                    "host": {
 633                                        "default": "127.0.0.1",
 634                                        "description": "Hostname or IP address of the interface to listen on.",
 635                                        "type": "string"
 636                                    },
 637                                    "port": {
 638                                        "description": "Port to listen on.",
 639                                        "type": [
 640                                            "number",
 641                                            "string"
 642                                        ]
 643                                    }
 644                                },
 645                                "required": [
 646                                    "port"
 647                                ],
 648                                "type": "object"
 649                            },
 650                            "processId": {
 651                                "anyOf": [
 652                                    {
 653                                        "default": "${command:pickProcess}",
 654                                        "description": "Use process picker to select a process to attach, or Process ID as integer.",
 655                                        "enum": [
 656                                            "${command:pickProcess}"
 657                                        ]
 658                                    },
 659                                    {
 660                                        "description": "ID of the local process to attach to.",
 661                                        "type": "integer"
 662                                    }
 663                                ]
 664                            }
 665                        }
 666                    }
 667                },
 668                {
 669                    "if": {
 670                        "properties": {
 671                            "request": {
 672                                "enum": ["launch"]
 673                            }
 674                        }
 675                    },
 676                    "then": {
 677                        "properties": {
 678                            "args": {
 679                                "default": [],
 680                                "description": "Command line arguments passed to the program. For string type arguments, it will pass through the shell as is, and therefore all shell variable expansions will apply. But for the array type, the values will be shell-escaped.",
 681                                "items": {
 682                                    "type": "string"
 683                                },
 684                                "anyOf": [
 685                                    {
 686                                        "default": "${command:pickArgs}",
 687                                        "enum": [
 688                                            "${command:pickArgs}"
 689                                        ]
 690                                    },
 691                                    {
 692                                        "type": [
 693                                            "array",
 694                                            "string"
 695                                        ]
 696                                    }
 697                                ]
 698                            },
 699                            "console": {
 700                                "default": "integratedTerminal",
 701                                "description": "Where to launch the debug target: internal console, integrated terminal, or external terminal.",
 702                                "enum": [
 703                                    "externalTerminal",
 704                                    "integratedTerminal",
 705                                    "internalConsole"
 706                                ]
 707                            },
 708                            "cwd": {
 709                                "default": "${ZED_WORKTREE_ROOT}",
 710                                "description": "Absolute path to the working directory of the program being debugged. Default is the root directory of the file (leave empty).",
 711                                "type": "string"
 712                            },
 713                            "autoStartBrowser": {
 714                                "default": false,
 715                                "description": "Open external browser to launch the application",
 716                                "type": "boolean"
 717                            },
 718                            "env": {
 719                                "additionalProperties": {
 720                                    "type": "string"
 721                                },
 722                                "default": {},
 723                                "description": "Environment variables defined as a key value pair. Property ends up being the Environment Variable and the value of the property ends up being the value of the Env Variable.",
 724                                "type": "object"
 725                            },
 726                            "envFile": {
 727                                "default": "${ZED_WORKTREE_ROOT}/.env",
 728                                "description": "Absolute path to a file containing environment variable definitions.",
 729                                "type": "string"
 730                            },
 731                            "gevent": {
 732                                "default": false,
 733                                "description": "Enable debugging of gevent monkey-patched code.",
 734                                "type": "boolean"
 735                            },
 736                            "module": {
 737                                "default": "",
 738                                "description": "Name of the module to be debugged.",
 739                                "type": "string"
 740                            },
 741                            "program": {
 742                                "default": "${ZED_FILE}",
 743                                "description": "Absolute path to the program.",
 744                                "type": "string"
 745                            },
 746                            "purpose": {
 747                                "default": [],
 748                                "description": "Tells extension to use this configuration for test debugging, or when using debug-in-terminal command.",
 749                                "items": {
 750                                    "enum": [
 751                                        "debug-test",
 752                                        "debug-in-terminal"
 753                                    ],
 754                                    "enumDescriptions": [
 755                                        "Use this configuration while debugging tests using test view or test debug commands.",
 756                                        "Use this configuration while debugging a file using debug in terminal button in the editor."
 757                                    ]
 758                                },
 759                                "type": "array"
 760                            },
 761                            "pyramid": {
 762                                "default": false,
 763                                "description": "Whether debugging Pyramid applications.",
 764                                "type": "boolean"
 765                            },
 766                            "python": {
 767                                "default": "${command:python.interpreterPath}",
 768                                "description": "Absolute path to the Python interpreter executable; overrides workspace configuration if set.",
 769                                "type": "string"
 770                            },
 771                            "pythonArgs": {
 772                                "default": [],
 773                                "description": "Command-line arguments passed to the Python interpreter. To pass arguments to the debug target, use \"args\".",
 774                                "items": {
 775                                    "type": "string"
 776                                },
 777                                "type": "array"
 778                            },
 779                            "stopOnEntry": {
 780                                "default": false,
 781                                "description": "Automatically stop after launch.",
 782                                "type": "boolean"
 783                            },
 784                            "sudo": {
 785                                "default": false,
 786                                "description": "Running debug program under elevated permissions (on Unix).",
 787                                "type": "boolean"
 788                            },
 789                            "guiEventLoop": {
 790                                "default": "matplotlib",
 791                                "description": "The GUI event loop that's going to run. Possible values: \"matplotlib\", \"wx\", \"qt\", \"none\", or a custom function that'll be imported and run.",
 792                                "type": "string"
 793                            }
 794                        }
 795                    }
 796                }
 797            ]
 798        })
 799    }
 800
 801    async fn get_binary(
 802        &self,
 803        delegate: &Arc<dyn DapDelegate>,
 804        config: &DebugTaskDefinition,
 805        user_installed_path: Option<PathBuf>,
 806        user_args: Option<Vec<String>>,
 807        user_env: Option<HashMap<String, String>>,
 808        cx: &mut AsyncApp,
 809    ) -> Result<DebugAdapterBinary> {
 810        if let Some(local_path) = &user_installed_path {
 811            log::debug!(
 812                "Using user-installed debugpy adapter from: {}",
 813                local_path.display()
 814            );
 815            return self
 816                .get_installed_binary(
 817                    delegate,
 818                    config,
 819                    Some(local_path.clone()),
 820                    user_args,
 821                    user_env,
 822                    None,
 823                )
 824                .await;
 825        }
 826
 827        let base_path = config
 828            .config
 829            .get("cwd")
 830            .and_then(|cwd| {
 831                RelPath::new(
 832                    cwd.as_str()
 833                        .map(Path::new)?
 834                        .strip_prefix(delegate.worktree_root_path())
 835                        .ok()?,
 836                    PathStyle::local(),
 837                )
 838                .ok()
 839            })
 840            .unwrap_or_else(|| RelPath::empty().into());
 841        let toolchain = delegate
 842            .toolchain_store()
 843            .active_toolchain(
 844                delegate.worktree_id(),
 845                base_path.into_arc(),
 846                language::LanguageName::new(Self::LANGUAGE_NAME),
 847                cx,
 848            )
 849            .await;
 850
 851        self.fetch_debugpy_whl(toolchain.clone(), delegate)
 852            .await
 853            .map_err(|e| anyhow::anyhow!("{e}"))?;
 854        if let Some(toolchain) = &toolchain {
 855            return self
 856                .get_installed_binary(
 857                    delegate,
 858                    config,
 859                    None,
 860                    user_args,
 861                    user_env,
 862                    Some(toolchain.path.to_string()),
 863                )
 864                .await;
 865        }
 866
 867        self.get_installed_binary(delegate, config, None, user_args, user_env, None)
 868            .await
 869    }
 870
 871    fn label_for_child_session(&self, args: &StartDebuggingRequestArguments) -> Option<String> {
 872        let label = args
 873            .configuration
 874            .get("name")?
 875            .as_str()
 876            .filter(|label| !label.is_empty())?;
 877        Some(label.to_owned())
 878    }
 879}
 880
 881#[cfg(test)]
 882mod tests {
 883    use util::path;
 884
 885    use super::*;
 886    use task::TcpArgumentsTemplate;
 887
 888    #[gpui::test]
 889    async fn test_tcp_connection_conflict_with_connect_args() {
 890        let adapter = PythonDebugAdapter {
 891            base_venv_path: OnceCell::new(),
 892            debugpy_whl_base_path: OnceCell::new(),
 893        };
 894
 895        let config_with_port_conflict = json!({
 896            "request": "attach",
 897            "connect": {
 898                "port": 5679
 899            }
 900        });
 901
 902        let tcp_connection = TcpArgumentsTemplate {
 903            host: None,
 904            port: Some(5678),
 905            timeout: None,
 906        };
 907
 908        let task_def = DebugTaskDefinition {
 909            label: "test".into(),
 910            adapter: PythonDebugAdapter::ADAPTER_NAME.into(),
 911            config: config_with_port_conflict,
 912            tcp_connection: Some(tcp_connection.clone()),
 913        };
 914
 915        let result = adapter
 916            .get_installed_binary(
 917                &MockDelegate::new(),
 918                &task_def,
 919                None,
 920                None,
 921                None,
 922                Some("python3".to_string()),
 923            )
 924            .await;
 925
 926        assert!(result.is_err());
 927        assert!(
 928            result
 929                .unwrap_err()
 930                .to_string()
 931                .contains("Cannot have two different ports")
 932        );
 933
 934        let host = Ipv4Addr::new(127, 0, 0, 1);
 935        let config_with_host_conflict = json!({
 936            "request": "attach",
 937            "connect": {
 938                "host": "192.168.1.1",
 939                "port": 5678
 940            }
 941        });
 942
 943        let tcp_connection_with_host = TcpArgumentsTemplate {
 944            host: Some(host),
 945            port: None,
 946            timeout: None,
 947        };
 948
 949        let task_def_host = DebugTaskDefinition {
 950            label: "test".into(),
 951            adapter: PythonDebugAdapter::ADAPTER_NAME.into(),
 952            config: config_with_host_conflict,
 953            tcp_connection: Some(tcp_connection_with_host),
 954        };
 955
 956        let result_host = adapter
 957            .get_installed_binary(
 958                &MockDelegate::new(),
 959                &task_def_host,
 960                None,
 961                None,
 962                None,
 963                Some("python3".to_string()),
 964            )
 965            .await;
 966
 967        assert!(result_host.is_err());
 968        assert!(
 969            result_host
 970                .unwrap_err()
 971                .to_string()
 972                .contains("Cannot have two different hosts")
 973        );
 974    }
 975
 976    #[gpui::test]
 977    async fn test_attach_with_connect_mode_generates_correct_arguments() {
 978        let host = Ipv4Addr::new(127, 0, 0, 1);
 979        let port = 5678;
 980
 981        let args_without_host = PythonDebugAdapter::generate_debugpy_arguments(
 982            &host,
 983            port,
 984            DebugpyLaunchMode::AttachWithConnect { host: None },
 985            None,
 986            None,
 987        )
 988        .await
 989        .unwrap();
 990
 991        let expected_suffix = path!("debug_adapters/Debugpy/debugpy/adapter");
 992        assert!(args_without_host[0].ends_with(expected_suffix));
 993        assert_eq!(args_without_host[1], "connect");
 994        assert_eq!(args_without_host[2], "5678");
 995
 996        let args_with_host = PythonDebugAdapter::generate_debugpy_arguments(
 997            &host,
 998            port,
 999            DebugpyLaunchMode::AttachWithConnect {
1000                host: Some("192.168.1.100"),
1001            },
1002            None,
1003            None,
1004        )
1005        .await
1006        .unwrap();
1007
1008        assert!(args_with_host[0].ends_with(expected_suffix));
1009        assert_eq!(args_with_host[1], "connect");
1010        assert_eq!(args_with_host[2], "192.168.1.100:");
1011        assert_eq!(args_with_host[3], "5678");
1012
1013        let args_normal = PythonDebugAdapter::generate_debugpy_arguments(
1014            &host,
1015            port,
1016            DebugpyLaunchMode::Normal,
1017            None,
1018            None,
1019        )
1020        .await
1021        .unwrap();
1022
1023        assert!(args_normal[0].ends_with(expected_suffix));
1024        assert_eq!(args_normal[1], "--host=127.0.0.1");
1025        assert_eq!(args_normal[2], "--port=5678");
1026        assert!(!args_normal.contains(&"connect".to_string()));
1027    }
1028
1029    #[gpui::test]
1030    async fn test_debugpy_install_path_cases() {
1031        let host = Ipv4Addr::new(127, 0, 0, 1);
1032        let port = 5678;
1033
1034        // Case 1: User-defined debugpy path (highest precedence)
1035        let user_path = PathBuf::from("/custom/path/to/debugpy/src/debugpy/adapter");
1036        let user_args = PythonDebugAdapter::generate_debugpy_arguments(
1037            &host,
1038            port,
1039            DebugpyLaunchMode::Normal,
1040            Some(&user_path),
1041            None,
1042        )
1043        .await
1044        .unwrap();
1045
1046        let venv_args = PythonDebugAdapter::generate_debugpy_arguments(
1047            &host,
1048            port,
1049            DebugpyLaunchMode::Normal,
1050            None,
1051            None,
1052        )
1053        .await
1054        .unwrap();
1055
1056        assert_eq!(user_args[0], "/custom/path/to/debugpy/src/debugpy/adapter");
1057        assert_eq!(user_args[1], "--host=127.0.0.1");
1058        assert_eq!(user_args[2], "--port=5678");
1059
1060        let expected_suffix = path!("debug_adapters/Debugpy/debugpy/adapter");
1061        assert!(venv_args[0].ends_with(expected_suffix));
1062        assert_eq!(venv_args[1], "--host=127.0.0.1");
1063        assert_eq!(venv_args[2], "--port=5678");
1064
1065        // The same cases, with arguments overridden by the user
1066        let user_args = PythonDebugAdapter::generate_debugpy_arguments(
1067            &host,
1068            port,
1069            DebugpyLaunchMode::Normal,
1070            Some(&user_path),
1071            Some(vec!["foo".into()]),
1072        )
1073        .await
1074        .unwrap();
1075        let venv_args = PythonDebugAdapter::generate_debugpy_arguments(
1076            &host,
1077            port,
1078            DebugpyLaunchMode::Normal,
1079            None,
1080            Some(vec!["foo".into()]),
1081        )
1082        .await
1083        .unwrap();
1084
1085        assert!(user_args[0].ends_with("src/debugpy/adapter"));
1086        assert_eq!(user_args[1], "foo");
1087
1088        assert!(venv_args[0].ends_with(expected_suffix));
1089        assert_eq!(venv_args[1], "foo");
1090
1091        // Note: Case 3 (GitHub-downloaded debugpy) is not tested since this requires mocking the Github API.
1092    }
1093}