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