diff --git a/.githooks/tests.sh b/.githooks/tests.sh new file mode 100755 index 0000000..982663b --- /dev/null +++ b/.githooks/tests.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +make test diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..27d96f1 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,28 @@ +name: Test + +on: + push: + branches: + - "main" + pull_request: + branches: + - "main" + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + nvim-versions: ['stable', 'nightly'] + name: Plenary Tests + steps: + - name: checkout + uses: actions/checkout@v3 + + - uses: rhysd/action-setup-vim@v1 + with: + neovim: true + version: ${{ matrix.nvim-versions }} + + - name: run tests + run: make test diff --git a/.luarc.json b/.luarc.json new file mode 100644 index 0000000..26a1737 --- /dev/null +++ b/.luarc.json @@ -0,0 +1,7 @@ +{ + "diagnostics.globals": [ + "describe", + "it", + "before_each" + ] +} \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..54bc132 --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +TESTS_INIT=tests/minimal_init.lua +TESTS_DIR=tests/ + +.PHONY: test + +test: + @nvim \ + --headless \ + --noplugin \ + -u ${TESTS_INIT} \ + -c "PlenaryBustedDirectory ${TESTS_DIR} { minimal_init = '${TESTS_INIT}' }" diff --git a/lua/hawtkeys/init.lua b/lua/hawtkeys/init.lua index 087ac83..9bf877e 100644 --- a/lua/hawtkeys/init.lua +++ b/lua/hawtkeys/init.lua @@ -1,10 +1,53 @@ local M = {} + +---@alias SupportedKeyboardLayouts "qwerty" | "dvorak" + +---@class HawtKeyConfig +---@field leader string +---@field homerow number +---@field powerFingers number[] +---@field keyboardLayout SupportedKeyboardLayouts +---@field customMaps { [string] : TSKeyMapArgs | WhichKeyMapargs } | nil + +---@class HawtKeyPartialConfig +---@field leader string | nil +---@field homerow number | nil +---@field powerFingers number[] | nil +---@field keyboardLayout SupportedKeyboardLayouts | nil +---@field customMaps { [string] : TSKeyMapArgs | WhichKeyMapargs } | nil +--- + +---@type { [string] : TSKeyMapArgs | WhichKeyMapargs }--- + +local _defaultSet = { + ["vim.keymap.set"] = { + modeIndex = 1, + lhsIndex = 2, + rhsIndex = 3, + optsIndex = 4, + method = "dot_index_expression", + }, --method 1 + ["vim.api.nvim_set_keymap"] = { + modeIndex = 1, + lhsIndex = 2, + rhsIndex = 3, + optsIndex = 4, + method = "dot_index_expression", + }, --method 2 + ["whichkey.register"] = { + method = "which_key", + }, -- method 6 +} + +---@param config HawtKeyPartialConfig function M.setup(config) config = config or {} M.leader = config.leader or " " M.homerow = config.homerow or 2 M.powerFingers = config.powerFingers or { 2, 3, 6, 7 } M.keyboardLayout = config.keyboardLayout or "qwerty" + M.keyMapSet = vim.tbl_extend("force", _defaultSet, config.customMaps or {}) + vim.api.nvim_create_user_command( "Hawtkeys", "lua require('hawtkeys.ui').show()", diff --git a/lua/hawtkeys/ts.lua b/lua/hawtkeys/ts.lua index c8d9686..b11b35e 100644 --- a/lua/hawtkeys/ts.lua +++ b/lua/hawtkeys/ts.lua @@ -6,7 +6,106 @@ local config = require("hawtkeys") local ts = require("nvim-treesitter.compat") local tsQuery = require("nvim-treesitter.query") +---@alias VimModes 'n' | 'x' | 'v' | 'i' + +---@alias WhichKeyMethods 'which_key' +--- +---@alias TreeSitterMethods 'dot_index_expression' | 'function_call' | 'expression_list' +--- +---@alias SetMethods WhichKeyMethods | TreeSitterMethods + +---@class TSKeyMapArgs +---@field modeIndex number | VimModes +---@field lhsIndex number +---@field rhsIndex number +---@field optsIndex number|nil +---@field method TreeSitterMethods +--- +---@class WhichKeyMapargs +---@field method WhichKeyMethods + +---@type table local scannedFiles = {} + +---@param params TSKeyMapArgs[] +---@param method SetMethods +---@return string +local function build_args(params, method) + local args = "" + for name, opts in pairs(params) do + if opts.method == method then + args = args .. ' "' .. name .. '"' + end + end + return args +end + +---@param mapDefs TSKeyMapArgs[] +---@return string +local function build_dot_index_expression_query(mapDefs) + local query = [[ + (function_call + (dot_index_expression) @exp (#any-of? @exp %s) + (arguments) @args) + ]] + + return string.format(query, build_args(mapDefs, "dot_index_expression")) +end + +---@param mapDefs TSKeyMapArgs[] +---@return string +local function build_which_key_query(mapDefs) + local query = [[ + (function_call + name: (dot_index_expression) @exp (#any-of? @exp %s) + (arguments) @args) + ]] + return string.format(query, build_args(mapDefs, "which_key")) +end + +---@param mapDefs TSKeyMapArgs[] +---@return string +local function build_function_call_query(mapDefs) + local query = [[ + (function_call + name: (identifier) @exp (#any-of? @exp %s) + (arguments) @args) + ]] + return string.format(query, build_args(mapDefs, "function_call")) +end + +---@param node TSNode +---@param indexData TSKeyMapArgs | WhichKeyMapargs +---@param targetData string +---@param fileContent string +---@return string +local function return_field_data(node, indexData, targetData, fileContent) + ---@param i number + ---@return number + local function index_offset(i) + return (2 * i) - 1 + end + local success, result = pcall(function() + if type(indexData[targetData]) == "number" then + local index = index_offset(indexData[targetData]) + ---@diagnostic disable-next-line: param-type-mismatch + return vim.treesitter.get_node_text(node:child(index), fileContent) + else + return tostring(indexData[targetData]) + end + end) + if success then + result = result:gsub("[\n\r]", "") + --remove surrounding quotes + result = result:gsub('^"(.*)"$', "%1") + --remove single quotes + result = result:gsub("^'(.*)'$", "%1") + return result + else + return "error" + end +end + ---@param dir string ---@return table local function find_files(dir) @@ -16,83 +115,189 @@ local function find_files(dir) return files end ----@param file_path string +---@param filePath string ---@return table -local function find_maps_in_file(file_path) - print("Scanning files " .. file_path) - if scannedFiles[file_path] then +local function find_maps_in_file(filePath) + if scannedFiles[filePath] then print("Already scanned") return {} end - scannedFiles[file_path] = true + scannedFiles[filePath] = true --if not a lua file, return empty table - if not string.match(file_path, "%.lua$") then + if not string.match(filePath, "%.lua$") then return {} end - local file_content = Path:new(file_path):read() - local parser = vim.treesitter.get_string_parser(file_content, "lua", {}) -- Get the Lua parser + local fileContent = Path:new(filePath):read() + local parser = vim.treesitter.get_string_parser(fileContent, "lua", {}) -- Get the Lua parser local tree = parser:parse()[1]:root() local tsKemaps = {} -- TODO: This currently doesnt always work, as the options for helper functions are different, - -- need to use TS to resolve it back to a native keymap function - local query = ts.parse_query( + -- need to use TS to resolve it back to a native keymap + local dotIndexExpressionQuery = ts.parse_query( "lua", - [[ - (function_call - name: (dot_index_expression) @exp (#any-of? @exp "vim.api.nvim_set_keymap" "vim.keymap.set") - (arguments) @args - ) - ]] + build_dot_index_expression_query(config.keyMapSet) ) - for match in tsQuery.iter_prepared_matches(query, tree, file_content, 0, -1) do + for match in + tsQuery.iter_prepared_matches( + dotIndexExpressionQuery, + tree, + fileContent, + 0, + -1 + ) + do for type, node in pairs(match) do if type == "args" then - local buf_local = false - local opts_arg = node.node:child(7) + local parent = vim.treesitter.get_node_text( + node.node:parent():child(0), + fileContent + ) + local mapDef = config.keyMapSet[parent] + ---@type string + local mode = return_field_data( + node.node, + mapDef, + "modeIndex", + fileContent + ) + + ---@type string + local lhs = return_field_data( + node.node, + mapDef, + "lhsIndex", + fileContent + ) + + ---@type string + local rhs = return_field_data( + node.node, + mapDef, + "rhsIndex", + fileContent + ) + local bufLocal = false + local optsArg = node.node:child(mapDef.optsIndex) -- the opts table arg of `vim.keymap.set` is optional, only -- do this check if it's present. - if opts_arg then + if optsArg then -- check for `buffer = `, since we shouldn't show -- buf-local mappings - buf_local = vim.treesitter - .get_node_text(opts_arg, file_content) + bufLocal = vim.treesitter + .get_node_text(optsArg, fileContent) :gsub("[\n\r]", "") :match("^.*(buffer%s*=.+)%s*[,}].*$") ~= nil end - if not buf_local then + if not bufLocal then local map = { - mode = vim.treesitter - .get_node_text(node.node:child(1), file_content) - :gsub("^%s*(['\"])(.*)%1%s*$", "%2") - :gsub("[\n\r]", ""), - lhs = vim.treesitter - .get_node_text(node.node:child(3), file_content) - :gsub("^%s*(['\"])(.*)%1%s*$", "%2") - :gsub("[\n\r]", ""), - rhs = vim.treesitter - .get_node_text(node.node:child(5), file_content) - :gsub("^%s*(['\"])(.*)%1%s*$", "%2") - :gsub("[\n\r]", ""), - from_file = file_path, + mode = mode, + lhs = lhs, + rhs = rhs, + from_file = filePath, } if map.mode:match("^%s*{.*},?.*$") then - local mode = {} + local modes = {} for i, child in vim.iter(node.node:child(1):iter_children()) :enumerate() do if i % 2 == 0 then local ty = vim.treesitter - .get_node_text(child, file_content) + .get_node_text(child, fileContent) + :gsub("['\"]", "") + :gsub("[\n\r]", "") + table.insert(modes, ty) + end + end + map.mode = table.concat(modes, ", ") + end + table.insert(tsKemaps, map) + end + end + end + end + + local functionCallQuery = + ts.parse_query("lua", build_function_call_query(config.keyMapSet)) + + for match in + tsQuery.iter_prepared_matches( + functionCallQuery, + tree, + fileContent, + 0, + -1 + ) + do + for expCap, node in pairs(match) do + if expCap == "args" then + local parent = vim.treesitter.get_node_text( + node.node:parent():child(0), + fileContent + ) + local mapDef = config.keyMapSet[parent] + ---@type string + local mode = return_field_data( + node.node, + mapDef, + "modeIndex", + fileContent + ) + + ---@type string + local lhs = return_field_data( + node.node, + mapDef, + "lhsIndex", + fileContent + ) + + ---@type string + local rhs = return_field_data( + node.node, + mapDef, + "rhsIndex", + fileContent + ) + local bufLocal = false + local optsArg = node.node:child(mapDef.optsIndex) + -- the opts table arg of `vim.keymap.set` is optional, only + -- do this check if it's present. + if optsArg then + -- check for `buffer = `, since we shouldn't show + -- buf-local mappings + bufLocal = vim.treesitter + .get_node_text(optsArg, fileContent) + :gsub("[\n\r]", "") + :match("^.*(buffer%s*=.+)%s*[,}].*$") ~= nil + end + + if not bufLocal then + local map = { + mode = mode, + lhs = lhs, + rhs = rhs, + from_file = filePath, + } + + if map.mode:match("^%s*{.*},?.*$") then + local modes = {} + for i, child in + vim.iter(node.node:child(1):iter_children()) + :enumerate() + do + if i % 2 == 0 then + local ty = vim.treesitter + .get_node_text(child, fileContent) :gsub("['\"]", "") :gsub("[\n\r]", "") vim.print("type: " .. vim.inspect(ty)) - table.insert(mode, ty) + table.insert(modes, ty) end end - map.mode = table.concat(mode, ", ") + map.mode = table.concat(modes, ", ") end table.insert(tsKemaps, map) end @@ -100,6 +305,47 @@ local function find_maps_in_file(file_path) end end + local whichKeyQuery = + ts.parse_query("lua", build_which_key_query(config.keyMapSet)) + + for match in + tsQuery.iter_prepared_matches(whichKeyQuery, tree, fileContent, 0, -1) + do + for expCap, node in pairs(match) do + if expCap == "args" then + local wkLoaded, which_key = pcall(function() + return require("which-key.mappings") + end) + if not wkLoaded then + vim.print( + "Which Key Mappings require which-key to be installed" + ) + break + end + local strObj = + vim.treesitter.get_node_text(node.node, fileContent) + local ok, tableObj = pcall(function() + return loadstring("return " .. strObj)() + end) + if not ok then + vim.print("Error parsing which-key table") + break + end + local wkMapping = which_key.parse(tableObj) + + for _, mapping in ipairs(wkMapping) do + local map = { + mode = mapping.mode, + lhs = mapping.prefix, + rhs = mapping.cmd, + from_file = filePath, + } + table.insert(tsKemaps, map) + end + end + end + end + return tsKemaps end @@ -107,14 +353,14 @@ end local function get_keymaps_from_vim() local vimKeymaps = {} - local vim_keymaps_raw = vim.api.nvim_get_keymap("n") + local vimKeymapsRaw = vim.api.nvim_get_keymap("") print("Collecting vim keymaps") - for _, vim_keymap in ipairs(vim_keymaps_raw) do + for _, vimKeymap in ipairs(vimKeymapsRaw) do table.insert(vimKeymaps, { - mode = vim_keymap.mode, + mode = vimKeymap.mode, -- TODO: leader subsitiution as vim keymaps contain raw leader - lhs = vim_keymap.lhs:gsub(config.leader, ""), - rhs = vim_keymap.rhs, + lhs = vimKeymap.lhs:gsub(config.leader, ""), + rhs = vimKeymap.rhs, from_file = "Vim Defaults", }) end @@ -151,4 +397,10 @@ function M.get_all_keymaps() return returnKeymaps end +M.reset_scanned_files = function() + scannedFiles = {} +end + +M.find_maps_in_file = find_maps_in_file + return M diff --git a/tests/hawtkeys/example_configs/aliased_vim.api.nvim_set_keymap.lua b/tests/hawtkeys/example_configs/aliased_vim.api.nvim_set_keymap.lua new file mode 100644 index 0000000..c8bb33d --- /dev/null +++ b/tests/hawtkeys/example_configs/aliased_vim.api.nvim_set_keymap.lua @@ -0,0 +1,4 @@ +local shortIndex = vim.api + +shortIndex.nvim_set_keymap("n", "4", ':echo "hello"', {}) + diff --git a/tests/hawtkeys/example_configs/function_vim.api.nvim_set_keymap.lua b/tests/hawtkeys/example_configs/function_vim.api.nvim_set_keymap.lua new file mode 100644 index 0000000..549eb25 --- /dev/null +++ b/tests/hawtkeys/example_configs/function_vim.api.nvim_set_keymap.lua @@ -0,0 +1,4 @@ +local normalMap = function(lhs, rhs) + vim.api.nvim_set_keymap('n', lhs, rhs, { noremap = true, silent = true }) +end +normalMap('5', ':echo "hello"') diff --git a/tests/hawtkeys/example_configs/vim.api.nvim_set_keymap.lua b/tests/hawtkeys/example_configs/vim.api.nvim_set_keymap.lua new file mode 100644 index 0000000..fa57002 --- /dev/null +++ b/tests/hawtkeys/example_configs/vim.api.nvim_set_keymap.lua @@ -0,0 +1 @@ +vim.api.nvim_set_keymap('n', '1', ':echo "hello"', {noremap = true}) diff --git a/tests/hawtkeys/example_configs/vim.keymap.set_keymap.lua b/tests/hawtkeys/example_configs/vim.keymap.set_keymap.lua new file mode 100644 index 0000000..4faaeee --- /dev/null +++ b/tests/hawtkeys/example_configs/vim.keymap.set_keymap.lua @@ -0,0 +1 @@ +vim.keymap.set('n', '2', ':echo "hello"', {noremap = true}) diff --git a/tests/hawtkeys/example_configs/which-key.register_keymap.lua b/tests/hawtkeys/example_configs/which-key.register_keymap.lua new file mode 100644 index 0000000..fdd187c --- /dev/null +++ b/tests/hawtkeys/example_configs/which-key.register_keymap.lua @@ -0,0 +1,8 @@ +local whichkey = require("which-key") + +whichkey.register({ + [""] = { + name = "test", + ["3"] = { ':lua print("hello")', "hello" }, + }, +}) diff --git a/tests/hawtkeys/ts_spec.lua b/tests/hawtkeys/ts_spec.lua new file mode 100644 index 0000000..ac90121 --- /dev/null +++ b/tests/hawtkeys/ts_spec.lua @@ -0,0 +1,79 @@ +local hawtkeys = require("hawtkeys") +local ts = require("hawtkeys.ts") +---@diagnostic disable-next-line: undefined-field +local eq = assert.are.same + +describe("Treesitter can extract keymaps", function() + before_each(function() + require("plenary.reload").reload_module("hawtkeys") + hawtkeys.setup({}) + end) + it("extract vim.api.nvim_set_keymap()", function() + local keymap = ts.find_maps_in_file( + "tests/hawtkeys/example_configs/vim.api.nvim_set_keymap.lua" + ) + eq("n", keymap[1].mode) + eq("1", keymap[1].lhs) + eq(':echo "hello"', keymap[1].rhs) + end) + + it("extract vim.keymap.set()", function() + local keymap = ts.find_maps_in_file( + "tests/hawtkeys/example_configs/vim.keymap.set_keymap.lua" + ) + eq("n", keymap[1].mode) + eq("2", keymap[1].lhs) + eq(':echo "hello"', keymap[1].rhs) + end) + + it("extract whichkey.register() keymap", function() + local keymap = ts.find_maps_in_file( + "tests/hawtkeys/example_configs/which-key.register_keymap.lua" + ) + eq("n", keymap[1].mode) + eq("3", keymap[1].lhs) + eq(':lua print("hello")', keymap[1].rhs) + end) + + it("Extract short dot index aliasesd keymap", function() + hawtkeys.setup({ + customMaps = { + ["shortIndex.nvim_set_keymap"] = { + modeIndex = 1, + lhsIndex = 2, + rhsIndex = 3, + method = "dot_index_expression", + }, + }, + }) + local keymap = ts.find_maps_in_file( + "tests/hawtkeys/example_configs/aliased_vim.api.nvim_set_keymap.lua" + ) + eq("n", keymap[1].mode) + eq("4", keymap[1].lhs) + eq(':echo "hello"', keymap[1].rhs) + end) + + it("Extract function call aliased keymap with a fixed mode", function() + hawtkeys.setup({ + customMaps = { + ["normalMap"] = { + modeIndex = 'n', + lhsIndex = 1, + rhsIndex = 2, + method = "function_call", + }, + }, + }) + local keymap = ts.find_maps_in_file( + "tests/hawtkeys/example_configs/function_vim.api.nvim_set_keymap.lua" + ) + -- TODO: currently index 2, as the first index is the function itself + -- This needs to be fixed + eq("n", keymap[2].mode) + eq("5", keymap[2].lhs) + eq(':echo "hello"', keymap[2].rhs) + end) + + +end) diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua new file mode 100644 index 0000000..075d7fd --- /dev/null +++ b/tests/minimal_init.lua @@ -0,0 +1,28 @@ +local plenary_dir = os.getenv("PLENARY_DIR") or "/tmp/plenary.nvim" +local treesitter_dir = os.getenv("TREESITTER_DIR") or "/tmp/nvim-treesitter" +local whichkey_dir = os.getenv("WHICHKEY_DIR") or "/tmp/which-key.nvim" +if vim.fn.isdirectory(plenary_dir) == 0 then + vim.fn.system({"git", "clone", "https://github.com/nvim-lua/plenary.nvim", plenary_dir}) +end +if vim.fn.isdirectory(treesitter_dir) == 0 then + vim.fn.system({"git", "clone", "https://github.com/nvim-treesitter/nvim-treesitter", treesitter_dir}) +end +if vim.fn.isdirectory(whichkey_dir) == 0 then + vim.fn.system({"git", "clone", "https://github.com/folke/which-key.nvim", whichkey_dir}) +end +vim.opt.rtp:append(".") +vim.opt.rtp:append(plenary_dir) +vim.opt.rtp:append(treesitter_dir) +vim.opt.rtp:append(whichkey_dir) + +vim.cmd("runtime plugin/plenary.vim") +vim.cmd("runtime plugin/treesitter.vim") +require("nvim-treesitter.configs").setup { + ensure_installed = "lua", + highlight = { + enable = true, + }, +} +vim.cmd("runtime plugin/which-key.vim") +require("which-key").setup {} +require("plenary.busted")