diff --git a/integration-tests/cypress/e2e/lazy-loading.cy.ts b/integration-tests/cypress/e2e/lazy-loading.cy.ts index 09f2b684..b4f9ef6b 100644 --- a/integration-tests/cypress/e2e/lazy-loading.cy.ts +++ b/integration-tests/cypress/e2e/lazy-loading.cy.ts @@ -21,6 +21,6 @@ describe("lazy loading yazi.nvim", () => { // NOTE: if this number changes in the future, it's ok. This test is just // to make sure that we don't accidentally load all modules up front due to // an unrelated change. - cy.contains("Loaded 4 modules") + cy.contains("Loaded 6 modules") }) }) diff --git a/lazy.lua b/lazy.lua index 693ca583..db97963d 100644 --- a/lazy.lua +++ b/lazy.lua @@ -10,6 +10,17 @@ return { { 'nvim-lua/plenary.nvim', lazy = true }, { 'akinsho/bufferline.nvim', lazy = true }, + + -- + -- TODO enable after https://github.com/nvim-neorocks/nvim-busted-action/issues/4 is resolved + -- + -- { + -- -- Neovim plugin that adds support for file operations using built-in LSP + -- -- https://github.com/antosha417/nvim-lsp-file-operations + -- 'antosha417/nvim-lsp-file-operations', + -- lazy = true, + -- }, + { 'mikavilpas/yazi.nvim', ---@type YaziConfig diff --git a/lua/yazi.lua b/lua/yazi.lua index 6df13d5f..3d4c0e65 100644 --- a/lua/yazi.lua +++ b/lua/yazi.lua @@ -163,6 +163,10 @@ function M.setup(opts) local Log = require('yazi.log') Log.level = M.config.log_level + pcall(function() + require('yazi.lsp.embedded.lsp-file-operations').setup() + end) + local yazi_augroup = vim.api.nvim_create_augroup('yazi', { clear = true }) if M.config.open_for_directories == true then diff --git a/lua/yazi/log.lua b/lua/yazi/log.lua index f05dbf69..79059649 100644 --- a/lua/yazi/log.lua +++ b/lua/yazi/log.lua @@ -56,13 +56,14 @@ function Log:write_message(level, message) end end +---@param level yazi.LogLevel +function Log:active_for_level(level) + return self.level and self.level ~= log_levels.OFF and self.level <= level +end + ---@param message string function Log:debug(message) - if - self.level - and self.level ~= log_levels.OFF - and self.level <= log_levels.DEBUG - then + if self:active_for_level(log_levels.DEBUG) then self:write_message('DEBUG', message) end end diff --git a/lua/yazi/lsp/delete.lua b/lua/yazi/lsp/delete.lua index b15bc886..fcfc411e 100644 --- a/lua/yazi/lsp/delete.lua +++ b/lua/yazi/lsp/delete.lua @@ -1,57 +1,14 @@ -local M = {} - ----@param path string -local function notify_file_was_deleted(path) - -- https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_willDeleteFiles - local method = 'workspace/willDeleteFiles' - - local clients = vim.lsp.get_clients({ - method = method, - bufnr = vim.api.nvim_get_current_buf(), - }) - - for _, client in ipairs(clients) do - local resp = client.request_sync(method, { - files = { - { - uri = vim.uri_from_fname(path), - }, - }, - }, 1000, 0) - - if resp and resp.result ~= nil then - vim.lsp.util.apply_workspace_edit(resp.result, client.offset_encoding) - end - end -end - ----@param path string -local function notify_delete_complete(path) - -- https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_didDeleteFiles - local method = 'workspace/didDeleteFiles' +local will_delete = require('yazi.lsp.embedded.lsp-file-operations.will-delete') +local did_delete = require('yazi.lsp.embedded.lsp-file-operations.did-delete') - local clients = vim.lsp.get_clients({ - method = method, - bufnr = vim.api.nvim_get_current_buf(), - }) - - for _, client in ipairs(clients) do - -- NOTE: this returns nothing, so no need to do anything with the response - client.request_sync(method, { - files = { - { - uri = vim.uri_from_fname(path), - }, - }, - }, 1000, 0) - end -end +local M = {} --- Send a notification to LSP servers, letting them know that yazi just deleted some files +-- Send a notification to LSP servers, letting them know that yazi just deleted +-- some files. Execute any changes that the LSP says are needed in other files. ---@param path string function M.file_deleted(path) - notify_file_was_deleted(path) - notify_delete_complete(path) + will_delete.callback({ fname = path }) + did_delete.callback({ fname = path }) end return M diff --git a/lua/yazi/lsp/embedded.lua b/lua/yazi/lsp/embedded.lua new file mode 100644 index 00000000..e69de29b diff --git a/lua/yazi/lsp/embedded/lsp-file-operations.lua b/lua/yazi/lsp/embedded/lsp-file-operations.lua new file mode 100644 index 00000000..2f23649d --- /dev/null +++ b/lua/yazi/lsp/embedded/lsp-file-operations.lua @@ -0,0 +1,147 @@ +local M = {} + +local log = require('yazi.lsp.embedded.lsp-file-operations.log') + +local default_config = { + debug = false, + timeout_ms = 10000, + operations = { + willRenameFiles = true, + didRenameFiles = true, + willCreateFiles = true, + didCreateFiles = true, + willDeleteFiles = true, + didDeleteFiles = true, + }, +} + +local modules = { + willRenameFiles = 'yazi.lsp.embedded.lsp-file-operations.will-rename', + didRenameFiles = 'yazi.lsp.embedded.lsp-file-operations.did-rename', + willCreateFiles = 'yazi.lsp.embedded.lsp-file-operations.will-create', + didCreateFiles = 'yazi.lsp.embedded.lsp-file-operations.did-create', + willDeleteFiles = 'yazi.lsp.embedded.lsp-file-operations.will-delete', + didDeleteFiles = 'yazi.lsp.embedded.lsp-file-operations.did-delete', +} + +local capabilities = { + willRenameFiles = 'willRename', + didRenameFiles = 'didRename', + willCreateFiles = 'willCreate', + didCreateFiles = 'didCreate', + willDeleteFiles = 'willDelete', + didDeleteFiles = 'didDelete', +} + +---@alias HandlerMap table a mapping from modules to events that trigger it + +--- helper function to subscribe events to a given module callback +---@param op_events HandlerMap the table that maps modules to event strings +---@param subscribe fun(module: string, event: string) the function for how to subscribe a module to an event +local function setup_events(op_events, subscribe) + for operation, enabled in pairs(M.config.operations) do + if enabled then + local module, events = modules[operation], op_events[operation] + if module and events then + vim.tbl_map(function(event) + subscribe(module, event) + end, events) + end + end + end +end + +M.setup = function(opts) + M.config = vim.tbl_deep_extend('force', default_config, opts or {}) + if M.config.debug then + log.level = 'debug' + end + + -- nvim-tree integration + local ok_nvim_tree, nvim_tree_api = pcall(require, 'nvim-tree.api') + if ok_nvim_tree then + log.debug('Setting up nvim-tree integration') + + ---@type HandlerMap + local nvim_tree_event = nvim_tree_api.events.Event + local events = { + willRenameFiles = { nvim_tree_event.WillRenameNode }, + didRenameFiles = { nvim_tree_event.NodeRenamed }, + willCreateFiles = { nvim_tree_event.WillCreateFile }, + didCreateFiles = { + nvim_tree_event.FileCreated, + nvim_tree_event.FolderCreated, + }, + willDeleteFiles = { nvim_tree_event.WillRemoveFile }, + didDeleteFiles = { + nvim_tree_event.FileRemoved, + nvim_tree_event.FolderRemoved, + }, + } + setup_events(events, function(module, event) + nvim_tree_api.events.subscribe(event, function(args) + require(module).callback(args) + end) + end) + end + + -- neo-tree integration + local ok_neo_tree, neo_tree_events = pcall(require, 'neo-tree.events') + if ok_neo_tree then + log.debug('Setting up neo-tree integration') + + ---@type HandlerMap + local events = { + willRenameFiles = { + neo_tree_events.BEFORE_FILE_RENAME, + neo_tree_events.BEFORE_FILE_MOVE, + }, + didRenameFiles = { + neo_tree_events.FILE_RENAMED, + neo_tree_events.FILE_MOVED, + }, + didCreateFiles = { neo_tree_events.FILE_ADDED }, + didDeleteFiles = { neo_tree_events.FILE_DELETED }, + -- currently no events in neo-tree for before creating or deleting, so unable to support those file operations + -- Issue to add the missing events: https://github.com/nvim-neo-tree/neo-tree.nvim/issues/1276 + } + setup_events(events, function(module, event) + -- create an event name based on the module and the event + local id = ('%s.%s'):format(module, event) + -- just in case setup is called twice, unsubscribe from event + neo_tree_events.unsubscribe({ id = id }) + neo_tree_events.subscribe({ + id = id, + event = event, + handler = function(args) + -- translate neo-tree arguemnts to the same format as nvim-tree + if type(args) == 'table' then + args = { old_name = args.source, new_name = args.destination } + else + args = { fname = args } + end + -- load module and call the callback + require(module).callback(args) + end, + }) + end) + log.debug('Neo-tree integration setup complete') + end +end + +--- The extra client capabilities provided by this plugin. To be merged with +--- vim.lsp.protocol.make_client_capabilities() and sent to the LSP server. +M.default_capabilities = function() + local config = M.config or default_config + local result = { + workspace = { + fileOperations = {}, + }, + } + for operation, capability in pairs(capabilities) do + result.workspace.fileOperations[capability] = config.operations[operation] + end + return result +end + +return M diff --git a/lua/yazi/lsp/embedded/lsp-file-operations/did-create.lua b/lua/yazi/lsp/embedded/lsp-file-operations/did-create.lua new file mode 100644 index 00000000..78fc50c7 --- /dev/null +++ b/lua/yazi/lsp/embedded/lsp-file-operations/did-create.lua @@ -0,0 +1,27 @@ +local utils = require('yazi.lsp.embedded.lsp-file-operations.utils') +local log = require('yazi.lsp.embedded.lsp-file-operations.log') + +local M = {} + +M.callback = function(data) + for _, client in pairs(vim.lsp.get_active_clients()) do + local did_create = utils.get_nested_path( + client, + { 'server_capabilities', 'workspace', 'fileOperations', 'didCreate' } + ) + if did_create ~= nil then + local filters = did_create.filters or {} + if utils.matches_filters(filters, data.fname) then + local params = { + files = { + { uri = vim.uri_from_fname(data.fname) }, + }, + } + client.notify('workspace/didCreateFiles', params) + log.debug('Sending workspace/didCreateFiles notification', params) + end + end + end +end + +return M diff --git a/lua/yazi/lsp/embedded/lsp-file-operations/did-delete.lua b/lua/yazi/lsp/embedded/lsp-file-operations/did-delete.lua new file mode 100644 index 00000000..1d78fce1 --- /dev/null +++ b/lua/yazi/lsp/embedded/lsp-file-operations/did-delete.lua @@ -0,0 +1,27 @@ +local utils = require('yazi.lsp.embedded.lsp-file-operations.utils') +local log = require('yazi.lsp.embedded.lsp-file-operations.log') + +local M = {} + +M.callback = function(data) + for _, client in pairs(vim.lsp.get_active_clients()) do + local did_delete = utils.get_nested_path( + client, + { 'server_capabilities', 'workspace', 'fileOperations', 'didDelete' } + ) + if did_delete ~= nil then + local filters = did_delete.filters or {} + if utils.matches_filters(filters, data.fname) then + local params = { + files = { + { uri = vim.uri_from_fname(data.fname) }, + }, + } + client.notify('workspace/didDeleteFiles', params) + log.debug('Sending workspace/didDeleteFiles notification', params) + end + end + end +end + +return M diff --git a/lua/yazi/lsp/embedded/lsp-file-operations/did-rename.lua b/lua/yazi/lsp/embedded/lsp-file-operations/did-rename.lua new file mode 100644 index 00000000..3d02a420 --- /dev/null +++ b/lua/yazi/lsp/embedded/lsp-file-operations/did-rename.lua @@ -0,0 +1,30 @@ +local utils = require('yazi.lsp.embedded.lsp-file-operations.utils') +local log = require('yazi.lsp.embedded.lsp-file-operations.log') + +local M = {} + +M.callback = function(data) + for _, client in pairs(vim.lsp.get_active_clients()) do + local did_rename = utils.get_nested_path( + client, + { 'server_capabilities', 'workspace', 'fileOperations', 'didRename' } + ) + if did_rename ~= nil then + local filters = did_rename.filters or {} + if utils.matches_filters(filters, data.old_name) then + local params = { + files = { + { + oldUri = vim.uri_from_fname(data.old_name), + newUri = vim.uri_from_fname(data.new_name), + }, + }, + } + client.notify('workspace/didRenameFiles', params) + log.debug('Sending workspace/didRenameFiles notification', params) + end + end + end +end + +return M diff --git a/lua/yazi/lsp/embedded/lsp-file-operations/log.lua b/lua/yazi/lsp/embedded/lsp-file-operations/log.lua new file mode 100644 index 00000000..ef7a6071 --- /dev/null +++ b/lua/yazi/lsp/embedded/lsp-file-operations/log.lua @@ -0,0 +1,5 @@ +local log = require('plenary.log') + +return log.new({ + plugin = 'nvim-yazi.lsp.embedded.lsp-file-operations', +}, false) diff --git a/lua/yazi/lsp/embedded/lsp-file-operations/utils.lua b/lua/yazi/lsp/embedded/lsp-file-operations/utils.lua new file mode 100644 index 00000000..c8d9dfa7 --- /dev/null +++ b/lua/yazi/lsp/embedded/lsp-file-operations/utils.lua @@ -0,0 +1,75 @@ +local Path = require('plenary').path + +local log = require('yazi.lsp.embedded.lsp-file-operations.log') + +local M = {} + +M.get_nested_path = function(table, keys) + if #keys == 0 then + return table + end + local key = keys[1] + if table[key] == nil then + return nil + end + return M.get_nested_path(table[key], { unpack(keys, 2) }) +end + +-- needed for globs like `**/` +local ensure_dir_trailing_slash = function(path, is_dir) + if is_dir and not path:match('/$') then + return path .. '/' + end + return path +end + +local get_absolute_path = function(name) + local path = Path:new(name) + local is_dir = path:is_dir() + local absolute_path = ensure_dir_trailing_slash(path:absolute(), is_dir) + return absolute_path, is_dir +end + +local get_regex = function(pattern) + local regex = vim.fn.glob2regpat(pattern.glob) + if pattern.options and pattern.options.ignorecase then + return '\\c' .. regex + end + return regex +end + +-- filter: FileOperationFilter +local match_filter = function(filter, name, is_dir) + local pattern = filter.pattern + local match_type = pattern.matches + if + not match_type + or (match_type == 'folder' and is_dir) + or (match_type == 'file' and not is_dir) + then + local regex = get_regex(pattern) + log.debug('Matching name', name, 'to pattern', regex) + local previous_ignorecase = vim.o.ignorecase + vim.o.ignorecase = false + local matched = vim.fn.match(name, regex) ~= -1 + vim.o.ignorecase = previous_ignorecase + return matched + end + + return false +end + +-- filters: FileOperationFilter[] +M.matches_filters = function(filters, name) + local absolute_path, is_dir = get_absolute_path(name) + for _, filter in pairs(filters) do + if match_filter(filter, absolute_path, is_dir) then + log.debug('Path did match the filter', absolute_path, filter) + return true + end + end + log.debug("Path didn't match any filters", absolute_path, filters) + return false +end + +return M diff --git a/lua/yazi/lsp/embedded/lsp-file-operations/will-create.lua b/lua/yazi/lsp/embedded/lsp-file-operations/will-create.lua new file mode 100644 index 00000000..2caaef62 --- /dev/null +++ b/lua/yazi/lsp/embedded/lsp-file-operations/will-create.lua @@ -0,0 +1,54 @@ +local utils = require('yazi.lsp.embedded.lsp-file-operations.utils') +local log = require('yazi.lsp.embedded.lsp-file-operations.log') + +local M = {} + +local function getWorkspaceEdit(client, fname) + local will_create_params = { + files = { + { + uri = vim.uri_from_fname(fname), + }, + }, + } + log.debug('Sending workspace/willCreateFiles request', will_create_params) + local timeout_ms = + require('yazi.lsp.embedded.lsp-file-operations').config.timeout_ms + local success, resp = pcall( + client.request_sync, + 'workspace/willCreateFiles', + will_create_params, + timeout_ms + ) + log.debug('Got workspace/willCreateFiles response', resp) + if not success then + log.error('Error while sending workspace/willCreateFiles request', resp) + return nil + end + if resp == nil or resp.result == nil then + log.warn('Got empty workspace/willCreateFiles response, maybe a timeout?') + return nil + end + return resp.result +end + +M.callback = function(data) + for _, client in pairs(vim.lsp.get_active_clients()) do + local will_create = utils.get_nested_path( + client, + { 'server_capabilities', 'workspace', 'fileOperations', 'willCreate' } + ) + if will_create ~= nil then + local filters = will_create.filters or {} + if utils.matches_filters(filters, data.fname) then + local edit = getWorkspaceEdit(client, data.fname) + if edit ~= nil then + log.debug('Going to apply workspace/willCreateFiles edit', edit) + vim.lsp.util.apply_workspace_edit(edit, client.offset_encoding) + end + end + end + end +end + +return M diff --git a/lua/yazi/lsp/embedded/lsp-file-operations/will-delete.lua b/lua/yazi/lsp/embedded/lsp-file-operations/will-delete.lua new file mode 100644 index 00000000..94196f02 --- /dev/null +++ b/lua/yazi/lsp/embedded/lsp-file-operations/will-delete.lua @@ -0,0 +1,54 @@ +local utils = require('yazi.lsp.embedded.lsp-file-operations.utils') +local log = require('yazi.lsp.embedded.lsp-file-operations.log') + +local M = {} + +local function getWorkspaceEdit(client, fname) + local will_delete_params = { + files = { + { + uri = vim.uri_from_fname(fname), + }, + }, + } + log.debug('Sending workspace/willDeleteFiles request', will_delete_params) + local timeout_ms = + require('yazi.lsp.embedded.lsp-file-operations').config.timeout_ms + local success, resp = pcall( + client.request_sync, + 'workspace/willDeleteFiles', + will_delete_params, + timeout_ms + ) + log.debug('Got workspace/willDeleteFiles response', resp) + if not success then + log.error('Error while sending workspace/willDeleteFiles request', resp) + return nil + end + if resp == nil or resp.result == nil then + log.warn('Got empty workspace/willDeleteFiles response, maybe a timeout?') + return nil + end + return resp.result +end + +M.callback = function(data) + for _, client in pairs(vim.lsp.get_active_clients()) do + local will_delete = utils.get_nested_path( + client, + { 'server_capabilities', 'workspace', 'fileOperations', 'willDelete' } + ) + if will_delete ~= nil then + local filters = will_delete.filters or {} + if utils.matches_filters(filters, data.fname) then + local edit = getWorkspaceEdit(client, data.fname) + if edit ~= nil then + log.debug('Going to apply workspace/willDelete edit', edit) + vim.lsp.util.apply_workspace_edit(edit, client.offset_encoding) + end + end + end + end +end + +return M diff --git a/lua/yazi/lsp/embedded/lsp-file-operations/will-rename.lua b/lua/yazi/lsp/embedded/lsp-file-operations/will-rename.lua new file mode 100644 index 00000000..31b29474 --- /dev/null +++ b/lua/yazi/lsp/embedded/lsp-file-operations/will-rename.lua @@ -0,0 +1,55 @@ +local utils = require('yazi.lsp.embedded.lsp-file-operations.utils') +local log = require('yazi.lsp.embedded.lsp-file-operations.log') + +local M = {} + +local function getWorkspaceEdit(client, old_name, new_name) + local will_rename_params = { + files = { + { + oldUri = vim.uri_from_fname(old_name), + newUri = vim.uri_from_fname(new_name), + }, + }, + } + log.debug('Sending workspace/willRenameFiles request', will_rename_params) + local timeout_ms = + require('yazi.lsp.embedded.lsp-file-operations').config.timeout_ms + local success, resp = pcall( + client.request_sync, + 'workspace/willRenameFiles', + will_rename_params, + timeout_ms + ) + log.debug('Got workspace/willRenameFiles response', resp) + if not success then + log.error('Error while sending workspace/willRenameFiles request', resp) + return nil + end + if resp == nil or resp.result == nil then + log.warn('Got empty workspace/willRenameFiles response, maybe a timeout?') + return nil + end + return resp.result +end + +M.callback = function(data) + for _, client in pairs(vim.lsp.get_active_clients()) do + local will_rename = utils.get_nested_path( + client, + { 'server_capabilities', 'workspace', 'fileOperations', 'willRename' } + ) + if will_rename ~= nil then + local filters = will_rename.filters or {} + if utils.matches_filters(filters, data.old_name) then + local edit = getWorkspaceEdit(client, data.old_name, data.new_name) + if edit ~= nil then + log.debug('Going to apply workspace/willRename edit', edit) + vim.lsp.util.apply_workspace_edit(edit, client.offset_encoding) + end + end + end + end +end + +return M diff --git a/lua/yazi/lsp/rename.lua b/lua/yazi/lsp/rename.lua index ac05ade5..8582a72c 100644 --- a/lua/yazi/lsp/rename.lua +++ b/lua/yazi/lsp/rename.lua @@ -1,60 +1,15 @@ -local M = {} - ----@param from string ----@param to string -local function notify_file_was_renamed(from, to) - -- https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_willRenameFiles - local method = 'workspace/willRenameFiles' - - local clients = vim.lsp.get_clients({ - method = method, - bufnr = vim.api.nvim_get_current_buf(), - }) - - for _, client in ipairs(clients) do - local resp = client.request_sync(method, { - files = { - { - oldUri = vim.uri_from_fname(from), - newUri = vim.uri_from_fname(to), - }, - }, - }, 1000, 0) - - if resp and resp.result ~= nil then - vim.lsp.util.apply_workspace_edit(resp.result, client.offset_encoding) - end - end -end - ----@param from string ----@param to string -local function notify_rename_complete(from, to) - -- https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_didRenameFiles - local method = 'workspace/didRenameFiles' +local will_rename = require('yazi.lsp.embedded.lsp-file-operations.will-rename') +local did_rename = require('yazi.lsp.embedded.lsp-file-operations.did-rename') - local clients = vim.lsp.get_clients({ - method = method, - bufnr = vim.api.nvim_get_current_buf(), - }) - - for _, client in ipairs(clients) do - -- NOTE: this returns nothing, so no need to do anything with the response - client.request_sync(method, { - files = { - oldUri = vim.uri_from_fname(from), - newUri = vim.uri_from_fname(to), - }, - }, 1000, 0) - end -end +local M = {} --- Send a notification to LSP servers, letting them know that yazi just renamed some files +-- Send a notification to LSP servers, letting them know that yazi just renamed +-- some files. Execute any changes that the LSP says are needed in other files. ---@param from string ---@param to string function M.file_renamed(from, to) - notify_file_was_renamed(from, to) - notify_rename_complete(from, to) + will_rename.callback({ old_name = from, new_name = to }) + did_rename.callback({ old_name = from, new_name = to }) end return M