diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d921ab..9b59cc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,8 +33,9 @@ jobs: } mkdir -p ~/.local/share/nvim/site/pack/vendor/start git clone --depth 1 https://github.com/nvim-lua/plenary.nvim ~/.local/share/nvim/site/pack/vendor/start/plenary.nvim + git clone --depth 1 https://github.com/MunifTanjim/nui.nvim ~/.local/share/nvim/site/pack/vendor/start/nui.nvim + git clone --depth 1 https://github.com/grapp-dev/nui-components.nvim ~/.local/share/nvim/site/pack/vendor/start/nui-components.nvim ln -s $(pwd) ~/.local/share/nvim/site/pack/vendor/start - - name: Install latest stable `rustc` uses: dtolnay/rust-toolchain@stable with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6292737..432984e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,10 +3,17 @@ on: push: tags: - 'v*' + branches: + - master pull_request: jobs: + test: + name: Run tests + uses: ./.github/workflows/ci.yml luarocks-upload: + needs: test runs-on: ubuntu-22.04 + if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') steps: - uses: actions/checkout@v3 - name: LuaRocks Upload diff --git a/README.md b/README.md index f6c161a..25b8610 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,23 @@ vim.keymap.set('n', 'sp', 'lua require("spectre").open_file_search( }) ``` +You can also toggle between the legacy UI and the modern nui-based UI: + +```lua +-- Toggle between legacy and modern UI +vim.keymap.set('n', 'sU', 'lua require("spectre").toggle_ui()', { + desc = "Toggle between legacy and modern UI" +}) + +-- Switch to a specific UI +vim.keymap.set('n', 'su', 'lua require("spectre").set_ui_type(false)', { + desc = "Use modern UI" +}) +vim.keymap.set('n', 'sL', 'lua require("spectre").set_ui_type(true)', { + desc = "Use legacy UI" +}) +``` + Use command: `:Spectre` ## Warnings @@ -286,12 +303,9 @@ require('spectre').setup({ use_trouble_qf = false, -- use trouble.nvim as quickfix list is_open_target_win = true, --open file on opener window is_insert_mode = false, -- start open panel on is_insert_mode - is_block_ui_break = false -- mapping backspace and enter key to avoid ui break - open_template = { - -- an template to use on open function - -- see the 'custom function' section below to learn how to configure the template - -- { search_text = 'text1', replace_text = '', path = "" } - } + is_block_ui_break = false, -- mapping backspace and enter key to avoid ui break + open_template = {}, -- an template to use on open function + use_legacy_ui = false, -- set to true to use the legacy UI instead of nui-based modern UI }) ``` @@ -398,6 +412,4 @@ require('spectre').setup({ is_block_ui_break = true }) - Why is it called Spectre? -I wanted to call it `Search Panel` but this name is not cool. -I got the name of a hero on a game. -Spectre has a skill to find enemy on global map so I use it:) +I wanted to call it ` \ No newline at end of file diff --git a/lua/spectre/actions.lua b/lua/spectre/actions.lua index ee87ca9..cb3db8c 100644 --- a/lua/spectre/actions.lua +++ b/lua/spectre/actions.lua @@ -39,15 +39,18 @@ local get_file_path = function(filename) end M.select_entry = function() - local t = M.get_current_entry() - if t == nil then - return nil + local entry = M.get_current_entry() + if not entry then + return end - if config.is_open_target_win and state.target_winid ~= nil then - open_file(t.filename, t.lnum, t.col, state.target_winid) - else - open_file(t.filename, t.lnum, t.col) + + local full_path = vim.fn.fnamemodify(entry.filename, ':p') + if not vim.fn.filereadable(full_path) then + return end + + vim.cmd('edit ' .. full_path) + api.nvim_win_set_cursor(0, { entry.lnum, entry.col - 1 }) end M.get_state = function() @@ -66,26 +69,40 @@ M.set_entry_finish = function(display_lnum) end end -M.get_current_entry = function() - if not state.total_item then - return +function M.get_current_entry() + local bufnr = api.nvim_get_current_buf() + local cursor_pos = api.nvim_win_get_cursor(0) + local line = api.nvim_buf_get_lines(bufnr, cursor_pos[1] - 1, cursor_pos[1], false)[1] + + if not line then + return nil end - local lnum = unpack(vim.api.nvim_win_get_cursor(0)) - local item = state.total_item[lnum] - if item ~= nil and item.display_lnum == lnum - 1 then - local t = vim.deepcopy(item) - t.filename = get_file_path(item.filename) - return t + + local filename, lnum, col = line:match('([^:]+):(%d+):(%d+):') + if not filename or not lnum or not col then + return nil end + + return { + filename = filename, + lnum = tonumber(lnum), + col = tonumber(col), + text = line:match(':[^:]+$'):sub(2), + } end -M.get_all_entries = function() +function M.get_all_entries() local entries = {} - for _, item in pairs(state.total_item) do - if not item.disable then - local t = vim.deepcopy(item) - t.filename = get_file_path(item.filename) - table.insert(entries, t) + for display_lnum, item in ipairs(state.total_item) do + if item and item.filename then + table.insert(entries, { + filename = item.filename, + lnum = item.lnum, + col = item.col, + text = item.text, + display_lnum = display_lnum - 1, + is_replace_finish = item.is_replace_finish or false, + }) end end return entries @@ -93,18 +110,23 @@ end M.send_to_qf = function() local entries = M.get_all_entries() - vim.fn.setqflist(entries, 'r') - vim.fn.setqflist({}, 'r', { - title = string.format('Result Search: [%s]', state.query.search_query), - }) - local trouble_avail, _ = pcall(require, 'trouble') - local status = trouble_avail and state.user_config.use_trouble_qf - if status then - vim.cmd([[Trouble quickfix win.relative=win focus=true]]) - else - vim.cmd([[copen]]) + if #entries == 0 then + vim.notify('No entries to send to quickfix') + return end - return entries + + local qf_list = {} + for _, entry in ipairs(entries) do + table.insert(qf_list, { + filename = entry.filename, + lnum = entry.lnum, + col = entry.col, + text = entry.text, + }) + end + + vim.fn.setqflist(qf_list) + vim.cmd('copen') end -- input that comand to run on vim @@ -135,7 +157,7 @@ M.replace_cmd = function() end end -M.run_current_replace = function() +function M.run_current_replace() local entry = M.get_current_entry() if entry then M.run_replace({ entry }) @@ -146,72 +168,72 @@ end local is_running = false -M.run_replace = function(entries) - if is_running == true then - print('it is already running') +function M.run_replace(entries) + entries = entries or M.get_all_entries() + if #entries == 0 then + vim.notify('No entries to replace') return end - is_running = true - entries = entries or M.get_all_entries() - local replacer_creator = state_utils.get_replace_creator() - local done_item = 0 - local error_item = 0 - state.status_line = 'Run Replace.' - local replacer = replacer_creator:new(state_utils.get_replace_engine_config(), { - on_done = function(result) - if result.ref then - done_item = done_item + 1 - state.status_line = 'Replace: ' .. done_item .. ' Error:' .. error_item - M.set_entry_finish(result.ref.display_lnum) - local value = result.ref - value.text = ' DONE' - vim.fn.setqflist(entries, 'r') - api.nvim_buf_set_extmark( - state.bufnr, - config.namespace, - value.display_lnum, - 0, - { virt_text = { { '󰄲 DONE', 'String' } }, virt_text_pos = 'eol' } - ) - end - end, - on_error = function(result) - if type(result.value) == 'string' then - for line in result.value:gmatch('[^\r\n]+') do - print(line) + + vim.schedule(function() + local replacer_creator = state_utils.get_replace_creator() + local replacer = replacer_creator:new(state_utils.get_replace_engine_config(), { + on_done = function(result) + if result.ref then + M.set_entry_finish(result.ref.display_lnum) + -- Update UI by adding a checkmark to the line + local bufnr = api.nvim_get_current_buf() + local line = result.ref.display_lnum + api.nvim_buf_set_extmark( + bufnr, + config.namespace, + line, + 0, + { virt_text = { { '✓', 'String' } }, virt_text_pos = 'eol' } + ) + -- Trigger renderer redraw + if state.renderer then + print('redrawing') + state.renderer:redraw() + end end + end, + on_error = function(result) + if result.ref then + vim.notify('Error replacing: ' .. result.value, vim.log.levels.ERROR) + -- Add error mark to the line + local bufnr = api.nvim_get_current_buf() + local line = result.ref.display_lnum + api.nvim_buf_set_extmark( + bufnr, + config.namespace, + line, + 0, + { virt_text = { { '✗', 'Error' } }, virt_text_pos = 'eol' } + ) + -- Trigger renderer redraw + if state.renderer then + print('redrawing') + state.renderer:redraw() + end + end + end, + }) + + for _, entry in ipairs(entries) do + if not entry.is_replace_finish then + replacer:replace({ + lnum = entry.lnum, + col = entry.col, + cwd = state.cwd, + display_lnum = entry.display_lnum, + filename = entry.filename, + search_text = state.query.search_query, + replace_text = state.query.replace_query, + }) end - if result.ref then - error_item = error_item + 1 - local value = result.ref - value.text = 'ERROR' - vim.fn.setqflist(entries, 'r') - state.status_line = 'Replace: ' .. done_item .. ' Error:' .. error_item - api.nvim_buf_set_extmark( - state.bufnr, - config.namespace, - value.display_lnum, - 0, - { virt_text = { { '󰄱 ERROR', 'Error' } }, virt_text_pos = 'eol' } - ) - end - end, - }) - for _, value in pairs(entries) do - if not value.is_replace_finish then - replacer:replace({ - lnum = value.lnum, - col = value.col, - cwd = state.cwd, - display_lnum = value.display_lnum, - filename = value.filename, - search_text = state.query.search_query, - replace_text = state.query.replace_query, - }) end - end - is_running = false - vim.cmd.checktime() + end) end M.delete_line_file_current = function() @@ -245,6 +267,10 @@ M.run_delete_line = function(entries) { virt_text = { { '󰄲 DONE', 'String' } }, virt_text_pos = 'eol' } ) end + -- Trigger renderer redraw + if state.renderer then + state.renderer:redraw() + end end end, on_error = function(result) @@ -262,6 +288,10 @@ M.run_delete_line = function(entries) { virt_text = { { '󰄱 ERROR', 'Error' } }, virt_text_pos = 'eol' } ) end + -- Trigger renderer redraw + if state.renderer then + state.renderer:redraw() + end end end, }) diff --git a/lua/spectre/config.lua b/lua/spectre/config.lua index a7d9e43..df0a260 100644 --- a/lua/spectre/config.lua +++ b/lua/spectre/config.lua @@ -12,12 +12,6 @@ local config = { lnum_UI = 8, -- total line for ui you can edit it line_result = 10, -- line begin result - -- result_padding = '│ ', - -- color_devicons = true, - -- line_sep_start = '┌-----------------------------------------', - -- result_padding = '¦ ', - -- line_sep = '├──────────────────────────────────────', - line_sep_start = '┌──────────────────────────────────────────────────────', result_padding = '│ ', line_sep = '└──────────────────────────────────────────────────────', @@ -73,12 +67,12 @@ local config = { ['run_current_replace'] = { map = 'rc', cmd = "lua require('spectre.actions').run_current_replace()", - desc = 'replace item', + desc = 'replace current item', }, ['run_replace'] = { map = 'R', cmd = "lua require('spectre.actions').run_replace()", - desc = 'replace all', + desc = 'replace all items', }, -- only show replace text in result UI ['change_view_mode'] = { @@ -220,6 +214,7 @@ local config = { is_insert_mode = false, is_block_ui_break = false, open_template = {}, + ui = 'buffer', } return config diff --git a/lua/spectre/init.lua b/lua/spectre/init.lua index 4977482..4f12842 100644 --- a/lua/spectre/init.lua +++ b/lua/spectre/init.lua @@ -24,7 +24,8 @@ local config = require('spectre.config') local state = require('spectre.state') local state_utils = require('spectre.state_utils') local utils = require('spectre.utils') -local ui = require('spectre.ui') +-- Dynamically choose UI based on configuration +local ui = nil local log = require('spectre._log') local async = require('plenary.async') @@ -32,652 +33,197 @@ local scheduler = async.util.scheduler local M = {} -M.setup = function(cfg) - state.user_config = vim.tbl_deep_extend('force', config, cfg or {}) +M.setup = function(opts) + opts = opts or {} + state.user_config = vim.tbl_deep_extend('force', state.user_config, opts) for _, opt in pairs(state.user_config.default.find.options) do state.options[opt] = true end require('spectre.highlight').set_hl() M.check_replace_cmd_bins() -end - -M.check_replace_cmd_bins = function() - if state.user_config.default.replace.cmd == 'sed' then - if vim.loop.os_uname().sysname == 'Darwin' and vim.fn.executable('sed') == 0 then - config.replace_engine.sed.cmd = 'gsed' - if vim.fn.executable('gsed') == 0 and state.user_config.replace_engine.sed.warn then - print("You need to install gnu sed 'brew install gnu-sed'") - end - end - - if vim.loop.os_uname().sysname == 'Windows_NT' then - if vim.fn.executable('sed') == 0 and state.user_config.replace_engine.sed.warn then - print("You need to install gnu sed with 'scoop install sed' or 'choco install sed'") - end - end - end - - if state.user_config.default.replace.cmd == 'sd' then - if vim.fn.executable('sd') == 0 and state.user_config.replace_engine.sd.warn then - print("You need to install or build 'sd' from: https://github.com/chmln/sd") - end - end -end -M.open_visual = function(opts) - opts = opts or {} - if opts.select_word then - opts.search_text = vim.fn.expand('') - else - opts.search_text = utils.get_visual_selection() - end - M.open(opts) -end - -M.open_file_search = function(opts) - opts = opts or {} - if opts.select_word then - opts.search_text = vim.fn.expand('') - else - opts.search_text = utils.get_visual_selection() - end - - opts.path = vim.fn.fnameescape(vim.fn.expand('%:p:.')) - - if vim.loop.os_uname().sysname == 'Windows_NT' then - opts.path = vim.fn.substitute(opts.path, '\\', '/', 'g') - end - - M.open(opts) + -- Initialize UI based on configuration + M.init_ui() end -M.toggle_file_search = function(opts) - opts = opts or {} - if state.is_open then - M.close() +-- Initialize UI based on user config +M.init_ui = function() + if state.user_config.ui == 'buffer' then + ui = require('spectre.ui.buffer') + elseif state.user_config.ui == 'float' then + ui = require('spectre.ui.float') else - M.open_file_search(opts) + ui = require('spectre.ui.buffer') end end -M.close = function() - if state.bufnr ~= nil then - local wins = vim.fn.win_findbuf(state.bufnr) - if not wins then - return - end - for _, win_id in pairs(wins) do - vim.api.nvim_win_close(win_id, true) - end - state.is_open = false +M.check_replace_cmd_bins = function() + local replace_cmd = state.user_config.default.replace.cmd + if replace_cmd == 'oxi' then + local job = require('plenary.job') + job:new({ + command = 'which', + args = { 'oxi' }, + on_exit = function(j, return_val) + if return_val ~= 0 then + vim.notify('oxi not found. Please install it with: cargo install oxi', vim.log.levels.WARN) + end + end, + }):sync() end end M.open = function(opts) - log.debug('Start') - if state.user_config == nil then - M.setup() - end - - opts = vim.tbl_extend('force', { - cwd = nil, - is_insert_mode = state.user_config.is_insert_mode, - search_text = '', - replace_text = '', - path = '', - is_close = false, -- close an exists instance of spectre then open new - is_file = false, - begin_line_num = 3, - }, opts or {}) or {} - + opts = opts or {} state.is_open = true - state.status_line = '' - opts.search_text = utils.trim(opts.search_text) + state.cwd = opts.cwd or state.cwd or vim.fn.getcwd() state.target_winid = api.nvim_get_current_win() state.target_bufnr = api.nvim_get_current_buf() - if opts.is_close then - M.close() - end - - local is_new = true - --check reopen panel by reuse bufnr - if state.bufnr ~= nil and not opts.is_close then - local wins = vim.fn.win_findbuf(state.bufnr) - if #wins >= 1 then - for _, win_id in pairs(wins) do - if vim.fn.win_gotoid(win_id) == 1 then - is_new = false - end - end - end - end - if state.bufnr == nil or is_new then - if type(state.user_config.open_cmd) == 'function' then - state.user_config.open_cmd() - else - vim.cmd(state.user_config.open_cmd) - end - else - if state.query.path ~= nil and #state.query.path > 1 and opts.path == '' then - opts.path = state.query.path - end - end - - vim.wo.foldenable = false - vim.bo.buftype = 'nofile' - vim.bo.buflisted = false - state.bufnr = api.nvim_get_current_buf() - vim.cmd(string.format('file %s/spectre', state.bufnr)) - vim.bo.filetype = config.filetype - api.nvim_buf_clear_namespace(state.bufnr, config.namespace_status, 0, -1) - api.nvim_buf_clear_namespace(state.bufnr, config.namespace_result, 0, -1) - api.nvim_buf_set_lines(state.bufnr, 0, -1, false, {}) - - vim.api.nvim_buf_attach(state.bufnr, false, { - on_detach = M.stop, - }) - ui.render_text_query(opts) - - state.cwd = opts.cwd - state.search_paths = opts.search_paths - M.change_view('reset') - ui.render_search_ui() - - if opts.is_insert_mode == true then - vim.api.nvim_feedkeys('A', 'n', true) - end - - M.mapping_buffer(state.bufnr) - - if #opts.search_text > 0 then - M.search({ - cwd = opts.cwd, - search_query = opts.search_text, - replace_query = opts.replace_text, - path = opts.path, - }) - end -end - -M.toggle = function(opts) - if state.is_open then - M.close() - else - M.open(opts) - end -end - -function M.mapping_buffer(bufnr) - _G.__spectre_fold = M.get_fold - vim.cmd([[augroup spectre_panel - au! - au InsertEnter lua require"spectre".on_insert_enter() - au InsertLeave lua require"spectre".on_search_change() - au BufLeave lua require("spectre").on_leave() - au BufUnload lua require("spectre").on_close() - augroup END ]]) - vim.opt_local.wrap = false - vim.opt_local.foldexpr = 'spectre#foldexpr()' - vim.opt_local.foldmethod = 'expr' - local map_opt = { noremap = true, silent = _G.__is_dev == nil } - api.nvim_buf_set_keymap(bufnr, 'n', 'x', 'xlua require("spectre").on_search_change()', map_opt) - api.nvim_buf_set_keymap(bufnr, 'n', 'p', "plua require('spectre').on_search_change()", map_opt) - api.nvim_buf_set_keymap(bufnr, 'v', 'p', "plua require('spectre').on_search_change()", map_opt) - api.nvim_buf_set_keymap(bufnr, 'v', 'P', "Plua require('spectre').on_search_change()", map_opt) - api.nvim_buf_set_keymap(bufnr, 'n', 'd', '', map_opt) - api.nvim_buf_set_keymap(bufnr, 'i', '', '', map_opt) - api.nvim_buf_set_keymap(bufnr, 'v', 'd', 'lua require("spectre").toggle_checked()', map_opt) - api.nvim_buf_set_keymap(bufnr, 'n', 'o', 'ji', map_opt) -- don't append line on can make the UI wrong - api.nvim_buf_set_keymap(bufnr, 'n', 'O', 'ki', map_opt) - api.nvim_buf_set_keymap(bufnr, 'n', 'u', '', map_opt) -- disable undo, It breaks the UI. - api.nvim_buf_set_keymap(bufnr, 'n', 'yy', "lua require('spectre.actions').copy_current_line()", map_opt) - api.nvim_buf_set_keymap(bufnr, 'n', '?', "lua require('spectre').show_help()", map_opt) - - for _, map in pairs(state.user_config.mapping) do - if map.cmd then - api.nvim_buf_set_keymap( - bufnr, - 'n', - map.map, - map.cmd, - vim.tbl_deep_extend('force', map_opt, { desc = map.desc }) - ) - end - end - - vim.api.nvim_create_autocmd('BufWritePost', { - group = vim.api.nvim_create_augroup('SpectrePanelWrite', { clear = true }), - pattern = '*', - callback = require('spectre').on_write, - desc = 'spectre write autocmd', - }) - vim.api.nvim_create_autocmd('WinClosed', { - group = vim.api.nvim_create_augroup('SpectreStateOpened', { clear = true }), - buffer = 0, - callback = function() - if vim.api.nvim_buf_get_option(vim.api.nvim_get_current_buf(), 'filetype') == 'spectre_panel' then - state.is_open = false - end - end, - desc = 'Ensure spectre state when its window is closed by any mean', - }) - - if state.user_config.is_block_ui_break then - -- Anti UI breakage - -- * If the user enters insert mode on a forbidden line: leave insert mode. - -- * If the user passes over a forbidden line on insert mode: leave insert mode. - -- * Disable backspace jumping lines. - local backspace = vim.api.nvim_get_option('backspace') - local anti_insert_breakage_group = vim.api.nvim_create_augroup('SpectreAntiInsertBreakage', { clear = true }) - vim.api.nvim_create_autocmd({ 'InsertEnter', 'CursorMovedI' }, { - group = anti_insert_breakage_group, - buffer = 0, - callback = function() - local current_filetype = vim.bo.filetype - if current_filetype == 'spectre_panel' then - vim.cmd('set backspace=indent,start') - local line = vim.api.nvim_win_get_cursor(0)[1] - if line == 1 or line == 2 or line == 4 or line == 6 or line >= 8 then - vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'n', true) - end - end - end, - desc = 'spectre anti-insert-breakage → protect the user from breaking the UI while on insert mode.', - }) - vim.api.nvim_create_autocmd({ 'WinLeave' }, { - group = anti_insert_breakage_group, - buffer = 0, - callback = function() - local current_filetype = vim.bo.filetype - if current_filetype == 'spectre_panel' then - vim.cmd('set backspace=' .. backspace) - end - end, - desc = "spectre anti-insert-breakage → restore the 'backspace' option.", - }) - api.nvim_buf_set_keymap(bufnr, 'i', '', '', map_opt) -- disable ENTER on insert mode, it breaks the UI. - end -end - -local function hl_match(opts) - if #opts.search_query > 0 then - api.nvim_buf_add_highlight(state.bufnr, config.namespace, state.user_config.highlight.search, 2, 0, -1) - end - if #opts.replace_query > 0 then - api.nvim_buf_add_highlight(state.bufnr, config.namespace, state.user_config.highlight.replace, 4, 0, -1) - end -end - -local function can_edit_line() - local line = vim.fn.getpos('.') - if line[2] > config.lnum_UI then - return false - end - return true -end - -M.on_insert_enter = function() - if can_edit_line() then - return - end - local key = api.nvim_replace_termcodes('', true, false, true) - api.nvim_feedkeys(key, 'm', true) - print("You can't make changes in results.") -end - -M.on_search_change = function() - if not can_edit_line() then - return - end - local lines = api.nvim_buf_get_lines(state.bufnr, 0, config.lnum_UI, false) - - local query = { - replace_query = '', - search_query = '', - path = '', + state.query = vim.tbl_extend('force', state.query, opts) + state.query_backup = vim.deepcopy(state.query) + state.is_running = false + state.total_item = {} + state.status_line = '' + state.async_id = nil + state.view = { + mode = 'both', + show_search = true, } + state.regex = nil - for index, line in pairs(lines) do - if index <= 3 and #line > 0 then - query.search_query = query.search_query .. line - end - if index >= 5 and index < 7 and #line > 0 then - query.replace_query = query.replace_query .. line - end - if index >= 7 and index <= 9 and #line > 0 then - query.path = query.path .. line - end - end - local line = vim.fn.getpos('.') - -- check path to verify search in current file - if state.target_winid ~= nil then - local ok, bufnr = pcall(api.nvim_win_get_buf, state.target_winid) - if ok then - -- can't use api.nvim_buf_get_name it get a full path - local bufname = vim.fn.bufname(bufnr) - query.is_file = query.path == bufname - else - state.target_winid = nil - end + -- Ensure UI is initialized + if ui == nil then + M.init_ui() end - if line[2] >= 5 and line[2] < 7 then - M.async_replace(query) - else - M.search(query) - end + ui.open() end -M.on_write = function() - if state.user_config.live_update == true then - M.search() +M.close = function() + state.is_open = false + -- Ensure UI is initialized + if ui == nil then + M.init_ui() end + ui.close() end -M.toggle_live_update = function() - state.user_config.live_update = not state.user_config.live_update - ui.render_header(state.user_config) -end - -M.on_close = function() - M.stop() - vim.api.nvim_create_augroup('SpectrePanelWrite', { clear = true }) - state.query_backup = vim.tbl_extend('force', state.query, {}) -end - -M.on_leave = function() - state.query_backup = vim.tbl_extend('force', state.query, {}) -end - -M.resume_last_search = function() - if not state.query_backup then - print('No previous search!') +M.on_write = function() + if not state.is_open then return end - ui.render_text_query({ - replace_text = state.query_backup.replace_query, - search_text = state.query_backup.search_query, - path = state.query_backup.path, - }) - ui.render_search_ui() - M.search(state.query_backup) -end - -M.async_replace = function(query) - -- clear old search result - api.nvim_buf_clear_namespace(state.bufnr, config.namespace_result, 0, -1) - state.async_id = vim.loop.hrtime() - async.void(function() - M.do_replace_text(query, state.async_id) - end)() -end - -M.do_replace_text = function(opts, async_id) - state.query = opts or state.query - hl_match(state.query) - local count = 1 - for _, item in pairs(state.total_item) do - if state.async_id ~= async_id then - return - end - ui.render_line(state.bufnr, config.namespace, { - search_query = state.query.search_query, - replace_query = state.query.replace_query, - search_text = item.search_text, - lnum = item.display_lnum, - item_line = item.lnum, - is_replace = true, - }, { - is_disable = item.disable, - padding_text = state.user_config.result_padding, - padding = #state.user_config.result_padding, - show_search = state.view.show_search, - show_replace = state.view.show_replace, - }, state.regex) - count = count + 1 - -- delay to next scheduler after 100 time - if count > 100 then - scheduler() - count = 0 - end - end -end - -M.change_view = function(reset) - if reset then - state.view.mode = '' - end - if state.view.mode == 'replace' then - state.view.mode = 'search' - state.view.show_search = true - state.view.show_replace = false - elseif state.view.mode == 'both' then - state.view.mode = 'replace' - state.view.show_search = false - state.view.show_replace = true - else - state.view.mode = 'both' - state.view.show_search = true - state.view.show_replace = true - end - if not reset then - M.async_replace() + if state.user_config.live_update then + M.search(state.query) end end -M.toggle_checked = function() - local startline = unpack(vim.api.nvim_buf_get_mark(0, '<')) - local endline = unpack(vim.api.nvim_buf_get_mark(0, '>')) - for i = startline, endline, 1 do - M.toggle_line(i) - end -end - -M.toggle_line = function(line_visual) - if can_edit_line() then - -- delete line content - vim.cmd([[:normal! ^d$]]) - return false - end - local lnum = line_visual or unpack(vim.api.nvim_win_get_cursor(0)) - local item = state.total_item[lnum] - if item ~= nil and item.display_lnum == lnum - 1 then - item.disable = not item.disable - ui.render_line(state.bufnr, config.namespace, { - search_query = state.query.search_query, - replace_query = state.query.replace_query, - search_text = item.search_text, - lnum = item.display_lnum, - item_line = item.lnum, - is_replace = true, - }, { - is_disable = item.disable, - padding_text = state.user_config.result_padding, - padding = #state.user_config.result_padding, - show_search = state.view.show_search, - show_replace = state.view.show_replace, - }, state.regex) - +M.search = function(query) + query = query or state.query + if not query.search_query or #query.search_query == 0 then return - elseif not line_visual then - -- delete all item in 1 file - local line = vim.fn.getline(lnum) - local check = string.find(line, '([^%s]*%:%d*:%d*:)$') - if check then - check = state.total_item[lnum + 1] - if check == nil then - return - end - local disable = not check.disable - item = check - local index = lnum + 1 - while item ~= nil and check.filename == item.filename do - item.disable = disable - ui.render_line(state.bufnr, config.namespace, { - search_query = state.query.search_query, - replace_query = state.query.replace_query, - search_text = item.search_text, - lnum = item.display_lnum, - item_line = item.lnum, - is_replace = true, - }, { - is_disable = item.disable, - padding_text = state.user_config.result_padding, - padding = #state.user_config.result_padding, - show_search = state.view.show_search, - show_replace = state.view.show_replace, - }, state.regex) - index = index + 1 - item = state.total_item[index] - end - end end -end -M.search_handler = function() - local c_line = 0 - local total = 0 - local start_time = 0 - local padding = #state.user_config.result_padding - local cfg = state.user_config or {} - local last_filename = '' - return { + state.is_running = true + state.query = query + state.total_item = {} + state.status_line = 'Searching...' + + local finder_creator = state_utils.get_finder_creator() + state.finder_instance = finder_creator:new(state_utils.get_search_engine_config(), { on_start = function() state.total_item = {} - state.is_running = true state.status_line = 'Start search' - c_line = config.line_result - total = 0 - start_time = vim.loop.hrtime() end, on_result = function(item) if not state.is_running then return end - item.replace_text = '' + + -- Process the item for display if string.match(item.filename, '^%.%/') then item.filename = item.filename:sub(3, #item.filename) end item.search_text = utils.truncate(utils.trim(item.text), 255) + item.replace_text = '' + if #state.query.replace_query > 1 then - item.replace_text = - state.regex.replace_all(state.query.search_query, state.query.replace_query, item.search_text) - end - if last_filename ~= item.filename then - ui.render_filename(state.bufnr, config.namespace, c_line, item) - c_line = c_line + 1 - last_filename = item.filename + local regex = state_utils.get_regex() + if regex then + item.replace_text = + regex.replace_all(state.query.search_query, state.query.replace_query, item.search_text) + end end - item.display_lnum = c_line - ui.render_line(state.bufnr, config.namespace, { - search_query = state.query.search_query, - replace_query = state.query.replace_query, - search_text = item.search_text, - lnum = item.display_lnum, - item_line = item.lnum, - is_replace = false, - }, { - is_disable = item.disable, - padding_text = cfg.result_padding, - padding = padding, - show_search = state.view.show_search, - show_replace = state.view.show_replace, - }, state.regex) - c_line = c_line + 1 - total = total + 1 - state.status_line = 'Item ' .. total - state.total_item[c_line] = item + table.insert(state.total_item, item) end, on_error = function(error_msg) - api.nvim_buf_set_lines(state.bufnr, c_line, c_line + 1, false, { cfg.result_padding .. error_msg }) - api.nvim_buf_add_highlight(state.bufnr, config.namespace, cfg.highlight.border, c_line, 0, padding) - c_line = c_line + 1 + if not state.is_running then + return + end + state.status_line = 'Error: ' .. error_msg state.finder_instance = nil end, on_finish = function() if not state.is_running then return end - local end_time = (vim.loop.hrtime() - start_time) / 1E9 - state.status_line = string.format('Total: %s match, time: %ss', total, end_time) - - api.nvim_buf_set_lines(state.bufnr, c_line, c_line, false, { - cfg.line_sep, - }) - api.nvim_buf_add_highlight(state.bufnr, config.namespace, cfg.highlight.border, c_line, 0, -1) - - state.vt.status_id = utils.write_virtual_text( - state.bufnr, - config.namespace_status, - config.line_result - 2, - { { state.status_line, 'Question' } } - ) + state.status_line = string.format('Total: %s matches', #state.total_item) state.finder_instance = nil state.is_running = false + + -- Render the results in the UI + if ui and ui.render_results then + ui.render_results() + end end, - } + }) + + state.finder_instance:search({ + cwd = state.cwd, + search_text = query.search_query, + path = query.path, + }) end -M.stop = function() - state.is_running = false - log.debug('spectre stop') - if state.finder_instance ~= nil then - state.finder_instance:stop() - state.finder_instance = nil +M.run_replace = function() + if not state.is_running then + require('spectre.actions').run_replace() end end -M.search = function(opts) - M.stop() - opts = opts or state.query - local finder_creator = state_utils.get_finder_creator() - state.finder_instance = finder_creator:new(state_utils.get_search_engine_config(), M.search_handler()) - if not opts.search_query or #opts.search_query < 2 then - return +M.run_current_replace = function() + if not state.is_running then + require('spectre.actions').run_current_replace() end - state.query = opts - -- clear old search result - api.nvim_buf_clear_namespace(state.bufnr, config.namespace_result, 0, -1) - api.nvim_buf_set_lines(state.bufnr, config.line_result - 1, -1, false, {}) - hl_match(opts) - local c_line = config.line_result - api.nvim_buf_set_lines(state.bufnr, c_line - 1, c_line - 1, false, { state.user_config.line_sep_start }) - api.nvim_buf_add_highlight(state.bufnr, config.namespace, state.user_config.highlight.border, c_line - 1, 0, -1) - state.total_item = {} - state.finder_instance:search({ - cwd = state.cwd, - search_text = state.query.search_query, - path = state.query.path, - search_paths = state.search_paths, - }) - M.init_regex() end -M.init_regex = function() - local replace_config = state_utils.get_replace_engine_config() - if replace_config.cmd == 'oxi' then - state.regex = require('spectre.regex.rust') - else - state.regex = require('spectre.regex.vim') +M.delete_line_file_current = function() + if not state.is_running then + require('spectre.actions').delete_line_file_current() end - state.regex.change_options(replace_config.options_value) end -M.show_help = function() - ui.show_help() +M.send_to_qf = function() + if not state.is_running then + require('spectre.actions').send_to_qf() + end end -M.change_engine_replace = function(engine_name) - if state.user_config.replace_engine[engine_name] then - state.user_config.default.replace.cmd = engine_name - M.init_regex() - vim.notify('change replace engine to: ' .. engine_name) - ui.render_header(state.user_config) - M.search() - return - else - vim.notify(string.format('engine %s not found ' .. engine_name)) +M.select_entry = function() + if not state.is_running then + require('spectre.actions').select_entry() + end +end + +M.select_template = function() + if not state.is_running then + require('spectre.actions').select_template() + end +end + +M.copy_current_line = function() + if not state.is_running then + require('spectre.actions').copy_current_line() end end @@ -691,23 +237,38 @@ M.change_options = function(key) end state.regex.change_options(state_utils.get_replace_engine_config().options_value) if state.query.search_query ~= nil then - ui.render_search_ui() + -- Ensure UI is initialized + if ui == nil then + M.init_ui() + end + + if ui and ui.render_search_ui then + ui.render_search_ui() + end M.search() end end M.show_options = function() - local option_cmd = ui.show_options() - ---@diagnostic disable-next-line: param-type-mismatch - vim.defer_fn(function() - local char = vim.fn.getchar() - 48 - if option_cmd[char] then - M.change_options(option_cmd[char]) - end - end, 200) + if not ui then + M.init_ui() + end + + if ui and ui.show_options then + ui.show_options() + end end M.get_fold = function(lnum) + if not ui then + M.init_ui() + end + + if ui and ui.get_fold then + return ui.get_fold(lnum) + end + + -- Fallback implementation if lnum < config.lnum_UI then return '0' end @@ -730,22 +291,72 @@ M.get_fold = function(lnum) end M.tab = function() - local line = vim.api.nvim_win_get_cursor(0)[1] - if line == 3 then - vim.api.nvim_win_set_cursor(vim.api.nvim_get_current_win(), { 5, 1 }) + if not ui then + M.init_ui() end - if line == 5 then - vim.api.nvim_win_set_cursor(vim.api.nvim_get_current_win(), { 7, 1 }) + + if ui and ui.tab then + ui.tab() end end M.tab_shift = function() - local line = vim.api.nvim_win_get_cursor(0)[1] - if line == 5 then - vim.api.nvim_win_set_cursor(vim.api.nvim_get_current_win(), { 3, 1 }) + if not ui then + M.init_ui() end - if line == 7 then - vim.api.nvim_win_set_cursor(vim.api.nvim_get_current_win(), { 5, 1 }) + + if ui and ui.tab_shift then + ui.tab_shift() + end +end + +M.toggle_preview = function() + if not ui then + M.init_ui() + end + + if ui and ui.toggle_preview then + ui.toggle_preview() + end +end + +M.toggle_checked = function() + if not ui then + M.init_ui() + end + + local lnum = unpack(vim.api.nvim_win_get_cursor(0)) + local item = state.total_item[lnum] + if item and item.display_lnum == lnum - 1 then + item.disable = not item.disable + + if ui and ui.render_results then + ui.render_results() + end + end +end + +-- TODO: Should we need it? +M.change_view = function() + if not ui then + M.init_ui() + end + + -- Toggle view mode + if state.view.mode == 'both' then + state.view.mode = 'replace' + state.view.show_search = false + elseif state.view.mode == 'replace' then + state.view.mode = 'search' + state.view.show_search = true + else + state.view.mode = 'both' + state.view.show_search = true + end + + -- Trigger UI update if available + if ui and ui.render_search_ui then + ui.render_search_ui() end end diff --git a/lua/spectre/replace/sed.lua b/lua/spectre/replace/sed.lua index 4ce71a3..080f302 100644 --- a/lua/spectre/replace/sed.lua +++ b/lua/spectre/replace/sed.lua @@ -8,7 +8,7 @@ sed.init = function(_, config) local uname = vim.loop.os_uname().sysname local sed_args if uname == 'Darwin' then - sed_args = { '-i', '', '-e' } + sed_args = { '-i', '', '-E' } else sed_args = { '-i', '-E' } end diff --git a/lua/spectre/state.lua b/lua/spectre/state.lua index 1c9f7b6..557063c 100644 --- a/lua/spectre/state.lua +++ b/lua/spectre/state.lua @@ -19,42 +19,88 @@ ---@field async_id number ---@field target_winid number ---@field target_bufnr number -local state = { - -- current config - status_line = '', - query = { - search_query = '', - replace_query = '', - path = '', - is_file = false, -- search in current file +---@field renderer any|nil +local M = {} + +M.user_config = { + default = { + find = { + cmd = 'rg', + }, + replace = { + cmd = 'sed', + }, + }, + find_engine = { + rg = { + cmd = 'rg', + args = { + '--color=never', + '--no-heading', + '--with-filename', + '--line-number', + '--column', + }, + options = { + ['ignore-case'] = { + value = '-i', + icon = '[I]', + desc = 'ignore case', + }, + ['hidden'] = { + value = '--hidden', + desc = 'hidden file', + icon = '[H]', + }, + }, + }, }, - query_backup = nil, - -- display text and highlight on result - view = { - mode = 'both', - search = true, - replace = true, + replace_engine = { + oxi = { + cmd = 'oxi', + args = {}, + options = { + ['ignore-case'] = { + value = '-i', + icon = '[I]', + desc = 'ignore case', + }, + }, + }, }, - -- virtual text namespace - vt = {}, - --for options - options = { - ['ignore-case'] = false, - ['hidden'] = false, + live_update = false, + line_sep = '└──────────────────────────────────────────────────────', + result_padding = '│ ', + line_sep_start = '┌──────────────────────────────────────────────────────', + highlight = { + ui = 'SpectreBody', + search = 'SpectreSearch', + replace = 'SpectreReplace', + border = 'SpectreBorder', }, - regex = nil, - user_config = nil, - bufnr = nil, - cwd = nil, - target_winid = nil, - total_item = {}, - is_running = false, - is_open = false, } +M.query = {} +M.options = {} +M.search_paths = {} +M.cwd = nil +M.target_winid = nil +M.target_bufnr = nil +M.finder_instance = nil +M.total_item = {} +M.is_running = false +M.status_line = '' +M.async_id = nil +M.view = { + mode = 'both', + show_search = true, +} +M.regex = nil +M.renderer = nil + if _G.__is_dev then - _G.__spectre_state = _G.__spectre_state or state - state = _G.__spectre_state + _G.__spectre_state = _G.__spectre_state or M + M = _G.__spectre_state end -return state +return M diff --git a/lua/spectre/state_utils.lua b/lua/spectre/state_utils.lua index 0279f0b..d11f4a5 100644 --- a/lua/spectre/state_utils.lua +++ b/lua/spectre/state_utils.lua @@ -11,11 +11,59 @@ M.get_replace_creator = function() return replace_engine[state.user_config.default.replace.cmd] end +-- Get a regex engine instance, initializing it if needed +M.get_regex = function() + -- If regex is not initialized yet, initialize it + if not state.regex then + -- Default to vim regex as a fallback + local regex_engine_name = 'vim' + + -- Try to use the current replace engine's regex + if + state.user_config + and state.user_config.default + and state.user_config.default.replace + and state.user_config.default.replace.cmd + then + local replace_cmd = state.user_config.default.replace.cmd + + -- Map replace engines to regex engines + if replace_cmd == 'oxi' then + regex_engine_name = 'rust' + elseif replace_cmd == 'sed' then + regex_engine_name = 'vim' + elseif replace_cmd == 'sd' then + regex_engine_name = 'rust' + end + end + + -- Require the regex engine + local success, regex = pcall(require, 'spectre.regex.' .. regex_engine_name) + if success then + state.regex = regex + + -- Initialize options if available + local cfg = M.get_replace_engine_config() + if cfg and cfg.options_value then + state.regex.change_options(cfg.options_value) + end + else + -- Fallback to vim regex if the preferred engine couldn't be loaded + state.regex = require('spectre.regex.vim') + end + end + + return state.regex +end + local get_options = function(cfg) local options_value = {} - for key, value in pairs(state.options) do - if value and cfg.options[key] ~= nil then - table.insert(options_value, cfg.options[key].value) + -- Check if cfg.options exists before trying to iterate through it + if cfg.options then + for key, value in pairs(state.options) do + if value and cfg.options[key] ~= nil then + table.insert(options_value, cfg.options[key].value) + end end end return options_value diff --git a/lua/spectre/ui.lua b/lua/spectre/ui.lua deleted file mode 100644 index 3da0692..0000000 --- a/lua/spectre/ui.lua +++ /dev/null @@ -1,288 +0,0 @@ -local has_devicons, devicons = pcall(require, 'nvim-web-devicons') -local has_mini_icons, mini_icons = pcall(require, 'mini.icons') -local config = require('spectre.config') -local state = require('spectre.state') -local state_utils = require('spectre.state_utils') -local utils = require('spectre.utils') - -local Path = require('plenary.path') - -local popup = require('plenary.popup') -local api = vim.api - -local M = {} - ----@param regex RegexEngine -M.render_line = function(bufnr, namespace, text_opts, view_opts, regex) - local cfg = state.user_config - local diff = utils.get_hl_line_text({ - search_query = text_opts.search_query, - replace_query = text_opts.replace_query, - search_text = text_opts.search_text, - show_search = view_opts.show_search, - show_replace = view_opts.show_replace, - }, regex) - local end_lnum = text_opts.is_replace == true and text_opts.lnum + 1 or text_opts.lnum - - local item_line_len = 0 - if cfg.lnum_for_results == true then - item_line_len = string.len(text_opts.item_line) + 1 - api.nvim_buf_set_lines(bufnr, text_opts.lnum, end_lnum, false, { - view_opts.padding_text .. text_opts.item_line .. ' ' .. diff.text, - }) - else - api.nvim_buf_set_lines(bufnr, text_opts.lnum, end_lnum, false, { - view_opts.padding_text .. diff.text, - }) - end - - if not view_opts.is_disable then - for _, value in pairs(diff.search) do - api.nvim_buf_add_highlight( - bufnr, - namespace, - cfg.highlight.search, - text_opts.lnum, - value[1] + view_opts.padding + item_line_len, - value[2] + view_opts.padding + item_line_len - ) - end - for _, value in pairs(diff.replace) do - api.nvim_buf_add_highlight( - bufnr, - namespace, - cfg.highlight.replace, - text_opts.lnum, - value[1] + view_opts.padding + item_line_len, - value[2] + view_opts.padding + item_line_len - ) - end - api.nvim_buf_add_highlight( - state.bufnr, - config.namespace, - cfg.highlight.border, - text_opts.lnum, - 0, - view_opts.padding - ) - else - api.nvim_buf_add_highlight(state.bufnr, config.namespace, cfg.highlight.border, text_opts.lnum, 0, -1) - end -end - -local get_devicons = (function() - if has_devicons then - if not devicons.has_loaded() then - devicons.setup() - end - - return function(filename, enable_icon, default) - if not enable_icon or not filename then - return default or '|', '' - end - local icon, icon_highlight = devicons.get_icon(filename, string.match(filename, '%a+$'), { default = true }) - return icon, icon_highlight - end - elseif has_mini_icons then - if not _G.MiniIcons then - mini_icons.setup() - end - - return function(filename, enable_icon, default) - if not enable_icon or not filename then - return default or '|', '' - end - local icon, icon_highlight = mini_icons.get('file', filename) - return icon, icon_highlight - end - else - return function(_, _) - return '' - end - end -end)() - -M.render_filename = function(bufnr, namespace, line, entry) - local u_config = state.user_config - local filename = vim.fn.fnamemodify(entry.filename, ':t') - local directory = vim.fn.fnamemodify(entry.filename, ':h') - if directory == '.' then - directory = '' - else - directory = directory .. Path.path.sep - end - - local icon_length = state.user_config.color_devicons and 4 or 2 - local icon, icon_highlight = get_devicons(filename, state.user_config.color_devicons, '+') - - api.nvim_buf_set_lines(state.bufnr, line, line, false, { - string.format('%s %s%s:', icon, directory, filename), - }) - - local width = vim.api.nvim_strwidth(filename) - local hl = { - { { 0, icon_length }, icon_highlight }, - { { 0, vim.api.nvim_strwidth(directory) }, u_config.highlight.filedirectory }, - { { 0, width + 1 }, u_config.highlight.filename }, - } - if icon == '' then - table.remove(hl, 1) - end - local pos = 0 - for _, value in pairs(hl) do - pcall(function() - api.nvim_buf_add_highlight(bufnr, namespace, value[2], line, pos + value[1][1], pos + value[1][2]) - pos = value[1][2] + pos - end) - end -end - -function M.render_search_ui() - api.nvim_buf_clear_namespace(state.bufnr, config.namespace_ui, 0, config.lnum_UI) - local details_ui = {} - local search_message = 'Search: ' - local cfg = state_utils.get_search_engine_config() - for key, value in pairs(state.options) do - if value == true and cfg.options[key] then - search_message = search_message .. cfg.options[key].icon - end - end - - table.insert(details_ui, { { search_message, state.user_config.highlight.ui } }) - table.insert(details_ui, { { 'Replace: ', state.user_config.highlight.ui } }) - local path_message = 'Path:' - if state.cwd then - path_message = path_message .. string.format(' cwd=%s', state.cwd) - end - table.insert(details_ui, { { path_message, state.user_config.highlight.ui } }) - - local c_line = 1 - for _, vt_text in ipairs(details_ui) do - utils.write_virtual_text(state.bufnr, config.namespace_ui, c_line, vt_text) - c_line = c_line + 2 - end - M.render_header(state.user_config) -end - -function M.render_header(opts) - api.nvim_buf_clear_namespace(state.bufnr, config.namespace_header, 0, config.lnum_UI) - local help_text = string.format( - '[Nvim Spectre] (Search by %s) %s (Replace by %s) (Press ? for mappings)', - state.user_config.default.find.cmd, - opts.live_update and '(Auto update)' or '', - state.user_config.default.replace.cmd - ) - utils.write_virtual_text( - state.bufnr, - config.namespace_header, - 0, - { { help_text, state.user_config.highlight.headers } } - ) -end - -M.show_menu_options = function(title, content) - local win_width, win_height = vim.lsp.util._make_floating_popup_size(content, {}) - - local bufnr = vim.api.nvim_create_buf(false, true) - api.nvim_buf_set_option(bufnr, 'bufhidden', 'wipe') - api.nvim_buf_set_lines(bufnr, 0, -1, true, content) - - local help_win = vim.api.nvim_open_win(bufnr, false, { - style = 'minimal', - title = ' ' .. title .. ' ', - title_pos = 'center', - relative = 'cursor', - width = win_width, - height = win_height, - col = 0, - row = 1, - border = 'rounded', - }) - api.nvim_win_set_option(help_win, 'winblend', 0) - api.nvim_buf_set_keymap( - bufnr, - 'n', - '', - 'lua vim.api.nvim_win_close(' .. help_win .. ', true)', - { noremap = true } - ) - - api.nvim_create_autocmd({ - 'CursorMovedI', - 'CursorMoved', - 'CursorMovedI', - 'BufHidden', - 'BufLeave', - 'InsertEnter', - 'WinScrolled', - 'BufDelete', - }, { - callback = function() - pcall(vim.api.nvim_win_close, help_win, true) - end, - }) -end - -M.show_help = function() - local help_msg = {} - local map_tbl = {} - for _, map in pairs(state.user_config.mapping) do - table.insert(map_tbl, map) - end - -- sort by length - table.sort(map_tbl, function(a, b) - return (#a.map or 0) < (#b.map or 0) - end) - - for _, map in pairs(map_tbl) do - table.insert(help_msg, string.format('%9s : %s', map.map, map.desc)) - end - - M.show_menu_options('Mappings', help_msg) -end - -M.show_options = function() - local cfg = state_utils.get_search_engine_config() - local help_msg = { ' Press number to select option.' } - local option_cmd = {} - local i = 1 - - for key, option in pairs(cfg.options) do - table.insert(help_msg, string.format(' %s : toggle %s', i, option.desc or ' ')) - table.insert(option_cmd, key) - i = i + 1 - end - - M.show_menu_options('Options', help_msg) - return option_cmd -end - -M.show_find_engine = function() - local engines = state.user_config.find_engine - - local help_msg = { ' Press number to select option.' } - local option_cmd = {} - local i = 1 - - for key, option in pairs(engines) do - table.insert(help_msg, string.format(' %s : engine %s', i, option.desc or ' ')) - table.insert(option_cmd, key) - i = i + 1 - end -end - -M.render_text_query = function(opts) - -- set empty line for virtual text - local lines = {} - local length = config.lnum_UI - for _ = 1, length, 1 do - table.insert(lines, '') - end - api.nvim_buf_set_lines(state.bufnr, 0, 0, false, lines) - api.nvim_buf_set_lines(state.bufnr, 2, 2, false, { opts.search_text }) - api.nvim_buf_set_lines(state.bufnr, 4, 4, false, { opts.replace_text }) - api.nvim_buf_set_lines(state.bufnr, 6, 6, false, { opts.path }) - api.nvim_win_set_cursor(0, { opts.begin_line_num or 3, 0 }) -end - -return M diff --git a/lua/spectre/ui/buffer/init.lua b/lua/spectre/ui/buffer/init.lua new file mode 100644 index 0000000..592e1e5 --- /dev/null +++ b/lua/spectre/ui/buffer/init.lua @@ -0,0 +1,639 @@ +local has_devicons, devicons = pcall(require, 'nvim-web-devicons') +local has_mini_icons, mini_icons = pcall(require, 'mini.icons') +local config = require('spectre.config') +local state = require('spectre.state') +local state_utils = require('spectre.state_utils') +local utils = require('spectre.utils') + +local Path = require('plenary.path') +local popup = require('plenary.popup') +local api = vim.api + +local M = {} + +-- Buffer and UI state +M.bufnr = nil +M.namespace = vim.api.nvim_create_namespace('spectre_ui') +M.namespace_result = vim.api.nvim_create_namespace('spectre_result') +M.namespace_header = vim.api.nvim_create_namespace('spectre_header') +M.namespace_status = vim.api.nvim_create_namespace('spectre_status') +M.namespace_ui = vim.api.nvim_create_namespace('spectre_ui_components') + +-- Setup foldexpr function +local foldexpr = function(lnum) + if lnum < config.lnum_UI then + return '0' + end + local line = vim.fn.getline(lnum) + local padding = line:sub(0, #state.user_config.result_padding) + if padding ~= state.user_config.result_padding then + return '>1' + end + + local nextline = vim.fn.getline(lnum + 1) + padding = nextline:sub(0, #state.user_config.result_padding) + if padding ~= state.user_config.result_padding then + return '<1' + end + local item = state.total_item[lnum] + if item ~= nil then + return '1' + end + return '0' +end + +-- Setup folding using Vim script approach +M.setup_folding = function() + -- We'll use the autoload/spectre.vim function, which is already defined + -- Just set up folding parameters + vim.opt_local.foldexpr = 'spectre#foldexpr()' + vim.opt_local.foldmethod = 'expr' +end + +---@param regex RegexEngine +M.render_line = function(bufnr, namespace, text_opts, view_opts, regex) + local cfg = state.user_config + local diff = utils.get_hl_line_text({ + search_query = text_opts.search_query, + replace_query = text_opts.replace_query, + search_text = text_opts.search_text, + show_search = view_opts.show_search, + }, regex) + local end_lnum = text_opts.is_replace == true and text_opts.lnum + 1 or text_opts.lnum + + local item_line_len = 0 + if cfg.lnum_for_results == true then + item_line_len = string.len(text_opts.item_line) + 1 + api.nvim_buf_set_lines(bufnr, text_opts.lnum, end_lnum, false, { + view_opts.padding_text .. text_opts.item_line .. ' ' .. diff.text, + }) + else + api.nvim_buf_set_lines(bufnr, text_opts.lnum, end_lnum, false, { + view_opts.padding_text .. diff.text, + }) + end + + if not view_opts.is_disable then + for _, value in pairs(diff.search) do + api.nvim_buf_add_highlight( + bufnr, + namespace, + cfg.highlight.search, + text_opts.lnum, + value[1] + view_opts.padding + item_line_len, + value[2] + view_opts.padding + item_line_len + ) + end + for _, value in pairs(diff.replace) do + api.nvim_buf_add_highlight( + bufnr, + namespace, + cfg.highlight.replace, + text_opts.lnum, + value[1] + view_opts.padding + item_line_len, + value[2] + view_opts.padding + item_line_len + ) + end + api.nvim_buf_add_highlight( + M.bufnr, + config.namespace, + cfg.highlight.border, + text_opts.lnum, + 0, + view_opts.padding + ) + else + api.nvim_buf_add_highlight(M.bufnr, config.namespace, cfg.highlight.border, text_opts.lnum, 0, -1) + end +end + +local get_devicons = (function() + if has_devicons then + if not devicons.has_loaded() then + devicons.setup() + end + + return function(filename, enable_icon, default) + if not enable_icon or not filename then + return default or '|', '' + end + local icon, icon_highlight = devicons.get_icon(filename, string.match(filename, '%a+$'), { default = true }) + return icon, icon_highlight + end + elseif has_mini_icons then + if not _G.MiniIcons then + mini_icons.setup() + end + + return function(filename, enable_icon, default) + if not enable_icon or not filename then + return default or '|', '' + end + local icon, icon_highlight = mini_icons.get('file', filename) + return icon, icon_highlight + end + else + return function(_, _) + return '' + end + end +end)() + +M.render_filename = function(bufnr, namespace, line, entry) + local u_config = state.user_config + local filename = vim.fn.fnamemodify(entry.filename, ':t') + local directory = vim.fn.fnamemodify(entry.filename, ':h') + if directory == '.' then + directory = '' + else + directory = directory .. Path.path.sep + end + + local icon_length = state.user_config.color_devicons and 4 or 2 + local icon, icon_highlight = get_devicons(filename, state.user_config.color_devicons, '+') + + api.nvim_buf_set_lines(M.bufnr, line, line, false, { + string.format('%s %s%s:', icon, directory, filename), + }) + + local width = vim.api.nvim_strwidth(filename) + local hl = { + { { 0, icon_length }, icon_highlight }, + { { 0, vim.api.nvim_strwidth(directory) }, u_config.highlight.filedirectory }, + { { 0, width + 1 }, u_config.highlight.filename }, + } + if icon == '' then + table.remove(hl, 1) + end + local pos = 0 + for _, value in pairs(hl) do + pcall(function() + api.nvim_buf_add_highlight(bufnr, namespace, value[2], line, pos + value[1][1], pos + value[1][2]) + pos = value[1][2] + pos + end) + end +end + +function M.render_search_ui() + api.nvim_buf_clear_namespace(M.bufnr, M.namespace_ui, 0, config.lnum_UI) + local details_ui = {} + local search_message = 'Search: ' + local cfg = state_utils.get_search_engine_config() + for key, value in pairs(state.options) do + if value == true and cfg.options[key] then + search_message = search_message .. cfg.options[key].icon + end + end + + table.insert(details_ui, { { search_message, state.user_config.highlight.ui } }) + table.insert(details_ui, { { 'Replace: ', state.user_config.highlight.ui } }) + local path_message = 'Path:' + if state.cwd then + path_message = path_message .. string.format(' cwd=%s', state.cwd) + end + table.insert(details_ui, { { path_message, state.user_config.highlight.ui } }) + + local c_line = 1 + for _, vt_text in ipairs(details_ui) do + utils.write_virtual_text(M.bufnr, M.namespace_ui, c_line, vt_text) + c_line = c_line + 2 + end + M.render_header(state.user_config) +end + +function M.render_header(opts) + api.nvim_buf_clear_namespace(M.bufnr, M.namespace_header, 0, config.lnum_UI) + local help_text = string.format( + '[Nvim Spectre] (Search by %s) %s (Replace by %s) (Press ? for mappings)', + state.user_config.default.find.cmd, + opts.live_update and '(Auto update)' or '', + state.user_config.default.replace.cmd + ) + utils.write_virtual_text(M.bufnr, M.namespace_header, 0, { { help_text, state.user_config.highlight.headers } }) +end + +M.show_menu_options = function(title, content) + local win_width, win_height = vim.lsp.util._make_floating_popup_size(content, {}) + + local bufnr = vim.api.nvim_create_buf(false, true) + api.nvim_buf_set_option(bufnr, 'bufhidden', 'wipe') + api.nvim_buf_set_lines(bufnr, 0, -1, true, content) + + local help_win = vim.api.nvim_open_win(bufnr, false, { + style = 'minimal', + title = ' ' .. title .. ' ', + title_pos = 'center', + relative = 'cursor', + width = win_width, + height = win_height, + col = 0, + row = 1, + border = 'rounded', + }) + api.nvim_win_set_option(help_win, 'winblend', 0) + api.nvim_buf_set_keymap( + bufnr, + 'n', + '', + 'lua vim.api.nvim_win_close(' .. help_win .. ', true)', + { noremap = true } + ) + + api.nvim_create_autocmd({ + 'CursorMovedI', + 'CursorMoved', + 'CursorMovedI', + 'BufHidden', + 'BufLeave', + 'InsertEnter', + 'WinScrolled', + 'BufDelete', + }, { + callback = function() + pcall(vim.api.nvim_win_close, help_win, true) + end, + }) +end + +M.show_help = function() + local help_msg = {} + local map_tbl = {} + for _, map in pairs(state.user_config.mapping) do + table.insert(map_tbl, map) + end + -- sort by length + table.sort(map_tbl, function(a, b) + return (#a.map or 0) < (#b.map or 0) + end) + + for _, map in pairs(map_tbl) do + table.insert(help_msg, string.format('%9s : %s', map.map, map.desc)) + end + + M.show_menu_options('Mappings', help_msg) +end + +M.show_options = function() + local cfg = state_utils.get_search_engine_config() + local help_msg = { ' Press number to select option.' } + local option_cmd = {} + local i = 1 + + for key, option in pairs(cfg.options) do + table.insert(help_msg, string.format(' %s : toggle %s', i, option.desc or ' ')) + table.insert(option_cmd, key) + i = i + 1 + end + + M.show_menu_options('Options', help_msg) + return option_cmd +end + +M.show_find_engine = function() + local engines = state.user_config.find_engine + + local help_msg = { ' Press number to select option.' } + local option_cmd = {} + local i = 1 + + for key, option in pairs(engines) do + table.insert(help_msg, string.format(' %s : engine %s', i, option.desc or ' ')) + table.insert(option_cmd, key) + i = i + 1 + end +end + +M.render_text_query = function(opts) + -- set empty line for virtual text + local lines = {} + local length = config.lnum_UI + for _ = 1, length, 1 do + table.insert(lines, '') + end + api.nvim_buf_set_lines(M.bufnr, 0, 0, false, lines) + api.nvim_buf_set_lines(M.bufnr, 2, 2, false, { opts.search_text }) + api.nvim_buf_set_lines(M.bufnr, 4, 4, false, { opts.replace_text }) + api.nvim_buf_set_lines(M.bufnr, 6, 6, false, { opts.path }) + api.nvim_win_set_cursor(0, { opts.begin_line_num or 3, 0 }) +end + +-- Open the Spectre UI +M.open = function() + -- If already open, return + if M.bufnr and vim.api.nvim_buf_is_valid(M.bufnr) then + local wins = vim.fn.win_findbuf(M.bufnr) + if #wins >= 1 then + for _, win_id in pairs(wins) do + if vim.fn.win_gotoid(win_id) == 1 then + return + end + end + end + end + + -- Open a new window and buffer + if type(state.user_config.open_cmd) == 'function' then + state.user_config.open_cmd() + else + vim.cmd(state.user_config.open_cmd) + end + + vim.wo.foldenable = false + vim.bo.buftype = 'nofile' + vim.bo.buflisted = false + M.bufnr = api.nvim_get_current_buf() + state.bufnr = M.bufnr + vim.cmd(string.format('file %s/spectre', M.bufnr)) + vim.bo.filetype = config.filetype + + api.nvim_buf_clear_namespace(M.bufnr, M.namespace_status, 0, -1) + api.nvim_buf_clear_namespace(M.bufnr, M.namespace_result, 0, -1) + api.nvim_buf_set_lines(M.bufnr, 0, -1, false, {}) + + -- Setup folding + M.setup_folding() + + -- Setup the UI + M.render_text_query({ + search_text = state.query.search_query or '', + replace_text = state.query.replace_query or '', + path = state.query.path or '', + begin_line_num = 3, + }) + + M.render_search_ui() + + -- Create mappings + M.mapping_buffer() + + -- Focus on search line + if state.user_config.is_insert_mode == true then + vim.api.nvim_feedkeys('A', 'n', true) + end +end + +-- Close the Spectre UI +M.close = function() + if M.bufnr and vim.api.nvim_buf_is_valid(M.bufnr) then + local wins = vim.fn.win_findbuf(M.bufnr) + if #wins >= 1 then + for _, win_id in pairs(wins) do + vim.api.nvim_win_close(win_id, true) + end + end + end + M.bufnr = nil + state.bufnr = nil +end + +-- Function to handle search changes +M.on_search_change = function() + local lines = api.nvim_buf_get_lines(M.bufnr, 0, config.lnum_UI, false) + + local query = { + replace_query = '', + search_query = '', + path = '', + } + + for index, line in pairs(lines) do + if index <= 3 and #line > 0 then + query.search_query = query.search_query .. line + end + if index >= 5 and index < 7 and #line > 0 then + query.replace_query = query.replace_query .. line + end + if index >= 7 and index <= 9 and #line > 0 then + query.path = query.path .. line + end + end + + local line = vim.fn.getpos('.') + -- Update state + state.query = query + + -- Trigger appropriate action + if line[2] >= 5 and line[2] < 7 then + require('spectre').run_replace() + else + require('spectre').search(query) + end +end + +-- Setup buffer mappings +M.mapping_buffer = function() + -- Set up autocmds + vim.cmd([[augroup spectre_panel + au! + au InsertEnter lua require"spectre.ui.buffer".on_insert_enter() + au InsertLeave lua require"spectre.ui.buffer".on_search_change() + au BufLeave lua require("spectre").on_write() + au BufUnload lua require("spectre").close() + augroup END ]]) + + vim.opt_local.wrap = false + + -- Folding is already set up in setup_folding() + + local map_opt = { noremap = true, silent = _G.__is_dev == nil } + api.nvim_buf_set_keymap( + M.bufnr, + 'n', + 'x', + 'xlua require("spectre.ui.buffer").on_search_change()', + map_opt + ) + api.nvim_buf_set_keymap( + M.bufnr, + 'n', + 'p', + "plua require('spectre.ui.buffer').on_search_change()", + map_opt + ) + api.nvim_buf_set_keymap( + M.bufnr, + 'v', + 'p', + "plua require('spectre.ui.buffer').on_search_change()", + map_opt + ) + api.nvim_buf_set_keymap( + M.bufnr, + 'v', + 'P', + "Plua require('spectre.ui.buffer').on_search_change()", + map_opt + ) + api.nvim_buf_set_keymap(M.bufnr, 'n', 'd', '', map_opt) + api.nvim_buf_set_keymap(M.bufnr, 'i', '', '', map_opt) + api.nvim_buf_set_keymap(M.bufnr, 'v', 'd', 'lua require("spectre").toggle_checked()', map_opt) + api.nvim_buf_set_keymap(M.bufnr, 'n', 'o', 'ji', map_opt) -- don't append line on can make the UI wrong + api.nvim_buf_set_keymap(M.bufnr, 'n', 'O', 'ki', map_opt) + api.nvim_buf_set_keymap(M.bufnr, 'n', 'u', '', map_opt) -- disable undo, It breaks the UI. + api.nvim_buf_set_keymap(M.bufnr, 'n', 'yy', "lua require('spectre.actions').copy_current_line()", map_opt) + api.nvim_buf_set_keymap(M.bufnr, 'n', '?', "lua require('spectre.ui.buffer').show_help()", map_opt) + + for _, map in pairs(state.user_config.mapping) do + if map.cmd then + api.nvim_buf_set_keymap( + M.bufnr, + 'n', + map.map, + map.cmd, + vim.tbl_deep_extend('force', map_opt, { desc = map.desc }) + ) + end + end + + vim.api.nvim_create_autocmd('BufWritePost', { + group = vim.api.nvim_create_augroup('SpectrePanelWrite', { clear = true }), + pattern = '*', + callback = require('spectre').on_write, + desc = 'spectre write autocmd', + }) + + vim.api.nvim_create_autocmd('WinClosed', { + group = vim.api.nvim_create_augroup('SpectreStateOpened', { clear = true }), + buffer = M.bufnr, + callback = function() + if vim.api.nvim_buf_get_option(vim.api.nvim_get_current_buf(), 'filetype') == 'spectre_panel' then + state.is_open = false + end + end, + desc = 'Ensure spectre state when its window is closed by any mean', + }) + + if state.user_config.is_block_ui_break then + -- Anti UI breakage + -- * If the user enters insert mode on a forbidden line: leave insert mode. + -- * If the user passes over a forbidden line on insert mode: leave insert mode. + -- * Disable backspace jumping lines. + local backspace = vim.api.nvim_get_option('backspace') + local anti_insert_breakage_group = vim.api.nvim_create_augroup('SpectreAntiInsertBreakage', { clear = true }) + vim.api.nvim_create_autocmd({ 'InsertEnter', 'CursorMovedI' }, { + group = anti_insert_breakage_group, + buffer = M.bufnr, + callback = function() + local current_filetype = vim.bo.filetype + if current_filetype == 'spectre_panel' then + vim.cmd('set backspace=indent,start') + local line = vim.api.nvim_win_get_cursor(0)[1] + if line == 1 or line == 2 or line == 4 or line == 6 or line >= 8 then + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'n', true) + end + end + end, + desc = 'spectre anti-insert-breakage → protect the user from breaking the UI while on insert mode.', + }) + vim.api.nvim_create_autocmd({ 'WinLeave' }, { + group = anti_insert_breakage_group, + buffer = M.bufnr, + callback = function() + local current_filetype = vim.bo.filetype + if current_filetype == 'spectre_panel' then + vim.cmd('set backspace=' .. backspace) + end + end, + desc = "spectre anti-insert-breakage → restore the 'backspace' option.", + }) + api.nvim_buf_set_keymap(M.bufnr, 'i', '', '', map_opt) -- disable ENTER on insert mode, it breaks the UI. + end +end + +M.on_insert_enter = function() + local line = vim.fn.getpos('.') + if line[2] > config.lnum_UI then + local key = api.nvim_replace_termcodes('', true, false, true) + api.nvim_feedkeys(key, 'm', true) + print("You can't make changes in results.") + end +end + +M.tab = function() + local line = vim.api.nvim_win_get_cursor(0)[1] + if line == 3 then + vim.api.nvim_win_set_cursor(vim.api.nvim_get_current_win(), { 5, 1 }) + end + if line == 5 then + vim.api.nvim_win_set_cursor(vim.api.nvim_get_current_win(), { 7, 1 }) + end +end + +M.tab_shift = function() + local line = vim.api.nvim_win_get_cursor(0)[1] + if line == 5 then + vim.api.nvim_win_set_cursor(vim.api.nvim_get_current_win(), { 3, 1 }) + end + if line == 7 then + vim.api.nvim_win_set_cursor(vim.api.nvim_get_current_win(), { 5, 1 }) + end +end + +-- Placeholder for toggle_preview - not implemented in the plenary UI +M.toggle_preview = function() + -- Not implemented in plenary UI + vim.notify('Preview not available in plenary UI mode', vim.log.levels.WARN) +end + +-- Get fold level +M.get_fold = function(lnum) + return foldexpr(lnum) +end + +-- Function to render search results +M.render_results = function() + if #state.total_item == 0 then + return + end + + -- Clear old search results + api.nvim_buf_clear_namespace(M.bufnr, M.namespace_result, config.lnum_UI, -1) + api.nvim_buf_set_lines(M.bufnr, config.lnum_UI, -1, false, {}) + + -- Add separator + api.nvim_buf_set_lines(M.bufnr, config.lnum_UI, config.lnum_UI, false, { state.user_config.line_sep_start }) + api.nvim_buf_add_highlight(M.bufnr, M.namespace, state.user_config.highlight.border, config.lnum_UI, 0, -1) + + local c_line = config.lnum_UI + 1 + local total = 0 + local padding = #state.user_config.result_padding + local cfg = state.user_config + local last_filename = '' + + -- Add status line for total count + state.status_line = string.format('Total: %s matches', #state.total_item) + utils.write_virtual_text(M.bufnr, M.namespace_status, config.lnum_UI - 1, { { state.status_line, 'Question' } }) + + -- Render each item + for _, item in ipairs(state.total_item) do + if last_filename ~= item.filename then + M.render_filename(M.bufnr, M.namespace, c_line, item) + c_line = c_line + 1 + last_filename = item.filename + end + + item.display_lnum = c_line + M.render_line(M.bufnr, M.namespace, { + search_query = state.query.search_query, + replace_query = state.query.replace_query, + search_text = item.search_text, + lnum = item.display_lnum, + item_line = item.lnum, + is_replace = false, + }, { + is_disable = item.disable, + padding_text = cfg.result_padding, + padding = padding, + show_search = state.view.show_search, + }, state_utils.get_regex()) + + c_line = c_line + 1 + total = total + 1 + end + + -- Add final separator + api.nvim_buf_set_lines(M.bufnr, c_line, c_line, false, { cfg.line_sep }) + api.nvim_buf_add_highlight(M.bufnr, M.namespace, cfg.highlight.border, c_line, 0, -1) +end + +return M diff --git a/lua/spectre/ui/float/init.lua b/lua/spectre/ui/float/init.lua new file mode 100644 index 0000000..8b332c7 --- /dev/null +++ b/lua/spectre/ui/float/init.lua @@ -0,0 +1,474 @@ +local M = {} + +local n = require('nui-components') +if not n then + error('Failed to load nui-components') +end +local state = require('spectre.state') +local api = vim.api +local state_utils = require('spectre.state_utils') +local has_devicons, devicons = pcall(require, 'nvim-web-devicons') +local utils = require('spectre.utils') + +local preview_buf = nil +local preview_namespace = api.nvim_create_namespace('SPECTRE_PREVIEW') + +local function create_search_ui() + -- Create a new buffer for the results + local bufnr = api.nvim_create_buf(false, true) + api.nvim_buf_set_option(bufnr, 'filetype', 'spectre_panel') + api.nvim_buf_set_option(bufnr, 'buftype', 'nofile') + api.nvim_buf_set_option(bufnr, 'bufhidden', 'wipe') + api.nvim_buf_set_option(bufnr, 'buflisted', false) + + -- Create a separate buffer for preview + local preview_bufnr = api.nvim_create_buf(false, true) + api.nvim_buf_set_option(preview_bufnr, 'buflisted', false) + api.nvim_buf_set_option(preview_bufnr, 'wrap', true) + api.nvim_buf_set_option(preview_bufnr, 'number', true) + api.nvim_buf_set_option(preview_bufnr, 'relativenumber', true) + + local signal = n.create_signal({ + search_text = '', + replace_text = '', + path = '', + is_file = false, + results = {}, + has_search = false, + preview_visible = false, + }) + + local body = function() + return n.rows( + n.columns( + { flex = 0 }, + n.text_input({ + id = 'search-input', + border_label = 'Search', + autofocus = true, + flex = 1, + max_lines = 1, + on_change = function(value) + signal.search_text = value + signal.has_search = #value > 0 + vim.schedule(function() + M.on_search_change() + end) + end, + }) + ), + n.columns( + { flex = 0 }, + n.text_input({ + id = 'replace-input', + border_label = 'Replace', + flex = 1, + max_lines = 1, + on_change = function(value) + signal.replace_text = value + vim.schedule(function() + M.on_search_change() + end) + end, + }) + ), + + n.tree({ + id = 'results-tree', + flex = 1, + border_label = 'Results', + data = signal.results, + hidden = signal.has_search:negate(), + on_select = function(node, component) + if node.is_done ~= nil then + node.is_done = not node.is_done + end + end, + on_focus = function() + signal.preview_visible = true + end, + on_blur = function() + signal.preview_visible = false + end, + on_change = function(focused_node) + if focused_node.filename then + local full_path = vim.fn.fnamemodify(focused_node.filename, ':p') + if vim.fn.filereadable(full_path) then + local lines = vim.fn.readfile(full_path) + api.nvim_buf_set_lines(preview_bufnr, 0, -1, false, lines) + + -- Clear previous highlights + api.nvim_buf_clear_namespace(preview_bufnr, preview_namespace, 0, -1) + + -- Add search highlighting if there's a search query + if state.query and state.query.search_query and #state.query.search_query > 0 then + for i, line in ipairs(lines) do + -- Safely get matches with error handling + local matches = {} + local success, result = pcall(function() + return utils.match_text_line(state.query.search_query, line, 0) + end) + + if success and type(result) == 'table' then + matches = result + end + + for _, match in ipairs(matches) do + -- Safely add highlight + pcall(function() + api.nvim_buf_add_highlight( + preview_bufnr, + preview_namespace, + state.user_config.highlight.search, + i - 1, + match[1], + match[2] + ) + end) + end + end + end + + -- Highlight the current line in the preview buffer + if focused_node.lnum then + local line_num = tonumber(focused_node.lnum) + if line_num then + -- Set cursor to the line number in the preview buffer + api.nvim_buf_call(preview_bufnr, function() + vim.cmd('normal! ' .. line_num .. 'G') + end) + end + end + end + else + api.nvim_buf_set_lines(preview_bufnr, 0, -1, false, {}) + end + end, + prepare_node = function(node, line, component) + if node.is_done ~= nil then + if node.is_done then + local icon = '✔' + local hl = 'String' + if has_devicons then + icon = '󰱒' + end + line:append(' ' .. icon .. ' ', hl) + else + local icon = '◻' + local hl = 'Comment' + if has_devicons then + icon = '' + end + line:append(' ' .. icon .. ' ', hl) + end + end + + if node.icon then + line:append(' ' .. node.icon .. ' ', node.icon_highlight) + end + + -- Add search highlighting if there's a search query + if state.query and state.query.search_query and #state.query.search_query > 0 and node.text then + -- Safely get matches with error handling + local matches = {} + local success, result = pcall(function() + return utils.match_text_line(state.query.search_query, node.text, 0) + end) + + if success and type(result) == 'table' then + matches = result + end + + local last_pos = 0 + local max_width = vim.api.nvim_win_get_width(0) - 15 -- Leave some space for icons and padding + local truncated_text = utils.truncate(node.text, max_width) + + -- Find if this node has been replaced + local is_replaced = false + if state.total_item and node.display_lnum ~= nil then + for _, item in ipairs(state.total_item) do + if + item + and item.display_lnum + and item.display_lnum == node.display_lnum + and item.is_replace + then + is_replaced = true + break + end + end + end + + for _, match in ipairs(matches) do + -- Add text before the match + if match[1] > last_pos then + line:append(truncated_text:sub(last_pos + 1, match[1])) + end + + -- Add highlighted match + line:append(truncated_text:sub(match[1] + 1, match[2]), state.user_config.highlight.search) + + -- Add replacement preview if exists and not replaced yet + if state.query.replace_query and #state.query.replace_query > 0 and not is_replaced then + -- Get the regex engine with safety check + local regex = nil + local success, result = pcall(state_utils.get_regex) + if success then + regex = result + else + -- Fallback to vim regex + regex = require('spectre.regex.vim') + end + + -- Calculate replace_match with error handling + local replace_match = {} + success, result = pcall(function() + return utils.get_hl_line_text({ + search_query = state.query.search_query, + replace_query = state.query.replace_query, + search_text = truncated_text:sub(match[1] + 1, match[2]), + show_search = true, + }, regex).replace + end) + + if success then + replace_match = result + end + + if type(replace_match) == 'table' and #replace_match > 0 then + -- Calculate replace_text with error handling + local replace_text = '' + success, result = pcall(function() + return ' → (' + .. utils.get_hl_line_text({ + search_query = state.query.search_query, + replace_query = state.query.replace_query, + search_text = truncated_text:sub(match[1] + 1, match[2]), + }, regex).text + .. ')' + end) + + if success then + replace_text = result + line:append(replace_text, state.user_config.highlight.replace) + end + end + end + + last_pos = match[2] + end + -- Add remaining text after last match + if last_pos < #truncated_text then + line:append(truncated_text:sub(last_pos + 1)) + end + else + local max_width = vim.api.nvim_win_get_width(0) - 15 -- Leave some space for icons and padding + line:append(utils.truncate(node.text, max_width)) + end + return line + end, + }), + n.buffer({ + id = 'preview-buffer', + flex = 1, + border_label = 'Preview', + hidden = signal.preview_visible:negate(), + is_focusable = false, + buf = preview_bufnr, + autoscroll = true, + }), + n.columns( + { flex = 0 }, + n.text_input({ + id = 'path-input', + border_label = 'Path', + flex = 1, + max_lines = 1, + on_change = function(value) + signal.path = value + vim.schedule(function() + M.on_search_change() + end) + end, + }) + ), + n.columns( + { flex = 0 }, + n.button({ + label = 'Options', + on_press = function() + vim.schedule(function() + M.show_options() + end) + end, + }), + n.gap(3), + n.button({ + label = 'Replace All', + on_press = function() + vim.schedule(function() + require('spectre.actions').run_replace() + end) + end, + }) + ) + ) + end + + local new_renderer = n.create_renderer({ + width = 80, + height = 40, + buf = bufnr, + parent = vim.api.nvim_get_current_win(), + border = { + style = 'rounded', + text = { + top = '[Nvim Spectre]', + top_align = 'center', + }, + }, + }) + + if not new_renderer then + error('Failed to create renderer') + end + + new_renderer:render(body) + return new_renderer, signal +end + +function M.open() + if state.renderer then + M.close() + end + + local new_renderer, signal = create_search_ui() + state.renderer = new_renderer + M.signal = signal +end + +function M.on_search_change() + if not state.renderer then + return + end + local query = { + search_query = state.renderer:get_component_by_id('search-input'):get_current_value(), + replace_query = state.renderer:get_component_by_id('replace-input'):get_current_value(), + path = state.renderer:get_component_by_id('path-input'):get_current_value(), + } + state.query = query -- Store the query in state + M.search(query) +end + +function M.search(query) + if not state.renderer then + return + end + local results_component = state.renderer:get_component_by_id('results-tree') + if not results_component then + return + end + + local results = {} + local last_filename = '' + local current_group = nil + state.total_item = {} -- Reset total_item + + -- Start search + local finder_creator = state_utils.get_finder_creator() + state.finder_instance = finder_creator:new(state_utils.get_search_engine_config(), { + on_result = function(result) + if last_filename ~= result.filename then + local icon, icon_highlight = '', '' + if has_devicons then + icon, icon_highlight = devicons.get_icon(result.filename, '', { default = true }) + end + + current_group = n.node({ + text = result.filename, + icon = icon, + icon_highlight = icon_highlight, + children = {}, + }) + table.insert(results, current_group) + last_filename = result.filename + end + + if current_group then + local entry = n.node({ + filename = result.filename, + col = result.col, + lnum = result.lnum, + text = string.format('%d:%d: %s', result.lnum, result.col, result.text), + is_done = false, + display_lnum = #state.total_item, + }) + table.insert(results, entry) + -- Store the entry in state.total_item with all required fields + table.insert(state.total_item, { + filename = result.filename, + col = result.col, + lnum = result.lnum, + text = result.text, + display_lnum = #state.total_item, + is_replace_finish = false, + }) + end + end, + on_finish = function() + state.finder_instance = nil + if M.signal then + M.signal.results = results + state.renderer:redraw() + end + end, + }) + + state.finder_instance:search({ + cwd = state.cwd, + search_text = query.search_query, + path = query.path, + }) +end + +function M.show_options() + if not state.renderer then + return + end + local cfg = state_utils.get_search_engine_config() + local options = {} + local i = 1 + + for key, option in pairs(cfg.options) do + table.insert(options, n.option(string.format('%d: %s', i, option.desc or ''), { id = key })) + i = i + 1 + end + + local signal = n.create_signal({ + selected = {}, + }) + + local select_component = n.select({ + border_label = 'Options', + data = options, + selected = signal.selected, + multiselect = true, + on_select = function(nodes) + signal.selected = nodes + for _, node in ipairs(nodes) do + state.options[node.id] = not state.options[node.id] + end + M.on_search_change() + end, + }) + + select_component:mount() +end + +function M.close() + if state.renderer then + state.renderer = nil + end +end + +return M diff --git a/lua/spectre/utils.lua b/lua/spectre/utils.lua index 8c7dde1..ebfd889 100644 --- a/lua/spectre/utils.lua +++ b/lua/spectre/utils.lua @@ -155,8 +155,10 @@ local function match_text_line(match, str, padding) return col_tbl end +M.match_text_line = match_text_line + --- get highlight text from search_text and replace_text ---- @params opts {search_query, replace_query, search_text, padding} +--- @params opts {search_query, replace_query, search_text, padding, is_replace} --- @param regex RegexEngine --- @return table { text, search = {}, replace = {}} M.get_hl_line_text = function(opts, regex) @@ -166,24 +168,34 @@ M.get_hl_line_text = function(opts, regex) result.text = opts.search_text if search_match then result.search = match_text_line(search_match, opts.search_text, 0) - if opts.replace_query and #opts.replace_query > 0 and opts.show_replace ~= false then + if opts.replace_query and #opts.replace_query > 0 then local replace_match = regex.replace_all(opts.search_query, opts.replace_query, search_match) - local replace_length = #replace_match local total_increase = 0 - if opts.show_search == false then + + if opts.show_search == false or opts.is_replace then + -- After replacement: Just show the replaced text result.text = regex.replace_all(opts.search_query, opts.replace_query, opts.search_text) result.replace = match_text_line(replace_match, result.text, 0) result.search = {} - else - -- highlight and join replace text - for _, v in pairs(result.search) do - v[1] = v[1] + total_increase - v[2] = v[2] + total_increase - local pos = { v[2], v[2] + replace_length } - table.insert(result.replace, pos) - local text = result.text - result.text = text:sub(0, v[2]) .. replace_match .. text:sub(v[2] + 1) - total_increase = total_increase + replace_length + + -- If we want to show both the original and replaced text after replacement + if opts.is_replace then + -- Find all instances of replaced text and add the original in parentheses + local positions = match_text_line(replace_match, result.text, 0) + local new_text = result.text + local offset = 0 + + for _, pos in ipairs(positions) do + local display_original = ' (was: ' .. search_match .. ')' + local insert_pos = pos[2] + offset + new_text = new_text:sub(1, insert_pos) .. display_original .. new_text:sub(insert_pos + 1) + offset = offset + #display_original + + -- Add highlight for the "was" text + table.insert(result.search, { insert_pos + 6, insert_pos + 6 + #search_match }) + end + + result.text = new_text end end end diff --git a/plugin/spectre.lua b/plugin/spectre.lua index c33a3b0..7e92278 100644 --- a/plugin/spectre.lua +++ b/plugin/spectre.lua @@ -1,37 +1,37 @@ local function get_arg(str) - local key, value = str:match([=[^([^%s]*)=([^%s]*)$]=]) + local key, value = str:match([=[^([^%s]*)=([^%s]*)$]=]) - -- translate string 'true' and 'false' to boolen type - value = value == "true" or value - value = (value == "false" and { false } or { value })[1] + -- translate string 'true' and 'false' to boolen type + value = value == 'true' or value + value = (value == 'false' and { false } or { value })[1] - return key, value + return key, value end -vim.api.nvim_create_user_command("Spectre", function(ctx) - local spectre = require("spectre") - local args = {} - local user_args - if #ctx.fargs == 1 or vim.tbl_isempty(ctx.fargs) then - user_args = ctx.fargs[1] and ctx.fargs or { "" } - elseif #ctx.fargs > 1 then - user_args = ctx.fargs - end +vim.api.nvim_create_user_command('Spectre', function(ctx) + local spectre = require('spectre') + local args = {} + local user_args + if #ctx.fargs == 1 or vim.tbl_isempty(ctx.fargs) then + user_args = ctx.fargs[1] and ctx.fargs or { '' } + elseif #ctx.fargs > 1 then + user_args = ctx.fargs + end - for _, user_arg in ipairs(user_args) do - if user_arg == "%" then - args["path"] = vim.fn.expand("%") - elseif get_arg(user_arg) == nil then - args["path"] = user_arg - elseif get_arg(user_arg) then - local key, value = get_arg(user_arg) - args[key] = value - end - end + for _, user_arg in ipairs(user_args) do + if user_arg == '%' then + args['path'] = vim.fn.expand('%') + elseif get_arg(user_arg) == nil then + args['path'] = user_arg + elseif get_arg(user_arg) then + local key, value = get_arg(user_arg) + args[key] = value + end + end - spectre.open(args) + spectre.open(args) end, { - nargs = "*", - complete = "file", - desc = "Global find and replace", + nargs = '*', + complete = 'file', + desc = 'Global find and replace', }) diff --git a/tests/cwd_spec.lua b/tests/cwd_spec.lua index 25df012..e999556 100644 --- a/tests/cwd_spec.lua +++ b/tests/cwd_spec.lua @@ -22,11 +22,7 @@ describe('check search on another directory', function() }) local bufnr = api.nvim_get_current_buf() - local test1 = helper.defer_get_line( - bufnr, - config.lnum_UI + 2, - config.lnum_UI + 4 - ) + local test1 = helper.defer_get_line(bufnr, config.lnum_UI + 2, config.lnum_UI + 4) eq(' test2.txt:', test1[1], 'should have correct text') vim.api.nvim_win_set_cursor(0, { 12, 0 }) vim.api.nvim_feedkeys(helper.t(''), 'x', true) @@ -44,11 +40,7 @@ describe('check search on another directory', function() path = 'test2.txt', }) local bufnr = api.nvim_get_current_buf() - local test1 = helper.defer_get_line( - bufnr, - config.lnum_UI + 2, - config.lnum_UI + 4 - ) + local test1 = helper.defer_get_line(bufnr, config.lnum_UI + 2, config.lnum_UI + 4) eq(' test2.txt:', test1[1], 'should have correct text') api.nvim_feedkeys(helper.t('R'), 'x', true) vim.wait(1000) diff --git a/tests/escape_spec.lua b/tests/escape_spec.lua index 40d49b8..e2653ec 100644 --- a/tests/escape_spec.lua +++ b/tests/escape_spec.lua @@ -1,39 +1,38 @@ - -local utils = require("spectre.utils") +local utils = require('spectre.utils') local eq = assert.are.same local esc_test_data = { - {[[ . ]], [[ \. ]], " dot"}, - {[[ \aaaa]], [[ \\aaaa]], " character"}, - {[[ \ ]], [[ \\ ]], " slash"}, - {[[ \\ ]], [[ \\ ]], " don't escape double slash"}, - {[[ \ a. ]], [[ \\ a\. ]], " with dot"}, - {[[ \. \ ]], [[ \\. \\ ]], " with dot slash"}, - {[[ ( \\ ]], [[ \( \\ ]], " square slahs"}, - {[[ \{ ]], [[ \{ ]], " bracket"}, - {[[ \} ]], [[ \} ]], " bracket"}, - {[[ \\\ ]], [[ \\\ ]], " don't escape tripple slash"} + { [[ . ]], [[ \. ]], ' dot' }, + { [[ \aaaa]], [[ \\aaaa]], ' character' }, + { [[ \ ]], [[ \\ ]], ' slash' }, + { [[ \\ ]], [[ \\ ]], " don't escape double slash" }, + { [[ \ a. ]], [[ \\ a\. ]], ' with dot' }, + { [[ \. \ ]], [[ \\. \\ ]], ' with dot slash' }, + { [[ ( \\ ]], [[ \( \\ ]], ' square slahs' }, + { [[ \{ ]], [[ \{ ]], ' bracket' }, + { [[ \} ]], [[ \} ]], ' bracket' }, + { [[ \\\ ]], [[ \\\ ]], " don't escape tripple slash" }, } describe('escape chars ', function() for _, value in pairs(esc_test_data) do - it("should escape " .. value[3], function() - eq(value[2], utils.escape_chars(value[1]), "ERROR:" .. value[3]) + it('should escape ' .. value[3], function() + eq(value[2], utils.escape_chars(value[1]), 'ERROR:' .. value[3]) end) end end) local esc_vim_magic = { - {[[ > ]], [[ \> ]], " >"}, - {[[ = ]], [[ \= ]], " >"}, - {[[ < ]], [[ \< ]], " ="}, - {[[ \< ]], [[ \< ]], " <"}, + { [[ > ]], [[ \> ]], ' >' }, + { [[ = ]], [[ \= ]], ' >' }, + { [[ < ]], [[ \< ]], ' =' }, + { [[ \< ]], [[ \< ]], ' <' }, } describe('escape vim magic ', function() for _, value in pairs(esc_vim_magic) do - it("should escape " .. value[3], function() - eq(value[2], utils.escape_vim_magic(value[1]), "ERROR:" .. value[3]) + it('should escape ' .. value[3], function() + eq(value[2], utils.escape_vim_magic(value[1]), 'ERROR:' .. value[3]) end) end end) diff --git a/tests/helper.lua b/tests/helper.lua index 65bcaa9..072366d 100644 --- a/tests/helper.lua +++ b/tests/helper.lua @@ -1,16 +1,16 @@ local utils = require('spectre.utils') -local Path=require('plenary.path') +local Path = require('plenary.path') local M = {} local api = vim.api _G._pwd = '' -M.init = function () - _G._pwd = vim.fn.system('pwd'):gsub("\n", "") - vim.cmd ("set rtp +=".._G._pwd) +M.init = function() + _G._pwd = vim.fn.system('pwd'):gsub('\n', '') + vim.cmd('set rtp +=' .. _G._pwd) end M.get_cwd = function(path) - local root = Path:new (_G._pwd) + local root = Path:new(_G._pwd) local cwd = root:joinpath(path) return cwd.filename end @@ -18,46 +18,46 @@ M.defer_get_line = function(bufnr, start_col, end_col, time) assert(bufnr ~= nil, 'buffer not nil') time = time or 600 local done = false - local text='' + local text = '' vim.defer_fn(function() done = true text = api.nvim_buf_get_lines(bufnr, start_col, end_col, false) end, time) while not done do - vim.cmd [[ sleep 20ms]] + vim.cmd([[ sleep 20ms]]) end return text end - M.checkoutfile = function(filename) - utils.run_os_cmd({'git', 'checkout', 'HEAD', filename}) + utils.run_os_cmd({ 'git', 'checkout', 'HEAD', filename }) return end -M.t=function(cmd) - return vim.api.nvim_replace_termcodes(cmd, true, false, true) +M.t = function(cmd) + return vim.api.nvim_replace_termcodes(cmd, true, false, true) end M.test_replace = function(opts, f_replace) local eq = assert.are.same M.checkoutfile(opts.filename) local finish = false - local handler= { + local handler = { on_finish = function() finish = true - end + end, } local replacer = f_replace(handler) replacer:replace({ lnum = opts.lnum, filename = opts.filename, search_text = opts.search_text, - replace_text = opts.replace_text + replace_text = opts.replace_text, }) - vim.wait(1000, function() return finish end) - local output_txt = utils.run_os_cmd({"cat", opts.filename}) - eq(output_txt[opts.lnum], opts.expected, "test " .. opts.filename) + vim.wait(1000, function() + return finish + end) + local output_txt = utils.run_os_cmd({ 'cat', opts.filename }) + eq(output_txt[opts.lnum], opts.expected, 'test ' .. opts.filename) end return M - diff --git a/tests/line_render_spec.lua b/tests/line_render_spec.lua index 32a1264..56e01d7 100644 --- a/tests/line_render_spec.lua +++ b/tests/line_render_spec.lua @@ -1,150 +1,147 @@ -local utils = require("spectre.utils") +local utils = require('spectre.utils') local eq = assert.are.same local fixtures_different = { { - name = "case 1 ", - search_query = "data", - replace_query = "no\\0", - search_text = " data1 data_2 data 3", - replace_text = " datanodata1 datanodata_2 datanodata 3", - result = { search = { { 1, 5 }, { 13, 17 }, { 26, 30 } }, replace = { { 5, 11 }, { 17, 23 }, { 30, 36 } } } + name = 'case 1 ', + search_query = 'data', + replace_query = 'no\\0', + search_text = ' data1 data_2 data 3', + replace_text = ' datanodata1 datanodata_2 datanodata 3', + result = { search = { { 1, 5 }, { 13, 17 }, { 26, 30 } }, replace = { { 5, 11 }, { 17, 23 }, { 30, 36 } } }, }, { - name = "case 2 ", - search_query = "d\\S*a", - replace_query = "no\\0", - search_text = " data1 data_2 data 3", - replace_text = " datanodata1 datanodata_2 datanodata 3", - result = { search = { { 1, 5 }, { 13, 17 }, { 26, 30 } }, replace = { { 5, 11 }, { 17, 23 }, { 30, 36 } } } + name = 'case 2 ', + search_query = 'd\\S*a', + replace_query = 'no\\0', + search_text = ' data1 data_2 data 3', + replace_text = ' datanodata1 datanodata_2 datanodata 3', + result = { search = { { 1, 5 }, { 13, 17 }, { 26, 30 } }, replace = { { 5, 11 }, { 17, 23 }, { 30, 36 } } }, }, { - name = "case 3 ", - search_query = "data(.*)", - replace_query = "no", - search_text = " data1 data_2 data 3", - replace_text = " data1 data_2 data 3no", - result = { search = { { 1, 20 } }, replace = { { 20, 22 } } } + name = 'case 3 ', + search_query = 'data(.*)', + replace_query = 'no', + search_text = ' data1 data_2 data 3', + replace_text = ' data1 data_2 data 3no', + result = { search = { { 1, 20 } }, replace = { { 20, 22 } } }, }, { - name = "case 4 ", + name = 'case 4 ', search_query = [[data\(]], - replace_query = "no", - search_text = " data( data_2 data 3", - replace_text = " data(no data_2 data 3", - result = { search = { { 1, 6 } }, replace = { { 6, 8 } } } + replace_query = 'no', + search_text = ' data( data_2 data 3', + replace_text = ' data(no data_2 data 3', + result = { search = { { 1, 6 } }, replace = { { 6, 8 } } }, }, { - name = "case 5 ", + name = 'case 5 ', search_query = [[abcd\(]], - replace_query = "no", - search_text = " test function abcd(no)", - replace_text = " test function abcd(nono)", - result = { search = { { 19, 24 } }, replace = { { 24, 26 } } } + replace_query = 'no', + search_text = ' test function abcd(no)', + replace_text = ' test function abcd(nono)', + result = { search = { { 19, 24 } }, replace = { { 24, 26 } } }, }, { - name = "case 6 ", + name = 'case 6 ', search_query = [[^local]], - replace_query = "public", - search_text = "local data", - replace_text = "localpublic data", - result = { search = { { 0, 5 } }, replace = { { 5, 11 } } } + replace_query = 'public', + search_text = 'local data', + replace_text = 'localpublic data', + result = { search = { { 0, 5 } }, replace = { { 5, 11 } } }, }, { - name = "case 7 ", + name = 'case 7 ', search_query = [[]], - replace_query = "public", - search_text = "", - replace_text = "public", - result = { search = { { 0, 10 } }, replace = { { 10, 16 } } } + replace_query = 'public', + search_text = '', + replace_text = 'public', + result = { search = { { 0, 10 } }, replace = { { 10, 16 } } }, }, { - name = "case 8 ", + name = 'case 8 ', search_query = [[^local]], - replace_query = "public", - search_text = "local data", + replace_query = 'public', + search_text = 'local data', show_search = false, - replace_text = "public data", - result = { search = {}, replace = { { 0, 6 } } } + replace_text = 'public data', + result = { search = {}, replace = { { 0, 6 } } }, }, - } - local rust_fixtures_different = { { - name = "case 1 ", - search_query = "data", - replace_query = "no${0}", - search_text = " data1 data_2 data 3", - replace_text = " datanodata1 datanodata_2 datanodata 3", - result = { search = { { 1, 5 }, { 13, 17 }, { 26, 30 } }, replace = { { 5, 11 }, { 17, 23 }, { 30, 36 } } } + name = 'case 1 ', + search_query = 'data', + replace_query = 'no${0}', + search_text = ' data1 data_2 data 3', + replace_text = ' datanodata1 datanodata_2 datanodata 3', + result = { search = { { 1, 5 }, { 13, 17 }, { 26, 30 } }, replace = { { 5, 11 }, { 17, 23 }, { 30, 36 } } }, }, { - name = "case 2 ", - search_query = "d\\S*a", - replace_query = "no$0", - search_text = " data1 data_2 data 3", - replace_text = " datanodata1 datanodata_2 datanodata 3", - result = { search = { { 1, 5 }, { 13, 17 }, { 26, 30 } }, replace = { { 5, 11 }, { 17, 23 }, { 30, 36 } } } + name = 'case 2 ', + search_query = 'd\\S*a', + replace_query = 'no$0', + search_text = ' data1 data_2 data 3', + replace_text = ' datanodata1 datanodata_2 datanodata 3', + result = { search = { { 1, 5 }, { 13, 17 }, { 26, 30 } }, replace = { { 5, 11 }, { 17, 23 }, { 30, 36 } } }, }, { - name = "case 3 ", - search_query = "data(.*)", - replace_query = "no", - search_text = " data1 data_2 data 3", - replace_text = " data1 data_2 data 3no", - result = { search = { { 1, 20 } }, replace = { { 20, 22 } } } + name = 'case 3 ', + search_query = 'data(.*)', + replace_query = 'no', + search_text = ' data1 data_2 data 3', + replace_text = ' data1 data_2 data 3no', + result = { search = { { 1, 20 } }, replace = { { 20, 22 } } }, }, { - name = "case 4 ", + name = 'case 4 ', search_query = [[data\(]], - replace_query = "no", - search_text = " data( data_2 data 3", - replace_text = " data(no data_2 data 3", - result = { search = { { 1, 6 } }, replace = { { 6, 8 } } } + replace_query = 'no', + search_text = ' data( data_2 data 3', + replace_text = ' data(no data_2 data 3', + result = { search = { { 1, 6 } }, replace = { { 6, 8 } } }, }, { - name = "case 5 ", + name = 'case 5 ', search_query = [[abcd\(]], - replace_query = "no", - search_text = " test function abcd(no)", - replace_text = " test function abcd(nono)", - result = { search = { { 19, 24 } }, replace = { { 24, 26 } } } + replace_query = 'no', + search_text = ' test function abcd(no)', + replace_text = ' test function abcd(nono)', + result = { search = { { 19, 24 } }, replace = { { 24, 26 } } }, }, { - name = "case 6 ", + name = 'case 6 ', search_query = [[^local]], - replace_query = "public", - search_text = "local data", - replace_text = "localpublic data", - result = { search = { { 0, 5 } }, replace = { { 5, 11 } } } + replace_query = 'public', + search_text = 'local data', + replace_text = 'localpublic data', + result = { search = { { 0, 5 } }, replace = { { 5, 11 } } }, }, { - name = "case 7 ", + name = 'case 7 ', search_query = [[]], - replace_query = "public", - search_text = "", - replace_text = "public", - result = { search = { { 0, 10 } }, replace = { { 10, 16 } } } + replace_query = 'public', + search_text = '', + replace_text = 'public', + result = { search = { { 0, 10 } }, replace = { { 10, 16 } } }, }, { - name = "case 8 ", + name = 'case 8 ', search_query = [[^local]], - replace_query = "public", - search_text = "local data", + replace_query = 'public', + search_text = 'local data', show_search = false, - replace_text = "public data", - result = { search = {}, replace = { { 0, 6 } } } + replace_text = 'public data', + result = { search = {}, replace = { { 0, 6 } } }, }, - } describe('utils test different highlight', function() local regex = require('spectre.regex.vim') for _, value in pairs(fixtures_different) do - it(value.name .. " " .. value.search_query, function() + it(value.name .. ' ' .. value.search_query, function() local result = utils.get_hl_line_text(value, regex) eq(value.replace_text, result.text, 'text error :' .. value.name) eq(value.result.search, result.search, 'search error :' .. value.name) @@ -153,9 +150,9 @@ describe('utils test different highlight', function() end regex = require('spectre.regex.rust') - regex.change_options({ "i" }) + regex.change_options({ 'i' }) for _, value in pairs(rust_fixtures_different) do - it(value.name .. " " .. value.search_query, function() + it(value.name .. ' ' .. value.search_query, function() local result = utils.get_hl_line_text(value, regex) eq(value.replace_text, result.text, 'text error :' .. value.name) eq(value.result.search, result.search, 'search error :' .. value.name) diff --git a/tests/search/rg_spec.lua b/tests/search/rg_spec.lua index 2989aa0..6b04b64 100644 --- a/tests/search/rg_spec.lua +++ b/tests/search/rg_spec.lua @@ -1,13 +1,12 @@ local rg = require('spectre.search').rg local eq = assert.are.same -vim.cmd [[tcd tests/project]] +vim.cmd([[tcd tests/project]]) local time_wait = 1000 -describe("[rg] search ", function() - - it("should not empty", function() +describe('[rg] search ', function() + it('should not empty', function() local finish = false local total = {} local total_item = 0 @@ -18,17 +17,16 @@ describe("[rg] search ", function() end, on_finish = function() finish = true - end + end, }) - finder:search({search_text = "spectre"}) + finder:search({ search_text = 'spectre' }) vim.wait(time_wait, function() return finish end) - eq(2, total_item, "should have 2 item") - + eq(2, total_item, 'should have 2 item') end) - it("should call finish function", function() + it('should call finish function', function() local finish = false local total = {} local total_item = 0 @@ -39,16 +37,16 @@ describe("[rg] search ", function() end, on_finish = function() finish = true - end + end, }) - finder:search({search_text = "spectre"}) + finder:search({ search_text = 'spectre' }) vim.wait(time_wait, function() return finish end) - eq(true, finish, "finish is not call") + eq(true, finish, 'finish is not call') end) - it("search with path should not empty", function() + it('search with path should not empty', function() local finish = false local total = {} local total_item = 0 @@ -59,17 +57,16 @@ describe("[rg] search ", function() end, on_finish = function() finish = true - end + end, }) - finder:search({search_text = "spectre", path = "**/rg_spec/*.txt"}) + finder:search({ search_text = 'spectre', path = '**/rg_spec/*.txt' }) vim.wait(time_wait, function() return finish end) - eq(1, total_item, "should have 1 item") - + eq(1, total_item, 'should have 1 item') end) - it("search with multiple paths should not be empty", function() + it('search with multiple paths should not be empty', function() local finish = false local total = {} local total_item = 0 @@ -80,17 +77,15 @@ describe("[rg] search ", function() end, on_finish = function() finish = true - end + end, }) finder:search({ - search_text = "(data|spectre)", - path = "**/rg_spec/*.txt **/sed_spec/*.txt" + search_text = '(data|spectre)', + path = '**/rg_spec/*.txt **/sed_spec/*.txt', }) vim.wait(time_wait, function() return finish end) - eq(4, total_item, "should have 4 items") - + eq(4, total_item, 'should have 4 items') end) - end) diff --git a/tests/ui_spec.lua b/tests/ui_spec.lua index 2c96d1b..b4ef544 100644 --- a/tests/ui_spec.lua +++ b/tests/ui_spec.lua @@ -7,36 +7,35 @@ local helper = require('tests.helper') local eq = assert.are.same local api = vim.api -vim.cmd [[tcd tests/project]] +vim.cmd([[tcd tests/project]]) describe('spectre panel UI', function() -- before_each(function() -- -- vim.cmd[[:bd]] -- end) - it("check buffer option ", function() + it('check buffer option ', function() spectre.open() local bufnr = api.nvim_get_current_buf() eq(config.filetype, api.nvim_buf_get_option(bufnr, 'filetype'), 'should have corret file type') eq('spectre', api.nvim_buf_get_name(bufnr):match('spectre$'), 'shoule have correct filename') end) - it("open search and result not empty", function() - spectre.open({search_text = "spectre"}) + it('open search and result not empty', function() + spectre.open({ search_text = 'spectre' }) local bufnr = api.nvim_get_current_buf() local test1 = helper.defer_get_line(bufnr, config.lnum_UI + 2, config.lnum_UI + 4) eq(true, #test1[1] > 5, "it don't have result item") - end) - it("replace text ", function() - local filename = "test1.txt" + it('replace text ', function() + local filename = 'test1.txt' helper.checkoutfile(filename) - spectre.open({search_text = "spectre", replace_text = "data", path = 'test1.txt'}) + spectre.open({ search_text = 'spectre', replace_text = 'data', path = 'test1.txt' }) local bufnr = api.nvim_get_current_buf() local test1 = helper.defer_get_line(bufnr, config.lnum_UI + 2, config.lnum_UI + 4) - eq(" test1.txt:", test1[1], "should have correct text") - api.nvim_feedkeys(helper.t"R", 'x', true) + eq(' test1.txt:', test1[1], 'should have correct text') + api.nvim_feedkeys(helper.t('R'), 'x', true) vim.wait(500) - local output_txt = utils.run_os_cmd({"cat", 'test1.txt'}) - eq(output_txt[1], "data abcde", " test should match") + local output_txt = utils.run_os_cmd({ 'cat', 'test1.txt' }) + eq(output_txt[1], 'data abcde', ' test should match') helper.checkoutfile(filename) end) end)