1use crate::*;
2use anyhow::Context as _;
3use dap::{DebugRequest, StartDebuggingRequestArguments, adapters::DebugTaskDefinition};
4use gpui::{AsyncApp, SharedString};
5use json_dotpath::DotPaths;
6use language::{LanguageName, Toolchain};
7use serde_json::Value;
8use std::net::Ipv4Addr;
9use std::{
10 collections::HashMap,
11 ffi::OsStr,
12 path::{Path, PathBuf},
13 sync::OnceLock,
14};
15use util::ResultExt;
16
17#[derive(Default)]
18pub(crate) struct PythonDebugAdapter {
19 checked: OnceLock<()>,
20}
21
22impl PythonDebugAdapter {
23 const ADAPTER_NAME: &'static str = "Debugpy";
24 const ADAPTER_PACKAGE_NAME: &'static str = "debugpy";
25 const ADAPTER_PATH: &'static str = "src/debugpy/adapter";
26 const LANGUAGE_NAME: &'static str = "Python";
27
28 async fn generate_debugpy_arguments(
29 &self,
30 host: &Ipv4Addr,
31 port: u16,
32 user_installed_path: Option<&Path>,
33 installed_in_venv: bool,
34 ) -> Result<Vec<String>> {
35 if let Some(user_installed_path) = user_installed_path {
36 log::debug!(
37 "Using user-installed debugpy adapter from: {}",
38 user_installed_path.display()
39 );
40 Ok(vec![
41 user_installed_path
42 .join(Self::ADAPTER_PATH)
43 .to_string_lossy()
44 .to_string(),
45 format!("--host={}", host),
46 format!("--port={}", port),
47 ])
48 } else if installed_in_venv {
49 log::debug!("Using venv-installed debugpy");
50 Ok(vec![
51 "-m".to_string(),
52 "debugpy.adapter".to_string(),
53 format!("--host={}", host),
54 format!("--port={}", port),
55 ])
56 } else {
57 let adapter_path = paths::debug_adapters_dir().join(self.name().as_ref());
58 let file_name_prefix = format!("{}_", Self::ADAPTER_NAME);
59
60 let debugpy_dir =
61 util::fs::find_file_name_in_dir(adapter_path.as_path(), |file_name| {
62 file_name.starts_with(&file_name_prefix)
63 })
64 .await
65 .context("Debugpy directory not found")?;
66
67 log::debug!(
68 "Using GitHub-downloaded debugpy adapter from: {}",
69 debugpy_dir.display()
70 );
71 Ok(vec![
72 debugpy_dir
73 .join(Self::ADAPTER_PATH)
74 .to_string_lossy()
75 .to_string(),
76 format!("--host={}", host),
77 format!("--port={}", port),
78 ])
79 }
80 }
81
82 fn request_args(
83 &self,
84 task_definition: &DebugTaskDefinition,
85 ) -> Result<StartDebuggingRequestArguments> {
86 let request = self.request_kind(&task_definition.config)?;
87
88 let mut configuration = task_definition.config.clone();
89 if let Ok(console) = configuration.dot_get_mut("console") {
90 // Use built-in Zed terminal if user did not explicitly provide a setting for console.
91 if console.is_null() {
92 *console = Value::String("integratedTerminal".into());
93 }
94 }
95
96 Ok(StartDebuggingRequestArguments {
97 configuration,
98 request,
99 })
100 }
101 async fn fetch_latest_adapter_version(
102 &self,
103 delegate: &Arc<dyn DapDelegate>,
104 ) -> Result<AdapterVersion> {
105 let github_repo = GithubRepo {
106 repo_name: Self::ADAPTER_PACKAGE_NAME.into(),
107 repo_owner: "microsoft".into(),
108 };
109
110 adapters::fetch_latest_adapter_version_from_github(github_repo, delegate.as_ref()).await
111 }
112
113 async fn install_binary(
114 &self,
115 version: AdapterVersion,
116 delegate: &Arc<dyn DapDelegate>,
117 ) -> Result<()> {
118 let version_path = adapters::download_adapter_from_github(
119 self.name(),
120 version,
121 adapters::DownloadedFileType::Zip,
122 delegate.as_ref(),
123 )
124 .await?;
125
126 // only needed when you install the latest version for the first time
127 if let Some(debugpy_dir) =
128 util::fs::find_file_name_in_dir(version_path.as_path(), |file_name| {
129 file_name.starts_with("microsoft-debugpy-")
130 })
131 .await
132 {
133 // TODO Debugger: Rename folder instead of moving all files to another folder
134 // We're doing unnecessary IO work right now
135 util::fs::move_folder_files_to_folder(debugpy_dir.as_path(), version_path.as_path())
136 .await?;
137 }
138
139 Ok(())
140 }
141
142 async fn get_installed_binary(
143 &self,
144 delegate: &Arc<dyn DapDelegate>,
145 config: &DebugTaskDefinition,
146 user_installed_path: Option<PathBuf>,
147 toolchain: Option<Toolchain>,
148 installed_in_venv: bool,
149 ) -> Result<DebugAdapterBinary> {
150 const BINARY_NAMES: [&str; 3] = ["python3", "python", "py"];
151 let tcp_connection = config.tcp_connection.clone().unwrap_or_default();
152 let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?;
153
154 let python_path = if let Some(toolchain) = toolchain {
155 Some(toolchain.path.to_string())
156 } else {
157 let mut name = None;
158
159 for cmd in BINARY_NAMES {
160 name = delegate
161 .which(OsStr::new(cmd))
162 .await
163 .map(|path| path.to_string_lossy().to_string());
164 if name.is_some() {
165 break;
166 }
167 }
168 name
169 };
170
171 let python_command = python_path.context("failed to find binary path for Python")?;
172 log::debug!("Using Python executable: {}", python_command);
173
174 let arguments = self
175 .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: 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 DebugAdapterName(Self::ADAPTER_NAME.into())
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 self.install_binary(version, delegate).await?;
639 }
640 }
641
642 self.get_installed_binary(delegate, &config, None, toolchain, false)
643 .await
644 }
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650 use std::{net::Ipv4Addr, path::PathBuf};
651
652 #[gpui::test]
653 async fn test_debugpy_install_path_cases() {
654 let adapter = PythonDebugAdapter::default();
655 let host = Ipv4Addr::new(127, 0, 0, 1);
656 let port = 5678;
657
658 // Case 1: User-defined debugpy path (highest precedence)
659 let user_path = PathBuf::from("/custom/path/to/debugpy");
660 let user_args = adapter
661 .generate_debugpy_arguments(&host, port, Some(&user_path), false)
662 .await
663 .unwrap();
664
665 // Case 2: Venv-installed debugpy (uses -m debugpy.adapter)
666 let venv_args = adapter
667 .generate_debugpy_arguments(&host, port, None, true)
668 .await
669 .unwrap();
670
671 assert!(user_args[0].ends_with("src/debugpy/adapter"));
672 assert_eq!(user_args[1], "--host=127.0.0.1");
673 assert_eq!(user_args[2], "--port=5678");
674
675 assert_eq!(venv_args[0], "-m");
676 assert_eq!(venv_args[1], "debugpy.adapter");
677 assert_eq!(venv_args[2], "--host=127.0.0.1");
678 assert_eq!(venv_args[3], "--port=5678");
679
680 // Note: Case 3 (GitHub-downloaded debugpy) is not tested since this requires mocking the Github API.
681 }
682
683 #[test]
684 fn test_adapter_path_constant() {
685 assert_eq!(PythonDebugAdapter::ADAPTER_PATH, "src/debugpy/adapter");
686 }
687}