├── LICENSE ├── README.md ├── doc └── zond.txt └── lua └── lsp-notify └── init.lua /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Dmitry Demenchuk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nvim-lsp-notify 2 | 3 | NVIM plugin to notify about LSP processes 4 | 5 | ### Motivation 6 | 7 | The motivation was to address the uncertainty that can sometimes accompany using LSP. 8 | I wanted to create a solution that would provide better visibility into the LSP's processes. 9 | 10 | ### Examples 11 | 12 | ![null-ls and lua-ls](https://user-images.githubusercontent.com/44075969/226129296-a7997008-9163-4b42-9b91-04d2816620f7.gif) 13 | ![null-ls and rust-analyzer](https://user-images.githubusercontent.com/44075969/226129502-ff6a14b9-42ba-45ec-94e4-45ac900c23f6.gif) 14 | 15 | ### Optional dependencies 16 | 17 | - [nvim-notify](https://github.com/rcarriga/nvim-notify) 18 | 19 | ### Installation 20 | 21 | Using [packer.nvim](https://github.com/wbthomason/packer.nvim) 22 | 23 | Basic setup will use `vim.notify()` for notifications: 24 | ```lua 25 | use { 26 | 'mrded/nvim-lsp-notify', 27 | config = function() 28 | require('lsp-notify').setup({}) 29 | end 30 | } 31 | ``` 32 | 33 | You can pass `notify` function, for example from [nvim-notify](https://github.com/rcarriga/nvim-notify): 34 | ```lua 35 | use { 36 | 'mrded/nvim-lsp-notify', 37 | requires = { 'rcarriga/nvim-notify' }, 38 | config = function() 39 | require('lsp-notify').setup({ 40 | notify = require('notify'), 41 | }) 42 | end 43 | } 44 | ``` 45 | 46 | Or `icons` to customize icons: 47 | ```lua 48 | use { 49 | 'mrded/nvim-lsp-notify', 50 | requires = { 'rcarriga/nvim-notify' }, 51 | config = function() 52 | require('lsp-notify').setup({ 53 | icons = { 54 | spinner = { '|', '/', '-', '\\' }, -- `= false` to disable only this icon 55 | done = '!' -- `= false` to disable only this icon 56 | } 57 | }) 58 | end 59 | } 60 | ``` 61 | 62 | Or `icons = false` to disable them completely: 63 | ```lua 64 | use { 65 | 'mrded/nvim-lsp-notify', 66 | requires = { 'rcarriga/nvim-notify' }, 67 | config = function() 68 | require('lsp-notify').setup({ 69 | icons = false 70 | }) 71 | end 72 | } 73 | ``` 74 | 75 | ### Credits 76 | 77 | I am deeply grateful to the creators of [nvim-notify](https://github.com/rcarriga/nvim-notify) for their invaluable contributions. 78 | Their work, specifically the implementation of LSP notifications in their [usage recipes](https://github.com/rcarriga/nvim-notify/wiki/Usage-Recipes/#progress-updates), served as the foundation for this project, which has been developed into a convenient, standalone module. 79 | -------------------------------------------------------------------------------- /doc/zond.txt: -------------------------------------------------------------------------------- 1 | ================================================================================ 2 | INTRODUCTION *lsp-notify* 3 | 4 | lsp-notify is a plugin designed to keep you informed of the progress of your LSP 5 | (Language Server Protocol) process, providing realtime notifications and updates 6 | 7 | setup({opts}) *lsp-notify.setup()* 8 | Configure nvim-lsp-notify 9 | 10 | Parameters: ~ 11 | {opts} (table) options to pass to the function 12 | 13 | Options: ~ 14 | {notify} (function) function to show the notification 15 | (default: 'vim.notify') 16 | {excludes} (array) Exclude by client name. 17 | {icons} (table|false) icons to display or 'false' to 18 | disable 19 | (default: { 20 | spinner = { 21 | "⣾", 22 | "⣽", 23 | "⣻", 24 | "⢿", 25 | "⡿", 26 | "⣟", 27 | "⣯", 28 | "⣷" 29 | }, 30 | done = "󰗡" 31 | }) 32 | -------------------------------------------------------------------------------- /lua/lsp-notify/init.lua: -------------------------------------------------------------------------------- 1 | --- Options for the plugin. 2 | ---@class LspNotifyConfig 3 | local options = { 4 | --- Function to be used for notifies. 5 | --- Best works if `vim.notify` is already overwritten by `require('notify'). 6 | --- If no, you can manually pass `= require('notify')` here. 7 | notify = vim.notify, 8 | 9 | --- Exclude by client name. 10 | excludes = {}, 11 | 12 | --- Icons. 13 | --- Can be set to `= false` to disable. 14 | ---@type {spinner: string[] | false, done: string | false} | false 15 | icons = { 16 | --- Spinner animation frames. 17 | --- Can be set to `= false` to disable only spinner. 18 | spinner = { "⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷" }, 19 | --- Icon to show when done. 20 | --- Can be set to `= false` to disable only spinner. 21 | done = "✓" 22 | } 23 | } 24 | 25 | --- Whether current notification system supports replacing notifications. 26 | --- Will be `true` if `nvim-notify` handles notifications, `false` if `cmdline`. 27 | local supports_replace = false 28 | 29 | --- Check if current notification system supports replacing notifications. 30 | ---@return boolean suppors 31 | local function check_supports_replace() 32 | local n = options.notify( 33 | "lsp notify: test replace support", 34 | vim.log.levels.DEBUG, 35 | { 36 | hide_from_history = true, 37 | on_open = function(window) 38 | -- If window is hidden, `nvim-notify` prints errors 39 | -- This shrinks notifications and puts it in a corner where it will not be seen 40 | vim.api.nvim_win_set_buf(window, vim.api.nvim_create_buf(false, true)) 41 | vim.api.nvim_win_set_config( 42 | window, { 43 | width = 1, height = 1, 44 | border = "none", 45 | relative = "editor", 46 | row = 0, 47 | col = 0 48 | } 49 | ) 50 | end, 51 | timeout = 1, 52 | animate = false 53 | } 54 | ) 55 | local supports = pcall(options.notify, "lsp notify: test replace support", vim.log.levels.DEBUG, { replace = n }) 56 | return supports 57 | end 58 | 59 | 60 | 61 | --#region Task 62 | 63 | ---@class BaseLspTask 64 | local BaseLspTask = { 65 | ---@type string? 66 | title = "", 67 | ---@type string? 68 | message = "", 69 | ---@type number? 70 | percentage = nil 71 | } 72 | 73 | ---@param title string 74 | ---@param message string 75 | ---@return BaseLspTask 76 | function BaseLspTask.new(title, message) 77 | local self = vim.deepcopy(BaseLspTask) 78 | self.title = title 79 | self.message = message 80 | return self 81 | end 82 | 83 | function BaseLspTask:format() 84 | return ( 85 | (" ") 86 | .. (string.format( 87 | "%-8s", 88 | self.percentage and self.percentage .. "%" or "" 89 | )) 90 | .. (self.title or "") 91 | .. (self.title and self.message and " - " or "") 92 | .. (self.message or "") 93 | ) 94 | end 95 | 96 | --#endregion 97 | 98 | --#region Client 99 | 100 | ---@class BaseLspClient 101 | local BaseLspClient = { 102 | name = "", 103 | ---@type {any: BaseLspTask} 104 | tasks = {} 105 | } 106 | 107 | ---@param name string 108 | ---@return BaseLspClient 109 | function BaseLspClient.new(name) 110 | local self = vim.deepcopy(BaseLspClient) 111 | self.name = name 112 | return self 113 | end 114 | 115 | function BaseLspClient:count_tasks() 116 | local count = 0 117 | for _ in pairs(self.tasks) do 118 | count = count + 1 119 | end 120 | return count 121 | end 122 | 123 | function BaseLspClient:kill_task(task_id) 124 | self.tasks[task_id] = nil 125 | end 126 | 127 | function BaseLspClient:format() 128 | local tasks = "" 129 | for _, t in pairs(self.tasks) do 130 | tasks = tasks .. t:format() .. "\n" 131 | end 132 | 133 | return ( 134 | (self.name) 135 | .. ("\n") 136 | .. (tasks ~= "" and tasks:sub(1, -2) or " Complete") 137 | ) 138 | end 139 | 140 | --#endregion 141 | 142 | --#region Notification 143 | 144 | ---@class BaseLspNotification 145 | local BaseLspNotification = { 146 | spinner = 1, 147 | ---@type {integer: BaseLspClient} 148 | clients = {}, 149 | notification = nil, 150 | window = nil 151 | } 152 | 153 | ---@return BaseLspNotification 154 | function BaseLspNotification:new() 155 | return vim.deepcopy(BaseLspNotification) 156 | end 157 | 158 | function BaseLspNotification:count_clients() 159 | local count = 0 160 | for _ in pairs(self.clients) do 161 | count = count + 1 162 | end 163 | return count 164 | end 165 | 166 | function BaseLspNotification:notification_start() 167 | self.notification = options.notify( 168 | "", 169 | vim.log.levels.INFO, 170 | { 171 | title = self:format_title(), 172 | icon = (options.icons and options.icons.spinner and options.icons.spinner[1]) or nil, 173 | timeout = false, 174 | hide_from_history = false, 175 | on_open = function(window) 176 | self.window = window 177 | end 178 | } 179 | ) 180 | if not supports_replace then 181 | -- `options.notify` will not assign `self.notification` if can't be replaced, 182 | -- so do it manually here 183 | self.notification = true 184 | end 185 | end 186 | 187 | function BaseLspNotification:notification_progress() 188 | local message = self:format() 189 | local message_lines = select(2, message:gsub('\n', '\n')) 190 | 191 | if supports_replace then 192 | -- Can reuse same notification 193 | self.notification = options.notify( 194 | message, 195 | vim.log.levels.INFO, 196 | { 197 | replace = self.notification, 198 | hide_from_history = false, 199 | } 200 | ) 201 | if self.window then 202 | -- Update height because `nvim-notify` notifications don't do it automatically 203 | -- Can cover other notifications 204 | vim.api.nvim_win_set_height( 205 | self.window, 206 | 3 + message_lines 207 | ) 208 | end 209 | 210 | else 211 | -- Can't reuse same notification 212 | -- Print it line-by-line to not trigger "Press ENTER or type command to continue" 213 | for line in message:gmatch("[^\r\n]+") do 214 | options.notify( 215 | line, 216 | vim.log.levels.INFO 217 | ) 218 | end 219 | end 220 | end 221 | 222 | function BaseLspNotification:notification_end() 223 | options.notify( 224 | self:format(), 225 | vim.log.levels.INFO, 226 | { 227 | replace = self.notification, 228 | icon = options.icons and options.icons.done or nil, 229 | timeout = 1000 230 | } 231 | ) 232 | if self.window then 233 | -- Set the height back to the smallest notification size 234 | vim.api.nvim_win_set_height( 235 | self.window, 236 | 3 237 | ) 238 | end 239 | 240 | -- Clean up and reset 241 | self.notification = nil 242 | self.spinner = nil 243 | self.window = nil 244 | end 245 | 246 | function BaseLspNotification:update() 247 | if not self.notification then 248 | self:notification_start() 249 | self.spinner = 1 250 | self:spinner_start() 251 | elseif self:count_clients() > 0 then 252 | self:notification_progress() 253 | elseif self:count_clients() == 0 then 254 | self:notification_end() 255 | end 256 | end 257 | 258 | function BaseLspNotification:schedule_kill_task(client_id, task_id) 259 | -- Wait a bit before hiding the task to show that it's complete 260 | vim.defer_fn(function() 261 | local client = self.clients[client_id] 262 | client:kill_task(task_id) 263 | self:update() 264 | 265 | if client:count_tasks() == 0 then 266 | -- Wait a bit before hiding the client to show that its' tasks are complete 267 | vim.defer_fn(function() 268 | if client:count_tasks() == 0 then 269 | -- Make sure we don't hide a client notification if a task appeared in down time 270 | self.clients[client_id] = nil 271 | self:update() 272 | end 273 | 274 | end, 2000) 275 | end 276 | 277 | end, 1000) 278 | end 279 | 280 | function BaseLspNotification:format_title() 281 | return "LSP" 282 | end 283 | 284 | function BaseLspNotification:format() 285 | local clients = "" 286 | for _, c in pairs(self.clients) do 287 | clients = clients .. c:format() .. "\n" 288 | end 289 | 290 | return clients ~= "" and clients:sub(1, -2) or "Complete" 291 | end 292 | 293 | function BaseLspNotification:spinner_start() 294 | if self.spinner and options.icons and options.icons.spinner then 295 | self.spinner = (self.spinner % #options.icons.spinner) + 1 296 | 297 | if supports_replace then 298 | -- Don't spam spinner updates if notification can't be replaced 299 | self.notification = options.notify( 300 | nil, 301 | nil, 302 | { 303 | hide_from_history = true, 304 | icon = options.icons.spinner[self.spinner], 305 | replace = self.notification, 306 | } 307 | ) 308 | end 309 | 310 | -- Trigger new spinner frame 311 | vim.defer_fn(function() 312 | self:spinner_start() 313 | end, 100) 314 | end 315 | end 316 | 317 | --#endregion 318 | 319 | 320 | 321 | ---#region Handlers 322 | 323 | local function has_value (tab, val) 324 | for _, value in ipairs(tab) do 325 | if value == val then 326 | return true 327 | end 328 | end 329 | 330 | return false 331 | end 332 | 333 | local notification = BaseLspNotification:new() 334 | 335 | local function handle_progress(_, result, context) 336 | local value = result.value 337 | 338 | local client_id = context.client_id 339 | local client_name = vim.lsp.get_client_by_id(client_id).name 340 | 341 | if has_value(options.excludes,client_name) then 342 | return 343 | end 344 | 345 | -- Get client info from notification or generate it 346 | notification.clients[client_id] = 347 | notification.clients[client_id] 348 | or BaseLspClient.new(client_name) 349 | local client = notification.clients[client_id] 350 | 351 | local task_id = result.token 352 | -- Get task info from notification or generate it 353 | client.tasks[task_id] = 354 | client.tasks[task_id] 355 | or BaseLspTask.new(value.title, value.message) 356 | local task = client.tasks[task_id] 357 | 358 | if value.kind == "report" then 359 | -- Task update 360 | task.message = value.message 361 | task.percentage = value.percentage 362 | elseif value.kind == "end" then 363 | -- Task end 364 | task.message = value.message or "Complete" 365 | notification:schedule_kill_task(client_id, task_id) 366 | end 367 | 368 | -- Redraw notification 369 | notification:update() 370 | end 371 | 372 | local function handle_message(err, method, params, client_id) 373 | -- Table from LSP severity to VIM severity. 374 | local severity = { 375 | vim.log.levels.ERROR, 376 | vim.log.levels.WARN, 377 | vim.log.levels.INFO, 378 | vim.log.levels.INFO, -- Map both `hint` and `info` to `info` 379 | } 380 | options.notify(method.message, severity[params.type], { title = "LSP" }) 381 | end 382 | 383 | --#endregion 384 | 385 | 386 | 387 | --#region Setup 388 | 389 | local function init() 390 | if vim.lsp.handlers["$/progress"] then 391 | -- There was already a handler, execute it too 392 | local old = vim.lsp.handlers["$/progress"] 393 | vim.lsp.handlers["$/progress"] = function(...) 394 | old(...) 395 | handle_progress(...) 396 | end 397 | else 398 | vim.lsp.handlers["$/progress"] = handle_progress 399 | end 400 | 401 | if vim.lsp.handlers["window/showMessage"] then 402 | -- There was already a handler, execute it too 403 | local old = vim.lsp.handlers["window/showMessage"] 404 | vim.lsp.handlers["window/showMessage"] = function(...) 405 | old(...) 406 | handle_message(...) 407 | end 408 | else 409 | vim.lsp.handlers["window/showMessage"] = handle_message 410 | end 411 | end 412 | 413 | 414 | 415 | 416 | return { 417 | ---@param opts LspNotifyConfig? Configuration. 418 | setup = function(opts) 419 | options = vim.tbl_deep_extend("force", options, opts or {}) 420 | supports_replace = check_supports_replace() 421 | 422 | init() 423 | end 424 | } 425 | 426 | --#endregion 427 | --------------------------------------------------------------------------------