Skip to content

config share (and feature request): glob pattern executable discovery with persistent caching #1

@TilGP

Description

@TilGP

I have noticed that searching for the test executable takes a long time. So I have implemented an AI-Slob caching functionality into my resolve function.
The feature request is, that the test-file -> executable mapping cache is supported out of the box.

Here is how I have implemented it

-- Create the editor command
vim.api.nvim_create_user_command("NeotestCppBuildCache", function()
  local config = require("neotest-cpp.config").get()
  local resolve = config.executables.resolve
  local test_files = vim.fn.glob("**/*.test.cpp", false, true)

  local total = #test_files
  local processed, cached = 0, 0
  vim.notify(string.format("Building cache for %d test files...", total))

  local function step()
    local file = test_files[processed + 1]
    if not file then
      vim.notify(string.format("Cache build complete: %d/%d files cached", cached, total))
      return
    end

    processed = processed + 1
    local abs_path = vim.fn.fnamemodify(file, ":p")

    -- Yield to UI before running the next expensive resolve()
    vim.schedule(function()
      local ok, exe = pcall(resolve, abs_path)
      if ok and exe then
        cached = cached + 1
      end

      if processed % 10 == 0 then
        vim.notify(string.format("Progress: %d/%d processed, %d cached", processed, total, cached))
      end

      -- Schedule the next iteration after a short delay
      vim.defer_fn(step, 0)
    end)
  end

  step()
end, {
  desc = "Build neotest-cpp cache asynchronously",
})

return {
  {
    "nvim-neotest/neotest",
    dependencies = {
      "nvim-neotest/nvim-nio",
      "nvim-lua/plenary.nvim",
      "nvim-neotest/neotest-plenary",
      "fredrikaverpil/neotest-golang",
      {
        "ryanpholt/neotest-cpp",
        opts = {
          executables = {
            -- my own AI-Slob reslove function with caching
            -- will be used until the plugin implements proper caching for the glob pattern search
            resolve = function(file_path)
              -- 1. Setup cache
              local root = vim.fn.getcwd()
              local cache_dir = root .. "/.cache/neotest-cpp"
              vim.fn.mkdir(cache_dir, "p")

              local cache_key = vim.fn.sha256(file_path)
              local cache_file = cache_dir .. "/" .. cache_key .. ".txt"

              -- 2. Check cache
              if vim.fn.filereadable(cache_file) == 1 then
                local cached_exe = vim.fn.readfile(cache_file)[1]
                local stat = vim.uv.fs_stat(cached_exe)
                if stat and stat.type == "file" then
                  local user_execute = tonumber("00100", 8)
                  if bit.band(stat.mode, user_execute) == user_execute then
                    vim.notify("Cache hit for " .. file_path .. " -> " .. cached_exe, vim.log.levels.DEBUG)
                    return cached_exe
                  end
                end
              end

              -- 3. Cache miss - use pattern-based discovery (same as plugin)
              local patterns = { "cmake-build-debug/docker-wrappers/tst-*" }
              local executables = vim
                .iter(patterns)
                :map(function(glob_pattern)
                  return vim.fn.glob(glob_pattern, false, true)
                end)
                :flatten()
                :filter(function(match)
                  -- Check if file is executable (same logic as plugin)
                  local stat = vim.uv.fs_stat(match)
                  if stat and stat.type == "file" then
                    local user_execute = tonumber("00100", 8)
                    return bit.band(stat.mode, user_execute) == user_execute
                  end
                  return false
                end)
                :totable()

              -- 4. Find which executable contains tests from this file
              -- This requires querying each executable with --gtest_list_tests
              for _, exe_path in ipairs(executables) do
                local test_list_file = vim.fn.tempname()
                local cmd =
                  string.format("%s --gtest_list_tests --gtest_output=json:%s 2>/dev/null", exe_path, test_list_file)
                vim.fn.system(cmd)

                if vim.fn.filereadable(test_list_file) == 1 then
                  local json_content = vim.fn.readfile(test_list_file)
                  local ok, data = pcall(vim.json.decode, table.concat(json_content, "\n"))

                  if ok and data.testsuites then
                    for _, testsuite in ipairs(data.testsuites) do
                      for _, test in ipairs(testsuite.testsuite or {}) do
                        -- Normalize the file path from gtest output
                        local test_file = test.file
                        if test_file then
                          test_file = vim.fn.fnamemodify(test_file, ":p")
                          if test_file == file_path then
                            -- Found the executable for this test file
                            vim.fn.writefile({ exe_path }, cache_file)
                            vim.fn.delete(test_list_file)
                            vim.notify("Cache miss for " .. file_path .. " -> " .. exe_path, vim.log.levels.DEBUG)
                            return exe_path
                          end
                        end
                      end
                    end
                  end
                  vim.fn.delete(test_list_file)
                end
              end

              -- 5. No executable found
              vim.notify("No executable found for " .. file_path, vim.log.levels.DEBUG)
              return nil
            end,
            env = function(_)
              return { SKIP_BUILDING = "1" }
            end,
          },
        },
      },
    },
    opts = function(_, opts)
      if vim.bo.filetype == "cpp" then
        table.insert(opts.adapters, "neotest-cpp")
      end

      if vim.bo.filetype == "go" then
        opts.adapters["neotest-golang"] = {
          go_test_args = {
            "-v",
            "-race",
            "-coverprofile=" .. vim.fn.getcwd() .. "/coverage.out",
          },
        }
      end
    end,
  },
}

For context: runnig :NeotestCppBuildCache took about 1hr to finish but you could also build the cache over time.
Subsequent calls to run a test file just fetch the correct executable from the cache and are pretty quick.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions