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