Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
58cb604
fix: compatibility with Neovim 0.12
Andeshog Apr 1, 2026
e572dd0
remove comment blocks from TREESITTER_GTEST_QUERY
Andeshog Sep 13, 2026
509a31d
automatically map test files to executable using ctest
Sep 13, 2026
f8bab28
update test setup and remove anchors in parser
Andeshog Sep 13, 2026
b3174ac
update makefile integration tests command
Andeshog Sep 13, 2026
1913a7d
unit and integration test for ctest based configure_all
Andeshog Sep 13, 2026
6be35fe
caching and error messages
Andeshog Sep 13, 2026
6783297
update cached and more canonical usage
Andeshog Sep 13, 2026
02cc069
update CI
Andeshog Sep 13, 2026
95893f1
Automatic test-to-executable mapping via CTest, query fix, vim.pack t…
Andeshog Sep 13, 2026
8932c0d
Update CI status badge
Andeshog Sep 13, 2026
1b607c2
Run mapped files, warn about unmapped (#2)
Andeshog Sep 13, 2026
ee5623c
tolerate gtest results for uparsed tests (#3)
Andeshog Sep 13, 2026
76c73bf
feat: support Google Test parameterized tests
ranjithshegde Oct 9, 2026
dc8f301
feat: inventory compiled TEST_P cases and map them to source tests
ranjithshegde Oct 9, 2026
50b66e7
feat(gtest): selectable TEST_P runtime instances
ranjithshegde Oct 9, 2026
9b7a5b2
test(gtest): verify runtime instance selection in Neotest
ranjithshegde Oct 9, 2026
352bf33
feat(gtest): report parameterized instance results independently
ranjithshegde Oct 10, 2026
269a5b2
feat(gtest): support typed and type-parameterized test cases
ranjithshegde Oct 10, 2026
081b4c3
feat(gtest): add opt-in per-case process isolation
ranjithshegde Oct 10, 2026
9b6bd07
fix(gtest): refresh tests when configured executables rebuild
ranjithshegde Oct 11, 2026
7c33a54
Merge remote-tracking branch 'upstream/main' into dev
ranjithshegde Oct 11, 2026
e21776d
fix(test): build parameterized inventory fixture with Google Test 1.10
ranjithshegde Oct 11, 2026
2a393df
refactor: simplify test discovery and result handling
ranjithshegde Oct 11, 2026
c79c1a1
fix(gtest): run isolated cases with the strategy of the run
alfaix Oct 25, 2026
8007868
fix(gtest): keep isolated results when a case is stale or the run stops
alfaix Oct 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 9 additions & 16 deletions .github/workflows/workflow.yaml
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
name: neotest-gtest CI
name: CI

on:
push:
branches:
- main
pull_request: ~
branches: [main]
pull_request:

jobs:
style:
name: style
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: JohnnyMorganz/stylua-action@v4
Expand Down Expand Up @@ -46,24 +46,17 @@ jobs:
TREE_SITTER_CPP_TAG: v0.23.4
steps:
- uses: actions/checkout@v4
- name: Prepare dependencies
- name: Prepare Neovim
run: |
mkdir -p _neovim
curl -fsL "https://github.com/neovim/neovim/releases/download/${{ matrix.rev }}/nvim-linux-x86_64.tar.gz" | tar xzf - --strip-components=1 -C "${PWD}/_neovim"
export PLUGINS_PATH=~/.local/share/nvim/site/pack/vendor/start
mkdir -p $PLUGINS_PATH
git clone --depth 1 https://github.com/nvim-lua/plenary.nvim ${PLUGINS_PATH}/plenary.nvim
git clone --depth 1 https://github.com/nvim-neotest/nvim-nio ${PLUGINS_PATH}/nvim-nio
git clone --depth 1 https://github.com/mfussenegger/nvim-dap ${PLUGINS_PATH}/nvim-dap
git clone --depth 1 https://github.com/nvim-neotest/neotest ${PLUGINS_PATH}/neotest
ln -s ${PLUGINS_PATH} ~/.local/share/nvim/lazy

- name: Build cpp tree-sitter parser
run: |
git clone --depth 1 --branch ${TREE_SITTER_CPP_TAG} https://github.com/tree-sitter/tree-sitter-cpp _tree-sitter-cpp
mkdir -p ~/.local/share/nvim/site/parser
mkdir -p .tests/data/nvim/site/parser
cc -shared -fPIC -O2 -I_tree-sitter-cpp/src _tree-sitter-cpp/src/parser.c _tree-sitter-cpp/src/scanner.c \
-o ~/.local/share/nvim/site/parser/cpp.so
-o .tests/data/nvim/site/parser/cpp.so

- name: Run tests
run: |
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ build/
compile_commands.json
.cache/
.luarc.json
.tests/
16 changes: 9 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
.PHONY: test unit-test integration-test submodules

MINIMAL_INIT = tests/unit/minimal_init.lua
PLENARY_OPTS = {minimal_init='${MINIMAL_INIT}', sequential=true, timeout=5000}
GTEST_TAG ?= main
export GTEST_TAG
export GTEST_PATH
MINIMAL_INIT = tests/minimal_init.lua
PLENARY_OPTS = {minimal_init='$(MINIMAL_INIT)', sequential=true, timeout=30000}
TESTS_HOME = $(CURDIR)/.tests
export XDG_CONFIG_HOME = $(TESTS_HOME)/config
export XDG_DATA_HOME = $(TESTS_HOME)/data
export XDG_STATE_HOME = $(TESTS_HOME)/state
export XDG_CACHE_HOME = $(TESTS_HOME)/cache

test: unit-test integration-test ;

unit-test:
nvim --headless -u ${MINIMAL_INIT} -c "PlenaryBustedDirectory tests/unit ${PLENARY_OPTS}"
nvim --headless -u $(MINIMAL_INIT) -c "PlenaryBustedDirectory tests/unit $(PLENARY_OPTS)"

integration-test-all:
@for gtest_tag in release-1.10.0 release-1.11.0 release-1.12.1 v1.13.0 v1.14.0 main; do \
Expand All @@ -18,7 +20,7 @@ integration-test-all:
done

integration-test: build-tests
nvim --headless -u ${MINIMAL_INIT} -c "PlenaryBustedDirectory tests/integration ${PLENARY_OPTS}"
nvim --headless -u $(MINIMAL_INIT) -c "PlenaryBustedDirectory tests/integration $(PLENARY_OPTS)"

build-tests: tests/integration/cpp
$(MAKE) -C tests/integration/cpp build
Expand Down
35 changes: 32 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# neotest-gtest
![CI status](https://github.com/alfaix/neotest-gtest/actions/workflows/workflow.yaml/badge.svg?event=push)
![CI status](https://github.com/Andeshog/neotest-gtest/actions/workflows/workflow.yaml/badge.svg?event=push)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

This is a [neotest] adapter for [Google Test][google-test], a popular C++ testing
Expand All @@ -23,12 +23,39 @@ The plugin provides full support of all neotest features:
- debugging tests
- all other niceties of neotest

There are two major features which are not yet supported:
One major feature is not yet supported:

- `TEST_P` (parameterized tests)
- Build tool integration for recompilation - you have to do that manually (or with
some other plugin) for now

`TEST_P`, `TYPED_TEST`, and `TYPED_TEST_P` source declarations remain selectable
parents. After an executable is configured and the file is saved, the summary
shows each compiled case beneath its source declaration. Select a leaf to run or
debug its exact Google Test name, or select the parent to run every compiled case
for that declaration. Buffer-based nearest-test selection chooses the source
parent. Generated cases report their results independently; a failed case also
marks its parent failed.

The executable's `--gtest_list_tests` output supplies the runtime names. After
the first executable configuration, save the source file to show its compiled
cases. Later executable rebuilds refresh those cases automatically, including
when the source buffer is closed; `:ConfigureGtest` is only needed again to
change the executable mapping. Other generated Google Test macro families are
not yet supported.

To run each compiled case in a separate process, set `process_isolation = true`
in the adapter setup. This applies to normal runs; DAP runs keep their normal
debugging behavior. The default is `false`, so a selection runs grouped by
executable. Isolated runs start one process per case, which costs more time for
large selections but prevents process-local state and a crashed case from
affecting later cases. Each case uses an exact Google Test filter and its own
JSON report. Generated cases require a configured executable and a refreshed
runtime inventory. Rebuilding the executable refreshes that inventory after
the initial discovery.

You can override the setting for one run with
`require("neotest").run.run({ process_isolation = true })`.

Contributions are welcome! :)

## Installation
Expand Down Expand Up @@ -112,6 +139,8 @@ require("neotest-gtest").setup({
end,
-- How many old test results to keep on disk (stored in stdpath('data')/neotest-gtest/runs)
history_size = 3,
-- Run one compiled case per process for normal runs (slower on large selections).
process_isolation = false,
-- To prevent large projects from freezing your computer, there's some throttling
-- for -- parsing test files. Decrease if your parsing is slow and you have a
-- monster PC.
Expand Down
1 change: 1 addition & 0 deletions lua/neotest-gtest/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ local default_config = {
debug_adapter = "codelldb",
is_test_file = is_test_file,
history_size = 3,
process_isolation = false,
parsing_throttle_ms = 10,
mappings = { configure = nil },
summary_view = {
Expand Down
204 changes: 204 additions & 0 deletions lua/neotest-gtest/executables/ctest.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
local nio = require("nio")
local neotest = require("neotest")
local lib = require("neotest.lib")
local GlobalRegistry = require("neotest-gtest.executables.global_registry")
local utils = require("neotest-gtest.utils")

local ADAPTER_PREFIX = "neotest-gtest:"

local M = {}

local function find_compile_db(file)
for dir in vim.fs.parents(file) do
for _, candidate in ipairs({
dir .. "/compile_commands.json",
dir .. "/build/compile_commands.json",
}) do
if utils.fexists(candidate) then
return candidate
end
end
end
end

local function canonical(path)
return vim.uv.fs_realpath(path) or path
end

local function parse_compile_db(json)
local by_file = {}
for _, entry in ipairs(vim.json.decode(json)) do
local command = entry.command or table.concat(entry.arguments, " ")
by_file[canonical(entry.file)] = {
directory = entry.directory,
target = command:match("CMakeFiles/([^/]+)%.dir/"),
}
end
return by_file
end

---@return table<string, {directory: string, target: string?}> file -> where it was compiled
local function load_compile_db(path)
return parse_compile_db(lib.files.read(path))
end

local function parse_ctest_json(json)
local exe2tests = {}
for _, test in ipairs(vim.json.decode(json).tests) do
local command = test.command or {}
for _, arg in ipairs(command) do
local filter = arg:match("^%-%-gtest_filter=(.*)$")
if filter ~= nil then
exe2tests[command[1]] = exe2tests[command[1]] or {}
exe2tests[command[1]][filter] = true
end
end
end
return exe2tests
end

---@return table<string, table<string, boolean>> executable -> set of "Suite.Test"
local function ctest_tests(build_dir)
local code, result = lib.process.run(
{ "ctest", "--test-dir", build_dir, "--show-only=json-v1" },
{ stdout = true }
)
if code ~= 0 then
error("ctest --show-only failed in " .. build_dir)
end
return parse_ctest_json(result.stdout)
end

local function better(exe, hits, best, best_hits, target)
if hits ~= best_hits then
return hits > best_hits
end
local exe_is_target = vim.fs.basename(exe) == target
if exe_is_target ~= (vim.fs.basename(best) == target) then
return exe_is_target
end
return exe < best
end

---@param tests string[] "Suite.Test" names parsed from one source file
local function pick_executable(exe2tests, tests, target)
local best, best_hits = nil, 0
for exe, known in pairs(exe2tests) do
local available = {}
for runtime_name in pairs(known) do
available[runtime_name] = true
local source_name = runtime_name:match("([^/]+%.[^/]+)/[^/]+$")
if source_name then
available[source_name] = true
end

local typed_suite, typed_test = runtime_name:match("([^/]+)/[^/]+%.([^/]+)$")
if typed_suite then
available[typed_suite .. "." .. typed_test] = true
end
end
local hits = 0
for _, name in ipairs(tests) do
if available[name] then
hits = hits + 1
end
end
local plausible = hits > 0 or (#tests == 0 and vim.fs.basename(exe) == target)
if plausible and (best == nil or better(exe, hits, best, best_hits, target)) then
best, best_hits = exe, hits
end
end
return best
end

---@param node neotest.Tree a "file" node
local function tests_in_file(node)
local tests = {}
for _, child in node:iter_nodes() do
if child:data().type == "test" then
local parts = vim.split(child:data().id, "::", { plain = true })
tests[#tests + 1] = parts[2] .. "." .. parts[3]
end
end
return tests
end

local function cached(cache, key, load)
if cache[key] == nil then
local ok, value = pcall(load, key)
cache[key] = { ok = ok, value = value }
end
local hit = cache[key]
if not hit.ok then
error(hit.value, 0)
end
return hit.value
end

function M.resolve_file(file, tests, cache)
file = canonical(file)
local db_path = find_compile_db(file)
if db_path == nil then
return nil, "no compile_commands.json above " .. file
end
local entry = cached(cache.db, db_path, load_compile_db)[file]
if entry == nil then
return nil, "not in " .. db_path
end
local ok, exe2tests = pcall(cached, cache.ctest, entry.directory, ctest_tests)
if not ok then
return nil, exe2tests
end
local exe = pick_executable(exe2tests, tests, entry.target)
if exe == nil then
return nil, "no binary in " .. entry.directory .. " contains its tests"
end
return exe
end

local function configure_root(adapter_id)
local tree = neotest.state.positions(adapter_id)
local registry = GlobalRegistry:for_dir(adapter_id:sub(#ADAPTER_PREFIX + 1))
local mapped, unmapped = 0, {}
local cache = { db = {}, ctest = {} }
for _, node in tree:iter_nodes() do
if node:data().type == "file" then
local file = node:data().path
local exe, reason = M.resolve_file(file, tests_in_file(node), cache)
if exe ~= nil then
registry:update_executable(file, exe)
mapped = mapped + 1
else
unmapped[#unmapped + 1] = file .. " (" .. reason .. ")"
end
end
end
return mapped, unmapped
end

function M.configure_all()
return nio.run(function()
for _, adapter_id in ipairs(neotest.state.adapter_ids()) do
if vim.startswith(adapter_id, ADAPTER_PREFIX) then
local mapped, unmapped = configure_root(adapter_id)
local msg = string.format("neotest-gtest: mapped %d files", mapped)
if #unmapped > 0 then
msg = msg .. ", unresolved: " .. table.concat(unmapped, ", ")
end
utils.schedule_notify(msg, vim.log.levels.INFO)
end
end
end, function(ok, err)
if not ok then
vim.schedule(function()
vim.notify(tostring(err), vim.log.levels.ERROR)
end)
end
end)
end

M._parse_compile_db = parse_compile_db
M._parse_ctest_json = parse_ctest_json
M._pick_executable = pick_executable

return M
1 change: 1 addition & 0 deletions lua/neotest-gtest/executables/global_registry.lua
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ end
---intended for tests only
function GlobalExecutableRegistry:clear()
self._root2registry = {}
require("neotest-gtest.executables.refresh").reset()
end

local globalRegistry = GlobalExecutableRegistry:new()
Expand Down
2 changes: 2 additions & 0 deletions lua/neotest-gtest/executables/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ local nio = require("nio")
local GlobalRegistry = require("neotest-gtest.executables.global_registry")
local ui = require("neotest-gtest.executables.ui")
local config = require("neotest-gtest.config")
local ctest = require("neotest-gtest.executables.ctest")

local M = {}

Expand All @@ -25,6 +26,7 @@ function M.set_summary_autocmd()
callback = function(ctx)
local buf = ctx.buf
nio.api.nvim_buf_create_user_command(buf, "ConfigureGtest", M.configure_executable, {})
nio.api.nvim_buf_create_user_command(buf, "ConfigureGtestAll", ctest.configure_all, {})
if config.mappings.configure ~= nil then
vim.api.nvim_buf_set_keymap(buf, "n", config.mappings.configure, "", {
desc = "Select a Google Test executable for marked tests",
Expand Down
Loading
Loading