go: Add support for running sub-tests in table tests (#35657)

Lukas Spiss created

One killer feature for the Go runner is to execute individual subtests
within a table-test easily. Goland has had this feature forever, while
in VSCode this has been notably missing.


https://github.com/user-attachments/assets/363417a2-d1b1-43ca-8377-08ce062d6104


Release Notes:

- Added support to run Go table-test subtests.

Change summary

crates/languages/src/go.rs            | 345 ++++++++++++++++++++++++++++
crates/languages/src/go/runnables.scm | 100 ++++++++
2 files changed, 439 insertions(+), 6 deletions(-)

Detailed changes

crates/languages/src/go.rs 🔗

@@ -487,6 +487,8 @@ const GO_MODULE_ROOT_TASK_VARIABLE: VariableName =
     VariableName::Custom(Cow::Borrowed("GO_MODULE_ROOT"));
 const GO_SUBTEST_NAME_TASK_VARIABLE: VariableName =
     VariableName::Custom(Cow::Borrowed("GO_SUBTEST_NAME"));
+const GO_TABLE_TEST_CASE_NAME_TASK_VARIABLE: VariableName =
+    VariableName::Custom(Cow::Borrowed("GO_TABLE_TEST_CASE_NAME"));
 
 impl ContextProvider for GoContextProvider {
     fn build_context(
@@ -545,10 +547,19 @@ impl ContextProvider for GoContextProvider {
         let go_subtest_variable = extract_subtest_name(_subtest_name.unwrap_or(""))
             .map(|subtest_name| (GO_SUBTEST_NAME_TASK_VARIABLE.clone(), subtest_name));
 
+        let table_test_case_name = variables.get(&VariableName::Custom(Cow::Borrowed(
+            "_table_test_case_name",
+        )));
+
+        let go_table_test_case_variable = table_test_case_name
+            .and_then(extract_subtest_name)
+            .map(|case_name| (GO_TABLE_TEST_CASE_NAME_TASK_VARIABLE.clone(), case_name));
+
         Task::ready(Ok(TaskVariables::from_iter(
             [
                 go_package_variable,
                 go_subtest_variable,
+                go_table_test_case_variable,
                 go_module_root_variable,
             ]
             .into_iter()
@@ -570,6 +581,28 @@ impl ContextProvider for GoContextProvider {
         let module_cwd = Some(GO_MODULE_ROOT_TASK_VARIABLE.template_value());
 
         Task::ready(Some(TaskTemplates(vec![
+            TaskTemplate {
+                label: format!(
+                    "go test {} -v -run {}/{}",
+                    GO_PACKAGE_TASK_VARIABLE.template_value(),
+                    VariableName::Symbol.template_value(),
+                    GO_TABLE_TEST_CASE_NAME_TASK_VARIABLE.template_value(),
+                ),
+                command: "go".into(),
+                args: vec![
+                    "test".into(),
+                    "-v".into(),
+                    "-run".into(),
+                    format!(
+                        "\\^{}\\$/\\^{}\\$",
+                        VariableName::Symbol.template_value(),
+                        GO_TABLE_TEST_CASE_NAME_TASK_VARIABLE.template_value(),
+                    ),
+                ],
+                cwd: package_cwd.clone(),
+                tags: vec!["go-table-test-case".to_owned()],
+                ..TaskTemplate::default()
+            },
             TaskTemplate {
                 label: format!(
                     "go test {} -run {}",
@@ -842,10 +875,21 @@ mod tests {
                 .collect()
         });
 
+        let tag_strings: Vec<String> = runnables
+            .iter()
+            .flat_map(|r| &r.runnable.tags)
+            .map(|tag| tag.0.to_string())
+            .collect();
+
         assert!(
-            runnables.len() == 2,
-            "Should find test function and subtest with double quotes, found: {}",
-            runnables.len()
+            tag_strings.contains(&"go-test".to_string()),
+            "Should find go-test tag, found: {:?}",
+            tag_strings
+        );
+        assert!(
+            tag_strings.contains(&"go-subtest".to_string()),
+            "Should find go-subtest tag, found: {:?}",
+            tag_strings
         );
 
         let buffer = cx.new(|cx| {
@@ -860,10 +904,299 @@ mod tests {
                 .collect()
         });
 
+        let tag_strings: Vec<String> = runnables
+            .iter()
+            .flat_map(|r| &r.runnable.tags)
+            .map(|tag| tag.0.to_string())
+            .collect();
+
+        assert!(
+            tag_strings.contains(&"go-test".to_string()),
+            "Should find go-test tag, found: {:?}",
+            tag_strings
+        );
+        assert!(
+            tag_strings.contains(&"go-subtest".to_string()),
+            "Should find go-subtest tag, found: {:?}",
+            tag_strings
+        );
+    }
+
+    #[gpui::test]
+    fn test_go_table_test_slice_detection(cx: &mut TestAppContext) {
+        let language = language("go", tree_sitter_go::LANGUAGE.into());
+
+        let table_test = r#"
+        package main
+
+        import "testing"
+
+        func TestExample(t *testing.T) {
+            _ = "some random string"
+
+            testCases := []struct{
+                name string
+                anotherStr string
+            }{
+                {
+                    name: "test case 1",
+                    anotherStr: "foo",
+                },
+                {
+                    name: "test case 2",
+                    anotherStr: "bar",
+                },
+            }
+
+            notATableTest := []struct{
+                name string
+            }{
+                {
+                    name: "some string",
+                },
+                {
+                    name: "some other string",
+                },
+            }
+
+            for _, tc := range testCases {
+                t.Run(tc.name, func(t *testing.T) {
+                    // test code here
+                })
+            }
+        }
+        "#;
+
+        let buffer =
+            cx.new(|cx| crate::Buffer::local(table_test, cx).with_language(language.clone(), cx));
+        cx.executor().run_until_parked();
+
+        let runnables: Vec<_> = buffer.update(cx, |buffer, _| {
+            let snapshot = buffer.snapshot();
+            snapshot.runnable_ranges(0..table_test.len()).collect()
+        });
+
+        let tag_strings: Vec<String> = runnables
+            .iter()
+            .flat_map(|r| &r.runnable.tags)
+            .map(|tag| tag.0.to_string())
+            .collect();
+
+        assert!(
+            tag_strings.contains(&"go-test".to_string()),
+            "Should find go-test tag, found: {:?}",
+            tag_strings
+        );
+        assert!(
+            tag_strings.contains(&"go-table-test-case".to_string()),
+            "Should find go-table-test-case tag, found: {:?}",
+            tag_strings
+        );
+
+        let go_test_count = tag_strings.iter().filter(|&tag| tag == "go-test").count();
+        let go_table_test_count = tag_strings
+            .iter()
+            .filter(|&tag| tag == "go-table-test-case")
+            .count();
+
+        assert!(
+            go_test_count == 1,
+            "Should find exactly 1 go-test, found: {}",
+            go_test_count
+        );
+        assert!(
+            go_table_test_count == 2,
+            "Should find exactly 2 go-table-test-case, found: {}",
+            go_table_test_count
+        );
+    }
+
+    #[gpui::test]
+    fn test_go_table_test_slice_ignored(cx: &mut TestAppContext) {
+        let language = language("go", tree_sitter_go::LANGUAGE.into());
+
+        let table_test = r#"
+        package main
+
+        func Example() {
+            _ = "some random string"
+
+            notATableTest := []struct{
+                name string
+            }{
+                {
+                    name: "some string",
+                },
+                {
+                    name: "some other string",
+                },
+            }
+        }
+        "#;
+
+        let buffer =
+            cx.new(|cx| crate::Buffer::local(table_test, cx).with_language(language.clone(), cx));
+        cx.executor().run_until_parked();
+
+        let runnables: Vec<_> = buffer.update(cx, |buffer, _| {
+            let snapshot = buffer.snapshot();
+            snapshot.runnable_ranges(0..table_test.len()).collect()
+        });
+
+        let tag_strings: Vec<String> = runnables
+            .iter()
+            .flat_map(|r| &r.runnable.tags)
+            .map(|tag| tag.0.to_string())
+            .collect();
+
+        assert!(
+            !tag_strings.contains(&"go-test".to_string()),
+            "Should find go-test tag, found: {:?}",
+            tag_strings
+        );
+        assert!(
+            !tag_strings.contains(&"go-table-test-case".to_string()),
+            "Should find go-table-test-case tag, found: {:?}",
+            tag_strings
+        );
+    }
+
+    #[gpui::test]
+    fn test_go_table_test_map_detection(cx: &mut TestAppContext) {
+        let language = language("go", tree_sitter_go::LANGUAGE.into());
+
+        let table_test = r#"
+        package main
+
+        import "testing"
+
+        func TestExample(t *testing.T) {
+            _ = "some random string"
+
+           	testCases := map[string]struct {
+          		someStr string
+          		fail    bool
+           	}{
+          		"test failure": {
+         			someStr: "foo",
+         			fail:    true,
+          		},
+          		"test success": {
+         			someStr: "bar",
+         			fail:    false,
+          		},
+           	}
+
+           	notATableTest := map[string]struct {
+          		someStr string
+           	}{
+          		"some string": {
+         			someStr: "foo",
+          		},
+          		"some other string": {
+         			someStr: "bar",
+          		},
+           	}
+
+            for name, tc := range testCases {
+                t.Run(name, func(t *testing.T) {
+                    // test code here
+                })
+            }
+        }
+        "#;
+
+        let buffer =
+            cx.new(|cx| crate::Buffer::local(table_test, cx).with_language(language.clone(), cx));
+        cx.executor().run_until_parked();
+
+        let runnables: Vec<_> = buffer.update(cx, |buffer, _| {
+            let snapshot = buffer.snapshot();
+            snapshot.runnable_ranges(0..table_test.len()).collect()
+        });
+
+        let tag_strings: Vec<String> = runnables
+            .iter()
+            .flat_map(|r| &r.runnable.tags)
+            .map(|tag| tag.0.to_string())
+            .collect();
+
+        assert!(
+            tag_strings.contains(&"go-test".to_string()),
+            "Should find go-test tag, found: {:?}",
+            tag_strings
+        );
+        assert!(
+            tag_strings.contains(&"go-table-test-case".to_string()),
+            "Should find go-table-test-case tag, found: {:?}",
+            tag_strings
+        );
+
+        let go_test_count = tag_strings.iter().filter(|&tag| tag == "go-test").count();
+        let go_table_test_count = tag_strings
+            .iter()
+            .filter(|&tag| tag == "go-table-test-case")
+            .count();
+
+        assert!(
+            go_test_count == 1,
+            "Should find exactly 1 go-test, found: {}",
+            go_test_count
+        );
+        assert!(
+            go_table_test_count == 2,
+            "Should find exactly 2 go-table-test-case, found: {}",
+            go_table_test_count
+        );
+    }
+
+    #[gpui::test]
+    fn test_go_table_test_map_ignored(cx: &mut TestAppContext) {
+        let language = language("go", tree_sitter_go::LANGUAGE.into());
+
+        let table_test = r#"
+        package main
+
+        func Example() {
+            _ = "some random string"
+
+           	notATableTest := map[string]struct {
+          		someStr string
+           	}{
+          		"some string": {
+         			someStr: "foo",
+          		},
+          		"some other string": {
+         			someStr: "bar",
+          		},
+           	}
+        }
+        "#;
+
+        let buffer =
+            cx.new(|cx| crate::Buffer::local(table_test, cx).with_language(language.clone(), cx));
+        cx.executor().run_until_parked();
+
+        let runnables: Vec<_> = buffer.update(cx, |buffer, _| {
+            let snapshot = buffer.snapshot();
+            snapshot.runnable_ranges(0..table_test.len()).collect()
+        });
+
+        let tag_strings: Vec<String> = runnables
+            .iter()
+            .flat_map(|r| &r.runnable.tags)
+            .map(|tag| tag.0.to_string())
+            .collect();
+
+        assert!(
+            !tag_strings.contains(&"go-test".to_string()),
+            "Should find go-test tag, found: {:?}",
+            tag_strings
+        );
         assert!(
-            runnables.len() == 2,
-            "Should find test function and subtest with backticks, found: {}",
-            runnables.len()
+            !tag_strings.contains(&"go-table-test-case".to_string()),
+            "Should find go-table-test-case tag, found: {:?}",
+            tag_strings
         );
     }
 

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

@@ -91,3 +91,103 @@
   ) @_
   (#set! tag go-main)
 )
+
+; Table test cases - slice and map
+(
+  (short_var_declaration
+    left: (expression_list (identifier) @_collection_var)
+    right: (expression_list
+      (composite_literal
+        type: [
+          (slice_type)
+          (map_type
+            key: (type_identifier) @_key_type
+            (#eq? @_key_type "string")
+          )
+        ]
+        body: (literal_value
+          [
+            (literal_element
+              (literal_value
+                (keyed_element
+                  (literal_element
+                    (identifier) @_field_name
+                  )
+                  (literal_element
+                    [
+                      (interpreted_string_literal) @run @_table_test_case_name
+                      (raw_string_literal) @run @_table_test_case_name
+                    ]
+                  )
+                )
+              )
+            )
+            (keyed_element
+              (literal_element
+                [
+                  (interpreted_string_literal) @run @_table_test_case_name
+                  (raw_string_literal) @run @_table_test_case_name
+                ]
+              )
+            )
+          ]
+        )
+      )
+    )
+  )
+  (for_statement
+    (range_clause
+      left: (expression_list
+        [
+          (
+            (identifier)
+            (identifier) @_loop_var
+          )
+          (identifier) @_loop_var
+        ]
+      )
+      right: (identifier) @_range_var
+      (#eq? @_range_var @_collection_var)
+    )
+    body: (block
+      (expression_statement
+        (call_expression
+          function: (selector_expression
+            operand: (identifier) @_t_var
+            field: (field_identifier) @_run_method
+            (#eq? @_run_method "Run")
+          )
+          arguments: (argument_list
+            .
+            [
+              (selector_expression
+                operand: (identifier) @_tc_var
+                (#eq? @_tc_var @_loop_var)
+                field: (field_identifier) @_field_check
+                (#eq? @_field_check @_field_name)
+              )
+              (identifier) @_arg_var
+              (#eq? @_arg_var @_loop_var)
+            ]
+            .
+            (func_literal
+              parameters: (parameter_list
+                (parameter_declaration
+                  type: (pointer_type
+                    (qualified_type
+                      package: (package_identifier) @_pkg
+                      name: (type_identifier) @_type
+                      (#eq? @_pkg "testing")
+                      (#eq? @_type "T")
+                    )
+                  )
+                )
+              )
+            )
+          )
+        )
+      )
+    )
+  ) @_
+  (#set! tag go-table-test-case)
+)