canola.nvim/lua/canola/lsp/workspace.lua
Barrett Ruth 0d3088f57e
refactor: rename oil to canola across entire codebase (#70)
Problem: the codebase still used the upstream \`oil\` naming everywhere —
URL schemes, the \`:Oil\` command, highlight groups, user events, module
paths, filetypes, buffer/window variables, LuaCATS type annotations,
vimdoc help tags, syntax groups, and internal identifiers.

Solution: mechanical rename of every reference. URL schemes now use
\`canola://\` (plus \`canola-ssh://\`, \`canola-s3://\`, \`canola-sss://\`,
\`canola-trash://\`, \`canola-test://\`). The \`:Canola\` command replaces
\`:Oil\`. All highlight groups, user events, augroups, namespaces,
filetypes, require paths, type annotations, help tags, and identifiers
follow suit. The \`upstream\` remote to \`stevearc/oil.nvim\` has been
removed and the \`vim.g.oil\` deprecation shim dropped.
2026-03-05 14:50:10 -05:00

351 lines
12 KiB
Lua

local fs = require('canola.fs')
local ms = require('vim.lsp.protocol').Methods
if vim.fn.has('nvim-0.10') == 0 then
ms = {
workspace_willCreateFiles = 'workspace/willCreateFiles',
workspace_didCreateFiles = 'workspace/didCreateFiles',
workspace_willDeleteFiles = 'workspace/willDeleteFiles',
workspace_didDeleteFiles = 'workspace/didDeleteFiles',
workspace_willRenameFiles = 'workspace/willRenameFiles',
workspace_didRenameFiles = 'workspace/didRenameFiles',
}
end
local M = {}
---@param method string
---@return vim.lsp.Client[]
local function get_clients(method)
if vim.fn.has('nvim-0.10') == 1 then
return vim.lsp.get_clients({ method = method })
else
---@diagnostic disable-next-line: deprecated
local clients = vim.lsp.get_active_clients()
return vim.tbl_filter(function(client)
return client.supports_method(method)
end, clients)
end
end
---@param glob string|vim.lpeg.Pattern
---@param path string
---@return boolean
local function match_glob(glob, path)
-- nvim-0.10 will have vim.glob.to_lpeg, so this will be a LPeg pattern
if type(glob) ~= 'string' then
return glob:match(path) ~= nil
end
-- older versions fall back to glob2regpat
local pat = vim.fn.glob2regpat(glob)
local ignorecase = vim.o.ignorecase
vim.o.ignorecase = false
local ok, match = pcall(vim.fn.match, path, pat)
vim.o.ignorecase = ignorecase
if not ok then
error(match)
end
return match >= 0
end
---@param client vim.lsp.Client
---@param filters nil|lsp.FileOperationFilter[]
---@param paths string[]
---@return nil|string[]
local function get_matching_paths(client, filters, paths)
if not filters then
return nil
end
local match_fns = {}
for _, filter in ipairs(filters) do
if filter.scheme == nil or filter.scheme == 'file' then
local pattern = filter.pattern
local glob = pattern.glob
local ignore_case = pattern.options and pattern.options.ignoreCase
if ignore_case then
glob = glob:lower()
end
-- Some language servers use forward slashes as path separators on Windows (LuaLS)
-- We no longer need this after 0.12: https://github.com/neovim/neovim/commit/322a6d305d088420b23071c227af07b7c1beb41a
if vim.fn.has('nvim-0.12') == 0 and fs.is_windows then
glob = glob:gsub('/', '\\')
end
---@type string|vim.lpeg.Pattern
local glob_to_match = glob
if vim.glob and vim.glob.to_lpeg then
glob = glob:gsub('{(.-)}', function(s)
local patterns = vim.split(s, ',')
local filtered = {}
for _, pat in ipairs(patterns) do
if pat ~= '' then
table.insert(filtered, pat)
end
end
if #filtered == 0 then
return ''
end
-- HACK around https://github.com/neovim/neovim/issues/28931
-- find alternations and sort them by length to try to match the longest first
if vim.fn.has('nvim-0.11') == 0 then
table.sort(filtered, function(a, b)
return a:len() > b:len()
end)
end
return '{' .. table.concat(filtered, ',') .. '}'
end)
glob_to_match = vim.glob.to_lpeg(glob)
end
local matches = pattern.matches
table.insert(match_fns, function(path)
local is_dir = vim.fn.isdirectory(path) == 1
if matches and ((matches == 'file' and is_dir) or (matches == 'folder' and not is_dir)) then
return false
end
if ignore_case then
path = path:lower()
end
return match_glob(glob_to_match, path)
end)
end
end
local function match_any_pattern(workspace, path)
local relative_path = path:sub(workspace:len() + 2)
for _, match_fn in ipairs(match_fns) do
-- The glob pattern might be relative to workspace OR absolute
if match_fn(relative_path) or match_fn(path) then
return true
end
end
return false
end
local workspace_folders = vim.tbl_map(function(folder)
return vim.uri_to_fname(folder.uri)
end, client.workspace_folders or {})
local function get_matching_workspace(path)
for _, workspace in ipairs(workspace_folders) do
if fs.is_subpath(workspace, path) then
return workspace
end
end
end
local ret = {}
for _, path in ipairs(paths) do
local workspace = get_matching_workspace(path)
if workspace and match_any_pattern(workspace, path) then
table.insert(ret, path)
end
end
if vim.tbl_isempty(ret) then
return nil
else
return ret
end
end
---@param method string The method to call
---@param capability_name string The name of the fileOperations server capability
---@param files string[] The files and folders that will be created
---@param options table|nil
---@return nil|{edit: lsp.WorkspaceEdit, offset_encoding: string}[]
---@return nil|string|lsp.ResponseError err
local function will_file_operation(method, capability_name, files, options)
options = options or {}
local clients = get_clients(method)
local edits = {}
for _, client in ipairs(clients) do
local filters = vim.tbl_get(
client.server_capabilities,
'workspace',
'fileOperations',
capability_name,
'filters'
)
local matching_files = get_matching_paths(client, filters, files)
if matching_files then
local params = {
files = vim.tbl_map(function(file)
return {
uri = vim.uri_from_fname(file),
}
end, matching_files),
}
local result, err
if vim.fn.has('nvim-0.11') == 1 then
result, err = client:request_sync(method, params, options.timeout_ms or 1000, 0)
else
---@diagnostic disable-next-line: param-type-mismatch
result, err = client.request_sync(method, params, options.timeout_ms or 1000, 0)
end
if result and result.result then
if options.apply_edits ~= false then
vim.lsp.util.apply_workspace_edit(result.result, client.offset_encoding)
end
table.insert(edits, { edit = result.result, offset_encoding = client.offset_encoding })
else
return nil, err or result and result.err
end
end
end
return edits
end
---@param method string The method to call
---@param capability_name string The name of the fileOperations server capability
---@param files string[] The files and folders that will be created
local function did_file_operation(method, capability_name, files)
local clients = get_clients(method)
for _, client in ipairs(clients) do
local filters = vim.tbl_get(
client.server_capabilities,
'workspace',
'fileOperations',
capability_name,
'filters'
)
local matching_files = get_matching_paths(client, filters, files)
if matching_files then
local params = {
files = vim.tbl_map(function(file)
return {
uri = vim.uri_from_fname(file),
}
end, matching_files),
}
if vim.fn.has('nvim-0.11') == 1 then
client:notify(method, params)
else
---@diagnostic disable-next-line: param-type-mismatch
client.notify(method, params)
end
end
end
end
--- Notify the server that the client is about to create files.
---@param files string[] The files and folders that will be created
---@param options table|nil Optional table which holds the following optional fields:
--- - timeout_ms (integer|nil, default 1000):
--- Time in milliseconds to block for rename requests.
--- - apply_edits (boolean|nil, default true):
--- Apply any workspace edits from these file operations.
---@return nil|{edit: lsp.WorkspaceEdit, offset_encoding: string}[]
---@return nil|string|lsp.ResponseError err
function M.will_create_files(files, options)
return will_file_operation(ms.workspace_willCreateFiles, 'willCreate', files, options)
end
--- Notify the server that files were created from within the client.
---@param files string[] The files and folders that will be created
function M.did_create_files(files)
did_file_operation(ms.workspace_didCreateFiles, 'didCreate', files)
end
--- Notify the server that the client is about to delete files.
---@param files string[] The files and folders that will be deleted
---@param options table|nil Optional table which holds the following optional fields:
--- - timeout_ms (integer|nil, default 1000):
--- Time in milliseconds to block for rename requests.
--- - apply_edits (boolean|nil, default true):
--- Apply any workspace edits from these file operations.
---@return nil|{edit: lsp.WorkspaceEdit, offset_encoding: string}[]
---@return nil|string|lsp.ResponseError err
function M.will_delete_files(files, options)
return will_file_operation(ms.workspace_willDeleteFiles, 'willDelete', files, options)
end
--- Notify the server that files were deleted from within the client.
---@param files string[] The files and folders that were deleted
function M.did_delete_files(files)
did_file_operation(ms.workspace_didDeleteFiles, 'didDelete', files)
end
--- Notify the server that the client is about to rename files.
---@param files table<string, string> Mapping of old_path -> new_path
---@param options table|nil Optional table which holds the following optional fields:
--- - timeout_ms (integer|nil, default 1000):
--- Time in milliseconds to block for rename requests.
--- - apply_edits (boolean|nil, default true):
--- Apply any workspace edits from these file operations.
---@return nil|{edit: lsp.WorkspaceEdit, offset_encoding: string}[]
---@return nil|string|lsp.ResponseError err
function M.will_rename_files(files, options)
options = options or {}
local clients = get_clients(ms.workspace_willRenameFiles)
local edits = {}
for _, client in ipairs(clients) do
local filters = vim.tbl_get(
client.server_capabilities,
'workspace',
'fileOperations',
'willRename',
'filters'
)
local matching_files = get_matching_paths(client, filters, vim.tbl_keys(files))
if matching_files then
local params = {
files = vim.tbl_map(function(src_file)
return {
oldUri = vim.uri_from_fname(src_file),
newUri = vim.uri_from_fname(files[src_file]),
}
end, matching_files),
}
local result, err
if vim.fn.has('nvim-0.11') == 1 then
result, err =
client:request_sync(ms.workspace_willRenameFiles, params, options.timeout_ms or 1000, 0)
else
result, err =
---@diagnostic disable-next-line: param-type-mismatch
client.request_sync(ms.workspace_willRenameFiles, params, options.timeout_ms or 1000, 0)
end
if result and result.result then
if options.apply_edits ~= false then
vim.lsp.util.apply_workspace_edit(result.result, client.offset_encoding)
end
table.insert(edits, { edit = result.result, offset_encoding = client.offset_encoding })
else
return nil, err or result and result.err
end
end
end
return edits
end
--- Notify the server that files were renamed from within the client.
---@param files table<string, string> Mapping of old_path -> new_path
function M.did_rename_files(files)
local clients = get_clients(ms.workspace_didRenameFiles)
for _, client in ipairs(clients) do
local filters =
vim.tbl_get(client.server_capabilities, 'workspace', 'fileOperations', 'didRename', 'filters')
local matching_files = get_matching_paths(client, filters, vim.tbl_keys(files))
if matching_files then
local params = {
files = vim.tbl_map(function(src_file)
return {
oldUri = vim.uri_from_fname(src_file),
newUri = vim.uri_from_fname(files[src_file]),
}
end, matching_files),
}
if vim.fn.has('nvim-0.11') == 1 then
client:notify(ms.workspace_didRenameFiles, params)
else
---@diagnostic disable-next-line: param-type-mismatch
client.notify(ms.workspace_didRenameFiles, params)
end
end
end
end
return M