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