snippet.lua (22176B)
1 local G = vim.lsp._snippet_grammar 2 local snippet_group = vim.api.nvim_create_augroup('nvim.snippet', {}) 3 local snippet_ns = vim.api.nvim_create_namespace('nvim.snippet') 4 local hl_group = 'SnippetTabstop' 5 local hl_group_active = 'SnippetTabstopActive' 6 7 --- Returns the 0-based cursor position. 8 --- 9 --- @return integer, integer 10 local function cursor_pos() 11 local cursor = vim.api.nvim_win_get_cursor(0) 12 return cursor[1] - 1, cursor[2] 13 end 14 15 --- Resolves variables (like `$name` or `${name:default}`) as follows: 16 --- - When a variable is unknown (i.e.: its name is not recognized in any of the cases below), return `nil`. 17 --- - When a variable isn't set, return its default (if any) or an empty string. 18 --- 19 --- Note that in some cases, the default is ignored since it's not clear how to distinguish an empty 20 --- value from an unset value (e.g.: `TM_CURRENT_LINE`). 21 --- 22 --- @param var string 23 --- @param default string 24 --- @return string? 25 local function resolve_variable(var, default) 26 --- @param str string 27 --- @return string 28 local function expand_or_default(str) 29 local expansion = vim.fn.expand(str) --[[@as string]] 30 return expansion == '' and default or expansion 31 end 32 33 if var == 'TM_SELECTED_TEXT' then 34 -- Snippets are expanded in insert mode only, so there's no selection. 35 return default 36 elseif var == 'TM_CURRENT_LINE' then 37 return vim.api.nvim_get_current_line() 38 elseif var == 'TM_CURRENT_WORD' then 39 return expand_or_default('<cword>') 40 elseif var == 'TM_LINE_INDEX' then 41 return tostring(vim.fn.line('.') - 1) 42 elseif var == 'TM_LINE_NUMBER' then 43 return tostring(vim.fn.line('.')) 44 elseif var == 'TM_FILENAME' then 45 return expand_or_default('%:t') 46 elseif var == 'TM_FILENAME_BASE' then 47 return expand_or_default('%:t:r') 48 elseif var == 'TM_DIRECTORY' then 49 return expand_or_default('%:p:h:t') 50 elseif var == 'TM_FILEPATH' then 51 return expand_or_default('%:p') 52 end 53 54 -- Unknown variable. 55 return nil 56 end 57 58 --- Transforms the given text into an array of lines (so no line contains `\n`). 59 --- 60 --- @param text string|string[] 61 --- @return string[] 62 local function text_to_lines(text) 63 text = type(text) == 'string' and { text } or text 64 --- @cast text string[] 65 return vim.split(table.concat(text), '\n', { plain = true }) 66 end 67 68 --- Computes the 0-based position of a tabstop located at the end of `snippet` and spanning 69 --- `placeholder` (if given). 70 --- 71 --- @param snippet string[] 72 --- @param placeholder string? 73 --- @return Range4 74 local function compute_tabstop_range(snippet, placeholder) 75 local cursor_row, cursor_col = cursor_pos() 76 local snippet_text = text_to_lines(snippet) 77 local placeholder_text = text_to_lines(placeholder or '') 78 local start_row = cursor_row + #snippet_text - 1 79 local start_col = #(snippet_text[#snippet_text] or '') 80 81 -- Add the cursor's column offset to the first line. 82 if start_row == cursor_row then 83 start_col = start_col + cursor_col 84 end 85 86 local end_row = start_row + #placeholder_text - 1 87 local end_col = (start_row == end_row and start_col or 0) 88 + #(placeholder_text[#placeholder_text] or '') 89 90 return { start_row, start_col, end_row, end_col } 91 end 92 93 --- Returns the range spanned by the respective extmark. 94 --- 95 --- @param bufnr integer 96 --- @param extmark_id integer 97 --- @return Range4 98 local function get_extmark_range(bufnr, extmark_id) 99 local mark = vim.api.nvim_buf_get_extmark_by_id(bufnr, snippet_ns, extmark_id, { details = true }) 100 101 --- @diagnostic disable-next-line: undefined-field 102 return { mark[1], mark[2], mark[3].end_row, mark[3].end_col } 103 end 104 105 --- @class (private) vim.snippet.Tabstop 106 --- @field extmark_id integer 107 --- @field bufnr integer 108 --- @field index integer 109 --- @field placement integer 110 --- @field choices? string[] 111 local Tabstop = {} 112 113 --- Creates a new tabstop. 114 --- 115 --- @package 116 --- @param index integer 117 --- @param bufnr integer 118 --- @param placement integer 119 --- @param range Range4 120 --- @param choices? string[] 121 --- @return vim.snippet.Tabstop 122 function Tabstop.new(index, bufnr, placement, range, choices) 123 local extmark_id = vim.api.nvim_buf_set_extmark(bufnr, snippet_ns, range[1], range[2], { 124 right_gravity = true, 125 end_right_gravity = false, 126 end_line = range[3], 127 end_col = range[4], 128 hl_group = index == 1 and hl_group_active or hl_group, 129 }) 130 131 local self = setmetatable({ 132 extmark_id = extmark_id, 133 bufnr = bufnr, 134 index = index, 135 placement = placement, 136 choices = choices, 137 }, { __index = Tabstop }) 138 139 return self 140 end 141 142 --- Returns the tabstop's range. 143 --- 144 --- @package 145 --- @return Range4 146 function Tabstop:get_range() 147 return get_extmark_range(self.bufnr, self.extmark_id) 148 end 149 150 --- Returns the text spanned by the tabstop. 151 --- 152 --- @package 153 --- @return string 154 function Tabstop:get_text() 155 local range = self:get_range() 156 return table.concat( 157 vim.api.nvim_buf_get_text(self.bufnr, range[1], range[2], range[3], range[4], {}), 158 '\n' 159 ) 160 end 161 162 --- Sets the tabstop's text. 163 --- 164 --- @package 165 --- @param text string 166 function Tabstop:set_text(text) 167 local range = self:get_range() 168 vim.api.nvim_buf_set_text(self.bufnr, range[1], range[2], range[3], range[4], text_to_lines(text)) 169 end 170 171 ---@alias (private) vim.snippet.TabStopGravity 172 --- | "expand" Expand the current tabstop on text insert 173 --- | "lock" The tabstop should NOT move on text insert 174 --- | "shift" The tabstop should move on text insert (default) 175 176 --- Sets the right gravity of the tabstop's extmark. 177 --- Sets the active highlight group for current ("expand") tabstops 178 --- 179 ---@package 180 ---@param target vim.snippet.TabStopGravity 181 function Tabstop:set_gravity(target) 182 local hl = hl_group 183 local right_gravity = true 184 local end_right_gravity = true 185 186 if target == 'expand' then 187 hl = hl_group_active 188 right_gravity = false 189 end_right_gravity = true 190 elseif target == 'lock' then 191 right_gravity = false 192 end_right_gravity = false 193 end 194 195 local range = self:get_range() 196 vim.api.nvim_buf_del_extmark(self.bufnr, snippet_ns, self.extmark_id) 197 self.extmark_id = vim.api.nvim_buf_set_extmark(self.bufnr, snippet_ns, range[1], range[2], { 198 right_gravity = right_gravity, 199 end_right_gravity = end_right_gravity, 200 end_line = range[3], 201 end_col = range[4], 202 hl_group = hl, 203 }) 204 end 205 206 --- @class (private) vim.snippet.Session 207 --- @field bufnr integer 208 --- @field extmark_id integer 209 --- @field tabstops table<integer, vim.snippet.Tabstop[]> 210 --- @field tabstop_placements integer[] 211 --- @field current_tabstop vim.snippet.Tabstop 212 --- @field tab_keymaps { i: table<string, any>?, s: table<string, any>? } 213 --- @field shift_tab_keymaps { i: table<string, any>?, s: table<string, any>? } 214 local Session = {} 215 216 --- Creates a new snippet session in the current buffer. 217 --- 218 --- @package 219 --- @param bufnr integer 220 --- @param snippet_extmark integer 221 --- @param tabstop_data table<integer, { placement: integer, range: Range4, choices?: string[] }[]> 222 --- @return vim.snippet.Session 223 function Session.new(bufnr, snippet_extmark, tabstop_data) 224 local self = setmetatable({ 225 bufnr = bufnr, 226 extmark_id = snippet_extmark, 227 tabstops = {}, 228 tabstop_placements = {}, 229 current_tabstop = Tabstop.new(0, bufnr, 0, { 0, 0, 0, 0 }), 230 tab_keymaps = { i = nil, s = nil }, 231 shift_tab_keymaps = { i = nil, s = nil }, 232 }, { __index = Session }) 233 234 -- Create the tabstops. 235 for index, ranges in pairs(tabstop_data) do 236 for _, data in ipairs(ranges) do 237 self.tabstops[index] = self.tabstops[index] or {} 238 table.insert( 239 self.tabstops[index], 240 Tabstop.new(index, self.bufnr, data.placement, data.range, data.choices) 241 ) 242 table.insert(self.tabstop_placements, data.placement) 243 end 244 end 245 246 return self 247 end 248 249 --- Returns the destination tabstop index when jumping in the given direction. 250 --- 251 --- @package 252 --- @param direction vim.snippet.Direction 253 --- @return integer? 254 function Session:get_dest_index(direction) 255 local tabstop_indexes = vim.tbl_keys(self.tabstops) --- @type integer[] 256 table.sort(tabstop_indexes) 257 for i, index in ipairs(tabstop_indexes) do 258 if index == self.current_tabstop.index then 259 local dest_index = tabstop_indexes[i + direction] --- @type integer? 260 -- When jumping forwards, $0 is the last tabstop. 261 if not dest_index and direction == 1 then 262 dest_index = 0 263 end 264 -- When jumping backwards, make sure we don't think that $0 is the first tabstop. 265 if dest_index == 0 and direction == -1 then 266 dest_index = nil 267 end 268 return dest_index 269 end 270 end 271 end 272 273 --- Sets the right gravity for all the tabstops. 274 --- 275 --- @package 276 function Session:set_gravity() 277 local index = self.current_tabstop.index 278 local all_tabstop_placements = self.tabstop_placements 279 local dest_tabstop_placements = {} 280 281 for _, tabstop in ipairs(self.tabstops[index]) do 282 tabstop:set_gravity('expand') 283 table.insert(dest_tabstop_placements, tabstop.placement) 284 end 285 286 for i, tabstops in pairs(self.tabstops) do 287 if i ~= index then 288 for _, tabstop in ipairs(tabstops) do 289 local placement = tabstop.placement + 1 290 -- Check if there other tabstops directly adjacent 291 while 292 vim.list_contains(all_tabstop_placements, placement) 293 and not vim.list_contains(dest_tabstop_placements, placement) 294 do 295 placement = placement + 1 296 end 297 298 if vim.list_contains(dest_tabstop_placements, placement) then 299 tabstop:set_gravity('lock') 300 else 301 tabstop:set_gravity('shift') 302 end 303 end 304 end 305 end 306 end 307 308 local M = { session = nil } 309 310 --- Displays the choices for the given tabstop as completion items. 311 --- 312 --- @param tabstop vim.snippet.Tabstop 313 local function display_choices(tabstop) 314 assert(tabstop.choices, 'Tabstop has no choices') 315 316 local text = tabstop:get_text() 317 local found_text = false 318 319 local start_col = tabstop:get_range()[2] + 1 320 local matches = {} --- @type table[] 321 for _, choice in ipairs(tabstop.choices) do 322 if choice ~= text then 323 matches[#matches + 1] = { word = choice } 324 else 325 found_text = true 326 end 327 end 328 329 if found_text then 330 table.insert(matches, 1, text) 331 end 332 333 vim.defer_fn(function() 334 vim.fn.complete(start_col, matches) 335 end, 100) 336 end 337 338 --- Select the given tabstop range. 339 --- 340 --- @param tabstop vim.snippet.Tabstop 341 local function select_tabstop(tabstop) 342 --- @param keys string 343 local function feedkeys(keys) 344 keys = vim.api.nvim_replace_termcodes(keys, true, false, true) 345 vim.api.nvim_feedkeys(keys, 'n', true) 346 end 347 348 local range = tabstop:get_range() 349 local mode = vim.fn.mode() 350 351 if vim.fn.pumvisible() ~= 0 then 352 -- Close the choice completion menu if open. 353 vim.fn.complete(vim.fn.col('.'), {}) 354 end 355 356 -- Move the cursor to the start of the tabstop. 357 vim.api.nvim_win_set_cursor(0, { range[1] + 1, range[2] }) 358 359 -- For empty, choice and the final tabstops, start insert mode at the end of the range. 360 if tabstop.choices or tabstop.index == 0 or (range[1] == range[3] and range[2] == range[4]) then 361 if mode ~= 'i' then 362 if mode == 's' then 363 feedkeys('<Esc>') 364 end 365 vim.cmd.startinsert({ bang = range[4] >= #vim.api.nvim_get_current_line() }) 366 end 367 if tabstop.choices then 368 vim.fn.cursor(range[3] + 1, range[4] + 1) 369 display_choices(tabstop) 370 end 371 else 372 -- Else, select the tabstop's text. 373 -- Need this exact order so cannot mix regular API calls with feedkeys, which 374 -- are not executed immediately. Use <Cmd> to set the cursor position. 375 local keys = { 376 mode ~= 'n' and '<Esc>' or '', 377 ('<Cmd>call cursor(%s,%s)<CR>'):format(range[1] + 1, range[2] + 1), 378 'v', 379 ('<Cmd>call cursor(%s,%s)<CR>'):format(range[3] + 1, range[4]), 380 'o<c-g><c-r>_', 381 } 382 feedkeys(table.concat(keys)) 383 end 384 end 385 386 --- Sets up the necessary autocommands for snippet expansion. 387 --- 388 --- @param bufnr integer 389 local function setup_autocmds(bufnr) 390 vim.api.nvim_create_autocmd({ 'CursorMoved', 'CursorMovedI' }, { 391 group = snippet_group, 392 desc = 'Update snippet state when the cursor moves', 393 buffer = bufnr, 394 callback = function() 395 -- Just update the tabstop in insert and select modes. 396 if not vim.fn.mode():match('^[isS]') then 397 return 398 end 399 400 local cursor_row, cursor_col = cursor_pos() 401 402 -- The cursor left the snippet region. 403 local snippet_range = get_extmark_range(bufnr, M._session.extmark_id) 404 if 405 cursor_row < snippet_range[1] 406 or (cursor_row == snippet_range[1] and cursor_col < snippet_range[2]) 407 or cursor_row > snippet_range[3] 408 or (cursor_row == snippet_range[3] and cursor_col > snippet_range[4]) 409 then 410 M.stop() 411 return true 412 end 413 414 for tabstop_index, tabstops in pairs(M._session.tabstops) do 415 for _, tabstop in ipairs(tabstops) do 416 local range = tabstop:get_range() 417 if 418 (cursor_row > range[1] or (cursor_row == range[1] and cursor_col >= range[2])) 419 and (cursor_row < range[3] or (cursor_row == range[3] and cursor_col <= range[4])) 420 then 421 if tabstop_index ~= 0 then 422 return 423 end 424 end 425 end 426 end 427 428 -- The cursor is either not on a tabstop or we reached the end, so exit the session. 429 M.stop() 430 return true 431 end, 432 }) 433 434 vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI', 'TextChangedP' }, { 435 group = snippet_group, 436 desc = 'Update active tabstops when buffer text changes', 437 buffer = bufnr, 438 callback = function() 439 -- Check that the snippet hasn't been deleted. 440 local snippet_range = get_extmark_range(M._session.bufnr, M._session.extmark_id) 441 if 442 (snippet_range[1] == snippet_range[3] and snippet_range[2] == snippet_range[4]) 443 or snippet_range[3] + 1 > vim.fn.line('$') 444 then 445 M.stop() 446 end 447 448 if not M.active() then 449 return true 450 end 451 452 -- Sync the tabstops in the current group. 453 local current_tabstop = M._session.current_tabstop 454 local current_text = current_tabstop:get_text() 455 for _, tabstop in ipairs(M._session.tabstops[current_tabstop.index]) do 456 if tabstop.extmark_id ~= current_tabstop.extmark_id then 457 tabstop:set_text(current_text) 458 end 459 end 460 end, 461 }) 462 463 vim.api.nvim_create_autocmd('BufLeave', { 464 group = snippet_group, 465 desc = 'Stop the snippet session when leaving the buffer', 466 buffer = bufnr, 467 callback = function() 468 M.stop() 469 end, 470 }) 471 end 472 473 --- Expands the given snippet text. 474 --- Refer to https://microsoft.github.io/language-server-protocol/specification/#snippet_syntax 475 --- for the specification of valid input. 476 --- 477 --- Tabstops are highlighted with |hl-SnippetTabstop| and |hl-SnippetTabstopActive|. 478 --- 479 --- @param input string 480 function M.expand(input) 481 local snippet = G.parse(input) 482 local snippet_text = {} 483 ---@type string 484 local base_indent = vim.api.nvim_get_current_line():match('^%s*') or '' 485 486 -- Get the placeholders we should use for each tabstop index. 487 --- @type table<integer, string> 488 local placeholders = {} 489 for _, child in ipairs(snippet.data.children) do 490 local type, data = child.type, child.data 491 if type == G.NodeType.Placeholder then 492 --- @cast data vim.snippet.PlaceholderData 493 local tabstop, value = data.tabstop, tostring(data.value) 494 if placeholders[tabstop] and placeholders[tabstop] ~= value then 495 error('Snippet has multiple placeholders for tabstop $' .. tabstop) 496 end 497 placeholders[tabstop] = value 498 end 499 end 500 501 -- Keep track of tabstop nodes during expansion. 502 --- @type table<integer, { placement: integer, range: Range4, choices?: string[] }[]> 503 local tabstop_data = {} 504 505 --- @param placement integer 506 --- @param index integer 507 --- @param placeholder? string 508 --- @param choices? string[] 509 local function add_tabstop(placement, index, placeholder, choices) 510 tabstop_data[index] = tabstop_data[index] or {} 511 local range = compute_tabstop_range(snippet_text, placeholder) 512 table.insert(tabstop_data[index], { placement = placement, range = range, choices = choices }) 513 end 514 515 --- Appends the given text to the snippet, taking care of indentation. 516 --- 517 --- @param text string|string[] 518 local function append_to_snippet(text) 519 local shiftwidth = vim.fn.shiftwidth() 520 local curbuf = vim.api.nvim_get_current_buf() 521 local expandtab = vim.bo[curbuf].expandtab 522 523 local lines = {} --- @type string[] 524 for i, line in ipairs(text_to_lines(text)) do 525 -- Replace tabs by spaces. 526 if expandtab then 527 line = line:gsub('\t', (' '):rep(shiftwidth)) --- @type string 528 end 529 -- Add the base indentation. 530 if i > 1 then 531 line = base_indent .. line 532 end 533 lines[#lines + 1] = line 534 end 535 536 table.insert(snippet_text, table.concat(lines, '\n')) 537 end 538 539 for index, child in ipairs(snippet.data.children) do 540 local type, data = child.type, child.data 541 if type == G.NodeType.Tabstop then 542 --- @cast data vim.snippet.TabstopData 543 local placeholder = placeholders[data.tabstop] 544 add_tabstop(index, data.tabstop, placeholder) 545 if placeholder then 546 append_to_snippet(placeholder) 547 end 548 elseif type == G.NodeType.Placeholder then 549 --- @cast data vim.snippet.PlaceholderData 550 local value = placeholders[data.tabstop] 551 add_tabstop(index, data.tabstop, value) 552 append_to_snippet(value) 553 elseif type == G.NodeType.Choice then 554 --- @cast data vim.snippet.ChoiceData 555 add_tabstop(index, data.tabstop, nil, data.values) 556 elseif type == G.NodeType.Variable then 557 --- @cast data vim.snippet.VariableData 558 -- Try to get the variable's value. 559 local value = resolve_variable(data.name, data.default and tostring(data.default) or '') 560 if not value then 561 -- Unknown variable, make this a tabstop and use the variable name as a placeholder. 562 value = data.name 563 local tabstop_indexes = vim.tbl_keys(tabstop_data) 564 local tabstop_index = math.max(unpack((#tabstop_indexes == 0 and { 0 }) or tabstop_indexes)) 565 + 1 566 add_tabstop(index, tabstop_index, value) 567 end 568 append_to_snippet(value) 569 elseif type == G.NodeType.Text then 570 --- @cast data vim.snippet.TextData 571 append_to_snippet(data.text) 572 end 573 end 574 575 -- $0, which defaults to the end of the snippet, defines the final cursor position. 576 -- Make sure the snippet has exactly one of these. 577 if vim.tbl_contains(vim.tbl_keys(tabstop_data), 0) then 578 assert(#tabstop_data[0] == 1, 'Snippet has multiple $0 tabstops') 579 else 580 add_tabstop(#snippet.data.children + 1, 0) 581 end 582 583 snippet_text = text_to_lines(snippet_text) 584 585 -- Insert the snippet text. 586 local bufnr = vim.api.nvim_get_current_buf() 587 local cursor_row, cursor_col = cursor_pos() 588 vim.api.nvim_buf_set_text(bufnr, cursor_row, cursor_col, cursor_row, cursor_col, snippet_text) 589 590 -- Create the session. 591 local snippet_extmark = vim.api.nvim_buf_set_extmark(bufnr, snippet_ns, cursor_row, cursor_col, { 592 end_line = cursor_row + #snippet_text - 1, 593 end_col = #snippet_text > 1 and #snippet_text[#snippet_text] or cursor_col + #snippet_text[1], 594 right_gravity = false, 595 end_right_gravity = true, 596 }) 597 M._session = Session.new(bufnr, snippet_extmark, tabstop_data) 598 599 -- Jump to the first tabstop. 600 M.jump(1) 601 end 602 603 --- @alias vim.snippet.Direction -1 | 1 604 605 --- Jumps to the next (or previous) placeholder in the current snippet, if possible. 606 --- 607 --- By default `<Tab>` is setup to jump if a snippet is active. The default mapping looks like: 608 --- 609 --- ```lua 610 --- vim.keymap.set({ 'i', 's' }, '<Tab>', function() 611 --- if vim.snippet.active({ direction = 1 }) then 612 --- return '<Cmd>lua vim.snippet.jump(1)<CR>' 613 --- else 614 --- return '<Tab>' 615 --- end 616 --- end, { desc = '...', expr = true, silent = true }) 617 --- ``` 618 --- 619 --- @param direction (vim.snippet.Direction) Navigation direction. -1 for previous, 1 for next. 620 function M.jump(direction) 621 -- Get the tabstop index to jump to. 622 local dest_index = M._session and M._session:get_dest_index(direction) 623 if not dest_index then 624 return 625 end 626 627 -- Find the tabstop with the lowest range. 628 local tabstops = M._session.tabstops[dest_index] 629 local dest = tabstops[1] 630 for _, tabstop in ipairs(tabstops) do 631 local dest_range, range = dest:get_range(), tabstop:get_range() 632 if (range[1] < dest_range[1]) or (range[1] == dest_range[1] and range[2] < dest_range[2]) then 633 dest = tabstop 634 end 635 end 636 637 -- Clear the autocommands so that we can move the cursor freely while selecting the tabstop. 638 vim.api.nvim_clear_autocmds({ group = snippet_group, buffer = M._session.bufnr }) 639 640 M._session.current_tabstop = dest 641 M._session:set_gravity() 642 select_tabstop(dest) 643 644 -- The cursor is not on a tabstop so exit the session. 645 if dest.index == 0 then 646 M.stop() 647 return 648 end 649 650 -- Restore the autocommands. 651 setup_autocmds(M._session.bufnr) 652 end 653 654 --- @class vim.snippet.ActiveFilter 655 --- @field direction vim.snippet.Direction Navigation direction. -1 for previous, 1 for next. 656 657 --- Returns `true` if there's an active snippet in the current buffer, 658 --- applying the given filter if provided. 659 --- 660 --- @param filter? vim.snippet.ActiveFilter Filter to constrain the search with: 661 --- - `direction` (vim.snippet.Direction): Navigation direction. Will return `true` if the snippet 662 --- can be jumped in the given direction. 663 --- @return boolean 664 function M.active(filter) 665 local active = M._session ~= nil and M._session.bufnr == vim.api.nvim_get_current_buf() 666 667 local in_direction = true 668 if active and filter and filter.direction then 669 in_direction = M._session:get_dest_index(filter.direction) ~= nil 670 end 671 672 return active and in_direction 673 end 674 675 --- Exits the current snippet. 676 function M.stop() 677 if not M.active() then 678 return 679 end 680 681 vim.api.nvim_clear_autocmds({ group = snippet_group, buffer = M._session.bufnr }) 682 vim.api.nvim_buf_clear_namespace(M._session.bufnr, snippet_ns, 0, -1) 683 684 M._session = nil 685 end 686 687 return M