From 1a9af8c94ac6cab60c18b6a394bd90b9c2383e39 Mon Sep 17 00:00:00 2001 From: Denis Date: Tue, 26 Aug 2025 15:01:32 +0200 Subject: [PATCH 1/2] Add subtest navigation --- lua/quicktest/adapters/golang/init.lua | 8 +- lua/quicktest/adapters/golang/ts/init.lua | 431 +++++++++++++++++++++- tests/support/gotest/abc/abc_test.go | 201 +++++++++- 3 files changed, 625 insertions(+), 15 deletions(-) diff --git a/lua/quicktest/adapters/golang/init.lua b/lua/quicktest/adapters/golang/init.lua index 1d0455b..c2c223f 100644 --- a/lua/quicktest/adapters/golang/init.lua +++ b/lua/quicktest/adapters/golang/init.lua @@ -109,12 +109,14 @@ end ---@return GoRunParams | nil, string | nil M.build_line_run_params = function(bufnr, cursor_pos, opts) local func_names = ts.get_nearest_func_names(bufnr, cursor_pos) - local sub_name = ts.get_sub_testcase_name(bufnr, cursor_pos) + local sub_test_name = ts.get_sub_testcase_name(bufnr, cursor_pos) or ts.get_table_test_name(bufnr, cursor_pos) + --- @type string[] local sub_func_names = {} - if sub_name then - sub_func_names = { sub_name } + if sub_test_name then + sub_func_names = { sub_test_name } end + local cwd = M.get_cwd(bufnr) local module = get_module_path(cwd, bufnr) or "." diff --git a/lua/quicktest/adapters/golang/ts/init.lua b/lua/quicktest/adapters/golang/ts/init.lua index fa3ebd5..d757e25 100644 --- a/lua/quicktest/adapters/golang/ts/init.lua +++ b/lua/quicktest/adapters/golang/ts/init.lua @@ -1,16 +1,6 @@ -- taken from https://github.com/yanskun/gotests.nvim local M = { - query_tbl_testcase_name = [[ ( literal_value ( - literal_element ( - literal_value .( - keyed_element - (literal_element (identifier)) - (literal_element (interpreted_string_literal) @test.name) - ) - ) @test.block - )) - ]], query_func_name = [[(function_declaration name: (identifier) @func_name)]], @@ -27,6 +17,365 @@ local M = { (func_literal) ) (#eq? @method.name "Run") ) @tc.run ]], + + table_tests_list = [[ + ;; query for list table tests + (block + (short_var_declaration + left: (expression_list + (identifier) @test.cases + ) + right: (expression_list + (composite_literal + (literal_value + (literal_element + (literal_value + (keyed_element + (literal_element + (identifier) @test.field.name + ) + (literal_element + (interpreted_string_literal) @test.name + ) + ) + ) + ) @test.definition + ) + ) + ) + ) + (for_statement + (range_clause + left: (expression_list + (identifier) @test.case + ) + right: (identifier) @test.cases1 (#eq? @test.cases @test.cases1) + ) + body: (block + (expression_statement + (call_expression + function: (selector_expression + operand: (identifier) @test.operand (#match? @test.operand "^[t]$") + field: (field_identifier) @test.method (#match? @test.method "^Run$") + ) + arguments: (argument_list + (selector_expression + operand: (identifier) @test.case1 (#eq? @test.case @test.case1) + field: (field_identifier) @test.field.name1 (#eq? @test.field.name @test.field.name1) + ) + ) + ) + ) + ) + ) + ) + ]], + + table_tests_loop = [[ + ;; query for list table tests (wrapped in loop) + (for_statement + (range_clause + left: (expression_list + (identifier) + (identifier) @test.case + ) + right: (composite_literal + type: (slice_type + element: (struct_type + (field_declaration_list + (field_declaration + name: (field_identifier) + type: (type_identifier) + ) + ) + ) + ) + body: (literal_value + (literal_element + (literal_value + (keyed_element + (literal_element + (identifier) + ) @test.field.name + (literal_element + (interpreted_string_literal) @test.name + ) + ) + ) @test.definition + ) + ) + ) + ) + body: (block + (expression_statement + (call_expression + function: (selector_expression + operand: (identifier) + field: (field_identifier) + ) + arguments: (argument_list + (selector_expression + operand: (identifier) + field: (field_identifier) @test.field.name1 + ) (#eq? @test.field.name @test.field.name1) + ) + ) + ) + ) + ) + ]], + + table_tests_unkeyed = [[ + ;; query for table tests with unkeyed struct literals + (block + (short_var_declaration + left: (expression_list + (identifier) @test.cases + ) + right: (expression_list + (composite_literal + type: (slice_type + element: (struct_type + (field_declaration_list + (field_declaration + name: (field_identifier) @test.field.name + type: (type_identifier) + ) + ) + ) + ) + body: (literal_value + (literal_element + (literal_value + (literal_element + (interpreted_string_literal) @test.name + ) + ) @test.definition @test.name + ) + ) + ) + ) + ) + (for_statement + (range_clause + left: (expression_list + (identifier) @test.index + (identifier) @test.case + ) + right: (identifier) @test.cases1 (#eq? @test.cases @test.cases1) + ) + body: (block + (expression_statement + (call_expression + function: (selector_expression + operand: (identifier) @test.operand (#match? @test.operand "^[t]$") + field: (field_identifier) @test.method (#match? @test.method "^Run$") + ) + arguments: (argument_list + (selector_expression + operand: (identifier) @test.case1 (#eq? @test.case @test.case1) + field: (field_identifier) @test.field.name1 (#eq? @test.field.name @test.field.name1) + ) + ) + ) + ) + ) + ) + ) + ]], + + table_tests_loop_unkeyed = [[ + ;; query for table tests with inline structs (not keyed, wrapped in loop) + (for_statement + (range_clause + left: (expression_list + (identifier) + (identifier) @test.case + ) + right: (composite_literal + type: (slice_type + element: (struct_type + (field_declaration_list + (field_declaration + name: (field_identifier) @test.field.name + type: (type_identifier) @field.type (#eq? @field.type "string") + ) + ) + ) + ) + body: (literal_value + (literal_element + (literal_value + (literal_element + (interpreted_string_literal) @test.name + ) + (literal_element) + ) @test.definition @test.name + ) + ) + ) + ) + body: (block + (expression_statement + (call_expression + function: (selector_expression + operand: (identifier) @test.operand (#match? @test.operand "^[t]$") + field: (field_identifier) @test.method (#match? @test.method "^Run$") + ) + arguments: (argument_list + (selector_expression + operand: (identifier) @test.case1 (#eq? @test.case @test.case1) + field: (field_identifier) @test.field.name1 (#eq? @test.field.name @test.field.name1) + ) + ) + ) + ) + ) + ) + ]], + + table_tests_inline = [[ + ;; query for inline table tests (range over slice literal) + (for_statement + (range_clause + left: (expression_list + (identifier) + (identifier) @test.case + ) + right: (composite_literal + type: (slice_type + element: (type_identifier) + ) + body: (literal_value + (literal_element + (literal_value + (keyed_element + (literal_element + (identifier) @test.field.name + ) + (literal_element + (interpreted_string_literal) @test.name + ) + ) + ) @test.definition + ) + ) + ) + ) + body: (block + (expression_statement + (call_expression + function: (selector_expression + operand: (identifier) @test.operand (#match? @test.operand "^[t]$") + field: (field_identifier) @test.method (#match? @test.method "^Run$") + ) + arguments: (argument_list + (selector_expression + operand: (identifier) @test.case1 (#eq? @test.case @test.case1) + field: (field_identifier) @test.field.name1 (#eq? @test.field.name @test.field.name1) + ) + ) + ) + ) + ) + ) + ]], + + -- Map-based table tests where test name is the map key + table_tests_map_key = [[ + ;; query for map-based table tests with string keys + (for_statement + (range_clause + left: (expression_list + (identifier) @test.key.name + (identifier) @test.case + ) + right: (composite_literal + type: (map_type + key: (type_identifier) @map.key.type + value: (type_identifier) + ) (#eq? @map.key.type "string") + body: (literal_value + (keyed_element + (literal_element + (interpreted_string_literal) @test.name + ) + (literal_element + (literal_value) @test.definition + ) + ) @test.definition + ) + ) + ) + body: (block + (expression_statement + (call_expression + function: (selector_expression + operand: (identifier) @test.operand (#match? @test.operand "^[t]$") + field: (field_identifier) @test.method (#match? @test.method "^Run$") + ) + arguments: (argument_list + (identifier) @test.key.name1 (#eq? @test.key.name @test.key.name1) + ) + ) + ) + ) + ) + ]], + + -- Map-based table tests where test name is a struct field (like tt.name) + table_tests_map_field = [[ + ;; query for map-based table tests using struct field as test name + (for_statement + (range_clause + left: (expression_list + (identifier) @test.key.name + (identifier) @test.case + ) + right: (composite_literal + type: (map_type + key: (type_identifier) @map.key.type + value: (type_identifier) + ) (#eq? @map.key.type "string") + body: (literal_value + (keyed_element + (literal_element + (interpreted_string_literal) @test.map.key + ) + (literal_element + (literal_value + (keyed_element + (literal_element + (identifier) @test.field.name + ) + (literal_element + (interpreted_string_literal) @test.name + ) + ) + ) @test.definition + ) + ) @test.definition + ) + ) + ) + body: (block + (expression_statement + (call_expression + function: (selector_expression + operand: (identifier) @test.operand (#match? @test.operand "^[t]$") + field: (field_identifier) @test.method (#match? @test.method "^Run$") + ) + arguments: (argument_list + (selector_expression + operand: (identifier) @test.case1 (#eq? @test.case @test.case1) + field: (field_identifier) @test.field.name1 (#eq? @test.field.name @test.field.name1) + ) + ) + ) + ) + ) + ) + ]], } ---@param bufnr integer @@ -161,4 +510,66 @@ function M.get_sub_testcase_name(bufnr, cursor_pos) return nil end +---@param bufnr integer +---@param cursor_pos integer[] +---@return string? +function M.get_table_test_name(bufnr, cursor_pos) + local root = get_root_node(bufnr) + if not root then + return + end + + local all_queries = M.table_tests_list + .. M.table_tests_loop + .. M.table_tests_unkeyed + .. M.table_tests_loop_unkeyed + .. M.table_tests_inline + .. M.table_tests_map_key + .. M.table_tests_map_field + local query = vim.treesitter.query.parse("go", all_queries) + local curr_row, _ = unpack(cursor_pos) + -- from 1-based to 0-based indexing + curr_row = curr_row - 1 + + -- Find test name at cursor position by checking test definitions + local test_names = {} + local test_definitions = {} + + for id, node in query:iter_captures(root, bufnr, 0, -1) do + local name = query.captures[id] + local start_row, start_col, end_row, end_col = node:range() + + if name == "test.name" then + table.insert(test_names, { + text = vim.treesitter.get_node_text(node, bufnr), + start_row = start_row, + end_row = end_row, + start_col = start_col, + end_col = end_col + }) + elseif name == "test.definition" then + table.insert(test_definitions, { + start_row = start_row, + end_row = end_row, + start_col = start_col, + end_col = end_col + }) + end + end + + -- Find test definition that contains cursor, then find corresponding test name + for _, def in ipairs(test_definitions) do + if curr_row >= def.start_row and curr_row <= def.end_row then + -- Find test name within this definition + for _, name in ipairs(test_names) do + if name.start_row >= def.start_row and name.end_row <= def.end_row then + return name.text + end + end + end + end + + return nil +end + return M diff --git a/tests/support/gotest/abc/abc_test.go b/tests/support/gotest/abc/abc_test.go index 01454c9..cdaf345 100644 --- a/tests/support/gotest/abc/abc_test.go +++ b/tests/support/gotest/abc/abc_test.go @@ -2,13 +2,12 @@ package abc_test import ( "fmt" + "gotest/abc" "math" "os" "strconv" "testing" "time" - - "gotest/abc" ) func TestSum(t *testing.T) { @@ -76,3 +75,201 @@ func TestIntensiveOutput(t *testing.T) { fmt.Println("hi!hi!hi!hi!hi!hi!hi!hi!hi!hi!hi!hi!hi!hi!hi!hi!hi!hi!hi!hi!hi!hi!" + strconv.Itoa(i)) } } + +func TestTableDrivenTyped(t *testing.T) { + type testCase struct { + name string + } + + for _, tt := range []testCase{ + { + name: "json", + }, + { + name: "yaml", + }, + } { + t.Run(tt.name, func(t *testing.T) { + fmt.Println(tt.name) + }) + } +} + +func TestTableDrivenTypedDifferentNameField(t *testing.T) { + type testCase struct { + name string + realName string + } + + for _, tt := range []testCase{ + { + name: "json", + realName: "also json", + }, + { + name: "yaml", + realName: "also yaml", + }, + } { + t.Run(tt.realName, func(t *testing.T) { + fmt.Println(tt.name) + }) + } +} + +func TestTableDrivenInlined(t *testing.T) { + for _, tt := range []struct { + name string + }{ + { + name: "json", + }, + { + name: "yaml", + }, + } { + t.Run(tt.name, func(t *testing.T) { + fmt.Println(tt.name) + }) + } +} + +func TestTableDrivenInlinedDifferentName(t *testing.T) { + for _, tt := range []struct { + name string + realName string + }{ + { + name: "json", + realName: "also json", + }, + { + name: "yaml", + realName: "also yaml", + }, + } { + t.Run(tt.realName, func(t *testing.T) { + fmt.Println(tt.name) + }) + } +} + +func TestTableDrivenMapKeyIsName(t *testing.T) { + type testCase struct { + name string + } + + for name, tt := range map[string]testCase{ + "json": { + name: "not a name", + }, + "yaml": { + name: "not a name either", + }, + } { + t.Run(name, func(t *testing.T) { + fmt.Println(tt.name) + }) + } +} + +func TestTableDrivenMap(t *testing.T) { + type testCase struct { + name string + } + + for name, tt := range map[string]testCase{ + "json": { + name: "real name json", + }, + "yaml": { + name: "real name yaml", + }, + } { + _ = name + t.Run(tt.name, func(t *testing.T) { + fmt.Println(tt.name) + }) + } +} + +func TestTableDrivenList(t *testing.T) { + testCases := []struct { + name string + input int + expected int + }{ + { + name: "positive number", + input: 5, + expected: 25, + }, + { + name: "zero", + input: 0, + expected: 0, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := tc.input * tc.input + if result != tc.expected { + t.Errorf("got %d, want %d", result, tc.expected) + } + }) + } +} + +func TestTableDrivenUnkeyed(t *testing.T) { + testCases := []struct { + name string + input int + expected int + }{ + { + "positive number", // unkeyed literal - no field names + 5, + 25, + }, + { + "zero", // unkeyed literal - no field names + 0, + 0, + }, + } + + for i, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := tc.input * tc.input + if result != tc.expected { + t.Errorf("Test %d: got %d, want %d", i, result, tc.expected) + } + }) + } +} + +func TestTableDrivenLoopUnkeyed(t *testing.T) { + for _, tc := range []struct { + name string + age int + }{ + { + "Alice", + 25, + }, + { + "Bob", + 30, + }, + } { + t.Run(tc.name, func(t *testing.T) { + if len(tc.name) == 0 { + t.Error("name should not be empty") + } + if tc.age <= 0 { + t.Error("age should be positive") + } + }) + } +} From f577525d3674493f743fc60dc7eebaf19d335438 Mon Sep 17 00:00:00 2001 From: Denis Dvornikov Date: Sat, 20 Sep 2025 17:45:08 +0200 Subject: [PATCH 2/2] Fix stylua --- lua/quicktest/adapters/golang/ts/init.lua | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lua/quicktest/adapters/golang/ts/init.lua b/lua/quicktest/adapters/golang/ts/init.lua index d757e25..9e12df1 100644 --- a/lua/quicktest/adapters/golang/ts/init.lua +++ b/lua/quicktest/adapters/golang/ts/init.lua @@ -534,25 +534,25 @@ function M.get_table_test_name(bufnr, cursor_pos) -- Find test name at cursor position by checking test definitions local test_names = {} local test_definitions = {} - + for id, node in query:iter_captures(root, bufnr, 0, -1) do local name = query.captures[id] local start_row, start_col, end_row, end_col = node:range() - + if name == "test.name" then table.insert(test_names, { text = vim.treesitter.get_node_text(node, bufnr), start_row = start_row, end_row = end_row, start_col = start_col, - end_col = end_col + end_col = end_col, }) elseif name == "test.definition" then table.insert(test_definitions, { start_row = start_row, end_row = end_row, start_col = start_col, - end_col = end_col + end_col = end_col, }) end end