python: Add runnable unittest tasks (#12451)

Rayduck created

Add runnable tasks for Python, starting with `unittest` from the
standard library. Both `TestCase`s (classes meant to be a unit of
testing) and individual test functions in a `TestCase` will have
runnable icons. For completeness, I also included a task that will run
`unittest` on the current file.

The implementation follows the `unittest` CLI. The unittest module can
be used from the command line to run tests from modules, classes or even
individual test methods:

```
python -m unittest test_module.TestClass
python -m unittest test_module.TestClass.test_method
```

```python
import unittest

class TestStringMethods(unittest.TestCase):

    def test_upper(self):
        self.assertEqual('foo'.upper(), 'FOO')

    def test_isupper(self):
        self.assertTrue('FOO'.isupper())
        self.assertFalse('Foo'.isupper())

    def test_split(self):
        s = 'hello world'
        self.assertEqual(s.split(), ['hello', 'world'])
        # check that s.split fails when the separator is not a string
        with self.assertRaises(TypeError):
            s.split(2)

if __name__ == '__main__':
    unittest.main()
```

From the snippet provided by `unittest` docs, a user may want to run
test_split independently of the other test functions in the test case.
Hence, I decided to make each test function runnable despite `TestCase`s
being the unit of testing.

## Example of running a `TestCase`
<img width="600" alt="image"
src="https://github.com/zed-industries/zed/assets/16619392/7be38b71-9d51-4b44-9840-f819502d600a">

## Example of running a test function in a `TestCase`
<img width="600" alt="image"
src="https://github.com/zed-industries/zed/assets/16619392/f0b6274c-4fa7-424e-a0f5-1dc723842046">

`unittest` will also run the `setUp` and `tearDown` fixtures.

Eventually, I want to add the more commonly used `pytest` runnables
(perhaps as an extension instead).

Release Notes:

- Added runnable tasks for Python `unittest`.
([#12080](https://github.com/zed-industries/zed/issues/12080)).

Change summary

crates/languages/src/lib.rs               |   8 -
crates/languages/src/python.rs            | 105 ++++++++++++++++++++----
crates/languages/src/python/runnables.scm |  31 +++++++
3 files changed, 122 insertions(+), 22 deletions(-)

Detailed changes

crates/languages/src/lib.rs 🔗

@@ -3,6 +3,7 @@ use gpui::{AppContext, UpdateGlobal};
 use json::json_task_context;
 pub use language::*;
 use node_runtime::NodeRuntime;
+use python::PythonContextProvider;
 use rust_embed::RustEmbed;
 use settings::SettingsStore;
 use smol::stream::StreamExt;
@@ -10,10 +11,7 @@ use std::{str, sync::Arc};
 use typescript::typescript_task_context;
 use util::{asset_str, ResultExt};
 
-use crate::{
-    bash::bash_task_context, go::GoContextProvider, python::python_task_context,
-    rust::RustContextProvider,
-};
+use crate::{bash::bash_task_context, go::GoContextProvider, rust::RustContextProvider};
 
 mod bash;
 mod c;
@@ -130,7 +128,7 @@ pub fn init(
         vec![Arc::new(python::PythonLspAdapter::new(
             node_runtime.clone(),
         ))],
-        python_task_context()
+        PythonContextProvider
     );
     language!(
         "rust",

crates/languages/src/python.rs 🔗

@@ -1,11 +1,11 @@
 use anyhow::Result;
 use async_trait::async_trait;
-use language::{LanguageServerName, LspAdapter, LspAdapterDelegate};
+use language::{ContextProvider, LanguageServerName, LspAdapter, LspAdapterDelegate};
 use lsp::LanguageServerBinary;
 use node_runtime::NodeRuntime;
-use project::ContextProviderWithTasks;
 use std::{
     any::Any,
+    borrow::Cow,
     ffi::OsString,
     path::{Path, PathBuf},
     sync::Arc,
@@ -182,21 +182,92 @@ async fn get_cached_server_binary(
     }
 }
 
-pub(super) fn python_task_context() -> ContextProviderWithTasks {
-    ContextProviderWithTasks::new(TaskTemplates(vec![
-        TaskTemplate {
-            label: "execute selection".to_owned(),
-            command: "python3".to_owned(),
-            args: vec!["-c".to_owned(), VariableName::SelectedText.template_value()],
-            ..TaskTemplate::default()
-        },
-        TaskTemplate {
-            label: format!("run '{}'", VariableName::File.template_value()),
-            command: "python3".to_owned(),
-            args: vec![VariableName::File.template_value()],
-            ..TaskTemplate::default()
-        },
-    ]))
+pub(crate) struct PythonContextProvider;
+
+const PYTHON_UNITTEST_TARGET_TASK_VARIABLE: VariableName =
+    VariableName::Custom(Cow::Borrowed("PYTHON_UNITTEST_TARGET"));
+
+impl ContextProvider for PythonContextProvider {
+    fn build_context(
+        &self,
+        variables: &task::TaskVariables,
+        _location: &project::Location,
+        _cx: &mut gpui::AppContext,
+    ) -> Result<task::TaskVariables> {
+        let python_module_name = python_module_name_from_relative_path(
+            variables.get(&VariableName::RelativeFile).unwrap_or(""),
+        );
+        let unittest_class_name =
+            variables.get(&VariableName::Custom(Cow::Borrowed("_unittest_class_name")));
+        let unittest_method_name = variables.get(&VariableName::Custom(Cow::Borrowed(
+            "_unittest_method_name",
+        )));
+
+        let unittest_target_str = match (unittest_class_name, unittest_method_name) {
+            (Some(class_name), Some(method_name)) => {
+                format!("{}.{}.{}", python_module_name, class_name, method_name)
+            }
+            (Some(class_name), None) => format!("{}.{}", python_module_name, class_name),
+            (None, None) => python_module_name,
+            (None, Some(_)) => return Ok(task::TaskVariables::default()), // should never happen, a TestCase class is the unit of testing
+        };
+
+        let unittest_target = (
+            PYTHON_UNITTEST_TARGET_TASK_VARIABLE.clone(),
+            unittest_target_str,
+        );
+
+        Ok(task::TaskVariables::from_iter([unittest_target]))
+    }
+
+    fn associated_tasks(&self) -> Option<TaskTemplates> {
+        Some(TaskTemplates(vec![
+            TaskTemplate {
+                label: "execute selection".to_owned(),
+                command: "python3".to_owned(),
+                args: vec!["-c".to_owned(), VariableName::SelectedText.template_value()],
+                ..TaskTemplate::default()
+            },
+            TaskTemplate {
+                label: format!("run '{}'", VariableName::File.template_value()),
+                command: "python3".to_owned(),
+                args: vec![VariableName::File.template_value()],
+                ..TaskTemplate::default()
+            },
+            TaskTemplate {
+                label: format!("unittest '{}'", VariableName::File.template_value()),
+                command: "python3".to_owned(),
+                args: vec![
+                    "-m".to_owned(),
+                    "unittest".to_owned(),
+                    VariableName::File.template_value(),
+                ],
+                ..TaskTemplate::default()
+            },
+            TaskTemplate {
+                label: "unittest $ZED_CUSTOM_PYTHON_UNITTEST_TARGET".to_owned(),
+                command: "python3".to_owned(),
+                args: vec![
+                    "-m".to_owned(),
+                    "unittest".to_owned(),
+                    "$ZED_CUSTOM_PYTHON_UNITTEST_TARGET".to_owned(),
+                ],
+                tags: vec![
+                    "python-unittest-class".to_owned(),
+                    "python-unittest-method".to_owned(),
+                ],
+                ..TaskTemplate::default()
+            },
+        ]))
+    }
+}
+
+fn python_module_name_from_relative_path(relative_path: &str) -> String {
+    let path_with_dots = relative_path.replace('/', ".");
+    path_with_dots
+        .strip_suffix(".py")
+        .unwrap_or(&path_with_dots)
+        .to_string()
 }
 
 #[cfg(test)]

crates/languages/src/python/runnables.scm 🔗

@@ -0,0 +1,31 @@
+; subclasses of unittest.TestCase or TestCase
+(
+    (class_definition
+        name: (identifier) @run @_unittest_class_name
+        superclasses: (argument_list
+            [(identifier) @_superclass
+            (attribute (identifier) @_superclass)]
+        )
+        (#eq? @_superclass "TestCase")
+    ) @python-unittest-class
+    (#set! tag python-unittest-class)
+)
+
+; test methods whose names start with `test` in a TestCase
+(
+    (class_definition
+        name: (identifier) @_unittest_class_name
+        superclasses: (argument_list
+            [(identifier) @_superclass
+            (attribute (identifier) @_superclass)]
+        )
+        (#eq? @_superclass "TestCase")
+        body: (block
+                (function_definition
+                    name: (identifier) @run @_unittest_method_name
+                    (#match? @_unittest_method_name "^test.*")
+                ) @python-unittest-method
+                (#set! tag python-unittest-method)
+            )
+        )
+)