debugger: Add locator for Python tasks (#31533)

Piotr Osiewicz and Kirill Bulatov created

Closes #ISSUE

Release Notes:

- debugger: Python tests/main functions can now we debugged from the
gutter.

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>

Change summary

crates/debugger_ui/src/debugger_panel.rs       |  1 
crates/debugger_ui/src/session/running.rs      |  8 +
crates/languages/src/python.rs                 |  7 +
crates/project/src/debugger/dap_store.rs       |  5 
crates/project/src/debugger/locators.rs        |  1 
crates/project/src/debugger/locators/python.rs | 99 ++++++++++++++++++++
6 files changed, 118 insertions(+), 3 deletions(-)

Detailed changes

crates/debugger_ui/src/session/running.rs 🔗

@@ -547,6 +547,10 @@ impl RunningState {
                     .for_each(|value| Self::substitute_variables_in_config(value, context));
             }
             serde_json::Value::String(s) => {
+                // Some built-in zed tasks wrap their arguments in quotes as they might contain spaces.
+                if s.starts_with("\"$ZED_") && s.ends_with('"') {
+                    *s = s[1..s.len() - 1].to_string();
+                }
                 if let Some(substituted) = substitute_variables_in_str(&s, context) {
                     *s = substituted;
                 }
@@ -571,6 +575,10 @@ impl RunningState {
                     .for_each(|value| Self::relativlize_paths(None, value, context));
             }
             serde_json::Value::String(s) if key == Some("program") || key == Some("cwd") => {
+                // Some built-in zed tasks wrap their arguments in quotes as they might contain spaces.
+                if s.starts_with("\"$ZED_") && s.ends_with('"') {
+                    *s = s[1..s.len() - 1].to_string();
+                }
                 resolve_path(s);
 
                 if let Some(substituted) = substitute_variables_in_str(&s, context) {

crates/languages/src/python.rs 🔗

@@ -413,6 +413,7 @@ impl ContextProvider for PythonContextProvider {
                     "-c".to_owned(),
                     VariableName::SelectedText.template_value_with_whitespace(),
                 ],
+                cwd: Some("$ZED_WORKTREE_ROOT".into()),
                 ..TaskTemplate::default()
             },
             // Execute an entire file
@@ -420,6 +421,7 @@ impl ContextProvider for PythonContextProvider {
                 label: format!("run '{}'", VariableName::File.template_value()),
                 command: PYTHON_ACTIVE_TOOLCHAIN_PATH.template_value(),
                 args: vec![VariableName::File.template_value_with_whitespace()],
+                cwd: Some("$ZED_WORKTREE_ROOT".into()),
                 ..TaskTemplate::default()
             },
             // Execute a file as module
@@ -430,6 +432,7 @@ impl ContextProvider for PythonContextProvider {
                     "-m".to_owned(),
                     PYTHON_MODULE_NAME_TASK_VARIABLE.template_value(),
                 ],
+                cwd: Some("$ZED_WORKTREE_ROOT".into()),
                 tags: vec!["python-module-main-method".to_owned()],
                 ..TaskTemplate::default()
             },
@@ -447,6 +450,7 @@ impl ContextProvider for PythonContextProvider {
                             "unittest".to_owned(),
                             VariableName::File.template_value_with_whitespace(),
                         ],
+                        cwd: Some("$ZED_WORKTREE_ROOT".into()),
                         ..TaskTemplate::default()
                     },
                     // Run test(s) for a specific target within a file
@@ -462,6 +466,7 @@ impl ContextProvider for PythonContextProvider {
                             "python-unittest-class".to_owned(),
                             "python-unittest-method".to_owned(),
                         ],
+                        cwd: Some("$ZED_WORKTREE_ROOT".into()),
                         ..TaskTemplate::default()
                     },
                 ]
@@ -477,6 +482,7 @@ impl ContextProvider for PythonContextProvider {
                             "pytest".to_owned(),
                             VariableName::File.template_value_with_whitespace(),
                         ],
+                        cwd: Some("$ZED_WORKTREE_ROOT".into()),
                         ..TaskTemplate::default()
                     },
                     // Run test(s) for a specific target within a file
@@ -488,6 +494,7 @@ impl ContextProvider for PythonContextProvider {
                             "pytest".to_owned(),
                             PYTHON_TEST_TARGET_TASK_VARIABLE.template_value_with_whitespace(),
                         ],
+                        cwd: Some("$ZED_WORKTREE_ROOT".into()),
                         tags: vec![
                             "python-pytest-class".to_owned(),
                             "python-pytest-method".to_owned(),

crates/project/src/debugger/dap_store.rs 🔗

@@ -101,7 +101,9 @@ impl DapStore {
     pub fn init(client: &AnyProtoClient, cx: &mut App) {
         static ADD_LOCATORS: Once = Once::new();
         ADD_LOCATORS.call_once(|| {
-            DapRegistry::global(cx).add_locator(Arc::new(locators::cargo::CargoLocator {}))
+            let registry = DapRegistry::global(cx);
+            registry.add_locator(Arc::new(locators::cargo::CargoLocator {}));
+            registry.add_locator(Arc::new(locators::python::PythonLocator));
         });
         client.add_entity_request_handler(Self::handle_run_debug_locator);
         client.add_entity_request_handler(Self::handle_get_debug_adapter_binary);
@@ -412,7 +414,6 @@ impl DapStore {
                         this.get_debug_adapter_binary(definition.clone(), session_id, console, cx)
                     })?
                     .await?;
-
                 session
                     .update(cx, |session, cx| {
                         session.boot(binary, worktree, dap_store, cx)

crates/project/src/debugger/locators/python.rs 🔗

@@ -0,0 +1,99 @@
+use std::path::Path;
+
+use anyhow::{Result, bail};
+use async_trait::async_trait;
+use dap::{DapLocator, DebugRequest, adapters::DebugAdapterName};
+use gpui::SharedString;
+
+use task::{DebugScenario, SpawnInTerminal, TaskTemplate};
+
+pub(crate) struct PythonLocator;
+
+#[async_trait]
+impl DapLocator for PythonLocator {
+    fn name(&self) -> SharedString {
+        SharedString::new_static("Python")
+    }
+
+    /// Determines whether this locator can generate debug target for given task.
+    fn create_scenario(
+        &self,
+        build_config: &TaskTemplate,
+        resolved_label: &str,
+        adapter: DebugAdapterName,
+    ) -> Option<DebugScenario> {
+        if adapter.as_ref() != "Debugpy" {
+            return None;
+        }
+        let valid_program = build_config.command.starts_with("$ZED_")
+            || Path::new(&build_config.command)
+                .file_name()
+                .map_or(false, |name| {
+                    name.to_str().is_some_and(|path| path.starts_with("python"))
+                });
+        if !valid_program || build_config.args.iter().any(|arg| arg == "-c") {
+            // We cannot debug selections.
+            return None;
+        }
+        let module_specifier_position = build_config
+            .args
+            .iter()
+            .position(|arg| arg == "-m")
+            .map(|position| position + 1);
+        // Skip the -m and module name, get all that's after.
+        let mut rest_of_the_args = module_specifier_position
+            .and_then(|position| build_config.args.get(position..))
+            .into_iter()
+            .flatten()
+            .fuse();
+        let mod_name = rest_of_the_args.next();
+        let args = rest_of_the_args.collect::<Vec<_>>();
+
+        let program_position = mod_name
+            .is_none()
+            .then(|| {
+                build_config
+                    .args
+                    .iter()
+                    .position(|arg| *arg == "\"$ZED_FILE\"")
+            })
+            .flatten();
+        let args = if let Some(position) = program_position {
+            args.into_iter().skip(position).collect::<Vec<_>>()
+        } else {
+            args
+        };
+        if program_position.is_none() && mod_name.is_none() {
+            return None;
+        }
+        let mut config = serde_json::json!({
+            "request": "launch",
+            "python": build_config.command,
+            "args": args,
+            "cwd": build_config.cwd.clone()
+        });
+        if let Some(config_obj) = config.as_object_mut() {
+            if let Some(module) = mod_name {
+                config_obj.insert("module".to_string(), module.clone().into());
+            }
+            if let Some(program) = program_position {
+                config_obj.insert(
+                    "program".to_string(),
+                    build_config.args[program].clone().into(),
+                );
+            }
+        }
+
+        Some(DebugScenario {
+            adapter: adapter.0,
+            label: resolved_label.to_string().into(),
+            build: None,
+            config,
+            tcp_connection: None,
+        })
+    }
+
+    async fn run(&self, _: SpawnInTerminal) -> Result<DebugRequest> {
+        bail!("Python locator should not require DapLocator::run to be ran");
+    }
+}