diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..e1e4997 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +export PATH="${PWD}/lua_modules/bin:${PATH}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d14386a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/luarocks +/lua_modules +/.luarocks diff --git a/go-to-test-file.nvim-test.init-1.rockspec b/go-to-test-file.nvim-test.init-1.rockspec new file mode 100644 index 0000000..7334fff --- /dev/null +++ b/go-to-test-file.nvim-test.init-1.rockspec @@ -0,0 +1,24 @@ +package = "go-to-test-file.nvim" +version = "test.init-1" +source = { + url = "git+ssh://git@github.com/jtzero/go-to-test-file.nvim.git" +} +description = { + homepage = "*** please enter a project homepage ***", + license = "*** please specify a license ***" +} +dependencies = { + "lua >= 5.1, < 5.2", + "vusted >= 2.3.4-1, < 3" +} +build = { + type = "builtin", + modules = { + ["go-to-test-file"] = "lua/go-to-test-file.lua", + ["go-to-test-file.dir"] = "lua/go-to-test-file/dir.lua", + ["go-to-test-file.project"] = "lua/go-to-test-file/project.lua", + ["go-to-test-file.str"] = "lua/go-to-test-file/str.lua", + ["go-to-test-file.system"] = "lua/go-to-test-file/system.lua", + ["go-to-test-file.matrix"] = "lua/go-to-test-file/matrix.lua" + } +} diff --git a/lua/go_to_test_file.lua b/lua/go_to_test_file.lua new file mode 100644 index 0000000..d018d32 --- /dev/null +++ b/lua/go_to_test_file.lua @@ -0,0 +1,243 @@ +local str = require('go_to_test_file.str') +local matrix = require('go_to_test_file.matrix') +local dir = require('go_to_test_file.dir') + +GoToTestFile = {} +-- requirements fzf, rg, realpath, git, fd + + +vim.api.nvim_set_var('src_chomp_prefixes', '^src/|lib/|app/|lua/') +vim.api.nvim_set_var('lua_src_chomp_prefixes', '^src/|^lib/|^app/|^lua/') +vim.api.nvim_set_var('test_file_identifiers', '(test|tests|spec)') +vim.api.nvim_set_var('lua_test_file_identifiers', 'test|tests|spec') +vim.api.nvim_set_var('test_folder_prefixes', '^(test/|tests/|spec/)(unit/|integration/)?') +vim.api.nvim_set_var('deep_test_folder_prefixes', '(test|tests|spec)(/unit|/integration)?$') +-- TODO add second level +vim.api.nvim_set_var('lua_test_folder_prefixes', '^test/|^tests/|^spec/') + +local match_one = function(test_str, list, prefix, suffix) + local exhausted = false + local i = 1 + local found = '' + while (not found) or (not exhausted) do + if string.match(test_str, prefix .. list[i] .. suffix) then + found = prefix .. list[i] .. suffix + end + i = i + 1 + if #list < i then + exhausted = true + end + end + return found or '' +end + +GoToTestFile.FindProjectTestDirectory = function(git_root, source_file_folder_abs_path) + local test_dirs = vim.fn.system("cd " .. git_root .. " > /dev/null && fd --type d --hidden --exclude .git | rg '" .. vim.g.deep_test_folder_prefixes .. "'") + if test_dirs == '' then + return '' + end + local git_root_rel_source_file_folder_path = dir.difference_between_ancestor_folder_and_sub_folder(git_root, source_file_folder_abs_path) + local hops = {} + for _k,v in pairs(str.split(test_dirs, '\n')) do + local path = vim.fn.trim(vim.fn.system('cd ' .. git_root .. ' > /dev/null && realpath --relative-to="' .. git_root_rel_source_file_folder_path .. '" "' .. v .. '"')) + local _, count = string.gsub(path, '/', {}) + table.insert(hops, {count, path}) + end + local idx = matrix.row_with_smallest_first_item(hops) + local path_with_smallest_hops = hops[idx][2] + local rel_path_to_pwd_from_git_root = dir.difference_between_ancestor_folder_and_sub_folder(git_root, source_file_folder_abs_path) + local full_path = vim.fn.trim(vim.fn.system('realpath ' .. git_root .. '/' .. rel_path_to_pwd_from_git_root .. '/' .. path_with_smallest_hops)) + return full_path +end + +GoToTestFile.ProjectSourceFolder = function(project_abs_path_root, source_file_folder_path_rel_to_project_root) + local project_source_folder = vim.fn.trim(vim.fn.system('realpath --relative-to="' .. project_abs_path_root .. '" "' .. source_file_folder_path_rel_to_project_root .. '"')) + return str.split(project_source_folder, '/')[1] +end + +GoToTestFile.DomainlessSourceFolderGoToTestFileatch = function(prefixes, source_folder) + local identifiers = str.split(prefixes, '|') + return match_one(source_folder .. "/", identifiers, '', '') +end + +GoToTestFile.RemoveProjectDomainlessPrefix = function(domainless_source_folder_match, git_repo_pwd) + return string.gsub(git_repo_pwd, domainless_source_folder_match, '') +end + +GoToTestFile.GitRootOfFile = function(file_abs_path) + local file_folder_abs_path = vim.fn.fnamemodify(file_abs_path, ":h") + return vim.fn.trim(vim.fn.system('cd ' .. file_folder_abs_path .. ' > /dev/null && git rev-parse --show-toplevel')) +end + +GoToTestFile.FindTestFolderTestFile = function(git_root, source_file_with_abs_path) + local source_file_folder_abs_path = vim.fn.fnamemodify(source_file_with_abs_path, ":h") + local test_folder_abs_path = GoToTestFile.FindProjectTestDirectory(git_root, source_file_folder_abs_path) + local project_abs_path_root = vim.fn.fnamemodify(test_folder_abs_path, ':h') + local source_file_folder_path_rel_to_project_root=dir.difference_between_ancestor_folder_and_sub_folder(project_abs_path_root, source_file_folder_abs_path) + local project_source_folder = GoToTestFile.ProjectSourceFolder(project_abs_path_root, source_file_folder_path_rel_to_project_root) + local domainless_source_folder_match = GoToTestFile.DomainlessSourceFolderGoToTestFileatch(vim.g.lua_src_chomp_prefixes, project_source_folder) + + local current_filename_no_ext = vim.fn.fnamemodify(source_file_with_abs_path, ':t:r') + local filepath_relative_to_project_root = source_file_folder_path_rel_to_project_root .. '/' .. current_filename_no_ext + + local prefixless_source_file_path = '' + if domainless_source_folder_match ~= '' then + prefixless_source_file_path = GoToTestFile.RemoveProjectDomainlessPrefix(project_source_folder .. '/', filepath_relative_to_project_root) + else + prefixless_source_file_path = filepath_relative_to_project_root + end + + local command = "cd " .. test_folder_abs_path .. " > /dev/null && rg --files | fzf --filter '" .. prefixless_source_file_path .. "' | head -1" + local file_relative_to_root = vim.fn.trim(vim.fn.system(command)) + if file_relative_to_root == '' then + return test_folder_abs_path + else + return test_folder_abs_path .. '/' .. file_relative_to_root + end +end + +vim.cmd('command! FindTestFolderTestFile :lua print(GoToTestFile.FindTestFolderTestFile(GoToTestFile.GitRootOfFile(vim.fn.expand("%:p")), vim.fn.expand("%:p")))') + +GoToTestFile.RemoveTestFileNameIdentifiers = function(test_filename_rel_path_from_project) + local identifiers = str.split(vim.g.lua_test_file_identifiers, '|') + local with_period = match_one(test_filename_rel_path_from_project, identifiers, '%.', '') + local with_underscore = match_one(test_filename_rel_path_from_project, identifiers, '_', '') + local path_without_infixes = string.gsub(test_filename_rel_path_from_project, with_period, '') + local path_without_infixes = string.gsub(test_filename_rel_path_from_project, with_underscore, '') + + return path_without_infixes +end + +GoToTestFile.RemoveTestFolderPrefixes = function(test_folder_prefixes, test_file_folder_path_rel_from_project_root) + local identifiers = str.split(test_folder_prefixes, '|') + local prefix_to_chomp = match_one(test_file_folder_path_rel_from_project_root, identifiers, '', '') + local path_without_prefixes = string.gsub(test_file_folder_path_rel_from_project_root, prefix_to_chomp, '') + return path_without_prefixes +end + +GoToTestFile.FindSrcFolderCodeFile = function(git_root, test_file_with_abs_path) + local test_file_folder_abs_path = vim.fn.fnamemodify(test_file_with_abs_path, ":h") + local test_file_name = vim.fn.fnamemodify(test_file_with_abs_path, ":t") + local test_folder_abs_path = GoToTestFile.FindProjectTestDirectory(git_root, test_file_folder_abs_path) + local project_abs_path_root = vim.fn.fnamemodify(test_folder_abs_path, ':h') + + local test_file_folder_path_rel_to_project_root=dir.difference_between_ancestor_folder_and_sub_folder(project_abs_path_root, test_file_folder_abs_path) + + local path_without_prefixes = GoToTestFile.RemoveTestFolderPrefixes(vim.g.lua_test_folder_prefixes, test_file_folder_path_rel_to_project_root) + local current_filename_no_ext = vim.fn.fnamemodify(test_file_with_abs_path, ':t:r') + local test_filepath_without_test_identifiers = GoToTestFile.RemoveTestFileNameIdentifiers(path_without_prefixes .. '/' .. current_filename_no_ext) + + -- TODO minimize pathing to make this faster and more precise + local command = "cd " .. project_abs_path_root .. " > /dev/null && rg --files | rg -v '^" .. test_file_name .. "$' | fzf --filter '" .. test_filepath_without_test_identifiers .. "' | head -1" + return vim.fn.trim(vim.fn.system(command)) +end +vim.cmd('command! FindSrcFolderCodeFile :lua print(GoToTestFile.FindSrcFolderCodeFile(GoToTestFile.GitRootOfFile(vim.fn.expand("%:p")), vim.fn.expand("%:p")))') + +-- TODO cd with abs path +GoToTestFile.FindPeerSourceCodeFile = function(file_with_path) + local test_filename = vim.fn.fnamemodify(file_with_path, ':t') + local list = str.split(vim.g.lua_test_file_identifiers, '|') + local matched = match_one(test_filename, list, '%.', '%.') + local expected_source_code_filename = string.gsub(test_filename, matched, '.') + local path = vim.fn.fnamemodify(file_with_path, ':.:h') + local command = "cd " .. path .. " > /dev/null && rg --files | rg -v '^" .. test_filename .. "$' | fzf --filter '" .. expected_source_code_filename .. "' | head -1" + local output = vim.fn.trim(vim.fn.system(command)) + return path .. '/' .. output +end + +vim.cmd('command! FindPeerSourceCodeFile :lua GoToTestFile.FindPeerSourceCodeFile(vim.fn.expand("%:p"))') + +-- TODO cd with abs path +GoToTestFile.HasPeerSourceCodeFile = function(file_with_path) + local current_file_name = vim.fn.fnamemodify(file_with_path, ':t') + local output = vim.fn.trim(vim.fn.system('printf "' .. current_file_name .. '" | rg "\\.' .. vim.g.test_file_identifiers .. '\\."')) + return output ~= '' +end + +vim.cmd('command! HasPeerSourceCodeFile :lua GoToTestFile.HasPeerSourceCodeFile(vim.fn.expand("%:."))') + +-- TODO cd with abs path +GoToTestFile.FindPeerTestCodeFile = function(file_with_path) + local current_file_name_no_ext = vim.fn.fnamemodify(file_with_path, ':t:r') + local path = vim.fn.fnamemodify(file_with_path, ':p:h') + local output = vim.fn.trim(vim.fn.system("cd " .. path .. " > /dev/null && rg --files | fzf --filter '" .. current_file_name_no_ext .. "' | rg '" .. vim.g.test_file_identifiers .. "' | head -n 1 ")) + if output == '' then + return '' + else + return path .. '/' .. output + end +end + +vim.cmd('command! FindPeerTestCodeFile :lua print(GoToTestFile.FindPeerTestCodeFile(vim.fn.expand("%:p")))') + +GoToTestFile.IsInATestFolder = function(git_root, current_file_with_abs_path) + local current_file_folder_abs_path = vim.fn.fnamemodify(current_file_with_abs_path, ":h") + local test_folder_abs_path = GoToTestFile.FindProjectTestDirectory(git_root, current_file_folder_abs_path) + if test_folder_abs_path == '' then + return false + else + -- TODO this is a little niave + return string.find(current_file_folder_abs_path, test_folder_abs_path, 1, true) ~= nil + end +end + +vim.cmd('command! IsInATestFolder :lua print(IsInATestFolder(GitRootOfFile(vim.fn.expand("%:p")), vim.fn.expand("%:p")))') + +GoToTestFile.FindTestOrSrcCodeFile = function(git_root, current_file_abs_path) + if GoToTestFile.HasPeerSourceCodeFile(current_file_abs_path) then + return GoToTestFile.FindPeerSourceCodeFile(current_file_abs_path) + elseif GoToTestFile.IsInATestFolder(git_root, current_file_abs_path) then + return GoToTestFile.FindSrcFolderCodeFile(git_root, current_file_abs_path) + else + local peer_test_code_file = GoToTestFile.FindPeerTestCodeFile(current_file_abs_path) + if peer_test_code_file ~= '' then + return peer_test_code_file + else + return GoToTestFile.FindTestFolderTestFile(git_root, current_file_abs_path) + end + end +end + +vim.cmd('command! FindTestOrSrcCodeFile :lua print(GoToTestFile.FindTestOrSrcCodeFile(GoToTestFile.GitRootOfFile(vim.fn.expand("%:p")), vim.fn.expand("%:p")))') + +GoToTestFile.RepoTestFolder = function() + local folder_name = vim.fn.trim(vim.fn.system('cd "$(git rev-parse --show-toplevel)" > /dev/null && find . -type d -print -maxdepth 1 | cut -d/ -f2- | rg "^' .. vim.g.test_file_identifiers .. '$"')) + return vim.fn.trim(vim.fn.system('git rev-parse --show-toplevel')) .. '/' .. folder_name +end + +vim.cmd('command! RepoTestFolder :lua print(GoToTestFile.RepoTestFolder())') + +-- rename to last resort, the above should provide some sane locations even if it cannot find the file +GoToTestFile.FindTestOrSrcCodeFileFolderOnFailure = function(current_file_abs_path) + local git_root = GoToTestFile.GitRootOfFile(current_file_abs_path) + local path = GoToTestFile.FindTestOrSrcCodeFile(git_root, current_file_abs_path) + if path == './' then + local test_path = GoToTestFile.RepoTestFolder() + if test_path ~= '' then + return test_path + else + return git_root + end + else + return path + end +end + +GoToTestFile.setup = function(opts) + opts = opts or {} + + --vim.notify_once("gruvbox.nvim: you must use neovim 0.8 or higher") + + vim.api.nvim_create_user_command("FindTestOrSrcCodeFileFolderOnFailure", + function() + local path = GoToTestFile.FindTestOrSrcCodeFileFolderOnFailure(vim.fn.expand("%:p")) + print(path) + vim.cmd('e ' .. path) + end, + {} + ) + --vim.cmd('command! FindTestOrSrcCodeFileFolderOnFailure :exec "e " . :lua FindTestOrSrcCodeFileFolderOnFailure(expand("%:p"))') + vim.keymap.set('n', '', 'FindTestOrSrcCodeFileFolderOnFailure', {desc = 'Opens a corresponding test file or src file if not found opens the test folder', noremap = true}) +end + +return GoToTestFile diff --git a/lua/go_to_test_file/dir.lua b/lua/go_to_test_file/dir.lua new file mode 100644 index 0000000..d4303de --- /dev/null +++ b/lua/go_to_test_file/dir.lua @@ -0,0 +1,25 @@ +-- replace with plenary +local system = require('go_to_test_file.system') + +local function path_separator(system_name) + if system_name == system.windows_name then + return '\\' + end + return '/' +end + +local function script_path(system_name) + local ps = path_separator(system_name) + local str = vim.fn.getcwd() .. ps .. debug.getinfo(2, 'S').source:sub(2) + str = str:gsub('/', ps) + return str:match('(.*' .. ps .. ')') +end + +return { + script_path = script_path, + path_separator = path_separator, + -- ancestor has to be relative to PWD or you have to pass in abs + difference_between_ancestor_folder_and_sub_folder = function(ancestor, sub_folder) + return vim.fn.trim(vim.fn.system('realpath --relative-to="' .. ancestor .. '" "' .. sub_folder .. '"')) + end +} diff --git a/lua/go_to_test_file/matrix.lua b/lua/go_to_test_file/matrix.lua new file mode 100644 index 0000000..2a9f6ea --- /dev/null +++ b/lua/go_to_test_file/matrix.lua @@ -0,0 +1,18 @@ +return { + row_with_smallest_first_item = function(table_to_search) + local min = math.huge + local idx = -1 + for i = 1, #table_to_search do + local new_min = min < table_to_search[i][1] and min or table_to_search[i][1] + if new_min == 0 then + return idx + else + if new_min < min then + min = new_min + idx = i + end + end + end + return idx + end +} diff --git a/lua/go_to_test_file/project.lua b/lua/go_to_test_file/project.lua new file mode 100644 index 0000000..a564707 --- /dev/null +++ b/lua/go_to_test_file/project.lua @@ -0,0 +1 @@ +return {} diff --git a/lua/go_to_test_file/str.lua b/lua/go_to_test_file/str.lua new file mode 100644 index 0000000..640a43d --- /dev/null +++ b/lua/go_to_test_file/str.lua @@ -0,0 +1,10 @@ +return { + split = function(str, sep) + local result = {} + local regex = ("([^%s]+)"):format(sep) + for each in str:gmatch(regex) do + table.insert(result, each) + end + return result + end +} diff --git a/lua/go_to_test_file/system.lua b/lua/go_to_test_file/system.lua new file mode 100644 index 0000000..1ed7a11 --- /dev/null +++ b/lua/go_to_test_file/system.lua @@ -0,0 +1,18 @@ +local windows_name = 'WIN' +local linux_name = 'NIX' +local function is_win() + return package.config:sub(1, 1) == '\\' +end + +return { + windows_name = windows_name, + linux_name = linux_name, + is_win = is_win, + name = function() + if is_win() then + return windows_name + else + return linux_name + end + end +} diff --git a/spec/go_to_test_file/dir_spec.lua b/spec/go_to_test_file/dir_spec.lua new file mode 100644 index 0000000..e042bc1 --- /dev/null +++ b/spec/go_to_test_file/dir_spec.lua @@ -0,0 +1,23 @@ +local assert = require('luassert') + + +local dir = require('go_to_test_file.dir') +local system = require('go_to_test_file.system') + +describe('dir', function() + describe('script_path', function() + it('will print the relative path of the current script directory', function() + local actual = dir.script_path(system.name) + local ps = dir.path_separator(system.name()) + local expected = vim.fn.getcwd() .. ps .. 'spec/go_to_test_file/' + assert.are.equal(expected, actual) + end) + end) + describe('difference_between_ancestor_folder_and_sub_folder', function() + it('will print the relative path from subfolder to ancestor', function() + local ps = dir.path_separator(system.name()) + local this_path = vim.fn.getcwd() .. ps .. 'spec/go_to_test_file' + assert.are.equal(dir.difference_between_ancestor_folder_and_sub_folder(this_path, 'tst'), '../../tst') + end) + end) +end) diff --git a/spec/go_to_test_file/matrix_spec.lua b/spec/go_to_test_file/matrix_spec.lua new file mode 100644 index 0000000..ea6bb07 --- /dev/null +++ b/spec/go_to_test_file/matrix_spec.lua @@ -0,0 +1,10 @@ +local matrix = require('go_to_test_file.matrix') + +describe('table', function() + describe('row_with_smallest_first_item', function() + it('finds the index of the row with the smallest first item', function() + local test_table = { {9, '/lib'}, {1, '/etc'}, {2, '/root'} } + assert.are.equal(2, matrix.row_with_smallest_first_item(test_table)) + end) + end) +end) diff --git a/spec/go_to_test_file/str_spec.lua b/spec/go_to_test_file/str_spec.lua new file mode 100644 index 0000000..f3c09ba --- /dev/null +++ b/spec/go_to_test_file/str_spec.lua @@ -0,0 +1,8 @@ +local str = require('go_to_test_file.str') + +describe('str', function() + it('splits a string', function() + + assert(str.split("asdf,zxcv", ',')) + end) +end) diff --git a/spec/go_to_test_file/tst/.keep b/spec/go_to_test_file/tst/.keep new file mode 100644 index 0000000..e69de29