1use crate::*;
2use anyhow::Context as _;
3use dap::adapters::latest_github_release;
4use dap::{DebugRequest, StartDebuggingRequestArguments, adapters::DebugTaskDefinition};
5use gpui::{AppContext, AsyncApp, SharedString};
6use json_dotpath::DotPaths;
7use language::{LanguageName, Toolchain};
8use serde_json::Value;
9use std::borrow::Cow;
10use std::net::Ipv4Addr;
11use std::sync::LazyLock;
12use std::{
13 collections::HashMap,
14 ffi::OsStr,
15 path::{Path, PathBuf},
16 sync::OnceLock,
17};
18#[cfg(feature = "update-schemas")]
19use tempfile::TempDir;
20use util::ResultExt;
21
22#[derive(Default)]
23pub struct PythonDebugAdapter {
24 checked: OnceLock<()>,
25}
26
27impl PythonDebugAdapter {
28 pub const ADAPTER_NAME: &'static str = "Debugpy";
29 const DEBUG_ADAPTER_NAME: DebugAdapterName =
30 DebugAdapterName(SharedString::new_static(Self::ADAPTER_NAME));
31 const ADAPTER_PACKAGE_NAME: &'static str = "debugpy";
32 const ADAPTER_PATH: &'static str = "src/debugpy/adapter";
33 const LANGUAGE_NAME: &'static str = "Python";
34
35 async fn generate_debugpy_arguments(
36 host: &Ipv4Addr,
37 port: u16,
38 user_installed_path: Option<&Path>,
39 user_args: Option<Vec<String>>,
40 installed_in_venv: bool,
41 ) -> Result<Vec<String>> {
42 let mut args = if let Some(user_installed_path) = user_installed_path {
43 log::debug!(
44 "Using user-installed debugpy adapter from: {}",
45 user_installed_path.display()
46 );
47 vec![
48 user_installed_path
49 .join(Self::ADAPTER_PATH)
50 .to_string_lossy()
51 .to_string(),
52 ]
53 } else if installed_in_venv {
54 log::debug!("Using venv-installed debugpy");
55 vec!["-m".to_string(), "debugpy.adapter".to_string()]
56 } else {
57 let adapter_path = paths::debug_adapters_dir().join(Self::DEBUG_ADAPTER_NAME.as_ref());
58 let file_name_prefix = format!("{}_", Self::ADAPTER_NAME);
59
60 let debugpy_dir =
61 util::fs::find_file_name_in_dir(adapter_path.as_path(), |file_name| {
62 file_name.starts_with(&file_name_prefix)
63 })
64 .await
65 .context("Debugpy directory not found")?;
66
67 log::debug!(
68 "Using GitHub-downloaded debugpy adapter from: {}",
69 debugpy_dir.display()
70 );
71 vec![
72 debugpy_dir
73 .join(Self::ADAPTER_PATH)
74 .to_string_lossy()
75 .to_string(),
76 ]
77 };
78
79 args.extend(if let Some(args) = user_args {
80 args
81 } else {
82 vec![format!("--host={}", host), format!("--port={}", port)]
83 });
84 Ok(args)
85 }
86
87 async fn request_args(
88 &self,
89 delegate: &Arc<dyn DapDelegate>,
90 task_definition: &DebugTaskDefinition,
91 ) -> Result<StartDebuggingRequestArguments> {
92 let request = self.request_kind(&task_definition.config).await?;
93
94 let mut configuration = task_definition.config.clone();
95 if let Ok(console) = configuration.dot_get_mut("console") {
96 // Use built-in Zed terminal if user did not explicitly provide a setting for console.
97 if console.is_null() {
98 *console = Value::String("integratedTerminal".into());
99 }
100 }
101
102 if let Some(obj) = configuration.as_object_mut() {
103 obj.entry("cwd")
104 .or_insert(delegate.worktree_root_path().to_string_lossy().into());
105 }
106
107 Ok(StartDebuggingRequestArguments {
108 configuration,
109 request,
110 })
111 }
112
113 async fn fetch_latest_adapter_version(
114 &self,
115 delegate: &Arc<dyn DapDelegate>,
116 ) -> Result<AdapterVersion> {
117 let github_repo = GithubRepo {
118 repo_name: Self::ADAPTER_PACKAGE_NAME.into(),
119 repo_owner: "microsoft".into(),
120 };
121
122 fetch_latest_adapter_version_from_github(github_repo, delegate.as_ref()).await
123 }
124
125 async fn install_binary(
126 adapter_name: DebugAdapterName,
127 version: AdapterVersion,
128 delegate: Arc<dyn DapDelegate>,
129 ) -> Result<()> {
130 let version_path = adapters::download_adapter_from_github(
131 adapter_name.as_ref(),
132 version,
133 adapters::DownloadedFileType::GzipTar,
134 paths::debug_adapters_dir(),
135 delegate.as_ref(),
136 )
137 .await?;
138 // only needed when you install the latest version for the first time
139 if let Some(debugpy_dir) =
140 util::fs::find_file_name_in_dir(version_path.as_path(), |file_name| {
141 file_name.starts_with("microsoft-debugpy-")
142 })
143 .await
144 {
145 // TODO Debugger: Rename folder instead of moving all files to another folder
146 // We're doing unnecessary IO work right now
147 util::fs::move_folder_files_to_folder(debugpy_dir.as_path(), version_path.as_path())
148 .await?;
149 }
150
151 Ok(())
152 }
153
154 async fn get_installed_binary(
155 &self,
156 delegate: &Arc<dyn DapDelegate>,
157 config: &DebugTaskDefinition,
158 user_installed_path: Option<PathBuf>,
159 user_args: Option<Vec<String>>,
160 toolchain: Option<Toolchain>,
161 installed_in_venv: bool,
162 ) -> Result<DebugAdapterBinary> {
163 const BINARY_NAMES: [&str; 3] = ["python3", "python", "py"];
164 let tcp_connection = config.tcp_connection.clone().unwrap_or_default();
165 let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?;
166
167 let python_path = if let Some(toolchain) = toolchain {
168 Some(toolchain.path.to_string())
169 } else {
170 let mut name = None;
171
172 for cmd in BINARY_NAMES {
173 name = delegate
174 .which(OsStr::new(cmd))
175 .await
176 .map(|path| path.to_string_lossy().to_string());
177 if name.is_some() {
178 break;
179 }
180 }
181 name
182 };
183
184 let python_command = python_path.context("failed to find binary path for Python")?;
185 log::debug!("Using Python executable: {}", python_command);
186
187 let arguments = Self::generate_debugpy_arguments(
188 &host,
189 port,
190 user_installed_path.as_deref(),
191 user_args,
192 installed_in_venv,
193 )
194 .await?;
195
196 log::debug!(
197 "Starting debugpy adapter with command: {} {}",
198 python_command,
199 arguments.join(" ")
200 );
201
202 Ok(DebugAdapterBinary {
203 command: Some(python_command),
204 arguments,
205 connection: Some(adapters::TcpArguments {
206 host,
207 port,
208 timeout,
209 }),
210 cwd: Some(delegate.worktree_root_path().to_path_buf()),
211 envs: HashMap::default(),
212 request_args: self.request_args(delegate, config).await?,
213 })
214 }
215}
216
217#[async_trait(?Send)]
218impl DebugAdapter for PythonDebugAdapter {
219 fn name(&self) -> DebugAdapterName {
220 Self::DEBUG_ADAPTER_NAME
221 }
222
223 fn adapter_language_name(&self) -> Option<LanguageName> {
224 Some(SharedString::new_static("Python").into())
225 }
226
227 async fn config_from_zed_format(&self, zed_scenario: ZedDebugConfig) -> Result<DebugScenario> {
228 let mut args = json!({
229 "request": match zed_scenario.request {
230 DebugRequest::Launch(_) => "launch",
231 DebugRequest::Attach(_) => "attach",
232 },
233 "subProcess": true,
234 "redirectOutput": true,
235 });
236
237 let map = args.as_object_mut().unwrap();
238 match &zed_scenario.request {
239 DebugRequest::Attach(attach) => {
240 map.insert("processId".into(), attach.process_id.into());
241 }
242 DebugRequest::Launch(launch) => {
243 map.insert("program".into(), launch.program.clone().into());
244 map.insert("args".into(), launch.args.clone().into());
245 if !launch.env.is_empty() {
246 map.insert("env".into(), launch.env_json());
247 }
248
249 if let Some(stop_on_entry) = zed_scenario.stop_on_entry {
250 map.insert("stopOnEntry".into(), stop_on_entry.into());
251 }
252 if let Some(cwd) = launch.cwd.as_ref() {
253 map.insert("cwd".into(), cwd.to_string_lossy().into_owned().into());
254 }
255 }
256 }
257
258 Ok(DebugScenario {
259 adapter: zed_scenario.adapter,
260 label: zed_scenario.label,
261 config: args,
262 build: None,
263 tcp_connection: None,
264 })
265 }
266
267 fn dap_schema(&self) -> Cow<'static, serde_json::Value> {
268 static SCHEMA: LazyLock<serde_json::Value> = LazyLock::new(|| {
269 const RAW_SCHEMA: &str = include_str!("../schemas/Debugpy.json");
270 serde_json::from_str(RAW_SCHEMA).unwrap()
271 });
272 Cow::Borrowed(&*SCHEMA)
273 }
274
275 async fn get_binary(
276 &self,
277 delegate: &Arc<dyn DapDelegate>,
278 config: &DebugTaskDefinition,
279 user_installed_path: Option<PathBuf>,
280 user_args: Option<Vec<String>>,
281 cx: &mut AsyncApp,
282 ) -> Result<DebugAdapterBinary> {
283 if let Some(local_path) = &user_installed_path {
284 log::debug!(
285 "Using user-installed debugpy adapter from: {}",
286 local_path.display()
287 );
288 return self
289 .get_installed_binary(
290 delegate,
291 &config,
292 Some(local_path.clone()),
293 user_args,
294 None,
295 false,
296 )
297 .await;
298 }
299
300 let toolchain = delegate
301 .toolchain_store()
302 .active_toolchain(
303 delegate.worktree_id(),
304 Arc::from("".as_ref()),
305 language::LanguageName::new(Self::LANGUAGE_NAME),
306 cx,
307 )
308 .await;
309
310 if let Some(toolchain) = &toolchain {
311 if let Some(path) = Path::new(&toolchain.path.to_string()).parent() {
312 let debugpy_path = path.join("debugpy");
313 if delegate.fs().is_file(&debugpy_path).await {
314 log::debug!(
315 "Found debugpy in toolchain environment: {}",
316 debugpy_path.display()
317 );
318 return self
319 .get_installed_binary(
320 delegate,
321 &config,
322 None,
323 user_args,
324 Some(toolchain.clone()),
325 true,
326 )
327 .await;
328 }
329 }
330 }
331
332 if self.checked.set(()).is_ok() {
333 delegate.output_to_console(format!("Checking latest version of {}...", self.name()));
334 if let Some(version) = self.fetch_latest_adapter_version(delegate).await.log_err() {
335 cx.background_spawn(Self::install_binary(self.name(), version, delegate.clone()))
336 .await
337 .context("Failed to install debugpy")?;
338 }
339 }
340
341 self.get_installed_binary(delegate, &config, None, user_args, toolchain, false)
342 .await
343 }
344
345 fn label_for_child_session(&self, args: &StartDebuggingRequestArguments) -> Option<String> {
346 let label = args
347 .configuration
348 .get("name")?
349 .as_str()
350 .filter(|label| !label.is_empty())?;
351 Some(label.to_owned())
352 }
353}
354
355#[cfg(feature = "update-schemas")]
356impl PythonDebugAdapter {
357 pub fn get_schema(
358 temp_dir: &TempDir,
359 delegate: UpdateSchemasDapDelegate,
360 ) -> anyhow::Result<serde_json::Value> {
361 use fs::Fs as _;
362
363 let temp_dir = std::fs::canonicalize(temp_dir.path())?;
364 let fs = delegate.fs.clone();
365 let executor = delegate.executor.clone();
366
367 let (package_json, package_nls_json) = executor.block(async move {
368 let version = fetch_latest_adapter_version_from_github(
369 GithubRepo {
370 repo_name: "vscode-python-debugger".into(),
371 repo_owner: "microsoft".into(),
372 },
373 &delegate,
374 )
375 .await?;
376
377 let path = adapters::download_adapter_from_github(
378 "schemas",
379 version,
380 adapters::DownloadedFileType::GzipTar,
381 &temp_dir,
382 &delegate,
383 )
384 .await?;
385
386 let path = util::fs::find_file_name_in_dir(path.as_path(), |file_name| {
387 file_name.starts_with("microsoft-vscode-python-debugger-")
388 })
389 .await
390 .context("find python debugger extension in download")?;
391
392 let package_json = fs.load(&path.join("package.json")).await?;
393 let package_nls_json = fs.load(&path.join("package.nls.json")).await.ok();
394
395 anyhow::Ok((package_json, package_nls_json))
396 })?;
397
398 let package_json = parse_package_json(package_json, package_nls_json)?;
399
400 let [debugger] =
401 <[_; 1]>::try_from(package_json.contributes.debuggers).map_err(|debuggers| {
402 anyhow::anyhow!("unexpected number of python debuggers: {}", debuggers.len())
403 })?;
404
405 Ok(schema_for_configuration_attributes(
406 debugger.configuration_attributes,
407 ))
408 }
409}
410
411async fn fetch_latest_adapter_version_from_github(
412 github_repo: GithubRepo,
413 delegate: &dyn DapDelegate,
414) -> Result<AdapterVersion> {
415 let release = latest_github_release(
416 &format!("{}/{}", github_repo.repo_owner, github_repo.repo_name),
417 false,
418 false,
419 delegate.http_client(),
420 )
421 .await?;
422
423 Ok(AdapterVersion {
424 tag_name: release.tag_name,
425 url: release.tarball_url,
426 })
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432 use std::{net::Ipv4Addr, path::PathBuf};
433
434 #[gpui::test]
435 async fn test_debugpy_install_path_cases() {
436 let host = Ipv4Addr::new(127, 0, 0, 1);
437 let port = 5678;
438
439 // Case 1: User-defined debugpy path (highest precedence)
440 let user_path = PathBuf::from("/custom/path/to/debugpy");
441 let user_args = PythonDebugAdapter::generate_debugpy_arguments(
442 &host,
443 port,
444 Some(&user_path),
445 None,
446 false,
447 )
448 .await
449 .unwrap();
450
451 // Case 2: Venv-installed debugpy (uses -m debugpy.adapter)
452 let venv_args =
453 PythonDebugAdapter::generate_debugpy_arguments(&host, port, None, None, true)
454 .await
455 .unwrap();
456
457 assert!(user_args[0].ends_with("src/debugpy/adapter"));
458 assert_eq!(user_args[1], "--host=127.0.0.1");
459 assert_eq!(user_args[2], "--port=5678");
460
461 assert_eq!(venv_args[0], "-m");
462 assert_eq!(venv_args[1], "debugpy.adapter");
463 assert_eq!(venv_args[2], "--host=127.0.0.1");
464 assert_eq!(venv_args[3], "--port=5678");
465
466 // The same cases, with arguments overridden by the user
467 let user_args = PythonDebugAdapter::generate_debugpy_arguments(
468 &host,
469 port,
470 Some(&user_path),
471 Some(vec!["foo".into()]),
472 false,
473 )
474 .await
475 .unwrap();
476 let venv_args = PythonDebugAdapter::generate_debugpy_arguments(
477 &host,
478 port,
479 None,
480 Some(vec!["foo".into()]),
481 true,
482 )
483 .await
484 .unwrap();
485
486 assert!(user_args[0].ends_with("src/debugpy/adapter"));
487 assert_eq!(user_args[1], "foo");
488
489 assert_eq!(venv_args[0], "-m");
490 assert_eq!(venv_args[1], "debugpy.adapter");
491 assert_eq!(venv_args[2], "foo");
492
493 // Note: Case 3 (GitHub-downloaded debugpy) is not tested since this requires mocking the Github API.
494 }
495}