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