diff --git a/lua/client/client.lua b/lua/client/client.lua index fc8f0db..a2c08df 100644 --- a/lua/client/client.lua +++ b/lua/client/client.lua @@ -1,31 +1,32 @@ require("common.common_functions") +require("common.arg_parse") -- We don't want to overwrite :h shada vim.o.sdf = "NONE" -- We don't want to start. Send the args to the server instance instead. -local args = vim.call("argv") - -local arg_str = "" -for index, iter in pairs(args) do - local absolute_filepath = unception_get_absolute_filepath(iter) - - if (string.len(arg_str) == 0) then - arg_str = unception_escape_special_chars(absolute_filepath) - else - arg_str = arg_str.." "..unception_escape_special_chars(absolute_filepath) - end +-- get only files argument already parsed by neovim +local true_file_args = vim.call("argv") +local options = { + open_in_new_tab = vim.g.unception_open_buffer_in_new_tab, + delete_replaced_buffer = vim.g.unception_delete_replaced_buffer, + enable_flavor_text = vim.g.unception_enable_flavor_text, + multi_file_open_method = vim.g.unception_multi_file_open_method, +} +-- Use raw argv to retreive some options as well as files lines when it exist +local file_lines = unception_arg_parse(vim.v.argv, options) +local file_args = {} +for _, file in ipairs(true_file_args) do + local absolute_filepath = unception_get_absolute_filepath(file) + table.insert(file_args , { + path = absolute_filepath, + line = file_lines[file] + }) end -- Send messages to host on existing pipe. -local sock = vim.fn.sockconnect("pipe", os.getenv(unception_pipe_path_host_env_var), {rpc = true}) -local edit_files_call = "unception_edit_files(" - .."\""..arg_str.."\", " - ..#args..", " - ..vim.inspect(vim.g.unception_open_buffer_in_new_tab)..", " - ..vim.inspect(vim.g.unception_delete_replaced_buffer)..", " - ..vim.inspect(vim.g.unception_enable_flavor_text)..")" -vim.fn.rpcrequest(sock, "nvim_exec_lua", edit_files_call, {}) +local sock = vim.fn.sockconnect("pipe", os.getenv(unception_pipe_path_host_env_var), { rpc = true }) +vim.rpcrequest(sock, "nvim_exec_lua", "unception_edit_files(...)", { file_args, options }) if (not vim.g.unception_block_while_host_edits) then -- Our work here is done. Kill the nvim session that would have started otherwise. @@ -50,14 +51,10 @@ end local nested_pipe_path = vim.call("serverstart") -- Send the pipe path and edited filepath to the host so that it knows what file to look for and who to respond to. -local notify_when_done_call = "unception_notify_when_done_editing(" - ..vim.inspect(nested_pipe_path).."," - ..vim.inspect(arg_str)..")" -vim.fn.rpcnotify(sock, "nvim_exec_lua", notify_when_done_call, {}) +vim.rpcnotify(sock, "nvim_exec_lua", "unception_notify_when_done_editing(...)", { nested_pipe_path, file_args[1].path }) -- Sleep forever. The host session will kill this when it's done editing. while (true) do vim.cmd("sleep 10") end - diff --git a/lua/common/arg_parse.lua b/lua/common/arg_parse.lua new file mode 100644 index 0000000..243c00b --- /dev/null +++ b/lua/common/arg_parse.lua @@ -0,0 +1,144 @@ +local function detect_line_option(str) + return str:match("^%+(%d+)$") +end + +local function detect_dash_parametes(str) + return str:match("^-") ~= nil +end + +local parameters_map = { + ["-o"] = "split", + ["-O"] = "vsplit", + ["-p"] = "tab", + ["-d"] = "diff", +} + +local function detect_parameters(str) + local handled_opt = parameters_map[str] + if handled_opt then + return handled_opt + end + return detect_dash_parametes(str) +end + + +local function parse_double_dash(arg, state) + if state.double_dash then + return false + end + if arg == "--" then + state.double_dash = true + return true + end + return false +end + +local function parse_option(arg, state) + if state.double_dash then + return false + end + + local opt = detect_parameters(arg) + + if not opt then + return false + end + + if opt ~= true then + state.options.multi_file_open_method = opt + end + return true +end + +local function parse_line_number(arg, state) + if state.double_dash then + return false + end + + local line = detect_line_option(arg) + if not line then + return false + end + + -- When + option used first let's apply this on the first file + local index = (state.index > 1) and (state.index - 1) or (state.index) + + if not state.files[index] then + state.files[index] = {} + end + + state.files[index].line = tonumber(line) + + return true +end + +local function parse_file(arg, state) + if not state.files[state.index] then + state.files[state.index] = {} + end + state.files[state.index].path = arg + state.index = state.index + 1 + return true +end + +---This function filter all files +---and return only the mapping of files -> line number +---@param files {[string]:any}[] +---@return { [string]: integer } dict with file -> line +local function extract_file_with_line_number(files) + local ret = {} + for _, file in ipairs(files) do + if file.line then + ret[file.path] = file.line + end + end + + return ret +end + +---This is the list of parsing function +---Note that the order matter +local parser = { + parse_double_dash, + parse_option, + parse_line_number, + parse_file, +} + +---This function parse bare neovim argument list +---It has two purpose: +--- +---1. Detect cmd line option `-o -O -p`, and fill the given options +---2. Detect +\d line number specifier, and returning a dictionary of file:line number +---Here we don't care of file without any file number since those +---will be handled by neovim argument parser which is smartest than this parser +--- +---Limitation: this is a really dumb parser if you run neovim for example with +---`nvim --cmd "this_is_cmd_argument" +32` this_is_cmd_argument will end up within +---the list of file returned by this function, we don't really care since files +---to open will be retrieved by the neovim parser via vim.call("argv") here we want +---a good enough parser to detect line number after a file and some options that's it +---@param args string[] this argument need the bare argv from neovim +---@param options table +---@return { [string]: integer } +function unception_arg_parse(args, options) + local state = { + files = {}, + index = 1, + double_dash = false, + options = options + } + + -- remove nvim in argv[1] + table.remove(args, 1) + + for _, arg in ipairs(args) do + for _, fn in ipairs(parser) do + if fn(arg, state) then + break + end + end + end + + return extract_file_with_line_number(state.files) +end diff --git a/lua/common/common_functions.lua b/lua/common/common_functions.lua index 2c6e68d..9faba8e 100644 --- a/lua/common/common_functions.lua +++ b/lua/common/common_functions.lua @@ -39,9 +39,9 @@ function _G.unception_escape_special_chars(str) -- filepaths. Lua needs \\ to define a \, so to escape special chars, -- there are twice as many backslashes as you would think that there -- should be. - str = string.gsub(str, "\\", "\\\\\\\\") - str = string.gsub(str, "\"", "\\\\\\\"") - str = string.gsub(str, " ", "\\\\ ") + str = string.gsub(str, "\\", "\\\\") + str = string.gsub(str, "\"", "\\\"") + str = string.gsub(str, " ", "\\ ") return str else return "" diff --git a/lua/server/server_functions.lua b/lua/server/server_functions.lua index ebfdde8..839fb8f 100644 --- a/lua/server/server_functions.lua +++ b/lua/server/server_functions.lua @@ -3,9 +3,11 @@ require("common.common_functions") local response_sock = nil local unception_quitpre_autocmd_id = nil local unception_bufunload_autocmd_id = nil +local unception_tabclosed_autocmd_id = nil local filepath_to_check = nil local blocked_terminal_buffer_id = nil local last_replaced_buffer_id = nil +local last_tabpage = nil local function unblock_client_and_reset_state() -- Remove the autocmds we made. @@ -25,9 +27,14 @@ local function unblock_client_and_reset_state() last_replaced_buffer_id = nil end +local function unception_reset_last_tab() + vim.api.nvim_del_autocmd(unception_tabclosed_autocmd_id) + last_tabpage = nil +end + function _G.unception_handle_bufunload(unloaded_buffer_filepath) unloaded_buffer_filepath = unception_get_absolute_filepath(unloaded_buffer_filepath) - unloaded_buffer_filepath = unception_escape_special_chars(unloaded_buffer_filepath) + --unloaded_buffer_filepath = unception_escape_special_chars(unloaded_buffer_filepath) if (unloaded_buffer_filepath == filepath_to_check) then unblock_client_and_reset_state() @@ -36,32 +43,125 @@ end function _G.unception_handle_quitpre(quitpre_buffer_filepath) quitpre_buffer_filepath = unception_get_absolute_filepath(quitpre_buffer_filepath) - quitpre_buffer_filepath = unception_escape_special_chars(quitpre_buffer_filepath) + --quitpre_buffer_filepath = unception_escape_special_chars(quitpre_buffer_filepath) if (quitpre_buffer_filepath == filepath_to_check) then -- If this buffer replaced the blocked terminal buffer, we should restore it to the same window. if (blocked_terminal_buffer_id ~= nil and vim.fn.bufexists(blocked_terminal_buffer_id) == 1) then - vim.cmd("split") -- Open a new window and switch focus to it. + vim.cmd("split") -- Open a new window and switch focus to it. vim.cmd("buffer " .. blocked_terminal_buffer_id) -- Set the buffer for that window to the buffer that was replaced. - vim.cmd("wincmd x") -- Navigate to previous (initial) window, and proceed with quitting. + vim.cmd("wincmd x") -- Navigate to previous (initial) window, and proceed with quitting. end unblock_client_and_reset_state() + -- this will delete holding buffer, so neovim will be forced + -- to reload the file, this fix sequential git rebase -i for example + vim.cmd(("bdelete! %d"):format(vim.api.nvim_get_current_buf())) + end +end + +function _G.unception_handle_tabclosed() + -- only switch tab when needed this will depend on the config + if last_tabpage ~= nil then + vim.api.nvim_set_current_tabpage(last_tabpage) end + unception_reset_last_tab() end function _G.unception_notify_when_done_editing(pipe_to_respond_on, filepath) filepath_to_check = filepath blocked_terminal_buffer_id = last_replaced_buffer_id - response_sock = vim.fn.sockconnect("pipe", pipe_to_respond_on, {rpc = true}) - unception_quitpre_autocmd_id = vim.api.nvim_create_autocmd("QuitPre",{ command = "lua unception_handle_quitpre(vim.fn.expand(':p'))"}) + response_sock = vim.fn.sockconnect("pipe", pipe_to_respond_on, { rpc = true }) + unception_quitpre_autocmd_id = vim.api.nvim_create_autocmd("QuitPre", + { command = "lua unception_handle_quitpre(vim.fn.expand(':p'))" }) -- Create an autocmd for BufUnload as a failsafe should QuitPre not get triggered on the target buffer (e.g. if a user runs :bdelete). - unception_bufunload_autocmd_id = vim.api.nvim_create_autocmd("BufUnload",{ command = "lua unception_handle_bufunload(vim.fn.expand(':p'))"}) + unception_bufunload_autocmd_id = vim.api.nvim_create_autocmd("BufUnload", + { command = "lua unception_handle_bufunload(vim.fn.expand(':p'))" }) + + -- When done editing in another tab we can't use QuitPre becuase if we switch + -- back to the previous tabpage within QuitPre it will prevent the tab to be closed + -- So here we use the TabClosed event + if unception_tabclosed_autocmd_id then + vim.api.nvim_del_autocmd(unception_tabclosed_autocmd_id) + end + unception_tabclosed_autocmd_id = vim.api.nvim_create_autocmd("TabClosed", + { callback = unception_handle_tabclosed }) end -function _G.unception_edit_files(file_args, num_files_in_list, open_in_new_tab, delete_replaced_buffer, enable_flavor_text) - vim.api.nvim_exec_autocmds("User", {pattern = "UnceptionEditRequestReceived"}) +local open_methods_table = { + split = "split", + vsplit = "vsplit", + tab = "tabnew", + argadd = "argadd", + diff = "diff", +} + +local function unception_detect_open_method(options) + local open_method = open_methods_table[options.multi_file_open_method] + if open_method == nil then + print("unception can't find multi_file_open_method fall back to tab") + open_method = "tabnew" + elseif open_method == "diff" then + -- For now net's assume that the only way to view diff is via vsplit + open_method = "vsplit" + options.diff = true + end + return open_method +end + +local function unception_open_file(open_method, file, diff) + local path = unception_escape_special_chars(file.path) + if file.line then + vim.cmd(("%s +%d %s"):format(open_method, file.line, path)) + else + vim.cmd(("%s %s"):format(open_method, path)) + end + if diff then + vim.cmd.diffthis() + end +end + +local function unception_open_file_other(file_args, options, open_method) + if options.open_in_new_tab and open_method ~= "tabnew" then + vim.cmd("tabnew") + unception_open_file("edit", file_args[1], options.diff) + table.remove(file_args, 1) + end + for _, file in ipairs(file_args) do + unception_open_file(open_method, file, options.diff) + end +end + +local function unception_open_file_argadd(file_args, options) + local path = {} + for _, file in ipairs(file_args) do + table.insert(path, unception_escape_special_chars(file.path)) + end + path = table.concat(path, " ") + -- Had some issues when using argedit. Explicitly calling these + -- separately appears to work though. + vim.cmd("0argadd " .. path) + + if (options.open_in_new_tab) then + last_replaced_buffer_id = nil + vim.cmd("tab argument 1") + else + last_replaced_buffer_id = vim.fn.bufnr() + vim.cmd("argument 1") + end + + -- This is kind of stupid, but basically, it appears that Neovim may + -- not always properly handle opening buffers using the method + -- above(?), notably if it's opening directly to a directory using + -- netrw. Calling "edit" here appears to give it another chance to + -- properly handle opening the buffer; otherwise it can occasionally + -- segfault. + vim.cmd("edit") +end + +function _G.unception_edit_files(file_args, options) + vim.api.nvim_exec_autocmds("User", { pattern = "UnceptionEditRequestReceived" }) -- log buffer number so that we can delete it later. We don't want a ton of -- running terminal buffers in the background when we switch to a new nvim buffer. @@ -69,28 +169,21 @@ function _G.unception_edit_files(file_args, num_files_in_list, open_in_new_tab, -- If there aren't arguments, we just want a new, empty buffer, but if -- there are, append them to the host Neovim session's arguments list. - if (num_files_in_list > 0) then - -- Had some issues when using argedit. Explicitly calling these - -- separately appears to work though. - vim.cmd("0argadd "..file_args) + local open_method = unception_detect_open_method(options) - if (open_in_new_tab) then - last_replaced_buffer_id = nil - vim.cmd("tab argument 1") + if (#file_args > 0) then + if options.open_in_new_tab or open_method == "tabnew" then + last_tabpage = vim.api.nvim_get_current_tabpage() + end + -- if argadd is selected but we have only one file + -- let's not use argadd so we can use line number specifier + if (open_method ~= "argadd" or #file_args == 1) then + unception_open_file_other(file_args, options, open_method) else - last_replaced_buffer_id = vim.fn.bufnr() - vim.cmd("argument 1") + unception_open_file_argadd(file_args, options) end - - -- This is kind of stupid, but basically, it appears that Neovim may - -- not always properly handle opening buffers using the method - -- above(?), notably if it's opening directly to a directory using - -- netrw. Calling "edit" here appears to give it another chance to - -- properly handle opening the buffer; otherwise it can occasionally - -- segfault. - vim.cmd("edit") else - if (open_in_new_tab) then + if (options.open_in_new_tab) then last_replaced_buffer_id = nil vim.cmd("tabnew") else @@ -100,14 +193,13 @@ function _G.unception_edit_files(file_args, num_files_in_list, open_in_new_tab, end -- We don't want to delete the replaced buffer if there wasn't a replaced buffer. - if (delete_replaced_buffer and last_replaced_buffer_id ~= nil) then + if (options.delete_replaced_buffer and last_replaced_buffer_id ~= nil) then if (vim.fn.len(vim.fn.win_findbuf(tmp_buf_number)) == 0 and string.sub(vim.api.nvim_buf_get_name(tmp_buf_number), 1, 7) == "term://") then - vim.cmd("bdelete! "..tmp_buf_number) + vim.cmd("bdelete! " .. tmp_buf_number) end end - if (enable_flavor_text) then + if (options.enable_flavor_text) then print("Unception prevented inception!") end end - diff --git a/plugin/main.lua b/plugin/main.lua index 91907ea..8c084d3 100644 --- a/plugin/main.lua +++ b/plugin/main.lua @@ -1,19 +1,28 @@ ------------------------------------------------------------------------------- -- Initialize all expected variables ------------------------------------------------------------------------------- -if(vim.g.unception_delete_replaced_buffer == nil) then +if(vim.g.unception_delete_replaced_buffer == nil or vim.g.unception_delete_replaced_buffer == 0) then vim.g.unception_delete_replaced_buffer = false end -if(vim.g.unception_open_buffer_in_new_tab == nil) then +if(vim.g.unception_open_buffer_in_new_tab == nil or vim.g.unception_open_buffer_in_new_tab == 0) then vim.g.unception_open_buffer_in_new_tab = false end -if (vim.g.unception_enable_flavor_text == nil) then +-- This is the default opening method, that can be override by cmd line arguement split -o vsplit -O tab -p +if(vim.g.unception_multi_file_open_method == nil) then + -- tab + -- split + -- vplit + -- argadd + vim.g.unception_multi_file_open_method = "argadd" +end + +if (vim.g.unception_enable_flavor_text == nil or vim.g.unception_enable_flavor_text == 0) then vim.g.unception_enable_flavor_text = true end -if (vim.g.unception_block_while_host_edits == nil) then +if (vim.g.unception_block_while_host_edits == nil or vim.g.unception_block_while_host_edits == 0) then vim.g.unception_block_while_host_edits = false end @@ -22,7 +31,7 @@ if (vim.g.unception_block_while_host_edits) then vim.g.unception_delete_replaced_buffer = false end -if (vim.g.unception_disable == nil) then +if (vim.g.unception_disable == nil or vim.g.unception_disable == 0) then vim.g.unception_disable = false end diff --git a/test/unception_arg_parse_test.lua b/test/unception_arg_parse_test.lua new file mode 100644 index 0000000..152b812 --- /dev/null +++ b/test/unception_arg_parse_test.lua @@ -0,0 +1,60 @@ +-- really need to find a better solution to include lua file into unit tests +local function get_source_path() + return debug.getinfo(2, "S").source:sub(2):gsub("[a-z-_1-9A-Z]+.lua$", "") +end +package.path = package.path .. ";" .. get_source_path() .. "../lua/?.lua" +require "common.arg_parse" + +describe("unception nvim tests", function() + describe("argument parser", function() + local tests_list = { + { + argv = { "/usr/bin/nvim", "file", "+5", "-p" }, + output = { file = 5 }, + option = { multi_file_open_method = "tab" } + }, + { + argv = { "/usr/bin/dontcare", "file", "file2", "+32" }, + output = { file2 = 32 }, + option = {} + }, + { + argv = { "/usr/bin/dontcare", "--ignored-option-long", "-i", "file2", "+32" }, + output = { file2 = 32 }, + option = {} + }, + -- { -- NYI + -- argv = { "/usr/bin/dontcare", "\\- file starting with dash" }, + -- output = { { file = "- file starting with dash" } } + -- }, + { + argv = { "/usr/bin/dontcare", "+15", "--", "- file starting with dash" }, + output = { ["- file starting with dash"] = 15 }, + option = {} + }, + { + argv = { "/usr/bin/nvim", "file", "+5", "-o" }, + output = { file = 5 }, + option = { multi_file_open_method = "split" } + }, + { + argv = { "/usr/bin/nvim", "file", "+5", "-O" }, + output = { file = 5 }, + option = { multi_file_open_method = "vsplit" } + }, + { + argv = { "/usr/bin/nvim", "file", "file2", "+3", "-d" }, + output = { file2 = 3 }, + option = { multi_file_open_method = "diff" } + }, + } + for i, test in ipairs(tests_list) do + it("argument parser " .. tostring(i), function() + local options = {} + local ret = unception_arg_parse(test.argv, options) + assert.are.same(test.output, ret) + assert.are.same(options, test.option) + end) + end + end) +end)