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