From c710f518bf59b41449393ded87a09f838ae3cbf9 Mon Sep 17 00:00:00 2001 From: TheLeoP Date: Fri, 24 Apr 2026 17:45:19 +0300 Subject: [PATCH] feat(files): make file system actions LSP aware Resolve #2215 Co-authored-by: Evgeni Chasnovski --- CHANGELOG.md | 8 + doc/mini-files.txt | 24 +- lua/mini/files.lua | 127 +++++++- readmes/mini-files.md | 4 +- tests/dir-files/lsp-files/main.lua | 1 + tests/mock-lsp/file-ops.lua | 66 ++++ ...a---Preview---does-not-highlight-big-files | 32 +- ...st_files.lua---open()---uses-icon-provider | 28 +- ...iles.lua---open()---uses-icon-provider-002 | 28 +- ...iles.lua---open()---uses-icon-provider-003 | 28 +- tests/test_files.lua | 300 +++++++++++++++++- 11 files changed, 582 insertions(+), 64 deletions(-) create mode 100644 tests/dir-files/lsp-files/main.lua create mode 100644 tests/mock-lsp/file-ops.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index ace816edb..d43dd0b5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,14 @@ There are following change types: - Do not treat focus as lost if it has changed from the explorer during `vim.ui.select()` or `vim.ui.input()`. These functions are useful during text editing inside the explorer and are often reimplemented via a separate floating window and dedicated buffer (like in 'mini.pick'). +### Expand + +- Add LSP integration (on Neovim>=0.11), i.e. make some files system actions (create, delete, rename) LSP aware. The information is forwarded to all active LSP servers for them to perform additional actions (like update imports after renaming a file). + + This also adds `config.options.lsp_timeout` to control or disable LSP integration. + + By @TheLeoP, PR #2340. + ## mini.hipatterns ### Evolve diff --git a/doc/mini-files.txt b/doc/mini-files.txt index e567b2353..e6c74e5f7 100644 --- a/doc/mini-files.txt +++ b/doc/mini-files.txt @@ -11,7 +11,8 @@ Features: - Opt-in preview of file or directory under cursor. - Manipulate files and directories by editing text buffers: create, delete, - copy, rename, move. See |MiniFiles-manipulation| for overview. + rename (all three are LSP aware), copy, move. + See |MiniFiles-manipulation| for an overview. - Use as default file explorer instead of `netrw`. @@ -79,6 +80,7 @@ more details. explored branch. - Also uses text editing to manipulate file system entries. - Can work for remote file systems, while this module can not (by design). + - Both provide LSP integration. - [nvim-neo-tree/neo-tree.nvim](https://github.com/nvim-neo-tree/neo-tree.nvim): - Compares to this module mostly the same as 'nvim-tree/nvim-tree.lua'. @@ -315,6 +317,20 @@ Note that order of text manipulation steps does not affect performed actions. - Moving directory inside itself is not supported. +# LSP integration ~ + +Create, delete, and rename are LSP aware (on Neovim>=0.11): the information +is forwarded to all active LSP servers for them to perform additional actions. +This means that LSP servers can, for example, update imports after renaming +a file or populate a file with a boilerplate code after creation. + +The actual changes depend entirely on the LSP server and whether it supports +relevant methods: +- `workspace/will{Create,Delete,Rename}Files` before a file system action. +- `workspace/did{Create,Delete,Rename}Files` after a file system action. + +It can be disabled by setting `options.lsp_timeout = 0` in |MiniFiles.config|. + ------------------------------------------------------------------------------ *MiniFiles-events* To allow user customization and integration of external tools, certain |User| @@ -599,6 +615,8 @@ Defaults ~ permanent_delete = true, -- Whether to use for editing directories use_as_default_explorer = true, + -- Timeout for synchronous LSP integration requests + lsp_timeout = 1000, }, -- Customization of explorer windows @@ -682,6 +700,10 @@ This is a module-specific variant of "remove to trash". Target directory is 'mini.files/trash' inside standard path of Neovim data directory (execute `:echo stdpath('data')` to see its path in your case). +`options.lsp_timeout` is a number that defines a timeout for synchronous +LSP integration requests (see |MiniFiles-manipulation|). +Set to 0 to disable LSP integration. + # Windows ~ `windows.max_number` is a maximum number of windows allowed to be open diff --git a/lua/mini/files.lua b/lua/mini/files.lua index 2a62808c0..c81bd3721 100644 --- a/lua/mini/files.lua +++ b/lua/mini/files.lua @@ -9,7 +9,8 @@ --- - Opt-in preview of file or directory under cursor. --- --- - Manipulate files and directories by editing text buffers: create, delete, ---- copy, rename, move. See |MiniFiles-manipulation| for overview. +--- rename (all three are LSP aware), copy, move. +--- See |MiniFiles-manipulation| for an overview. --- --- - Use as default file explorer instead of `netrw`. --- @@ -77,6 +78,7 @@ --- explored branch. --- - Also uses text editing to manipulate file system entries. --- - Can work for remote file systems, while this module can not (by design). +--- - Both provide LSP integration. --- --- - [nvim-neo-tree/neo-tree.nvim](https://github.com/nvim-neo-tree/neo-tree.nvim): --- - Compares to this module mostly the same as 'nvim-tree/nvim-tree.lua'. @@ -310,6 +312,20 @@ --- (not icon or path index to the left of it). --- --- - Moving directory inside itself is not supported. +--- +--- # LSP integration ~ +--- +--- Create, delete, and rename are LSP aware (on Neovim>=0.11): the information +--- is forwarded to all active LSP servers for them to perform additional actions. +--- This means that LSP servers can, for example, update imports after renaming +--- a file or populate a file with a boilerplate code after creation. +--- +--- The actual changes depend entirely on the LSP server and whether it supports +--- relevant methods: +--- - `workspace/will{Create,Delete,Rename}Files` before a file system action. +--- - `workspace/did{Create,Delete,Rename}Files` after a file system action. +--- +--- It can be disabled by setting `options.lsp_timeout = 0` in |MiniFiles.config|. ---@tag MiniFiles-manipulation --- To allow user customization and integration of external tools, certain |User| @@ -655,6 +671,10 @@ end --- Target directory is 'mini.files/trash' inside standard path of Neovim data --- directory (execute `:echo stdpath('data')` to see its path in your case). --- +--- `options.lsp_timeout` is a number that defines a timeout for synchronous +--- LSP integration requests (see |MiniFiles-manipulation|). +--- Set to 0 to disable LSP integration. +--- --- # Windows ~ --- --- `windows.max_number` is a maximum number of windows allowed to be open @@ -706,6 +726,8 @@ MiniFiles.config = { permanent_delete = true, -- Whether to use for editing directories use_as_default_explorer = true, + -- Timeout for synchronous LSP integration requests + lsp_timeout = 1000, }, -- Customization of explorer windows @@ -847,7 +869,7 @@ MiniFiles.synchronize = function() local msg = table.concat(H.fs_actions_to_lines(fs_actions), '\n') local confirm_res = vim.fn.confirm(msg, '&Yes\n&No\n&Cancel', 1, 'Question') if confirm_res == 3 then return false end - if confirm_res == 1 then H.fs_actions_apply(fs_actions) end + if confirm_res == 1 then H.fs_actions_apply(fs_actions, explorer.opts.options.lsp_timeout) end end H.explorer_refresh(explorer, { force_update = true }) @@ -1331,6 +1353,7 @@ H.setup_config = function(config) H.check_type('options', config.options, 'table') H.check_type('options.use_as_default_explorer', config.options.use_as_default_explorer, 'boolean') H.check_type('options.permanent_delete', config.options.permanent_delete, 'boolean') + H.check_type('options.lsp_timeout', config.options.lsp_timeout, 'number') H.check_type('windows', config.windows, 'table') H.check_type('windows.max_number', config.windows.max_number, 'number') @@ -2766,11 +2789,18 @@ H.fs_actions_to_lines = function(fs_actions) return res end -H.fs_actions_apply = function(fs_actions) +H.fs_actions_apply = function(fs_actions, lsp_timeout) + H.lsp_fs_hook('willCreate', fs_actions, lsp_timeout) + H.lsp_fs_hook('willDelete', fs_actions, lsp_timeout) + H.lsp_fs_hook('willRename', fs_actions, lsp_timeout) + + local ok_actions = {} for i = 1, #fs_actions do local diff, action = fs_actions[i], fs_actions[i].action local ok, success = pcall(H.fs_do[action], diff.from, diff.to) if ok and success then + table.insert(ok_actions, diff) + -- Trigger event local to = action == 'create' and diff.to:gsub('/$', '') or diff.to local data = { action = action, from = diff.from, to = to } @@ -2782,6 +2812,97 @@ H.fs_actions_apply = function(fs_actions) if has_moved then H.adjust_after_move(diff.from, to, fs_actions, i + 1) end end end + + H.lsp_fs_hook('didCreate', ok_actions, lsp_timeout) + H.lsp_fs_hook('didDelete', ok_actions, lsp_timeout) + H.lsp_fs_hook('didRename', ok_actions, lsp_timeout) +end + +H.lsp_fs_hook = function(method, diffs, lsp_timeout) + if lsp_timeout == 0 then return end + + local full_method = 'workspace/' .. method .. 'Files' + local clients = vim.lsp.get_clients({ method = full_method }) + if #clients == 0 then return end + + -- Transform 'mini.files' diffs into LSP file actions for the input method + -- https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#createFilesParams + -- https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#deleteFilesParams + -- https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#renameFilesParams + local files, to_uri = {}, vim.uri_from_fname + local needs_check = method == 'willCreate' or method == 'willRename' + local is_create, is_delete, is_rename = + vim.endswith(method, 'Create'), vim.endswith(method, 'Delete'), vim.endswith(method, 'Rename') + for _, d in ipairs(diffs) do + local file = {} + if is_create and d.action == 'create' then file = { uri = to_uri(d.to) } end + if is_delete and d.action == 'delete' then file = { uri = to_uri(d.from) } end + if is_rename and d.action == 'rename' then file = { oldUri = to_uri(d.from), newUri = to_uri(d.to) } end + + -- Precompute LSP file type for filters (path can be not yet on disk) + file.fs_type = (d.from or d.to):find('/$') ~= nil and 'folder' or 'file' + + -- Some actions might not succeed, so make best effort check before that + local pass_check = not needs_check or (needs_check and d.to ~= nil and not H.fs_is_present_path(d.to)) + if (file.uri or file.oldUri) ~= nil and pass_check then table.insert(files, file) end + end + + -- Execute LSP action for every currently existing client + if #files == 0 then return end + for _, client in ipairs(clients) do + H.lsp_fs_hook_client(client, full_method, files, lsp_timeout) + end +end +-- TODO: Remove after compatibility with Neovim=0.10 is dropped +if vim.fn.has('nvim-0.11') == 0 then H.lsp_fs_hook = function() end end + +H.lsp_fs_hook_client = function(client, full_method, lsp_files, timeout) + -- Compute parameters of the LSP action by filtering all input file actions + -- https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#fileOperationFilter + local is_scheme = function(uri, scheme) return scheme == nil or vim.startswith(uri, scheme .. ':') end + local is_fs_type = function(lsp_file, ref_fs_type) return ref_fs_type == nil or ref_fs_type == lsp_file.fs_type end + local make_filter = function(scheme, ref_fs_type, glob, ignore_case) + local adjust_case = ignore_case and vim.fn.tolower or function(x) return x end + -- On Windows `uri_to_fname` forces `\`, but forcing / seems more robust + local to_fname = H.is_windows and function(x) return (vim.uri_to_fname(x):gsub('\\', '/')) end or vim.uri_to_fname + local glob_lpeg = vim.glob.to_lpeg(adjust_case(glob) or '**') + return function(lsp_file) + local uri = lsp_file.uri or lsp_file.oldUri + local fname = adjust_case(to_fname(uri)) + return is_scheme(uri, scheme) and is_fs_type(lsp_file, ref_fs_type) and glob_lpeg:match(fname) ~= nil + end + end + + local method = full_method:match('^workspace/(.+)Files$') + local filter_configs = client.server_capabilities.workspace.fileOperations[method].filters + local filters = {} + for _, fc in ipairs(filter_configs) do + local glob, matches, scheme = fc.pattern.glob, fc.pattern.matches, fc.scheme + local ignore_case = type(fc.pattern.options) == 'table' and fc.pattern.options.ignoreCase + + table.insert(filters, make_filter(scheme, matches, glob, ignore_case)) + end + + local params = { files = {} } + for _, f in ipairs(lsp_files) do + -- https://github.com/microsoft/language-server-protocol/issues/2203 + -- Empty filters should match nothing, but this a useful default for misbehaving servers + local ok = #filters == 0 + for _, filt in ipairs(filters) do + -- It is not clear from LSP spec if it is `and` or `or`, but it needs to + -- handle filters matching both `file` and `folder`. So `or`. + ok = ok or filt(f) + end + -- Remove manually added field to comply with LSP spec + f.fs_type = nil + if ok then table.insert(params.files, f) end + end + + -- Perform an action + if vim.startswith(method, 'did') then return client:notify(full_method, params) end + -- - Use sync to comply with LSP spec (apply edit before file operations) + local response, err = client:request_sync(full_method, params, timeout) + if (response or {}).result ~= nil then vim.lsp.util.apply_workspace_edit(response.result, client.offset_encoding) end end H.fs_do = {} diff --git a/readmes/mini-files.md b/readmes/mini-files.md index aaea0bb14..22ac5e444 100644 --- a/readmes/mini-files.md +++ b/readmes/mini-files.md @@ -32,7 +32,7 @@ https://github.com/nvim-mini/mini.nvim/assets/24854248/530483a5-fe9a-4e18-9813-a - Opt-in preview of file or directory under cursor. -- Manipulate files and directories by editing text buffers: create, delete, copy, rename, move. See `:h MiniFiles-manipulation` for overview. +- Manipulate files and directories by editing text buffers: create, delete, rename (all three are LSP aware), copy, move. See `:h MiniFiles-manipulation` for an overview. - Use as default file explorer instead of `netrw`. @@ -206,6 +206,8 @@ Here are code snippets for some common installation methods (use only one): permanent_delete = true, -- Whether to use for editing directories use_as_default_explorer = true, + -- Timeout for synchronous LSP integration requests + lsp_timeout = 1000, }, -- Customization of explorer windows diff --git a/tests/dir-files/lsp-files/main.lua b/tests/dir-files/lsp-files/main.lua new file mode 100644 index 000000000..22bd5358b --- /dev/null +++ b/tests/dir-files/lsp-files/main.lua @@ -0,0 +1 @@ +require('something') diff --git a/tests/mock-lsp/file-ops.lua b/tests/mock-lsp/file-ops.lua new file mode 100644 index 000000000..3cf7db523 --- /dev/null +++ b/tests/mock-lsp/file-ops.lua @@ -0,0 +1,66 @@ +local server_name = _G.server_name or 'file-methods-lsp' +_G.lsp_requests = _G.lsp_requests or {} +_G.lsp_notifications = _G.lsp_notifications or {} + +_G.filter_configs = _G.filter_configs or { filters = { { pattern = { glob = '**' } } } } +local fc = _G.filter_configs +local file_operations_config = _G.file_operations_config + or { willCreate = fc, willDelete = fc, willRename = fc, didCreate = fc, didDelete = fc, didRename = fc } + +local capabilities = { workspace = { fileOperations = file_operations_config } } + +local make_will_request = function(method) + return function(params) + _G.lsp_requests[server_name] = _G.lsp_requests[server_name] or {} + table.insert(_G.lsp_requests[server_name], { method, params }) + return _G.workspace_edit_response + end +end + +_G.did_callback = _G.did_callback or function(_, _) end + +local make_did_notification = function(method) + return function(params, dispatchers) + _G.lsp_notifications[server_name] = _G.lsp_notifications[server_name] or {} + table.insert(_G.lsp_notifications[server_name], { method, params }) + _G.did_callback(params, dispatchers) + end +end + +local requests = { + initialize = function(_) return { capabilities = capabilities } end, + shutdown = function(_) return nil end, + + ['workspace/willCreateFiles'] = make_will_request('workspace/willCreateFiles'), + ['workspace/willRenameFiles'] = make_will_request('workspace/willRenameFiles'), + ['workspace/willDeleteFiles'] = make_will_request('workspace/willDeleteFiles'), +} + +local notifications = { + ['workspace/didCreateFiles'] = make_did_notification('workspace/didCreateFiles'), + ['workspace/didRenameFiles'] = make_did_notification('workspace/didRenameFiles'), + ['workspace/didDeleteFiles'] = make_did_notification('workspace/didDeleteFiles'), +} + +local cmd = function(dispatchers) + local is_closing, request_id = false, 0 + + return { + request = function(method, params, callback) + local method_impl = requests[method] + if method_impl ~= nil then callback(nil, method_impl(params)) end + request_id = request_id + 1 + return true, request_id + end, + notify = function(method, params) + local method_impl = notifications[method] + if method_impl ~= nil then method_impl(params, dispatchers) end + return true + end, + is_closing = function() return is_closing end, + terminate = function() is_closing = true end, + } +end + +-- Start server and attach to current buffer +return vim.lsp.start({ name = server_name, cmd = cmd, root_dir = vim.fn.getcwd() }) diff --git a/tests/screenshots/tests-test_files.lua---Preview---does-not-highlight-big-files b/tests/screenshots/tests-test_files.lua---Preview---does-not-highlight-big-files index 9b9e1b5a9..e2d14e2b1 100644 --- a/tests/screenshots/tests-test_files.lua---Preview---does-not-highlight-big-files +++ b/tests/screenshots/tests-test_files.lua---Preview---does-not-highlight-big-files @@ -1,31 +1,31 @@ --|---------|---------|---------|---------|---------|---------|---------|---------| 01|┌OCK_ROOT/tests/dir-files ┐┌ big.lua ────────────────┐ 02|│ common ││local a = "aaaaaaaaaaaaaa│ -03|│ lua │└─────────────────────────┘ -04|│ nested │ -05|│ real │ -06|│ big.lua │ -07|│ init-default-explorer.l│ -08|│ mock-win-functions.lua │ -09|└─────────────────────────┘ -10|~ +03|│ lsp-files │└─────────────────────────┘ +04|│ lua │ +05|│ nested │ +06|│ real │ +07|│ big.lua │ +08|│ init-default-explorer.l│ +09|│ mock-win-functions.lua │ +10|└─────────────────────────┘ 11|~ 12|~ 13|~ 14|~ -15| 5,9-7 All +15| 6,9-7 All --|---------|---------|---------|---------|---------|---------|---------|---------| 01|01111111111111111111111111002222222220000000000000000033333333333333333333333333 02|04444444455555555555555555005555555555555555555555555066666666666666666666666666 -03|04444455555555555555555555000000000000000000000000000066666666666666666666666666 -04|04444444455555555555555555066666666666666666666666666666666666666666666666666666 -05|04444445555555555555555555066666666666666666666666666666666666666666666666666666 -06|07777777777777777777777777066666666666666666666666666666666666666666666666666666 -07|05555555555555555555555555066666666666666666666666666666666666666666666666666666 +03|04444444444455555555555555000000000000000000000000000066666666666666666666666666 +04|04444455555555555555555555066666666666666666666666666666666666666666666666666666 +05|04444444455555555555555555066666666666666666666666666666666666666666666666666666 +06|04444445555555555555555555066666666666666666666666666666666666666666666666666666 +07|07777777777777777777777777066666666666666666666666666666666666666666666666666666 08|05555555555555555555555555066666666666666666666666666666666666666666666666666666 -09|00000000000000000000000000066666666666666666666666666666666666666666666666666666 -10|66666666666666666666666666666666666666666666666666666666666666666666666666666666 +09|05555555555555555555555555066666666666666666666666666666666666666666666666666666 +10|00000000000000000000000000066666666666666666666666666666666666666666666666666666 11|66666666666666666666666666666666666666666666666666666666666666666666666666666666 12|66666666666666666666666666666666666666666666666666666666666666666666666666666666 13|66666666666666666666666666666666666666666666666666666666666666666666666666666666 diff --git a/tests/screenshots/tests-test_files.lua---open()---uses-icon-provider b/tests/screenshots/tests-test_files.lua---open()---uses-icon-provider index 160f8d5ce..ea1a586bb 100644 --- a/tests/screenshots/tests-test_files.lua---open()---uses-icon-provider +++ b/tests/screenshots/tests-test_files.lua---open()---uses-icon-provider @@ -1,30 +1,30 @@ --|---------|---------|---------|---------|---------|---------|---------|---------| 01|┌MOCK_ROOT/tests/dir-files ────────────────────────┐┌ real ─────────┐ 02|│󰉋 common ││󰢱 a.lua │ -03|│󰉋 lua ││󰦪 b.txt │ -04|│󰉋 nested ││󰵸 c.gif │ -05|│󰉋 real ││ LICENSE │ -06|│󰢱 init-default-explorer.lua ││󱁤 Makefile │ -07|│󰢱 mock-win-functions.lua ││󰈔 top-secret │ -08|└──────────────────────────────────────────────────┘└───────────────┘ -09|~ +03|│󰉋 lsp-files ││󰦪 b.txt │ +04|│󰉋 lua ││󰵸 c.gif │ +05|│󰉋 nested ││ LICENSE │ +06|│󰉋 real ││󱁤 Makefile │ +07|│󰢱 init-default-explorer.lua ││󰈔 top-secret │ +08|│󰢱 mock-win-functions.lua │└───────────────┘ +09|└──────────────────────────────────────────────────┘ 10|~ 11|~ 12|~ 13|~ 14|~ -15| 4,11-8 All +15| 5,11-8 All --|---------|---------|---------|---------|---------|---------|---------|---------| 01|01111111111111111111111111100000000000000000000000000222222000000000033333333333 02|04444444455555555555555555555555555555555555555555500667777777777777088888888888 -03|09944455555555555555555555555555555555555555555555500::5555555555555088888888888 -04|04444444455555555555555555555555555555555555555555500445555555555555088888888888 -05|06666667777777777777777777777777777777777777777777700;;5555555555555088888888888 -06|04455555555555555555555555555555555555555555555555500<<5555555555555088888888888 +03|04444444444455555555555555555555555555555555555555500995555555555555088888888888 +04|0::44455555555555555555555555555555555555555555555500445555555555555088888888888 +05|04444444455555555555555555555555555555555555555555500;;5555555555555088888888888 +06|06666667777777777777777777777777777777777777777777700<<5555555555555088888888888 07|04455555555555555555555555555555555555555555555555500<<5555555555555088888888888 -08|00000000000000000000000000000000000000000000000000000000000000000000088888888888 -09|88888888888888888888888888888888888888888888888888888888888888888888888888888888 +08|04455555555555555555555555555555555555555555555555500000000000000000088888888888 +09|00000000000000000000000000000000000000000000000000008888888888888888888888888888 10|88888888888888888888888888888888888888888888888888888888888888888888888888888888 11|88888888888888888888888888888888888888888888888888888888888888888888888888888888 12|88888888888888888888888888888888888888888888888888888888888888888888888888888888 diff --git a/tests/screenshots/tests-test_files.lua---open()---uses-icon-provider-002 b/tests/screenshots/tests-test_files.lua---open()---uses-icon-provider-002 index 160f8d5ce..ea1a586bb 100644 --- a/tests/screenshots/tests-test_files.lua---open()---uses-icon-provider-002 +++ b/tests/screenshots/tests-test_files.lua---open()---uses-icon-provider-002 @@ -1,30 +1,30 @@ --|---------|---------|---------|---------|---------|---------|---------|---------| 01|┌MOCK_ROOT/tests/dir-files ────────────────────────┐┌ real ─────────┐ 02|│󰉋 common ││󰢱 a.lua │ -03|│󰉋 lua ││󰦪 b.txt │ -04|│󰉋 nested ││󰵸 c.gif │ -05|│󰉋 real ││ LICENSE │ -06|│󰢱 init-default-explorer.lua ││󱁤 Makefile │ -07|│󰢱 mock-win-functions.lua ││󰈔 top-secret │ -08|└──────────────────────────────────────────────────┘└───────────────┘ -09|~ +03|│󰉋 lsp-files ││󰦪 b.txt │ +04|│󰉋 lua ││󰵸 c.gif │ +05|│󰉋 nested ││ LICENSE │ +06|│󰉋 real ││󱁤 Makefile │ +07|│󰢱 init-default-explorer.lua ││󰈔 top-secret │ +08|│󰢱 mock-win-functions.lua │└───────────────┘ +09|└──────────────────────────────────────────────────┘ 10|~ 11|~ 12|~ 13|~ 14|~ -15| 4,11-8 All +15| 5,11-8 All --|---------|---------|---------|---------|---------|---------|---------|---------| 01|01111111111111111111111111100000000000000000000000000222222000000000033333333333 02|04444444455555555555555555555555555555555555555555500667777777777777088888888888 -03|09944455555555555555555555555555555555555555555555500::5555555555555088888888888 -04|04444444455555555555555555555555555555555555555555500445555555555555088888888888 -05|06666667777777777777777777777777777777777777777777700;;5555555555555088888888888 -06|04455555555555555555555555555555555555555555555555500<<5555555555555088888888888 +03|04444444444455555555555555555555555555555555555555500995555555555555088888888888 +04|0::44455555555555555555555555555555555555555555555500445555555555555088888888888 +05|04444444455555555555555555555555555555555555555555500;;5555555555555088888888888 +06|06666667777777777777777777777777777777777777777777700<<5555555555555088888888888 07|04455555555555555555555555555555555555555555555555500<<5555555555555088888888888 -08|00000000000000000000000000000000000000000000000000000000000000000000088888888888 -09|88888888888888888888888888888888888888888888888888888888888888888888888888888888 +08|04455555555555555555555555555555555555555555555555500000000000000000088888888888 +09|00000000000000000000000000000000000000000000000000008888888888888888888888888888 10|88888888888888888888888888888888888888888888888888888888888888888888888888888888 11|88888888888888888888888888888888888888888888888888888888888888888888888888888888 12|88888888888888888888888888888888888888888888888888888888888888888888888888888888 diff --git a/tests/screenshots/tests-test_files.lua---open()---uses-icon-provider-003 b/tests/screenshots/tests-test_files.lua---open()---uses-icon-provider-003 index 5653981ed..675a8935b 100644 --- a/tests/screenshots/tests-test_files.lua---open()---uses-icon-provider-003 +++ b/tests/screenshots/tests-test_files.lua---open()---uses-icon-provider-003 @@ -1,30 +1,30 @@ --|---------|---------|---------|---------|---------|---------|---------|---------| 01|┌MOCK_ROOT/tests/dir-files ────────────────────────┐┌ real ─────────┐ 02|│ common ││ a.lua │ -03|│ lua ││ b.txt │ -04|│ nested ││ c.gif │ -05|│ real ││ LICENSE │ -06|│ init-default-explorer.lua ││ Makefile │ -07|│ mock-win-functions.lua ││ top-secret │ -08|└──────────────────────────────────────────────────┘└───────────────┘ -09|~ +03|│ lsp-files ││ b.txt │ +04|│ lua ││ c.gif │ +05|│ nested ││ LICENSE │ +06|│ real ││ Makefile │ +07|│ init-default-explorer.lua ││ top-secret │ +08|│ mock-win-functions.lua │└───────────────┘ +09|└──────────────────────────────────────────────────┘ 10|~ 11|~ 12|~ 13|~ 14|~ -15| 4,10-8 All +15| 5,10-8 All --|---------|---------|---------|---------|---------|---------|---------|---------| 01|01111111111111111111111111100000000000000000000000000222222000000000033333333333 02|04444444455555555555555555555555555555555555555555500666666666666666077777777777 -03|04444455555555555555555555555555555555555555555555500555555555555555077777777777 -04|04444444455555555555555555555555555555555555555555500555555555555555077777777777 -05|08888886666666666666666666666666666666666666666666600555555555555555077777777777 -06|05555555555555555555555555555555555555555555555555500555555555555555077777777777 +03|04444444444455555555555555555555555555555555555555500555555555555555077777777777 +04|04444455555555555555555555555555555555555555555555500555555555555555077777777777 +05|04444444455555555555555555555555555555555555555555500555555555555555077777777777 +06|08888886666666666666666666666666666666666666666666600555555555555555077777777777 07|05555555555555555555555555555555555555555555555555500555555555555555077777777777 -08|00000000000000000000000000000000000000000000000000000000000000000000077777777777 -09|77777777777777777777777777777777777777777777777777777777777777777777777777777777 +08|05555555555555555555555555555555555555555555555555500000000000000000077777777777 +09|00000000000000000000000000000000000000000000000000007777777777777777777777777777 10|77777777777777777777777777777777777777777777777777777777777777777777777777777777 11|77777777777777777777777777777777777777777777777777777777777777777777777777777777 12|77777777777777777777777777777777777777777777777777777777777777777777777777777777 diff --git a/tests/test_files.lua b/tests/test_files.lua index 6ffde4902..a977ffeb0 100644 --- a/tests/test_files.lua +++ b/tests/test_files.lua @@ -154,7 +154,7 @@ local mock_stdpath_data = function() local data_dir = make_test_path('data') local lua_cmd = string.format( [[ - _G.stdpath_orig = vim.fn.stpath + _G.stdpath_orig = vim.fn.stdpath vim.fn.stdpath = function(what) if what == 'data' then return %s end return _G.stdpath_orig(what) @@ -263,6 +263,7 @@ T['setup()']['creates `config` field'] = function() expect_config('options.use_as_default_explorer', true) expect_config('options.permanent_delete', true) + expect_config('options.lsp_timeout', 1000) expect_config('windows.max_number', math.huge) expect_config('windows.preview', false) @@ -307,6 +308,7 @@ T['setup()']['validates `config` argument'] = function() expect_config_error({ options = 'a' }, 'options', 'table') expect_config_error({ options = { use_as_default_explorer = 1 } }, 'options.use_as_default_explorer', 'boolean') expect_config_error({ options = { permanent_delete = 1 } }, 'options.permanent_delete', 'boolean') + expect_config_error({ options = { lsp_timeout = 'a' } }, 'options.lsp_timeout', 'number') expect_config_error({ windows = 'a' }, 'windows', 'table') expect_config_error({ windows = { max_number = 'a' } }, 'windows.max_number', 'number') @@ -404,6 +406,7 @@ T['open()']['uses icon provider'] = function() go_out() --stylua: ignore eq(get_extmarks_hl(), { + 'MiniIconsAzure', 'MiniFilesDirectory', 'MiniIconsAzure', 'MiniFilesDirectory', -- 'lua' directory has special highlighting 'MiniIconsBlue', 'MiniFilesDirectory', @@ -5887,6 +5890,301 @@ T['Events']['`MiniFilesActionMove` triggers'] = function() validate('dir/', true) end +T['LSP'] = new_set({ + hooks = { + pre_case = function() + if child.fn.has('nvim-0.11') == 0 then MiniTest.skip('LSP integration requires Neovim>=0.11') end + end, + }, +}) + +local setup_lsp = function(skip_file_open) + -- Set up file + if not skip_file_open then + local file_path = make_test_path('lsp-files', 'main.lua') + child.cmd('edit ' .. file_path) + end + + -- Mock server + child.cmd('luafile tests/mock-lsp/file-ops.lua') +end + +local validate_lsp_will = function(method, files, lines) + eq(child.lua_get('_G.lsp_requests["file-methods-lsp"]'), { { method, { files = files } } }) + eq(get_lines(), lines) + child.lua('_G.lsp_requests = {}') +end + +T['LSP']['works with `willCreateFiles`'] = function() + child.lua([[ + local edit_range = { start = { line = 0, character = 0 }, ['end'] = { line = 0, character = 0 } } + local path = vim.fn.fnamemodify('tests/dir-files/lsp-files/main.lua', ':p') + local uri = vim.uri_from_fname(path) + _G.workspace_edit_response = { changes = { [uri] = { { range = edit_range, newText = '-- willCreate\n' } } } } + ]]) + setup_lsp() + local temp_dir = make_temp_dir('temp', {}) + + open(temp_dir) + local file_name = 'something.lua' + type_keys('C', file_name, '') + mock_confirm(1) + synchronize() + close() + local uri = vim.uri_from_fname(make_test_path('temp', file_name)) + validate_lsp_will('workspace/willCreateFiles', { { uri = uri } }, { '-- willCreate', "require('something')" }) +end + +T['LSP']['works with `willRenameFiles`'] = function() + child.lua([[ + local edit_range = { start = { line = 0, character = 9 }, ['end'] = { line = 0, character = 18 } } + local path = vim.fn.fnamemodify('tests/dir-files/lsp-files/main.lua', ':p') + local uri = vim.uri_from_fname(path) + _G.workspace_edit_response = { changes = { [uri] = { { range = edit_range, newText = 'something_else' } } } } + ]]) + setup_lsp() + local file_name = 'something.lua' + local temp_dir = make_temp_dir('temp', { file_name }) + + open(temp_dir) + type_keys('C', 'something_else.lua', '') + mock_confirm(1) + synchronize() + close() + local old_uri = vim.uri_from_fname(make_test_path('temp', file_name)) + local new_uri = vim.uri_from_fname(make_test_path('temp', 'something_else.lua')) + local ref_files = { { oldUri = old_uri, newUri = new_uri } } + validate_lsp_will('workspace/willRenameFiles', ref_files, { "require('something_else')" }) +end + +T['LSP']['works with `willDeleteFiles`'] = function() + child.lua([[ + local edit_range = { start = { line = 0, character = 0 }, ['end'] = { line = 0, character = 0 } } + local path = vim.fn.fnamemodify('tests/dir-files/lsp-files/main.lua', ':p') + local uri = vim.uri_from_fname(path) + _G.workspace_edit_response = { changes = { [uri] = { { range = edit_range, newText = '-- willDelete\n' } } } } + ]]) + setup_lsp() + local file_name = 'something.lua' + local temp_dir = make_temp_dir('temp', { file_name }) + + open(temp_dir) + type_keys('dd') + mock_confirm(1) + synchronize() + close() + local uri = vim.uri_from_fname(make_test_path('temp', file_name)) + validate_lsp_will('workspace/willDeleteFiles', { { uri = uri } }, { '-- willDelete', "require('something')" }) +end + +local validate_lsp_did = function(method, files, lines) + eq(child.lua_get('_G.lsp_notifications["file-methods-lsp"]'), { { method, { files = files } } }) + if lines ~= nil then eq(get_lines(), lines) end + child.lua('_G.lsp_notifications = {}') +end + +T['LSP']['works with `didCreateFiles`'] = function() + setup_lsp() + local temp_dir = make_temp_dir('temp', {}) + + open(temp_dir) + local file_name = 'something.lua' + type_keys('C', file_name, '') + mock_confirm(1) + synchronize() + close() + + local uri = vim.uri_from_fname(make_test_path('temp', file_name)) + validate_lsp_did('workspace/didCreateFiles', { { uri = uri } }) +end + +T['LSP']['works with `didRenameFiles`'] = function() + setup_lsp() + local file_name = 'something.lua' + local temp_dir = make_temp_dir('temp', { file_name }) + + open(temp_dir) + type_keys('C', 'something_else.lua', '') + mock_confirm(1) + synchronize() + close() + + local old_uri = vim.uri_from_fname(make_test_path('temp', file_name)) + local new_uri = vim.uri_from_fname(make_test_path('temp', 'something_else.lua')) + validate_lsp_did('workspace/didRenameFiles', { { oldUri = old_uri, newUri = new_uri } }) +end + +T['LSP']['works with `didRenameFiles` that applies workspace edit'] = function() + child.lua([[ + _G.did_callback = function(_, dispatchers) + local path = vim.fn.fnamemodify('tests/dir-files/lsp-files/main.lua', ':p') + local uri = vim.uri_from_fname(path) + local edit_range = { start = { line = 0, character = 9 }, ['end'] = { line = 0, character = 18 } } + local text_edit = { range = edit_range, newText = 'something_else' } + dispatchers.server_request('workspace/applyEdit', { edit = { changes = { [uri] = { text_edit } } } }) + end + ]]) + setup_lsp() + local file_name = 'something.lua' + local temp_dir = make_temp_dir('temp', { file_name }) + + open(temp_dir) + type_keys('C', 'something_else.lua', '') + mock_confirm(1) + synchronize() + close() + + local old_uri = vim.uri_from_fname(make_test_path('temp', file_name)) + local new_uri = vim.uri_from_fname(make_test_path('temp', 'something_else.lua')) + local ref_files = { { oldUri = old_uri, newUri = new_uri } } + validate_lsp_did('workspace/didRenameFiles', ref_files, { "require('something_else')" }) +end + +T['LSP']['works with `didRenameFiles` that applies workspace edit after confirmation'] = function() + child.lua([[ + _G.did_callback = function(_, dispatchers) + local msg = 'Do you want to modify the require path?' + local show_msg_params = { type = 'info', message = msg, actions = { { title = 'Confirm' } } } + local selected = dispatchers.server_request('window/showMessageRequest', show_msg_params) + if selected.title ~= 'Confirm' then return end + + local path = 'tests/dir-files/lsp-files/main.lua' + local uri = vim.uri_from_fname(vim.fn.fnamemodify(path, ':p')) + local edit_range = { start = { line = 0, character = 9 }, ['end'] = { line = 0, character = 18 } } + local text_edit = { range = edit_range, newText = 'something_else' } + dispatchers.server_request('workspace/applyEdit', { edit = { changes = { [uri] = { text_edit } } } }) + end + ]]) + setup_lsp() + local file_name = 'something.lua' + local temp_dir = make_temp_dir('temp', { file_name }) + + open(temp_dir) + type_keys('C', 'something_else.lua', '') + mock_confirm(1) + -- NOTE: Neovim's implementation of 'window/showMessageRequest' seems to use + -- `vim.fn.inputlist` directly here instead of `vim.ui.select` + child.lua('vim.fn.inputlist = function() return 1 end') + synchronize() + close() + + local old_uri = vim.uri_from_fname(make_test_path('temp', file_name)) + local new_uri = vim.uri_from_fname(make_test_path('temp', 'something_else.lua')) + local ref_files = { { oldUri = old_uri, newUri = new_uri } } + validate_lsp_did('workspace/didRenameFiles', ref_files, { "require('something_else')" }) +end + +T['LSP']['works with `didDeleteFiles`'] = function() + setup_lsp() + local file_name = 'something.lua' + local temp_dir = make_temp_dir('temp', { file_name }) + + open(temp_dir) + type_keys('dd') + mock_confirm(1) + synchronize() + close() + + local uri = vim.uri_from_fname(make_test_path('temp', file_name)) + validate_lsp_did('workspace/didDeleteFiles', { { uri = uri } }) +end + +T['LSP']['works with filters'] = function() + child.lua([[ + _G.filter_configs = { + filters = { + { pattern = { matches = 'file', glob = '**/*.lua' }, scheme = 'file' }, + { pattern = { matches = 'folder', glob = '**' }, scheme = 'file' }, + { pattern = { matches = 'file', glob = '**/{aaa,bbb}' }, scheme = 'file' }, + { pattern = { matches = 'file', glob = '**/C' }, scheme = 'file' }, + { pattern = { matches = 'file', glob = '**/D', options = { ignoreCase = true } }, scheme = 'file' }, + }, + } + ]]) + setup_lsp() + local temp_dir = make_temp_dir('temp', {}) + + local validate = function(file_name, should_match) + open(temp_dir) + type_keys('o', file_name, '') + mock_confirm(1) + synchronize() + close() + + local new_file_path = make_test_path('temp', file_name) + local new_file_uri = vim.uri_from_fname(new_file_path) + if file_name:match('/$') then new_file_uri = new_file_uri .. '/' end + local files = should_match and { { uri = new_file_uri } } or {} + + eq(child.lua_get('_G.lsp_requests["file-methods-lsp"]'), { { 'workspace/willCreateFiles', { files = files } } }) + child.lua('_G.lsp_requests = {}') + end + + validate('something.lua', true) + validate('something.py', false) + validate('some_dir/', true) + validate('aaa', true) + validate('bbb', true) + validate('c', false) + validate('d', true) + + if child.fn.has('fname_case') == 1 then + validate('BBB', false) + validate('D', true) + end +end + +T['LSP']['works with multiple language servers'] = function() + setup_lsp() + + child.lua([[ + _G.server_name = 'only-will-rename-lsp' + _G.file_operations_config = { willRename = { filters = { { pattern = { glob = '**' } } } } } + ]]) + setup_lsp(true) + + child.lua([[ + _G.server_name = 'only-did-rename-lsp' + _G.file_operations_config = { didRename = { filters = { { pattern = { glob = '**' } } } } } + ]]) + setup_lsp(true) + local file_name = 'something.lua' + local temp_dir = make_temp_dir('temp', { file_name }) + + open(temp_dir) + type_keys('C', 'something_else.lua', '') + mock_confirm(1) + synchronize() + close() + + local old_uri = vim.uri_from_fname(make_test_path('temp', file_name)) + local new_uri = vim.uri_from_fname(make_test_path('temp', 'something_else.lua')) + local params = { files = { { oldUri = old_uri, newUri = new_uri } } } + + eq(child.lua_get('_G.lsp_requests["file-methods-lsp"]'), { { 'workspace/willRenameFiles', params } }) + eq(child.lua_get('_G.lsp_notifications["file-methods-lsp"]'), { { 'workspace/didRenameFiles', params } }) + + eq(child.lua_get('_G.lsp_requests["only-will-rename-lsp"]'), { { 'workspace/willRenameFiles', params } }) + eq(child.lua_get('_G.lsp_notifications["only-will-rename-lsp"]'), vim.NIL) + + eq(child.lua_get('_G.lsp_requests["only-did-rename-lsp"]'), vim.NIL) + eq(child.lua_get('_G.lsp_notifications["only-did-rename-lsp"]'), { { 'workspace/didRenameFiles', params } }) +end + +T['LSP']['respects `options.lsp_timeout`'] = function() + child.lua('MiniFiles.config.options.lsp_timeout = 0') + setup_lsp() + local temp_dir = make_temp_dir('temp', {}) + + open(temp_dir) + local file_name = 'something.lua' + type_keys('C', file_name, '') + mock_confirm(1) + synchronize() + close() + eq(child.lua_get('_G.lsp_requests'), {}) +end + T['Default explorer'] = new_set() T['Default explorer']['works on startup'] = function()