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::net::Ipv4Addr;
10use std::{
11 collections::HashMap,
12 ffi::OsStr,
13 path::{Path, PathBuf},
14 sync::OnceLock,
15};
16use util::ResultExt;
17
18#[derive(Default)]
19pub(crate) struct PythonDebugAdapter {
20 checked: OnceLock<()>,
21}
22
23impl PythonDebugAdapter {
24 const ADAPTER_NAME: &'static str = "Debugpy";
25 const DEBUG_ADAPTER_NAME: DebugAdapterName =
26 DebugAdapterName(SharedString::new_static(Self::ADAPTER_NAME));
27 const ADAPTER_PACKAGE_NAME: &'static str = "debugpy";
28 const ADAPTER_PATH: &'static str = "src/debugpy/adapter";
29 const LANGUAGE_NAME: &'static str = "Python";
30
31 async fn generate_debugpy_arguments(
32 host: &Ipv4Addr,
33 port: u16,
34 user_installed_path: Option<&Path>,
35 installed_in_venv: bool,
36 ) -> Result<Vec<String>> {
37 if let Some(user_installed_path) = user_installed_path {
38 log::debug!(
39 "Using user-installed debugpy adapter from: {}",
40 user_installed_path.display()
41 );
42 Ok(vec![
43 user_installed_path
44 .join(Self::ADAPTER_PATH)
45 .to_string_lossy()
46 .to_string(),
47 format!("--host={}", host),
48 format!("--port={}", port),
49 ])
50 } else if installed_in_venv {
51 log::debug!("Using venv-installed debugpy");
52 Ok(vec![
53 "-m".to_string(),
54 "debugpy.adapter".to_string(),
55 format!("--host={}", host),
56 format!("--port={}", port),
57 ])
58 } else {
59 let adapter_path = paths::debug_adapters_dir().join(Self::DEBUG_ADAPTER_NAME.as_ref());
60 let file_name_prefix = format!("{}_", Self::ADAPTER_NAME);
61
62 let debugpy_dir =
63 util::fs::find_file_name_in_dir(adapter_path.as_path(), |file_name| {
64 file_name.starts_with(&file_name_prefix)
65 })
66 .await
67 .context("Debugpy directory not found")?;
68
69 log::debug!(
70 "Using GitHub-downloaded debugpy adapter from: {}",
71 debugpy_dir.display()
72 );
73 Ok(vec![
74 debugpy_dir
75 .join(Self::ADAPTER_PATH)
76 .to_string_lossy()
77 .to_string(),
78 format!("--host={}", host),
79 format!("--port={}", port),
80 ])
81 }
82 }
83
84 fn request_args(
85 &self,
86 task_definition: &DebugTaskDefinition,
87 ) -> Result<StartDebuggingRequestArguments> {
88 let request = self.request_kind(&task_definition.config)?;
89
90 let mut configuration = task_definition.config.clone();
91 if let Ok(console) = configuration.dot_get_mut("console") {
92 // Use built-in Zed terminal if user did not explicitly provide a setting for console.
93 if console.is_null() {
94 *console = Value::String("integratedTerminal".into());
95 }
96 }
97
98 Ok(StartDebuggingRequestArguments {
99 configuration,
100 request,
101 })
102 }
103 async fn fetch_latest_adapter_version(
104 &self,
105 delegate: &Arc<dyn DapDelegate>,
106 ) -> Result<AdapterVersion> {
107 let github_repo = GithubRepo {
108 repo_name: Self::ADAPTER_PACKAGE_NAME.into(),
109 repo_owner: "microsoft".into(),
110 };
111
112 fetch_latest_adapter_version_from_github(github_repo, delegate.as_ref()).await
113 }
114
115 async fn install_binary(
116 adapter_name: DebugAdapterName,
117 version: AdapterVersion,
118 delegate: Arc<dyn DapDelegate>,
119 ) -> Result<()> {
120 let version_path = adapters::download_adapter_from_github(
121 adapter_name,
122 version,
123 adapters::DownloadedFileType::GzipTar,
124 delegate.as_ref(),
125 )
126 .await?;
127 // only needed when you install the latest version for the first time
128 if let Some(debugpy_dir) =
129 util::fs::find_file_name_in_dir(version_path.as_path(), |file_name| {
130 file_name.starts_with("microsoft-debugpy-")
131 })
132 .await
133 {
134 // TODO Debugger: Rename folder instead of moving all files to another folder
135 // We're doing unnecessary IO work right now
136 util::fs::move_folder_files_to_folder(debugpy_dir.as_path(), version_path.as_path())
137 .await?;
138 }
139
140 Ok(())
141 }
142
143 async fn get_installed_binary(
144 &self,
145 delegate: &Arc<dyn DapDelegate>,
146 config: &DebugTaskDefinition,
147 user_installed_path: Option<PathBuf>,
148 toolchain: Option<Toolchain>,
149 installed_in_venv: bool,
150 ) -> Result<DebugAdapterBinary> {
151 const BINARY_NAMES: [&str; 3] = ["python3", "python", "py"];
152 let tcp_connection = config.tcp_connection.clone().unwrap_or_default();
153 let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?;
154
155 let python_path = if let Some(toolchain) = toolchain {
156 Some(toolchain.path.to_string())
157 } else {
158 let mut name = None;
159
160 for cmd in BINARY_NAMES {
161 name = delegate
162 .which(OsStr::new(cmd))
163 .await
164 .map(|path| path.to_string_lossy().to_string());
165 if name.is_some() {
166 break;
167 }
168 }
169 name
170 };
171
172 let python_command = python_path.context("failed to find binary path for Python")?;
173 log::debug!("Using Python executable: {}", python_command);
174
175 let arguments = Self::generate_debugpy_arguments(
176 &host,
177 port,
178 user_installed_path.as_deref(),
179 installed_in_venv,
180 )
181 .await?;
182
183 log::debug!(
184 "Starting debugpy adapter with command: {} {}",
185 python_command,
186 arguments.join(" ")
187 );
188
189 Ok(DebugAdapterBinary {
190 command: Some(python_command),
191 arguments,
192 connection: Some(adapters::TcpArguments {
193 host,
194 port,
195 timeout,
196 }),
197 cwd: Some(delegate.worktree_root_path().to_path_buf()),
198 envs: HashMap::default(),
199 request_args: self.request_args(config)?,
200 })
201 }
202}
203
204#[async_trait(?Send)]
205impl DebugAdapter for PythonDebugAdapter {
206 fn name(&self) -> DebugAdapterName {
207 Self::DEBUG_ADAPTER_NAME
208 }
209
210 fn adapter_language_name(&self) -> Option<LanguageName> {
211 Some(SharedString::new_static("Python").into())
212 }
213
214 fn config_from_zed_format(&self, zed_scenario: ZedDebugConfig) -> Result<DebugScenario> {
215 let mut args = json!({
216 "request": match zed_scenario.request {
217 DebugRequest::Launch(_) => "launch",
218 DebugRequest::Attach(_) => "attach",
219 },
220 "subProcess": true,
221 "redirectOutput": true,
222 });
223
224 let map = args.as_object_mut().unwrap();
225 match &zed_scenario.request {
226 DebugRequest::Attach(attach) => {
227 map.insert("processId".into(), attach.process_id.into());
228 }
229 DebugRequest::Launch(launch) => {
230 map.insert("program".into(), launch.program.clone().into());
231 map.insert("args".into(), launch.args.clone().into());
232 if !launch.env.is_empty() {
233 map.insert("env".into(), launch.env_json());
234 }
235
236 if let Some(stop_on_entry) = zed_scenario.stop_on_entry {
237 map.insert("stopOnEntry".into(), stop_on_entry.into());
238 }
239 if let Some(cwd) = launch.cwd.as_ref() {
240 map.insert("cwd".into(), cwd.to_string_lossy().into_owned().into());
241 }
242 }
243 }
244
245 Ok(DebugScenario {
246 adapter: zed_scenario.adapter,
247 label: zed_scenario.label,
248 config: args,
249 build: None,
250 tcp_connection: None,
251 })
252 }
253
254 async fn dap_schema(&self) -> serde_json::Value {
255 json!({
256 "properties": {
257 "request": {
258 "type": "string",
259 "enum": ["attach", "launch"],
260 "description": "Debug adapter request type"
261 },
262 "autoReload": {
263 "default": {},
264 "description": "Configures automatic reload of code on edit.",
265 "properties": {
266 "enable": {
267 "default": false,
268 "description": "Automatically reload code on edit.",
269 "type": "boolean"
270 },
271 "exclude": {
272 "default": [
273 "**/.git/**",
274 "**/.metadata/**",
275 "**/__pycache__/**",
276 "**/node_modules/**",
277 "**/site-packages/**"
278 ],
279 "description": "Glob patterns of paths to exclude from auto reload.",
280 "items": {
281 "type": "string"
282 },
283 "type": "array"
284 },
285 "include": {
286 "default": [
287 "**/*.py",
288 "**/*.pyw"
289 ],
290 "description": "Glob patterns of paths to include in auto reload.",
291 "items": {
292 "type": "string"
293 },
294 "type": "array"
295 }
296 },
297 "type": "object"
298 },
299 "debugAdapterPath": {
300 "description": "Path (fully qualified) to the python debug adapter executable.",
301 "type": "string"
302 },
303 "django": {
304 "default": false,
305 "description": "Django debugging.",
306 "type": "boolean"
307 },
308 "jinja": {
309 "default": null,
310 "description": "Jinja template debugging (e.g. Flask).",
311 "enum": [
312 false,
313 null,
314 true
315 ]
316 },
317 "justMyCode": {
318 "default": true,
319 "description": "If true, show and debug only user-written code. If false, show and debug all code, including library calls.",
320 "type": "boolean"
321 },
322 "logToFile": {
323 "default": false,
324 "description": "Enable logging of debugger events to a log file. This file can be found in the debugpy extension install folder.",
325 "type": "boolean"
326 },
327 "pathMappings": {
328 "default": [],
329 "items": {
330 "label": "Path mapping",
331 "properties": {
332 "localRoot": {
333 "default": "${ZED_WORKTREE_ROOT}",
334 "label": "Local source root.",
335 "type": "string"
336 },
337 "remoteRoot": {
338 "default": "",
339 "label": "Remote source root.",
340 "type": "string"
341 }
342 },
343 "required": [
344 "localRoot",
345 "remoteRoot"
346 ],
347 "type": "object"
348 },
349 "label": "Path mappings.",
350 "type": "array"
351 },
352 "redirectOutput": {
353 "default": true,
354 "description": "Redirect output.",
355 "type": "boolean"
356 },
357 "showReturnValue": {
358 "default": true,
359 "description": "Show return value of functions when stepping.",
360 "type": "boolean"
361 },
362 "subProcess": {
363 "default": false,
364 "description": "Whether to enable Sub Process debugging",
365 "type": "boolean"
366 },
367 "consoleName": {
368 "default": "Python Debug Console",
369 "description": "Display name of the debug console or terminal",
370 "type": "string"
371 },
372 "clientOS": {
373 "default": null,
374 "description": "OS that VS code is using.",
375 "enum": [
376 "windows",
377 null,
378 "unix"
379 ]
380 }
381 },
382 "required": ["request"],
383 "allOf": [
384 {
385 "if": {
386 "properties": {
387 "request": {
388 "enum": ["attach"]
389 }
390 }
391 },
392 "then": {
393 "properties": {
394 "connect": {
395 "label": "Attach by connecting to debugpy over a socket.",
396 "properties": {
397 "host": {
398 "default": "127.0.0.1",
399 "description": "Hostname or IP address to connect to.",
400 "type": "string"
401 },
402 "port": {
403 "description": "Port to connect to.",
404 "type": [
405 "number",
406 "string"
407 ]
408 }
409 },
410 "required": [
411 "port"
412 ],
413 "type": "object"
414 },
415 "listen": {
416 "label": "Attach by listening for incoming socket connection from debugpy",
417 "properties": {
418 "host": {
419 "default": "127.0.0.1",
420 "description": "Hostname or IP address of the interface to listen on.",
421 "type": "string"
422 },
423 "port": {
424 "description": "Port to listen on.",
425 "type": [
426 "number",
427 "string"
428 ]
429 }
430 },
431 "required": [
432 "port"
433 ],
434 "type": "object"
435 },
436 "processId": {
437 "anyOf": [
438 {
439 "default": "${command:pickProcess}",
440 "description": "Use process picker to select a process to attach, or Process ID as integer.",
441 "enum": [
442 "${command:pickProcess}"
443 ]
444 },
445 {
446 "description": "ID of the local process to attach to.",
447 "type": "integer"
448 }
449 ]
450 }
451 }
452 }
453 },
454 {
455 "if": {
456 "properties": {
457 "request": {
458 "enum": ["launch"]
459 }
460 }
461 },
462 "then": {
463 "properties": {
464 "args": {
465 "default": [],
466 "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.",
467 "items": {
468 "type": "string"
469 },
470 "anyOf": [
471 {
472 "default": "${command:pickArgs}",
473 "enum": [
474 "${command:pickArgs}"
475 ]
476 },
477 {
478 "type": [
479 "array",
480 "string"
481 ]
482 }
483 ]
484 },
485 "console": {
486 "default": "integratedTerminal",
487 "description": "Where to launch the debug target: internal console, integrated terminal, or external terminal.",
488 "enum": [
489 "externalTerminal",
490 "integratedTerminal",
491 "internalConsole"
492 ]
493 },
494 "cwd": {
495 "default": "${ZED_WORKTREE_ROOT}",
496 "description": "Absolute path to the working directory of the program being debugged. Default is the root directory of the file (leave empty).",
497 "type": "string"
498 },
499 "autoStartBrowser": {
500 "default": false,
501 "description": "Open external browser to launch the application",
502 "type": "boolean"
503 },
504 "env": {
505 "additionalProperties": {
506 "type": "string"
507 },
508 "default": {},
509 "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.",
510 "type": "object"
511 },
512 "envFile": {
513 "default": "${ZED_WORKTREE_ROOT}/.env",
514 "description": "Absolute path to a file containing environment variable definitions.",
515 "type": "string"
516 },
517 "gevent": {
518 "default": false,
519 "description": "Enable debugging of gevent monkey-patched code.",
520 "type": "boolean"
521 },
522 "module": {
523 "default": "",
524 "description": "Name of the module to be debugged.",
525 "type": "string"
526 },
527 "program": {
528 "default": "${ZED_FILE}",
529 "description": "Absolute path to the program.",
530 "type": "string"
531 },
532 "purpose": {
533 "default": [],
534 "description": "Tells extension to use this configuration for test debugging, or when using debug-in-terminal command.",
535 "items": {
536 "enum": [
537 "debug-test",
538 "debug-in-terminal"
539 ],
540 "enumDescriptions": [
541 "Use this configuration while debugging tests using test view or test debug commands.",
542 "Use this configuration while debugging a file using debug in terminal button in the editor."
543 ]
544 },
545 "type": "array"
546 },
547 "pyramid": {
548 "default": false,
549 "description": "Whether debugging Pyramid applications.",
550 "type": "boolean"
551 },
552 "python": {
553 "default": "${command:python.interpreterPath}",
554 "description": "Absolute path to the Python interpreter executable; overrides workspace configuration if set.",
555 "type": "string"
556 },
557 "pythonArgs": {
558 "default": [],
559 "description": "Command-line arguments passed to the Python interpreter. To pass arguments to the debug target, use \"args\".",
560 "items": {
561 "type": "string"
562 },
563 "type": "array"
564 },
565 "stopOnEntry": {
566 "default": false,
567 "description": "Automatically stop after launch.",
568 "type": "boolean"
569 },
570 "sudo": {
571 "default": false,
572 "description": "Running debug program under elevated permissions (on Unix).",
573 "type": "boolean"
574 },
575 "guiEventLoop": {
576 "default": "matplotlib",
577 "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.",
578 "type": "string"
579 }
580 }
581 }
582 }
583 ]
584 })
585 }
586
587 async fn get_binary(
588 &self,
589 delegate: &Arc<dyn DapDelegate>,
590 config: &DebugTaskDefinition,
591 user_installed_path: Option<PathBuf>,
592 cx: &mut AsyncApp,
593 ) -> Result<DebugAdapterBinary> {
594 if let Some(local_path) = &user_installed_path {
595 log::debug!(
596 "Using user-installed debugpy adapter from: {}",
597 local_path.display()
598 );
599 return self
600 .get_installed_binary(delegate, &config, Some(local_path.clone()), None, false)
601 .await;
602 }
603
604 let toolchain = delegate
605 .toolchain_store()
606 .active_toolchain(
607 delegate.worktree_id(),
608 Arc::from("".as_ref()),
609 language::LanguageName::new(Self::LANGUAGE_NAME),
610 cx,
611 )
612 .await;
613
614 if let Some(toolchain) = &toolchain {
615 if let Some(path) = Path::new(&toolchain.path.to_string()).parent() {
616 let debugpy_path = path.join("debugpy");
617 if delegate.fs().is_file(&debugpy_path).await {
618 log::debug!(
619 "Found debugpy in toolchain environment: {}",
620 debugpy_path.display()
621 );
622 return self
623 .get_installed_binary(
624 delegate,
625 &config,
626 None,
627 Some(toolchain.clone()),
628 true,
629 )
630 .await;
631 }
632 }
633 }
634
635 if self.checked.set(()).is_ok() {
636 delegate.output_to_console(format!("Checking latest version of {}...", self.name()));
637 if let Some(version) = self.fetch_latest_adapter_version(delegate).await.log_err() {
638 cx.background_spawn(Self::install_binary(self.name(), version, delegate.clone()))
639 .await
640 .context("Failed to install debugpy")?;
641 }
642 }
643
644 self.get_installed_binary(delegate, &config, None, toolchain, false)
645 .await
646 }
647}
648
649async fn fetch_latest_adapter_version_from_github(
650 github_repo: GithubRepo,
651 delegate: &dyn DapDelegate,
652) -> Result<AdapterVersion> {
653 let release = latest_github_release(
654 &format!("{}/{}", github_repo.repo_owner, github_repo.repo_name),
655 false,
656 false,
657 delegate.http_client(),
658 )
659 .await?;
660
661 Ok(AdapterVersion {
662 tag_name: release.tag_name,
663 url: release.tarball_url,
664 })
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670 use std::{net::Ipv4Addr, path::PathBuf};
671
672 #[gpui::test]
673 async fn test_debugpy_install_path_cases() {
674 let host = Ipv4Addr::new(127, 0, 0, 1);
675 let port = 5678;
676
677 // Case 1: User-defined debugpy path (highest precedence)
678 let user_path = PathBuf::from("/custom/path/to/debugpy");
679 let user_args =
680 PythonDebugAdapter::generate_debugpy_arguments(&host, port, Some(&user_path), false)
681 .await
682 .unwrap();
683
684 // Case 2: Venv-installed debugpy (uses -m debugpy.adapter)
685 let venv_args = PythonDebugAdapter::generate_debugpy_arguments(&host, port, None, true)
686 .await
687 .unwrap();
688
689 assert!(user_args[0].ends_with("src/debugpy/adapter"));
690 assert_eq!(user_args[1], "--host=127.0.0.1");
691 assert_eq!(user_args[2], "--port=5678");
692
693 assert_eq!(venv_args[0], "-m");
694 assert_eq!(venv_args[1], "debugpy.adapter");
695 assert_eq!(venv_args[2], "--host=127.0.0.1");
696 assert_eq!(venv_args[3], "--port=5678");
697
698 // Note: Case 3 (GitHub-downloaded debugpy) is not tested since this requires mocking the Github API.
699 }
700}