From 8f3c1d2d2e4f7b81d19f353c61cb4ccba6a26496 Mon Sep 17 00:00:00 2001 From: Steven Arcangeli Date: Sun, 1 Oct 2023 16:41:51 -0700 Subject: [PATCH] feat: add support for LSP willRenameFiles (#184) --- lua/oil/fs.lua | 39 +++++++++++ lua/oil/lsp_helpers.lua | 142 +++++++++++++++++++++++++++++++++++++++ lua/oil/mutator/init.lua | 14 ++++ 3 files changed, 195 insertions(+) create mode 100644 lua/oil/lsp_helpers.lua diff --git a/lua/oil/fs.lua b/lua/oil/fs.lua index 9ad1ba4c..e813e708 100644 --- a/lua/oil/fs.lua +++ b/lua/oil/fs.lua @@ -26,6 +26,13 @@ M.is_absolute = function(dir) end end +M.abspath = function(path) + if not M.is_absolute(path) then + path = vim.fn.fnamemodify(path, ":p") + end + return path +end + ---@param path string ---@param cb fun(err: nil|string) M.touch = function(path, cb) @@ -39,6 +46,38 @@ M.touch = function(path, cb) end) end +--- Returns true if candidate is a subpath of root, or if they are the same path. +---@param root string +---@param candidate string +---@return boolean +M.is_subpath = function(root, candidate) + if candidate == "" then + return false + end + root = vim.fs.normalize(M.abspath(root)) + -- Trim trailing "/" from the root + if root:find("/", -1) then + root = root:sub(1, -2) + end + candidate = vim.fs.normalize(M.abspath(candidate)) + if M.is_windows then + root = root:lower() + candidate = candidate:lower() + end + if root == candidate then + return true + end + local prefix = candidate:sub(1, root:len()) + if prefix ~= root then + return false + end + + local candidate_starts_with_sep = candidate:find("/", root:len() + 1, true) == root:len() + 1 + local root_ends_with_sep = root:find("/", root:len(), true) == root:len() + + return candidate_starts_with_sep or root_ends_with_sep +end + ---@param path string ---@return string M.posix_to_os_path = function(path) diff --git a/lua/oil/lsp_helpers.lua b/lua/oil/lsp_helpers.lua new file mode 100644 index 00000000..3c3f7845 --- /dev/null +++ b/lua/oil/lsp_helpers.lua @@ -0,0 +1,142 @@ +local fs = require("oil.fs") +local util = require("oil.util") + +local M = {} + +---@param filepath string +---@param pattern lsp.FileOperationPattern +---@return boolean +local function file_matches(filepath, pattern) + local is_dir = vim.fn.isdirectory(filepath) == 1 + if pattern.matches then + if (pattern.matches == "file" and is_dir) or (pattern.matches == "folder" and not is_dir) then + return false + end + end + if vim.tbl_get(pattern, "options", "ignoreCase") then + filepath = filepath:lower() + pattern.glob = pattern.glob:lower() + end + + local pat = vim.fn.glob2regpat(pattern.glob) + return vim.fn.match(filepath, pat) >= 0 +end + +---@param filepath string +---@param filters lsp.FileOperationFilter[] +---@return boolean +local function any_match(filepath, filters) + for _, filter in ipairs(filters) do + local scheme_match = not filter.scheme or filter.scheme == "file" + if scheme_match and file_matches(filepath, filter.pattern) then + return true + end + end + return false +end + +---@return nil|{src: string, dest: string} +local function get_matching_paths(client, path_pairs) + local filters = + vim.tbl_get(client.server_capabilities, "workspace", "fileOperations", "willRename", "filters") + if not filters then + return nil + end + local ret = {} + for _, pair in ipairs(path_pairs) do + if fs.is_subpath(client.config.root_dir, pair.src) then + local relative_file = pair.src:sub(client.config.root_dir:len() + 2) + if any_match(relative_file, filters) then + table.insert(ret, pair) + end + end + end + if vim.tbl_isempty(ret) then + return nil + else + return ret + end +end + +---Process LSP rename in the background +---@param actions oil.MoveAction[] +M.will_rename_files = function(actions) + local path_pairs = {} + for _, action in ipairs(actions) do + local _, src_path = util.parse_url(action.src_url) + assert(src_path) + local src_file = fs.posix_to_os_path(src_path) + local _, dest_path = util.parse_url(action.dest_url) + assert(dest_path) + local dest_file = fs.posix_to_os_path(dest_path) + table.insert(path_pairs, { src = src_file, dest = dest_file }) + end + + local clients = vim.lsp.get_active_clients() + for _, client in ipairs(clients) do + local pairs = get_matching_paths(client, path_pairs) + if pairs then + client.request("workspace/willRenameFiles", { + files = vim.tbl_map(function(pair) + return { + oldUri = vim.uri_from_fname(pair.src), + newUri = vim.uri_from_fname(pair.dest), + } + end, pairs), + }, function(_, result) + if result then + vim.lsp.util.apply_workspace_edit(result, client.offset_encoding) + end + end) + end + end +end + +-- LSP types from core Neovim + +---A filter to describe in which file operation requests or notifications +---the server is interested in receiving. +--- +---@since 3.16.0 +---@class lsp.FileOperationFilter +---A Uri scheme like `file` or `untitled`. +---@field scheme? string +---The actual file operation pattern. +---@field pattern lsp.FileOperationPattern + +---A pattern to describe in which file operation requests or notifications +---the server is interested in receiving. +--- +---@since 3.16.0 +---@class lsp.FileOperationPattern +---The glob pattern to match. Glob patterns can have the following syntax: +---- `*` to match one or more characters in a path segment +---- `?` to match on one character in a path segment +---- `**` to match any number of path segments, including none +---- `{}` to group sub patterns into an OR expression. (e.g. `**​/*.{ts,js}` matches all TypeScript and JavaScript files) +---- `[]` to declare a range of characters to match in a path segment (e.g., `example.[0-9]` to match on `example.0`, `example.1`, …) +---- `[!...]` to negate a range of characters to match in a path segment (e.g., `example.[!0-9]` to match on `example.a`, `example.b`, but not `example.0`) +---@field glob string +---Whether to match files or folders with this pattern. +--- +---Matches both if undefined. +---@field matches? lsp.FileOperationPatternKind +---Additional options used during matching. +---@field options? lsp.FileOperationPatternOptions + +---A pattern kind describing if a glob pattern matches a file a folder or +---both. +--- +---@since 3.16.0 +---@alias lsp.FileOperationPatternKind +---| "file" # file +---| "folder" # folder + +---Matching options for the file operation pattern. +--- +---@since 3.16.0 +---@class lsp.FileOperationPatternOptions +---The pattern should be matched ignoring casing. +---@field ignoreCase? boolean + +return M diff --git a/lua/oil/mutator/init.lua b/lua/oil/mutator/init.lua index 5f087491..7240572e 100644 --- a/lua/oil/mutator/init.lua +++ b/lua/oil/mutator/init.lua @@ -2,6 +2,7 @@ local cache = require("oil.cache") local columns = require("oil.columns") local config = require("oil.config") local constants = require("oil.constants") +local lsp_helpers = require("oil.lsp_helpers") local oil = require("oil") local parser = require("oil.mutator.parser") local pathutil = require("oil.pathutil") @@ -376,6 +377,19 @@ M.process_actions = function(actions, cb) end end + -- send all renames to LSP servers + local moves = {} + for _, action in ipairs(actions) do + if action.type == "move" then + local src_adapter = assert(config.get_adapter_by_scheme(action.src_url)) + local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url)) + if src_adapter.name == "files" and dest_adapter.name == "files" then + table.insert(moves, action) + end + end + end + lsp_helpers.will_rename_files(moves) + -- Convert cross-adapter moves to a copy + delete for _, action in ipairs(actions) do if action.type == "move" then